Skip to content

Commit 6557a0a

Browse files
fix: run effects in pending snippets (#17719)
Boundaries are buggy: if the `pending` snippet contains state, changes to that state [won't cause updates](https://svelte.dev/playground/hello-world?version=5.51.2#H4sIAAAAAAAAE22SQW-DMAyF_0qUTSpoE92uFJh223Hate0hJWaNliZRYsoqxH9fQtJW6noD-33Pz4aRKnYAWtIPkFKTQVvJSQZcIPCcPtNOSHC0XI8UTyboQsHXE_VuTOGOIDHUdszBvXqrFYJCb0Mr11phsNmoDUpAYsFpeQTrSE3W25Uv-0bXqxaFVsT0bp8dmewhJ2PobNB7OSQcOrAWuKc-rT4IB8UgcP91dsvyVZRf_IvZK8tJ3VzoInXTiCuDvVVXlYkT5u50k9DtRYfZJd11XGq8FSlKAsPOre4V-uSPDhlC9hIE1fJ6GFXtekRvrlUrRftTjzF25J5q8jrN9xOqtXDwhw14RO7jc5bIzI-3-vihyp3358yeZmFlmpENTGD8CIu0GV_kU7U0TdxmfHBKGON3MqC4UN9ZPsVDBHzOe1bjuEzaad7230j_nyD8Ii3R9jBt_RsTchCK07Jj0sH0B6hNF6aqAgAA): ```svelte <script> let resolvers = []; function push(value) { const deferred = Promise.withResolvers(); resolvers.push(() => deferred.resolve(value)); return deferred.promise; } function shift() { resolvers.shift()?.(); } let count = $state(0); </script> <button onclick={() => count += 1}> increment </button> <button onclick={shift}> shift </button> <svelte:boundary> <p>{await push('resolved')}</p> {#snippet pending()} <p>{count}</p> {/snippet} </svelte:boundary> ``` The issue is that the boundary's `this.#effect` has the `BOUNDARY_EFFECT` flag, and `this.#pending_effect` is a child thereof. Instead, `this.#main_effect` should have the flag. (It turns out `this.#failed_effect` _also_ needs the flag, because errors that occur in a `failed` snippet cause the boundary to re-render in its `failed` state, which I found somewhat confusing to be honest. Probably the right choice though.) I was able to simplify the code a bit, too. ~~(Actually now that I think about it do we need `this.#effect` at all? Will check.)~~ ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: Simon H <[email protected]>
1 parent ff70ab1 commit 6557a0a

5 files changed

Lines changed: 139 additions & 94 deletions

File tree

.changeset/many-dolls-argue.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: run effects in pending snippets

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

Lines changed: 55 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/** @import { Effect, Source, TemplateNode, } from '#client' */
22
import {
3-
BLOCK_EFFECT,
43
BOUNDARY_EFFECT,
54
COMMENT_NODE,
65
DIRTY,
@@ -53,7 +52,7 @@ import { set_signal_status } from '../../reactivity/status.js';
5352
* }} BoundaryProps
5453
*/
5554

56-
var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT;
55+
var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED;
5756

5857
/**
5958
* @param {TemplateNode} node
@@ -98,15 +97,10 @@ export class Boundary {
9897
/** @type {DocumentFragment | null} */
9998
#offscreen_fragment = null;
10099

101-
/** @type {TemplateNode | null} */
102-
#pending_anchor = null;
103-
104100
#local_pending_count = 0;
105101
#pending_count = 0;
106102
#pending_count_update_queued = false;
107103

108-
#is_creating_fallback = false;
109-
110104
/** @type {Set<Effect>} */
111105
#dirty_effects = new Set();
112106

@@ -142,51 +136,31 @@ export class Boundary {
142136
constructor(node, props, children) {
143137
this.#anchor = node;
144138
this.#props = props;
145-
this.#children = children;
146139

147-
this.parent = /** @type {Effect} */ (active_effect).b;
140+
this.#children = (anchor) => {
141+
var effect = /** @type {Effect} */ (active_effect);
148142

149-
this.is_pending = !!this.#props.pending;
143+
effect.b = this;
144+
effect.f |= BOUNDARY_EFFECT;
150145

151-
this.#effect = block(() => {
152-
/** @type {Effect} */ (active_effect).b = this;
146+
children(anchor);
147+
};
148+
149+
this.parent = /** @type {Effect} */ (active_effect).b;
153150

151+
this.#effect = block(() => {
154152
if (hydrating) {
155-
const comment = this.#hydrate_open;
153+
const comment = /** @type {Comment} */ (this.#hydrate_open);
156154
hydrate_next();
157155

158-
const server_rendered_pending =
159-
/** @type {Comment} */ (comment).nodeType === COMMENT_NODE &&
160-
/** @type {Comment} */ (comment).data === HYDRATION_START_ELSE;
161-
162-
if (server_rendered_pending) {
156+
if (comment.data === HYDRATION_START_ELSE) {
163157
this.#hydrate_pending_content();
164158
} else {
165159
this.#hydrate_resolved_content();
166-
167-
if (this.#pending_count === 0) {
168-
this.is_pending = false;
169-
}
170160
}
171161
} else {
172-
var anchor = this.#get_anchor();
173-
174-
try {
175-
this.#main_effect = branch(() => children(anchor));
176-
} catch (error) {
177-
this.error(error);
178-
}
179-
180-
if (this.#pending_count > 0) {
181-
this.#show_pending_snippet();
182-
} else {
183-
this.is_pending = false;
184-
}
162+
this.#render();
185163
}
186-
187-
return () => {
188-
this.#pending_anchor?.remove();
189-
};
190164
}, flags);
191165

192166
if (hydrating) {
@@ -206,19 +180,24 @@ export class Boundary {
206180
const pending = this.#props.pending;
207181
if (!pending) return;
208182

183+
this.is_pending = true;
209184
this.#pending_effect = branch(() => pending(this.#anchor));
210185

211186
queue_micro_task(() => {
212-
var anchor = this.#get_anchor();
187+
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
188+
var anchor = create_text();
189+
190+
fragment.append(anchor);
213191

214192
this.#main_effect = this.#run(() => {
215193
Batch.ensure();
216194
return branch(() => this.#children(anchor));
217195
});
218196

219-
if (this.#pending_count > 0) {
220-
this.#show_pending_snippet();
221-
} else {
197+
if (this.#pending_count === 0) {
198+
this.#anchor.before(fragment);
199+
this.#offscreen_fragment = null;
200+
222201
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
223202
this.#pending_effect = null;
224203
});
@@ -228,17 +207,28 @@ export class Boundary {
228207
});
229208
}
230209

231-
#get_anchor() {
232-
var anchor = this.#anchor;
210+
#render() {
211+
try {
212+
this.is_pending = this.has_pending_snippet();
213+
this.#pending_count = 0;
214+
this.#local_pending_count = 0;
215+
216+
this.#main_effect = branch(() => {
217+
this.#children(this.#anchor);
218+
});
233219

234-
if (this.is_pending) {
235-
this.#pending_anchor = create_text();
236-
this.#anchor.before(this.#pending_anchor);
220+
if (this.#pending_count > 0) {
221+
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
222+
move_effect(this.#main_effect, fragment);
237223

238-
anchor = this.#pending_anchor;
224+
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
225+
this.#pending_effect = branch(() => pending(this.#anchor));
226+
} else {
227+
this.is_pending = false;
228+
}
229+
} catch (error) {
230+
this.error(error);
239231
}
240-
241-
return anchor;
242232
}
243233

244234
/**
@@ -262,7 +252,8 @@ export class Boundary {
262252
}
263253

264254
/**
265-
* @param {() => Effect | null} fn
255+
* @template T
256+
* @param {() => T} fn
266257
*/
267258
#run(fn) {
268259
var previous_effect = active_effect;
@@ -285,20 +276,6 @@ export class Boundary {
285276
}
286277
}
287278

288-
#show_pending_snippet() {
289-
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
290-
291-
if (this.#main_effect !== null) {
292-
this.#offscreen_fragment = document.createDocumentFragment();
293-
this.#offscreen_fragment.append(/** @type {TemplateNode} */ (this.#pending_anchor));
294-
move_effect(this.#main_effect, this.#offscreen_fragment);
295-
}
296-
297-
if (this.#pending_effect === null) {
298-
this.#pending_effect = branch(() => pending(this.#anchor));
299-
}
300-
}
301-
302279
/**
303280
* Updates the pending count associated with the currently visible pending snippet,
304281
* if any, such that we can replace the snippet with content once work is done
@@ -383,7 +360,7 @@ export class Boundary {
383360

384361
// If we have nothing to capture the error, or if we hit an error while
385362
// rendering the fallback, re-throw for another boundary to handle
386-
if (this.#is_creating_fallback || (!onerror && !failed)) {
363+
if (!onerror && !failed) {
387364
throw error;
388365
}
389366

@@ -423,31 +400,18 @@ export class Boundary {
423400
e.svelte_boundary_reset_onerror();
424401
}
425402

426-
// If the failure happened while flushing effects, current_batch can be null
427-
Batch.ensure();
428-
429-
this.#local_pending_count = 0;
430-
431403
if (this.#failed_effect !== null) {
432404
pause_effect(this.#failed_effect, () => {
433405
this.#failed_effect = null;
434406
});
435407
}
436408

437-
// we intentionally do not try to find the nearest pending boundary. If this boundary has one, we'll render it on reset
438-
// but it would be really weird to show the parent's boundary on a child reset.
439-
this.is_pending = this.has_pending_snippet();
409+
this.#run(() => {
410+
// If the failure happened while flushing effects, current_batch can be null
411+
Batch.ensure();
440412

441-
this.#main_effect = this.#run(() => {
442-
this.#is_creating_fallback = false;
443-
return branch(() => this.#children(this.#anchor));
413+
this.#render();
444414
});
445-
446-
if (this.#pending_count > 0) {
447-
this.#show_pending_snippet();
448-
} else {
449-
this.is_pending = false;
450-
}
451415
};
452416

453417
queue_micro_task(() => {
@@ -462,10 +426,16 @@ export class Boundary {
462426
if (failed) {
463427
this.#failed_effect = this.#run(() => {
464428
Batch.ensure();
465-
this.#is_creating_fallback = true;
466429

467430
try {
468431
return branch(() => {
432+
// errors in `failed` snippets cause the boundary to error again
433+
// TODO Svelte 6: revisit this decision, most likely better to go to parent boundary instead
434+
var effect = /** @type {Effect} */ (active_effect);
435+
436+
effect.b = this;
437+
effect.f |= BOUNDARY_EFFECT;
438+
469439
failed(
470440
this.#anchor,
471441
() => error,
@@ -475,8 +445,6 @@ export class Boundary {
475445
} catch (error) {
476446
invoke_error_boundary(error, /** @type {Effect} */ (this.#effect.parent));
477447
return null;
478-
} finally {
479-
this.#is_creating_fallback = false;
480448
}
481449
});
482450
}

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -293,16 +293,19 @@ export class Batch {
293293
}
294294
}
295295

