From 342d8568f118e91375a452991335c9a89af8afee Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:52:23 +0100 Subject: [PATCH 1/3] fix: re-process batch if new root effects were scheduled (#17895) In some cases a new branch might create effects which via reading/writing reschedule an effect, causing `this.#roots` to become populated again. In this case we need to re-process the batch. Most of the time this will just result in a cleanup of the dirtied branches since other work is already handled via running the effects etc. - it's still crucial, else the reactive graph becomes frozen since no new root effects are scheduled. Fixes #17891 --------- Co-authored-by: Rich Harris --- .changeset/green-regions-write.md | 5 ++ .../src/internal/client/reactivity/batch.js | 8 ++++ .../new-branch-reschedule-2/_config.js | 46 +++++++++++++++++++ .../new-branch-reschedule-2/main.svelte | 37 +++++++++++++++ .../samples/new-branch-reschedule/_config.js | 44 ++++++++++++++++++ .../samples/new-branch-reschedule/main.svelte | 31 +++++++++++++ 6 files changed, 171 insertions(+) create mode 100644 .changeset/green-regions-write.md create mode 100644 packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/main.svelte diff --git a/.changeset/green-regions-write.md b/.changeset/green-regions-write.md new file mode 100644 index 0000000000..678e668b50 --- /dev/null +++ b/.changeset/green-regions-write.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: re-process batch if new root effects were scheduled diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index fc4c70c6b8..70bfbc0b47 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -271,6 +271,14 @@ export class Batch { var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); + // Edge case: During traversal new branches might create effects that run immediately and set state, + // causing an effect and therefore a root to be scheduled again. We need to traverse the current batch + // once more in that case - most of the time this will just clean up dirty branches. + if (this.#roots.length > 0) { + const batch = (next_batch ??= this); + batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); + } + if (next_batch !== null) { batches.add(next_batch); diff --git a/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/_config.js b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/_config.js new file mode 100644 index 0000000000..0d319b7274 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/_config.js @@ -0,0 +1,46 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [open, close, increment] = target.querySelectorAll('button'); + + open.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
open (width: 42)
+ ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
open (width: 42)
+ ` + ); + + close.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
closed
+ ` + ); + + assert.deepEqual(logs, ['effect ran']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/main.svelte new file mode 100644 index 0000000000..38a88a0bca --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule-2/main.svelte @@ -0,0 +1,37 @@ + + + + + + + + +
+ {#if store.active} + open (width: {store.panelWidth}) + {:else} + closed + {/if} +
+ \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/_config.js b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/_config.js new file mode 100644 index 0000000000..5ebc278f0b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/_config.js @@ -0,0 +1,44 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [open, close, increment] = target.querySelectorAll('button'); + + open.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
open (width: 42)
+ ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
open (width: 42)
+ ` + ); + + close.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +
closed
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/main.svelte b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/main.svelte new file mode 100644 index 0000000000..55b5baf62c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/new-branch-reschedule/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + +
+ {#if store.active} + open (width: {store.panelWidth}) + {:else} + closed + {/if} +
\ No newline at end of file From 72cd247c33ed5a2c9f0f952979336a3996617e1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:59:13 +0100 Subject: [PATCH 2/3] Version Packages (#17896) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## svelte@5.53.10 ### Patch Changes - fix: re-process batch if new root effects were scheduled ([#17895](https://github.com/sveltejs/svelte/pull/17895)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/green-regions-write.md | 5 ----- packages/svelte/CHANGELOG.md | 6 ++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/green-regions-write.md diff --git a/.changeset/green-regions-write.md b/.changeset/green-regions-write.md deleted file mode 100644 index 678e668b50..0000000000 --- a/.changeset/green-regions-write.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: re-process batch if new root effects were scheduled diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index f09032294a..35a0b2df3f 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte +## 5.53.10 + +### Patch Changes + +- fix: re-process batch if new root effects were scheduled ([#17895](https://github.com/sveltejs/svelte/pull/17895)) + ## 5.53.9 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 17fc4335d5..bd55811602 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.53.9", + "version": "5.53.10", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index e481799ad5..13f68c6467 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.53.9'; +export const VERSION = '5.53.10'; export const PUBLIC_VERSION = '5'; From 0e8f49b25fbb11bf0bf9378b9423feeb0176f316 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 10 Mar 2026 19:23:32 -0400 Subject: [PATCH 3/3] chore: rebase batches after process, not during (#17900) This is part of me trying to figure out #17162. It feels less confusing to rebase other branches after the current batch has been processed, rather than sort of doing it in the middle (which is an artifact of historical constraints that no longer apply). No test because it doesn't change any user-observable behaviour (but I added a changeset just in case) ### 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. - [ ] 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` --- .changeset/upset-parts-throw.md | 5 + .../src/internal/client/reactivity/batch.js | 112 ++++++++---------- 2 files changed, 57 insertions(+), 60 deletions(-) create mode 100644 .changeset/upset-parts-throw.md diff --git a/.changeset/upset-parts-throw.md b/.changeset/upset-parts-throw.md new file mode 100644 index 0000000000..5835fd48e0 --- /dev/null +++ b/.changeset/upset-parts-throw.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: rebase batches after process, not during diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 70bfbc0b47..cb115994f3 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -249,6 +249,10 @@ export class Batch { reset_branch(e, t); } } else { + if (this.#pending === 0) { + batches.delete(this); + } + // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. this.#dirty_effects.clear(); this.#maybe_dirty_effects.clear(); @@ -262,10 +266,6 @@ export class Batch { flush_queued_effects(effects); previous_batch = null; - if (this.#pending === 0) { - this.#commit(); - } - this.#deferred?.resolve(); } @@ -290,6 +290,10 @@ export class Batch { next_batch.#process(); } + + if (!batches.has(this)) { + this.#commit(); + } } /** @@ -433,74 +437,59 @@ export class Batch { // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more // recent value for a given source - if (batches.size > 1) { - this.previous.clear(); - - var previous_batch = current_batch; - var previous_batch_values = batch_values; - var is_earlier = true; - - for (const batch of batches) { - if (batch === this) { - is_earlier = false; - continue; + for (const batch of batches) { + var is_earlier = batch.id < this.id; + + /** @type {Source[]} */ + var sources = []; + + for (const [source, value] of this.current) { + if (batch.current.has(source)) { + if (is_earlier && value !== batch.current.get(source)) { + // bring the value up to date + batch.current.set(source, value); + } else { + // same value or later batch has more recent value, + // no need to re-run these effects + continue; + } } - /** @type {Source[]} */ - const sources = []; - - for (const [source, value] of this.current) { - if (batch.current.has(source)) { - if (is_earlier && value !== batch.current.get(source)) { - // bring the value up to date - batch.current.set(source, value); - } else { - // same value or later batch has more recent value, - // no need to re-run these effects - continue; - } - } + sources.push(source); + } - sources.push(source); - } + if (sources.length === 0) { + continue; + } - if (sources.length === 0) { - continue; - } + // Re-run async/block effects that depend on distinct values changed in both batches + var others = [...batch.current.keys()].filter((s) => !this.current.has(s)); + if (others.length > 0) { + batch.activate(); - // Re-run async/block effects that depend on distinct values changed in both batches - const others = [...batch.current.keys()].filter((s) => !this.current.has(s)); - if (others.length > 0) { - batch.activate(); - - /** @type {Set} */ - const marked = new Set(); - /** @type {Map} */ - const checked = new Map(); - for (const source of sources) { - mark_effects(source, others, marked, checked); - } + /** @type {Set} */ + var marked = new Set(); - if (batch.#roots.length > 0) { - batch.apply(); + /** @type {Map} */ + var checked = new Map(); - for (const root of batch.#roots) { - batch.#traverse(root, [], []); - } + for (var source of sources) { + mark_effects(source, others, marked, checked); + } - // TODO do we need to do anything with the dummy effect arrays? + if (batch.#roots.length > 0) { + batch.apply(); + + for (var root of batch.#roots) { + batch.#traverse(root, [], []); } - batch.deactivate(); + // TODO do we need to do anything with the dummy effect arrays? } - } - current_batch = previous_batch; - batch_values = previous_batch_values; + batch.deactivate(); + } } - - this.#skipped_branches.clear(); - batches.delete(this); } /** @@ -567,7 +556,10 @@ export class Batch { } apply() { - if (!async_mode_flag || (!this.is_fork && batches.size === 1)) return; + if (!async_mode_flag || (!this.is_fork && batches.size === 1)) { + batch_values = null; + return; + } // if there are multiple batches, we are 'time travelling' — // we need to override values with the ones in this batch...