Repository: sveltejs/svelte (2025-08-25T16:49:55Z)

Issue:
TypeError: Can't access property "increment", batch is null
### Describe the bug

In specific cases, involving async ~and remote functions~ (see reproduction), Svelte throws an error:

```
TypeError: Can't access property "increment", batch is null
```

### Reproduction

~https://stackblitz.com/edit/svelte-await-null-batch-repro?file=src%2Froutes%2F%2Bpage.svelte~

~Open the preview in new tab, so that you can see console errors. Click the „OK” button in any row, then click „Verify next one”.~

Found a simpler reproduction that doesn't involve SvelteKit: https://svelte.dev/playground/bc31c16761ea4fabbaf8634c62d0698f?version=5.38.0.

### Logs

```shell
Uncaught (in promise) TypeError: can't access property "increment", batch is null
    async_derived deriveds.js:139
    update_reaction runtime.js:287
    update_effect runtime.js:467
    create_effect effects.js:125
    async_effect effects.js:338
    async_derived deriveds.js:118
    $$body Form.svelte:11
    Form Form.svelte:94
    effect2 hmr.js:47
    update_reaction runtime.js:287
    update_effect runtime.js:467
    create_effect effects.js:125
    branch effects.js:377
    wrapper hmr.js:38
    update_reaction runtime.js:287
    update_effect runtime.js:467
    create_effect effects.js:125
    block effects.js:365
    wrapper hmr.js:28
    alternate +page.svelte:32
    add_svelte_meta context.js:49
    alternate +page.svelte:32
    alternate_effect if.js:115
    update_reaction runtime.js:287
    update_effect runtime.js:467
    create_effect effects.js:125
    branch effects.js:377
    update_branch if.js:115
    set_branch if.js:48
    _page +page.svelte:7
    if_block if.js:140
    update_reaction runtime.js:287
    update_effect runtime.js:467
    #traverse_effect_tree batch.js:305
    process batch.js:212
    flush_effects batch.js:561
    flush batch.js:381
    ensure batch.js:460
    dequeue batch.js:69
    enqueue batch.js:471
    ensure batch.js:454
    internal_set sources.js:182
    set sources.js:161
    on_click +page.svelte:9
    handle_event_propagation events.js:263
```

### System Info

```shell
(StackBlitz container)

System:
  OS: Linux 5.0 undefined
  CPU: (8) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
  Memory: 0 Bytes / 0 Bytes
  Shell: 1.0 - /bin/jsh
Binaries:
  Node: 20.19.1 - /usr/local/bin/node
  Yarn: 1.22.19 - /usr/local/bin/yarn
  npm: 10.8.2 - /usr/local/bin/npm
  pnpm: 8.15.6 - /usr/local/bin/pnpm
npmPackages:
  svelte: ^5.38 => 5.38.0
```

### Severity

blocking all usage of svelte


Discussion:
Here's a simpler reproduction, that I've originally used for #16597. Go to https://stackblitz.com/edit/svelte-waterfall-warning-repro?file=src%2Froutes%2F%2Bpage.svelte, add another `<br>` and save. The HMR update will trigger the same „batch is null” error.
This is duplicate of this here https://github.com/sveltejs/kit/issues/14124#issue-3296947221

If you got multiple parent await calls, wrap that into a boundary, and child also wrap it in a boundary. what the async feature trying to do now is trying to batch everything, so you have to independently wrap them into it's own boundaries, that's just my experience in using it right now and avoid this batching is null issue.
Thank you @directormac, thanks to the additional info I was able to make a super simple reproduction that doesn't even involve SvelteKit: https://svelte.dev/playground/bc31c16761ea4fabbaf8634c62d0698f?version=5.38.0.



