From 1b3e6605190ab8ca21292ddd3ccf2a71387d79fe Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:02:55 +0100 Subject: [PATCH] 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. --- .changeset/warm-trams-smash.md | 5 ++ .../src/internal/client/reactivity/async.js | 1 + .../src/internal/client/reactivity/batch.js | 7 +- .../async-boundary-nav-race/_config.js | 82 +++++++++++++++++++ .../async-boundary-nav-race/main.svelte | 52 ++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 .changeset/warm-trams-smash.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-nav-race/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-nav-race/main.svelte diff --git a/.changeset/warm-trams-smash.md b/.changeset/warm-trams-smash.md new file mode 100644 index 0000000000..b9053e2617 --- /dev/null +++ b/.changeset/warm-trams-smash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent flushed effects from running again diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 31408ee8f9..f2643e0c34 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -248,6 +248,7 @@ export function run(thunks) { promise.finally(() => { blocker.settled = true; + unset_context(); }); for (const fn of thunks.slice(1)) { diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 3832438f47..edaac0c37c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -1,6 +1,5 @@ /** @import { Fork } from 'svelte' */ /** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ -/** @import { Boundary } from '../dom/blocks/boundary' */ import { BLOCK_EFFECT, BRANCH_EFFECT, @@ -14,7 +13,6 @@ import { ROOT_EFFECT, MAYBE_DIRTY, DERIVED, - BOUNDARY_EFFECT, EAGER_EFFECT, HEAD_EFFECT, ERROR_VALUE, @@ -225,6 +223,10 @@ export class Batch { flush_queued_effects(render_effects); flush_queued_effects(effects); + // Clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. + this.#dirty_effects.clear(); + this.#maybe_dirty_effects.clear(); + previous_batch = null; this.#deferred?.resolve(); @@ -423,6 +425,7 @@ export class Batch { batch_values = previous_batch_values; } + this.#skipped_branches.clear(); batches.delete(this); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-nav-race/_config.js b/packages/svelte/tests/runtime-runes/samples/async-boundary-nav-race/_config.js new file mode 100644 index 0000000000..940299618e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-nav-race/_config.js @@ -0,0 +1,82 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + + + + +

pending a

+ `, + async test({ assert, target }) { + const [a, b, resolve_a, resolve_b] = target.querySelectorAll('button'); + + resolve_a.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +

page a

+ ` + ); + + b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +

pending b

+ ` + ); + + a.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +

pending a

+ ` + ); + + resolve_b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +

pending a

+ ` + ); + + resolve_a.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +

page a

+ ` + ); + + await new Promise((r) => setTimeout(r, 100)); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-nav-race/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-nav-race/main.svelte new file mode 100644 index 0000000000..a6554f5aa6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-nav-race/main.svelte @@ -0,0 +1,52 @@ + + + + + + + +{#snippet snippet_a()} + + {@const _a = await gate('a')} +

page a

+ + {#snippet pending()} +

pending a

+ {/snippet} +
+{/snippet} + +{#snippet snippet_b()} + + {@const _b = await gate('b')} +

page b

+ + {#snippet pending()} +

pending b

+ {/snippet} +
+{/snippet} + +{@render to_render()}