296-
var parent = effect.parent;
297-
effect = effect.next;
298-
299-
while (effect === null && parent !== null) {
300-
if (parent === pending_boundary) {
296+
while (effect !== null) {
297+
if (effect === pending_boundary) {
301298
pending_boundary = null;
302299
}
303300

304-
effect = parent.next;
305-
parent = parent.parent;
301+
var next = effect.next;
302+
303+
if (next !== null) {
304+
effect = next;
305+
break;
306+
}
307+
308+
effect = effect.parent;
306309
}
307310
}
308311
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
html: `
6+
<button>increment</button>
7+
<button>shift</button>
8+
<p>0</p>
9+
`,
10+
11+
async test({ assert, target }) {
12+
const [increment, shift] = target.querySelectorAll('button');
13+
14+
increment.click();
15+
await tick();
16+
17+
assert.htmlEqual(
18+
target.innerHTML,
19+
`
20+
<button>increment</button>
21+
<button>shift</button>
22+
<p>1</p>
23+
`
24+
);
25+
26+
shift.click();
27+
await tick();
28+
29+
assert.htmlEqual(
30+
target.innerHTML,
31+
`
32+
<button>increment</button>
33+
<button>shift</button>
34+
<p>resolved</p>
35+
`
36+
);
37+
}
38+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script>
2+
let resolvers = [];
3+
4+
function push(value) {
5+
const deferred = Promise.withResolvers();
6+
resolvers.push(() => deferred.resolve(value));
7+
return deferred.promise;
8+
}
9+
10+
function shift() {
11+
resolvers.shift()?.();
12+
}
13+
14+
let count = $state(0);
15+
</script>
16+
17+
<button onclick={() => count += 1}>
18+
increment
19+
</button>
20+
21+
<button onclick={shift}>
22+
shift
23+
</button>
24+
25+
<svelte:boundary>
26+
<p>{await push('resolved')}</p>
27+
28+
{#snippet pending()}
29+
<p>{count}</p>
30+
{/snippet}
31+
</svelte:boundary>

0 commit comments

Comments
 (0)