From 98e8b635fab10be1d82c593097276af373300a55 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 14 Mar 2026 17:43:04 -0400 Subject: [PATCH] fix: discard batches made obsolete by commit (#17934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batches that are made stale (because of a `STALE_REACTION`) can end up sticking around indefinitely, forcing every subsequent batch into time-traveling mode and causing incorrect `previous` values to be rendered. This partially fixes it, by discarding any older batches that are subsets of a batch currently being committed. It's not a complete fix, though — if an earlier batch is stale but is _not_ a subset of the committed batch, it becomes a zombie, and its changes will never be applied. Haven't quite figured out how to think about that yet. ### 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. - [x] 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/modern-towns-call.md | 5 ++ .../src/internal/client/reactivity/batch.js | 15 ++-- .../async-discard-obsolete-batch/_config.js | 89 +++++++++++++++++++ .../async-discard-obsolete-batch/main.svelte | 36 ++++++++ 4 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 .changeset/modern-towns-call.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte diff --git a/.changeset/modern-towns-call.md b/.changeset/modern-towns-call.md new file mode 100644 index 0000000000..77ca8e9185 --- /dev/null +++ b/.changeset/modern-towns-call.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: discard batches made obsolete by commit diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 3a4872b0b6..b100d559d2 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -438,6 +438,8 @@ export class Batch { discard() { for (const fn of this.#discard_callbacks) fn(this); this.#discard_callbacks.clear(); + + batches.delete(this); } #commit() { @@ -466,13 +468,15 @@ export class Batch { sources.push(source); } - 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) { + + if (others.length === 0) { + if (is_earlier) { + // this batch is now obsolete and can be discarded + batch.discard(); + } + } else if (sources.length > 0) { if (DEV) { invariant(batch.#roots.length === 0, 'Batch has scheduled roots'); } @@ -1095,7 +1099,6 @@ export function fork(fn) { } if (!committed && batches.has(batch)) { - batches.delete(batch); batch.discard(); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js new file mode 100644 index 0000000000..64e1a4b2b5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js @@ -0,0 +1,89 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, shift, pop] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

1 = 1

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

1 = 1

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

1 = 1

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

3 = 3

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

3 = 3

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

5 = 5

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte new file mode 100644 index 0000000000..faa8d139a6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte @@ -0,0 +1,36 @@ + + + + + + + +

{n} = {await push(n)}