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] 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, + ` + + + +

A0/B1

+
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte new file mode 100644 index 0000000000..68c3937c25 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte @@ -0,0 +1,54 @@ + + + + + + +

{a}/{b}

+ + +
+ {#each items as item (item)} + {item} + {/each} +