From 65283bc13a54c41ee96940b559c1a027b8770882 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 20 May 2026 23:49:38 +0200 Subject: [PATCH] fix: transfer effects when merging batches (#18254) An effect could be gated behind a branch. If we don't defer + transfer them upon merge, the branch would still be marked clean but the effect behind it is dirty but no longer reachable. It's not reachable via mark either because that one only concerns itself with block/async effects, and the branch gating the effect is not guaranteed to be touched by that. Fixes #18249 Little sad side-effect: Since we cannot reliably know _before_ traversal if we have no blocking pending work left (the traversal could mark an if block falsy which contains the last blocker), we gotta undo a performance optimization. --- .changeset/evil-stars-wave.md | 5 +++ .../src/internal/client/reactivity/batch.js | 31 ++++++++++++------- .../async-batch-merge-effect/_config.js | 25 +++++++++++++++ .../async-batch-merge-effect/main.svelte | 25 +++++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 .changeset/evil-stars-wave.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte diff --git a/.changeset/evil-stars-wave.md b/.changeset/evil-stars-wave.md new file mode 100644 index 0000000000..b199afe1dd --- /dev/null +++ b/.changeset/evil-stars-wave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: transfer effects when merging batches diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ae4f5a6dac..2803ffc84c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -289,19 +289,19 @@ export class Batch { } } - // we only reschedule previously-deferred effects if we expect - // to be able to run them after processing the batch - if (!this.#is_deferred()) { - for (const e of this.#dirty_effects) { - this.#maybe_dirty_effects.delete(e); - set_signal_status(e, DIRTY); - this.schedule(e); - } + // We always reschedule previously-deferred effects, not just when + // #is_deferred() is true, because traversing the tree could make + // an if block that contains the last blocking pending effect falsy, + // causing the block to no longer be deferred. + for (const e of this.#dirty_effects) { + this.#maybe_dirty_effects.delete(e); + set_signal_status(e, DIRTY); + this.schedule(e); + } - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); - this.schedule(e); - } + for (const e of this.#maybe_dirty_effects) { + set_signal_status(e, MAYBE_DIRTY); + this.schedule(e); } const roots = this.#roots; @@ -362,6 +362,10 @@ export class Batch { const earlier_batch = this.#find_earlier_batch(); if (earlier_batch) { + // If this batch collected deferred effects during traversal, they still need + // to run after being merged into the earlier batch. + this.#defer_effects(render_effects); + this.#defer_effects(effects); earlier_batch.#merge(this); return; } @@ -503,6 +507,9 @@ export class Batch { if (d) deferred.promise.then(d.resolve); } + // Mark is not guaranteed not touch these, so we transfer them + this.transfer_effects(batch.#dirty_effects, batch.#maybe_dirty_effects); + /** * mark all effects that depend on `batch.current`, except the * async effects that we just resolved (TODO unless they depend diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js new file mode 100644 index 0000000000..c2be623de2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [x, x_y, pop] = target.querySelectorAll('button'); + + x.click(); + await tick(); + x_y.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ' 2 1 1' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte new file mode 100644 index 0000000000..61efd4fca0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte @@ -0,0 +1,25 @@ + + + + + + +{await push(x)} {await push(y)} + +{#if true} + {y} +{/if} +