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()}