From 26786e92985842a6d563c67049a43bc5857bf792 Mon Sep 17 00:00:00 2001
From: Sankalp Thakur <31366524+sankalpsthakur@users.noreply.github.com>
Date: Sat, 8 Aug 2026 01:07:07 +0530
Subject: [PATCH 1/7] fix: skip controlled each fast path while another batch
is pending (#18625)
Fixes #18610
### Problem
In async mode, a controlled keyed `{#each}` throws `TypeError: Cannot
read properties of undefined (reading 'e')` when its collection becomes
empty while an earlier batch is still pending on the same block.
The fast path in `pause_effects` cleared `state.items` and then called
`destroy_effects`, which walks pending batch keys and reads
`state.items.get(key).e`. Those EachItems are still needed for the
pending batch (preserved offscreen), so clearing the map makes the
dereference throw and aborts the commit mid-flight.
### Fix
Only take the controlled-each fast path when `state.pending.size === 0`,
so pending batches keep their items until they commit or discard.
---
.changeset/async-each-controlled-pending.md | 5 ++
.../src/internal/client/dom/blocks/each.js | 8 ++-
.../_config.js | 50 +++++++++++++++++
.../main.svelte | 54 +++++++++++++++++++
4 files changed, 115 insertions(+), 2 deletions(-)
create mode 100644 .changeset/async-each-controlled-pending.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte
diff --git a/.changeset/async-each-controlled-pending.md b/.changeset/async-each-controlled-pending.md
new file mode 100644
index 0000000000..c37f914d13
--- /dev/null
+++ b/.changeset/async-each-controlled-pending.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: skip controlled each fast path while another batch is pending
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index 2df1d6ffa1..9a2504f887 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -103,8 +103,12 @@ function pause_effects(state, to_destroy, controlled_anchor) {
if (remaining === 0) {
// If we're in a controlled each block (i.e. the block is the only child of an
// element), and we are removing all items, _and_ there are no out transitions,
- // we can use the fast path — emptying the element and replacing the anchor
- var fast_path = transitions.length === 0 && controlled_anchor !== null;
+ // we can use the fast path — emptying the element and replacing the anchor.
+ // Skip the fast path when another batch is still pending on this each block:
+ // that batch's keys still reference EachItems in `state.items`, which
+ // `destroy_effects` needs to preserve offscreen (see #18610).
+ var fast_path =
+ transitions.length === 0 && controlled_anchor !== null && state.pending.size === 0;
if (fast_path) {
var anchor = /** @type {Element} */ (controlled_anchor);
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js
new file mode 100644
index 0000000000..29d374deec
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js
@@ -0,0 +1,50 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+// Regression for #18610: emptying a controlled keyed {#each} while another
+// batch is still pending must not take the fast path that clears state.items
+// before destroy_effects walks pending keys.
+export default test({
+ mode: ['client'],
+
+ async test({ assert, target }) {
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+
+
+
+
A0/B0
+
12
+ `
+ );
+
+ const [startA, startB, settleB] = target.querySelectorAll('button');
+
+ // Batch A: add key 9, then block forever on gate A.
+ startA.click();
+ await tick();
+
+ // Batch B: empty the collection, then block on gate B.
+ startB.click();
+ await tick();
+
+ // Settle B first so B commits while A is still pending.
+ // Without the fix this throws reading `.e` of undefined and leaves a/b stuck.
+ settleB.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+
+
+
+