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}
+