From d654db83ef3c299e0b7833e6c418ce02d6127abd Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 20 May 2026 03:04:31 +0200 Subject: [PATCH] fix: avoid false-positive batch invariant error (#18246) Thanks to https://github.com/sveltejs/svelte/issues/17940#issuecomment-4480016550 I was finally able to isolate and reproduce a false-positive invariant error. I had a hunch this could happen and this shows it. Essentially, you can end up in situations where two batches are scheduled to run in the same microtask queue flush, and if the first rebases the second the invariant will throw, which is wrong. We can avoid this by checking if a decrement is queued. --- .changeset/slimy-frogs-share.md | 5 +++++ .../src/internal/client/reactivity/batch.js | 7 +++++-- .../samples/async-pending-batch/_config.js | 16 ++++++++++++++++ .../samples/async-pending-batch/main.svelte | 8 ++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 .changeset/slimy-frogs-share.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte diff --git a/.changeset/slimy-frogs-share.md b/.changeset/slimy-frogs-share.md new file mode 100644 index 0000000000..27a393d564 --- /dev/null +++ b/.changeset/slimy-frogs-share.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid false-positive batch invariant error diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a5c9a51eec..ae4f5a6dac 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -679,7 +679,9 @@ export class Batch { batch.discard(); } } else if (sources.length > 0) { - if (DEV) { + // The microtask queue can contain the batch already scheduled to run right + // after this one is finished, so throwing the invariant would be wrong here. + if (DEV && !batch.#decrement_queued) { invariant(batch.#roots.length === 0, 'Batch has scheduled roots'); } @@ -732,7 +734,8 @@ export class Batch { } // Only apply and traverse when we know we triggered async work with marking the effects - if (batch.#roots.length > 0) { + // and know this won't run anyway right afterwards + if (batch.#roots.length > 0 && !batch.#decrement_queued) { batch.apply(); for (var root of batch.#roots) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js new file mode 100644 index 0000000000..e7359efa2d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // This test mainly checks that we don't run into the 'Batch has scheduled roots' invariant wrongly. + // It is crafted such that two batches are scheduled to run in the same microtask, and the first + // tries to rebase the second. + async test({ assert, target }) { + await tick(); + const [run] = target.querySelectorAll('button'); + + run.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' none none 0'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte new file mode 100644 index 0000000000..19305cfb76 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte @@ -0,0 +1,8 @@ + + + + +{selectedId ?? "none"} {selectedOption ?? "none"} {$effect.pending()}