Patch:
diff --git a/.changeset/silent-pigs-relax.md b/.changeset/silent-pigs-relax.md
new file mode 100644
index 000000000000..5acf185ffedf
--- /dev/null
+++ b/.changeset/silent-pigs-relax.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: restore batch along with effect context
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 1ea1bbe56160..65d004137fcb 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -73,11 +73,13 @@ function capture() {
 	var previous_effect = active_effect;
 	var previous_reaction = active_reaction;
 	var previous_component_context = component_context;
+	var previous_batch = current_batch;
 
 	return function restore() {
 		set_active_effect(previous_effect);
 		set_active_reaction(previous_reaction);
 		set_component_context(previous_component_context);
+		previous_batch?.activate();
 
 		if (DEV) {
 			set_from_async_derived(null);
@@ -176,8 +178,8 @@ export function unset_context() {
  * @param {() => Promise<void>} fn
  */
 export async function async_body(fn) {
-	const unsuspend = suspend();
-	const active = /** @type {Effect} */ (active_effect);
+	var unsuspend = suspend();
+	var active = /** @type {Effect} */ (active_effect);
 
 	try {
 		await fn();
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 60fa03c56cd2..2c60fc8313b6 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -76,8 +76,8 @@ let queued_root_effects = [];
 let last_scheduled_effect = null;
 
 let is_flushing = false;
-
 let is_flushing_sync = false;
+
 export class Batch {
 	/**
 	 * The current values of any sources that are updated in this batch
@@ -678,6 +678,8 @@ export function suspend() {
 		if (!pending) {
 			batch.activate();
 			batch.decrement();
+		} else {
+			batch.deactivate();
 		}
 
 		unset_context();


Test patch:
diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Bar.svelte b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Bar.svelte
new file mode 100644
index 000000000000..f1ac9ab760cf
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Bar.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+	import { resolve } from './main.svelte';
+
+	const bar = await new Promise((r) => resolve.push(() => r('bar')));
+</script>
+
+<p>bar: {bar}</p>
diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Foo.svelte b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Foo.svelte
new file mode 100644
index 000000000000..e2029a3033ed
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/Foo.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+	import { resolve } from './main.svelte';
+	import Bar from './Bar.svelte';
+
+	const foo = await new Promise((r) => resolve.push(() => r('foo')));
+</script>
+
+<p>foo: {foo}</p>
+
+<Bar/>
diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/_config.js b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/_config.js
new file mode 100644
index 000000000000..ca7965bf79e0
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/_config.js
@@ -0,0 +1,42 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+	async test({ assert, target }) {
+		const [show, resolve] = target.querySelectorAll('button');
+
+		show.click();
+		await tick();
+		assert.htmlEqual(
+			target.innerHTML,
+			`
+				<button>show</button>
+				<button>resolve</button>
+				<p>pending...</p>
+			`
+		);
+
+		resolve.click();
+		await tick();
+		assert.htmlEqual(
+			target.innerHTML,
+			`
+				<button>show</button>
+				<button>resolve</button>
+				<p>pending...</p>
+			`
+		);
+
+		resolve.click();
+		await tick();
+		assert.htmlEqual(
+			target.innerHTML,
+			`
+				<button>show</button>
+				<button>resolve</button>
+				<p>foo: foo</p>
+				<p>bar: bar</p>
+			`
+		);
+	}
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/main.svelte
new file mode 100644
index 000000000000..bd0efaa4f85c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-nested-top-level/main.svelte
@@ -0,0 +1,31 @@
+<script module>
+	export let resolve = [];
+</script>
+
+<script>
+	import Foo from './Foo.svelte';
+	
+	let show = $state(false);
+</script>
+
+<button onclick={() => show = true}>
+	show
+</button>
+
+<button onclick={() => resolve.shift()()}>
+	resolve
+</button>
+
+<svelte:boundary>
+	{#if show}
+		<Foo/>
+	{/if}
+
+	{#if $effect.pending()}
+		<p>pending...</p>
+	{/if}
+
+	{#snippet pending()}
+		<p>initializing...</p>
+	{/snippet}
+</svelte:boundary>
diff --git a/packages/svelte/tests/runtime-runes/samples/async-redirect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-redirect/_config.js
index ebbe642860d0..fe92977c21f4 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-redirect/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-redirect/_config.js
@@ -29,6 +29,7 @@ export default test({
 				<button>c</button>
 				<button>ok</button>
 				<p>c</p>
+				<p>b or c</p>
 			`
 		);
 
@@ -46,6 +47,7 @@ export default test({
 				<button>c</button>
 				<button>ok</button>
 				<p>b</p>
+				<p>b or c</p>
 			`
 		);
 	}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-redirect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-redirect/main.svelte
index bf5fdf9ed395..aead1b00e597 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-redirect/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/async-redirect/main.svelte
@@ -33,6 +33,10 @@
 		<p>c</p>
 	{/if}
 
+	{#if route === 'b' || route === 'c'}
+		<p>b or c</p>
+	{/if}
+
 	{#snippet pending()}
 		<p>pending...</p>
 	{/snippet}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/Foo.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/Foo.svelte
new file mode 100644
index 000000000000..e8a7c8413748
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/Foo.svelte
@@ -0,0 +1,8 @@
+<script lang="ts">
+	import { resolve } from './main.svelte';
+
+	const foo = $derived(await new Promise((r) => resolve.push(() => r('foo'))));
+	const bar = $derived(await new Promise((r) => resolve.push(() => r('bar'))));
+</script>
+
+<p>{foo} {bar}</p>
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/_config.js b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/_config.js
new file mode 100644
index 000000000000..2c7ffd395265
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/_config.js
@@ -0,0 +1,41 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+	async test({ assert, target }) {
+		const [show, resolve] = target.querySelectorAll('button');
+
+		show.click();
+		await tick();
+		assert.htmlEqual(
+			target.innerHTML,
+			`
+				<button>show</button>
+				<button>resolve</button>
+				<p>pending...</p>
+			`
+		);
+
+		resolve.click();
+		await tick();
+		assert.htmlEqual(
+			target.innerHTML,
+			`
+				<button>show</button>
+				<button>resolve</button>
+				<p>pending...</p>
+			`
+		);
+
+		resolve.click();
+		await tick();
+		assert.htmlEqual(
+			target.innerHTML,
+			`
+				<button>show</button>
+				<button>resolve</button>
+				<p>foo bar</p>
+			`
+		);
+	}
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/main.svelte
new file mode 100644
index 000000000000..bd0efaa4f85c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-deriveds/main.svelte
@@ -0,0 +1,31 @@
+<script module>
+	export let resolve = [];
+</script>
+
+<script>
+	import Foo from './Foo.svelte';
+	
+	let show = $state(false);
+</script>
+
+<button onclick={() => show = true}>
+	show
+</button>
+
+<button onclick={() => resolve.shift()()}>
+	resolve
+</button>
+
+<svelte:boundary>
+	{#if show}
+		<Foo/>
+	{/if}
+
+	{#if $effect.pending()}
+		<p>pending...</p>
+	{/if}
+
+	{#snippet pending()}
+		<p>initializing...</p>
+	{/snippet}
+</svelte:boundary>