Skip to content

Commit 18dd456

Browse files
authored
fix: send $effect.pending count to the correct boundary (#16732)
* fix: send `$effect.pending` count to the correct boundary * make boundary.pending private, use boundary.is_pending consistently * move error to correct place * we need that error * update JSDoc
1 parent d92fa43 commit 18dd456

File tree

8 files changed

+189
-32
lines changed

8 files changed

+189
-32
lines changed

.changeset/dirty-cycles-smash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: send `$effect.pending` count to the correct boundary

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: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ export function boundary(node, props, children) {
4949
}
5050

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

55+
#pending = false;
56+
5757
/** @type {TemplateNode} */
5858
#anchor;
5959

@@ -81,6 +81,7 @@ export class Boundary {
8181
/** @type {DocumentFragment | null} */
8282
#offscreen_fragment = null;
8383

84+
#local_pending_count = 0;
8485
#pending_count = 0;
8586
#is_creating_fallback = false;
8687

@@ -95,12 +96,12 @@ export class Boundary {
9596

9697
#effect_pending_update = () => {
9798
if (this.#effect_pending) {
98-
internal_set(this.#effect_pending, this.#pending_count);
99+
internal_set(this.#effect_pending, this.#local_pending_count);
99100
}
100101
};
101102

102103
#effect_pending_subscriber = createSubscriber(() => {
103-
this.#effect_pending = source(this.#pending_count);
104+
this.#effect_pending = source(this.#local_pending_count);
104105

105106
if (DEV) {
106107
tag(this.#effect_pending, '$effect.pending()');
@@ -125,7 +126,7 @@ export class Boundary {
125126

126127
this.parent = /** @type {Effect} */ (active_effect).b;
127128

128-
this.pending = !!this.#props.pending;
129+
this.#pending = !!this.#props.pending;
129130

130131
this.#effect = block(() => {
131132
/** @type {Effect} */ (active_effect).b = this;
@@ -156,7 +157,7 @@ export class Boundary {
156157
this.#pending_effect = null;
157158
});
158159

159-
this.pending = false;
160+
this.#pending = false;
160161
}
161162
});
162163
} else {
@@ -169,7 +170,7 @@ export class Boundary {
169170
if (this.#pending_count > 0) {
170171
this.#show_pending_snippet();
171172
} else {
172-
this.pending = false;
173+
this.#pending = false;
173174
}
174175
}
175176
}, flags);
@@ -179,6 +180,14 @@ export class Boundary {
179180
}
180181
}
181182

183+
/**
184+
* Returns `true` if the effect exists inside a boundary whose pending snippet is shown
185+
* @returns {boolean}
186+
*/
187+
is_pending() {
188+
return this.#pending || (!!this.parent && this.parent.is_pending());
189+
}
190+
182191
has_pending_snippet() {
183192
return !!this.#props.pending;
184193
}
@@ -220,12 +229,25 @@ export class Boundary {
220229
}
221230
}
222231

223-
/** @param {1 | -1} d */
232+
/**
233+
* Updates the pending count associated with the currently visible pending snippet,
234+
* if any, such that we can replace the snippet with content once work is done
235+
* @param {1 | -1} d
236+
*/
224237
#update_pending_count(d) {
238+
if (!this.has_pending_snippet()) {
239+
if (this.parent) {
240+
this.parent.#update_pending_count(d);
241+
return;
242+
}
243+
244+
e.await_outside_boundary();
245+
}
246+
225247
this.#pending_count += d;
226248

227249
if (this.#pending_count === 0) {
228-
this.pending = false;
250+
this.#pending = false;
229251

230252
if (this.#pending_effect) {
231253
pause_effect(this.#pending_effect, () => {
@@ -240,14 +262,16 @@ export class Boundary {
240262
}
241263
}
242264

243-
/** @param {1 | -1} d */
265+
/**
266+
* Update the source that powers `$effect.pending()` inside this boundary,
267+
* and controls when the current `pending` snippet (if any) is removed.
268+
* Do not call from inside the class
269+
* @param {1 | -1} d
270+
*/
244271
update_pending_count(d) {
245-
if (this.has_pending_snippet()) {
246-
this.#update_pending_count(d);
247-
} else if (this.parent) {
248-
this.parent.#update_pending_count(d);
249-
}
272+
this.#update_pending_count(d);
250273

274+
this.#local_pending_count += d;
251275
effect_pending_updates.add(this.#effect_pending_update);
252276
}
253277

@@ -308,7 +332,7 @@ export class Boundary {
308332
});
309333
}
310334

311-
this.pending = true;
335+
this.#pending = true;
312336

313337
this.#main_effect = this.#run(() => {
314338
this.#is_creating_fallback = false;
@@ -318,7 +342,7 @@ export class Boundary {
318342
if (this.#pending_count > 0) {
319343
this.#show_pending_snippet();
320344
} else {
321-
this.pending = false;
345+
this.#pending = false;
322346
}
323347
};
324348

@@ -384,12 +408,8 @@ function move_effect(effect, fragment) {
384408
}
385409
}
386410

387-
export function get_pending_boundary() {
388-
var boundary = /** @type {Effect} */ (active_effect).b;
389-
390-
while (boundary !== null && !boundary.has_pending_snippet()) {
391-
boundary = boundary.parent;
392-
}
411+
export function get_boundary() {
412+
const boundary = /** @type {Effect} */ (active_effect).b;
393413

394414
if (boundary === null) {
395415
e.await_outside_boundary();

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
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';
6+
import { get_boundary } from '../dom/blocks/boundary.js';
77
import { invoke_error_boundary } from '../error-handling.js';
88
import {
99
active_effect,
@@ -39,7 +39,7 @@ export function flatten(sync, async, fn) {
3939
var parent = /** @type {Effect} */ (active_effect);
4040

4141
var restore = capture();
42-
var boundary = get_pending_boundary();
42+
var boundary = get_boundary();
4343

4444
Promise.all(async.map((expression) => async_derived(expression)))
4545
.then((result) => {

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '#client/constants';
1616
import { async_mode_flag } from '../../flags/index.js';
1717
import { deferred, define_property } from '../../shared/utils.js';
18-
import { get_pending_boundary } from '../dom/blocks/boundary.js';
18+
import { get_boundary } from '../dom/blocks/boundary.js';
1919
import {
2020
active_effect,
2121
is_dirty,
@@ -298,7 +298,10 @@ export class Batch {
298298
this.#render_effects.push(effect);
299299
} else if ((flags & CLEAN) === 0) {
300300
if ((flags & ASYNC) !== 0) {
301-
var effects = effect.b?.pending ? this.#boundary_async_effects : this.#async_effects;
301+
var effects = effect.b?.is_pending()
302+
? this.#boundary_async_effects
303+
: this.#async_effects;
304+
302305
effects.push(effect);
303306
} else if (is_dirty(effect)) {
304307
if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect);
@@ -668,9 +671,9 @@ export function schedule_effect(signal) {
668671
}
669672

670673
export function suspend() {
671-
var boundary = get_pending_boundary();
674+
var boundary = get_boundary();
672675
var batch = /** @type {Batch} */ (current_batch);
673-
var pending = boundary.pending;
676+
var pending = boundary.is_pending();
674677

675678
boundary.update_pending_count(1);
676679
if (!pending) batch.increment();

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export function async_derived(fn, location) {
135135
prev = promise;
136136

137137
var batch = /** @type {Batch} */ (current_batch);
138-
var pending = boundary.pending;
138+
var pending = boundary.is_pending();
139139

140140
if (should_suspend) {
141141
boundary.update_pending_count(1);
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)