Skip to content

Commit 02b7372

Browse files
fix: $effect.pending sends updates to incorrect boundary
1 parent d92fa43 commit 02b7372

File tree

6 files changed

+207
-28
lines changed

6 files changed

+207
-28
lines changed

packages/svelte/src/internal/client/dom/blocks/async.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
/** @import { TemplateNode, Value } from '#client' */
22
import { flatten } from '../../reactivity/async.js';
33
import { get } from '../../runtime.js';
4-
import { get_pending_boundary } from './boundary.js';
4+
import { get_boundary } from './boundary.js';
55

66
/**
77
* @param {TemplateNode} node
88
* @param {Array<() => Promise<any>>} expressions
99
* @param {(anchor: TemplateNode, ...deriveds: Value[]) => void} fn
1010
*/
1111
export function async(node, expressions, fn) {
12-
var boundary = get_pending_boundary();
12+
var boundary = get_boundary();
1313

1414
boundary.update_pending_count(1);
1515

packages/svelte/src/internal/client/dom/blocks/boundary.js

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,34 @@ export function boundary(node, props, children) {
4949
}
5050

5151
export class Boundary {
52-
pending = false;
53-
5452
/** @type {Boundary | null} */
5553
parent;
5654

55+
/**
56+
* Whether this boundary is inside a boundary (including this one) that's showing a pending snippet.
57+
* @type {boolean}
58+
*/
59+
get pending() {
60+
if (this.has_pending_snippet()) {
61+
return this.#pending;
62+
}
63+
64+
// intentionally not throwing here, as the answer to "am I in a pending snippet" is false when
65+
// there's no pending snippet at all
66+
return this.parent?.pending ?? false;
67+
}
68+
69+
set pending(value) {
70+
if (this.has_pending_snippet()) {
71+
this.#pending = value;
72+
} else if (this.parent) {
73+
this.parent.pending = value;
74+
} else if (value) {
75+
e.await_outside_boundary();
76+
}
77+
// if we're trying to set it to `false` and yeeting that into the void, it's fine
78+
}
79+
5780
/** @type {TemplateNode} */
5881
#anchor;
5982

@@ -81,7 +104,28 @@ export class Boundary {
81104
/** @type {DocumentFragment | null} */
82105
#offscreen_fragment = null;
83106

107+
/**
108+
* Whether this boundary is inside a boundary (including this one) that's showing a pending snippet.
109+
* Derived from {@link props.pending} and {@link cascading_pending_count}.
110+
*/
111+
#pending = false;
112+
113+
/**
114+
* The number of pending async deriveds/expressions within this boundary, not counting any parent or child boundaries.
115+
* This controls `$effect.pending` for this boundary.
116+
*
117+
* Don't ever set this directly; use {@link update_pending_count} instead.
118+
*/
84119
#pending_count = 0;
120+
121+
/**
122+
* Like {@link #pending_count}, but treats boundaries with no `pending` snippet as porous.
123+
* This controls the pending snippet for this boundary.
124+
*
125+
* Don't ever set this directly; use {@link update_pending_count} instead.
126+
*/
127+
#cascading_pending_count = 0;
128+
85129
#is_creating_fallback = false;
86130

87131
/**
@@ -149,7 +193,7 @@ export class Boundary {
149193
return branch(() => this.#children(this.#anchor));
150194
});
151195

152-
if (this.#pending_count > 0) {
196+
if (this.#cascading_pending_count > 0) {
153197
this.#show_pending_snippet();
154198
} else {
155199
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
@@ -166,7 +210,7 @@ export class Boundary {
166210
this.error(error);
167211
}
168212

169-
if (this.#pending_count > 0) {
213+
if (this.#cascading_pending_count > 0) {
170214
this.#show_pending_snippet();
171215
} else {
172216
this.pending = false;
@@ -220,11 +264,11 @@ export class Boundary {
220264
}
221265
}
222266

223-
/** @param {1 | -1} d */
224-
#update_pending_count(d) {
225-
this.#pending_count += d;
267+
/** @param {number} d */
268+
#update_cascading_pending_count(d) {
269+
this.#cascading_pending_count = Math.max(this.#cascading_pending_count + d, 0);
226270

227-
if (this.#pending_count === 0) {
271+
if (this.#cascading_pending_count === 0) {
228272
this.pending = false;
229273

230274
if (this.#pending_effect) {
@@ -240,12 +284,21 @@ export class Boundary {
240284
}
241285
}
242286

243-
/** @param {1 | -1} d */
244-
update_pending_count(d) {
287+
/**
288+
* @param {number} d
289+
* @param {boolean} safe
290+
*/
291+
update_pending_count(d, safe = false, first = true) {
292+
if (first) {
293+
this.#pending_count = Math.max(this.#pending_count + d, 0);
294+
}
295+
245296
if (this.has_pending_snippet()) {
246-
this.#update_pending_count(d);
297+
this.#update_cascading_pending_count(d);
247298
} else if (this.parent) {
248-
this.parent.#update_pending_count(d);
299+
this.parent.update_pending_count(d, safe, false);
300+
} else if (this.parent === null && !safe) {
301+
e.await_outside_boundary();
249302
}
250303

251304
effect_pending_updates.add(this.#effect_pending_update);
@@ -300,22 +353,26 @@ export class Boundary {
300353
// If the failure happened while flushing effects, current_batch can be null
301354
Batch.ensure();
302355

303-
this.#pending_count = 0;
356+
// this ensures we modify the cascading_pending_count of the correct parent
357+
// by the number we're decreasing this boundary by
358+
this.update_pending_count(-this.#pending_count, true);
304359

305360
if (this.#failed_effect !== null) {
306361
pause_effect(this.#failed_effect, () => {
307362
this.#failed_effect = null;
308363
});
309364
}
310365

311-
this.pending = true;
366+
// we intentionally do not try to find the nearest pending boundary. If this boundary has one, we'll render it on reset
367+
// but it would be really weird to show the parent's boundary on a child reset.
368+
this.pending = this.has_pending_snippet();
312369

313370
this.#main_effect = this.#run(() => {
314371
this.#is_creating_fallback = false;
315372
return branch(() => this.#children(this.#anchor));
316373
});
317374

318-
if (this.#pending_count > 0) {
375+
if (this.#cascading_pending_count > 0) {
319376
this.#show_pending_snippet();
320377
} else {
321378
this.pending = false;
@@ -384,13 +441,9 @@ function move_effect(effect, fragment) {
384441
}
385442
}
386443

387-
export function get_pending_boundary() {
444+
export function get_boundary() {
388445
var boundary = /** @type {Effect} */ (active_effect).b;
389446

390-
while (boundary !== null && !boundary.has_pending_snippet()) {
391-
boundary = boundary.parent;
392-
}
393-
394447
if (boundary === null) {
395448
e.await_outside_boundary();
396449
}

packages/svelte/src/internal/client/reactivity/async.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import { DESTROYED } from '#client/constants';
44
import { DEV } from 'esm-env';
55
import { component_context, is_runes, set_component_context } from '../context.js';
6-
import { get_pending_boundary } from '../dom/blocks/boundary.js';
76
import { invoke_error_boundary } from '../error-handling.js';
87
import {
98
active_effect,
@@ -39,7 +38,6 @@ export function flatten(sync, async, fn) {
3938
var parent = /** @type {Effect} */ (active_effect);
4039

4140
var restore = capture();
42-
var boundary = get_pending_boundary();
4341

4442
Promise.all(async.map((expression) => async_derived(expression)))
4543
.then((result) => {
@@ -60,7 +58,7 @@ export function flatten(sync, async, fn) {
6058
unset_context();
6159
})
6260
.catch((error) => {
63-
boundary.error(error);
61+
invoke_error_boundary(error, parent);
6462
});
6563
}
6664

packages/svelte/src/internal/client/reactivity/batch.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ import {
1010
INERT,
1111
RENDER_EFFECT,
1212
ROOT_EFFECT,
13-
USER_EFFECT,
1413
MAYBE_DIRTY
1514
} from '#client/constants';
1615
import { async_mode_flag } from '../../flags/index.js';
1716
import { deferred, define_property } from '../../shared/utils.js';
18-
import { get_pending_boundary } from '../dom/blocks/boundary.js';
17+
import { get_boundary } from '../dom/blocks/boundary.js';
1918
import {
2019
active_effect,
2120
is_dirty,
@@ -668,7 +667,7 @@ export function schedule_effect(signal) {
668667
}
669668

670669
export function suspend() {
671-
var boundary = get_pending_boundary();
670+
var boundary = get_boundary();
672671
var batch = /** @type {Batch} */ (current_batch);
673672
var pending = boundary.pending;
674673

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
async test({ assert, target }) {
6+
const [increment, shift] = target.querySelectorAll('button');
7+
8+
assert.htmlEqual(
9+
target.innerHTML,
10+
`
11+
<button>increment</button>
12+
<button>shift</button>
13+
<p>loading...</p>
14+
`
15+
);
16+
17+
shift.click();
18+
shift.click();
19+
shift.click();
20+
21+
await tick();
22+
assert.htmlEqual(
23+
target.innerHTML,
24+
`
25+
<button>increment</button>
26+
<button>shift</button>
27+
<p>0</p>
28+
<p>0</p>
29+
<p>0</p>
30+
<p>inner pending: 0</p>
31+
<p>outer pending: 0</p>
32+
`
33+
);
34+
35+
increment.click();
36+
await tick();
37+
assert.htmlEqual(
38+
target.innerHTML,
39+
`
40+
<button>increment</button>
41+
<button>shift</button>
42+
<p>0</p>
43+
<p>0</p>
44+
<p>0</p>
45+
<p>inner pending: 3</p>
46+
<p>outer pending: 0</p>
47+
`
48+
);
49+
50+
shift.click();
51+
await tick();
52+
assert.htmlEqual(
53+
target.innerHTML,
54+
`
55+
<button>increment</button>
56+
<button>shift</button>
57+
<p>0</p>
58+
<p>0</p>
59+
<p>0</p>
60+
<p>inner pending: 2</p>
61+
<p>outer pending: 0</p>
62+
`
63+
);
64+
65+
shift.click();
66+
await tick();
67+
assert.htmlEqual(
68+
target.innerHTML,
69+
`
70+
<button>increment</button>
71+
<button>shift</button>
72+
<p>0</p>
73+
<p>0</p>
74+
<p>0</p>
75+
<p>inner pending: 1</p>
76+
<p>outer pending: 0</p>
77+
`
78+
);
79+
80+
shift.click();
81+
await tick();
82+
assert.htmlEqual(
83+
target.innerHTML,
84+
`
85+
<button>increment</button>
86+
<button>shift</button>
87+
<p>1</p>
88+
<p>1</p>
89+
<p>1</p>
90+
<p>inner pending: 0</p>
91+
<p>outer pending: 0</p>
92+
`
93+
);
94+
}
95+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<script>
2+
let value = $state(0);
3+
let deferreds = [];
4+
5+
function push(value) {
6+
const deferred = Promise.withResolvers();
7+
deferreds.push({ value, deferred });
8+
return deferred.promise;
9+
}
10+
11+
function shift() {
12+
const d = deferreds.shift();
13+
d?.deferred.resolve(d.value);
14+
}
15+
</script>
16+
17+
<button onclick={() => value++}>increment</button>
18+
<button onclick={() => shift()}>shift</button>
19+
20+
<svelte:boundary>
21+
<svelte:boundary>
22+
<p>{await push(value)}</p>
23+
<p>{await push(value)}</p>
24+
<p>{await push(value)}</p>
25+
<p>inner pending: {$effect.pending()}</p>
26+
</svelte:boundary>
27+
<p>outer pending: {$effect.pending()}</p>
28+
29+
{#snippet pending()}
30+
<p>loading...</p>
31+
{/snippet}
32+
</svelte:boundary>
33+
34+

0 commit comments

Comments
 (0)