Skip to content

Commit 1b3e660

Browse files
authored
fix: prevent flushed effects from running again (#17787)
We never cleared the list of (maybe)dirty_effects on the assumption that once a batch has run them it's complete. But that's not the case when a boundary has a pending snippet, in which case the pending snippet shows up, so `blocking_pending` is already 0 and effects are flushed. That can lead to effects being run unnecessarily, even leading to infinite loops. So we clear them. This is safe because any additional effects would either be scheduled by the boundary (which keeps track of the offscreen effects created while the pending snippet is shown, and schedules them once the pending snippet goes away) or by unskipping skipped branches (which reschedules the effects inside it) Fixes #17717 After creating the test I noticed it fails when run together with other tests, but not alone, which lead me to discover that we're missing an `unset_context`. I also added clearing of `#skipped_branches` just to be safe.
1 parent f6e8b1d commit 1b3e660

5 files changed

Lines changed: 145 additions & 2 deletions

File tree

.changeset/warm-trams-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: prevent flushed effects from running again

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ export function run(thunks) {
248248

249249
promise.finally(() => {
250250
blocker.settled = true;
251+
unset_context();
251252
});
252253

253254
for (const fn of thunks.slice(1)) {

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/** @import { Fork } from 'svelte' */
22
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
3-
/** @import { Boundary } from '../dom/blocks/boundary' */
43
import {
54
BLOCK_EFFECT,
65
BRANCH_EFFECT,
@@ -14,7 +13,6 @@ import {
1413
ROOT_EFFECT,
1514
MAYBE_DIRTY,
1615
DERIVED,
17-
BOUNDARY_EFFECT,
1816
EAGER_EFFECT,
1917
HEAD_EFFECT,
2018
ERROR_VALUE,
@@ -225,6 +223,10 @@ export class Batch {
225223
flush_queued_effects(render_effects);
226224
flush_queued_effects(effects);
227225

226+
// Clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
227+
this.#dirty_effects.clear();
228+
this.#maybe_dirty_effects.clear();
229+
228230
previous_batch = null;
229231

230232
this.#deferred?.resolve();
@@ -423,6 +425,7 @@ export class Batch {
423425
batch_values = previous_batch_values;
424426
}
425427

428+
this.#skipped_branches.clear();
426429
batches.delete(this);
427430
}
428431

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
html: `
6+
<button>a</button>
7+
<button>b</button>
8+
<button>resolve a</button>
9+
<button>resolve b</button>
10+
<p>pending a</p>
11+
`,
12+
async test({ assert, target }) {
13+
const [a, b, resolve_a, resolve_b] = target.querySelectorAll('button');
14+
15+
resolve_a.click();
16+
await tick();
17+
assert.htmlEqual(
18+
target.innerHTML,
19+
`
20+
<button>a</button>
21+
<button>b</button>
22+
<button>resolve a</button>
23+
<button>resolve b</button>
24+
<p>page a</p>
25+
`
26+
);
27+
28+
b.click();
29+
await tick();
30+
assert.htmlEqual(
31+
target.innerHTML,
32+
`
33+
<button>a</button>
34+
<button>b</button>
35+
<button>resolve a</button>
36+
<button>resolve b</button>
37+
<p>pending b</p>
38+
`
39+
);
40+
41+
a.click();
42+
await tick();
43+
assert.htmlEqual(
44+
target.innerHTML,
45+
`
46+
<button>a</button>
47+
<button>b</button>
48+
<button>resolve a</button>
49+
<button>resolve b</button>
50+
<p>pending a</p>
51+
`
52+
);
53+
54+
resolve_b.click();
55+
await tick();
56+
assert.htmlEqual(
57+
target.innerHTML,
58+
`
59+
<button>a</button>
60+
<button>b</button>
61+
<button>resolve a</button>
62+
<button>resolve b</button>
63+
<p>pending a</p>
64+
`
65+
);
66+
67+
resolve_a.click();
68+
await tick();
69+
assert.htmlEqual(
70+
target.innerHTML,
71+
`
72+
<button>a</button>
73+
<button>b</button>
74+
<button>resolve a</button>
75+
<button>resolve b</button>
76+
<p>page a</p>
77+
`
78+
);
79+
80+
await new Promise((r) => setTimeout(r, 100));
81+
}
82+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<script>
2+
let page = $state('a');
3+
4+
/** @type {Array<() => void>} */
5+
const a = [];
6+
/** @type {Array<() => void>} */
7+
const b = [];
8+
9+
function gate(next) {
10+
const deferred = Promise.withResolvers();
11+
12+
if (next === 'a') a.push(deferred.resolve);
13+
else b.push(deferred.resolve);
14+
15+
return deferred.promise;
16+
}
17+
18+
function nav(next) {
19+
page = next;
20+
}
21+
22+
const to_render = $derived(page === 'a' ? snippet_a : snippet_b);
23+
</script>
24+
25+
<button onclick={() => nav('a')}>a</button>
26+
<button onclick={() => nav('b')}>b</button>
27+
<button onclick={() => a.shift()?.()}>resolve a</button>
28+
<button onclick={() => b.shift()?.()}>resolve b</button>
29+
30+
{#snippet snippet_a()}
31+
<svelte:boundary>
32+
{@const _a = await gate('a')}
33+
<p>page a</p>
34+
35+
{#snippet pending()}
36+
<p>pending a</p>
37+
{/snippet}
38+
</svelte:boundary>
39+
{/snippet}
40+
41+
{#snippet snippet_b()}
42+
<svelte:boundary>
43+
{@const _b = await gate('b')}
44+
<p>page b</p>
45+
46+
{#snippet pending()}
47+
<p>pending b</p>
48+
{/snippet}
49+
</svelte:boundary>
50+
{/snippet}
51+
52+
{@render to_render()}

0 commit comments

Comments
 (0)