sveltejs / svelte · Issue No. 18610
Disclaimer: This report was diagnosed and generated with Claude Opus 5 - the original behaviour was identified in a real repo.
In async mode (compilerOptions.experimental.async: true), a controlled keyed {#each} block throws TypeError: Cannot read properties of undefined (reading 'e') when its collection becomes empty in a batch that commits while an earlier batch is still pending on the same block.
The cause is a statement-ordering bug in pause_effects in packages/svelte/src/internal/client/dom/blocks/each.js. The "fast path" clears state.items and then calls destroy_effects, which walks the keys of every still-pending batch and dereferences them against that now-empty map:
// each.js:103-119
if (remaining === 0) {
var fast_path = transitions.length === 0 && controlled_anchor !== null;
if (fast_path) {
// ...
state.items.clear(); // ← line 116: wipes every EachItem
}
destroy_effects(state, to_destroy, !fast_path);
}
// each.js:135-148
function destroy_effects(state, to_destroy, remove_dom = true) {
var preserved_effects;
if (state.pending.size > 0) {
preserved_effects = new Set();
for (const keys of state.pending.values()) {
for (const key of keys) {
preserved_effects.add(state.items.get(key).e); // ← line 145: undefined.e
}
}
}
state.pending holds Map<Batch, Set<key>> for batches that have run the block effect but not yet committed. Their EachItems exist (deferred runs create them offscreen) — state.items.clear() is what removes them. So the fast path is simply not valid while another batch is pending: it discards offscreen items that pending batch still references.
The throw is not cosmetic. It aborts the commit callback mid-flight, so the committing batch is left half-applied — in the reproduction below, the <p> still reads A0/B0 after the crash instead of A0/B1.
Preconditions, all of which are required:
experimental.async: true — makes should_defer_append() (dom/operations.js:227) return true for every each-block update after first render, so pending.set(batch, keys) is populated.{#each} is controlled — the sole child of its parent element, so controlled_anchor !== null.remaining === 0).transitions.length === 0).Condition 5 needs two batches whose changed-source sets are disjoint, otherwise they get merged in #process and there is only ever one entry in state.pending.
App.svelte, in a project with compilerOptions.experimental.async: true. Click the button.
<script>
let base = $state([1, 2]);
let extraKey = $state(null);
let tickA = $state(0);
let tickB = $state(0);
// Reads two independent sources so the two batches below touch disjoint
// source sets and are therefore not merged into one batch.
const items = $derived(extraKey === null ? base : [...base, extraKey]);
let resolveB;
const gate = (name, tick) =>
tick === 0
? Promise.resolve(`${name}0`)
: new Promise((r) => {
if (name === 'B') resolveB = r;
});
const a = $derived(await gate('A', tickA));
const b = $derived(await gate('B', tickB));
const nextTask = () => new Promise((r) => setTimeout(r, 0));
async function run() {
// Batch A: add key 9, then block on gate A (never settles).
extraKey = 9;
tickA = 1;
await nextTask();
// Batch B: empty the collection, then block on gate B.
base = [];
tickB = 1;
await nextTask();
// Settle B first, so B commits while A is still pending.
resolveB('B1');
}
</script>
<button onclick={run}>reproduce</button>
<p>{a}/{b}</p>
<!-- Must be the ONLY child of its parent element, so the each block is
"controlled" and the fast path in `pause_effects` is taken. -->
<div>
{#each items as item (item)}
<span>{item}</span>
{/each}
</div>
Expected: the list empties and the <p> reads A0/B1.
Actual: TypeError: Cannot read properties of undefined (reading 'e'), the list empties, and the <p> is stuck at A0/B0.
I verified this in a vitest-browser test (Chromium) and instrumented each.js to confirm the state at the moment of the throw:
[each block run] defer=true batch=2 keys=[1,2,9] ← batch A, pending
[each block run] defer=true batch=3 keys=[] ← batch B, pending
[pause_effects] fast_path=true controlled=true state.pending.size=1
[destroy_effects] pending keys=[[1,2,9]] state.items=[] ← cleared on line 116
[destroy_effects] missing key: 1
TypeError: Cannot read properties of undefined (reading 'e')
Removing the {#each}'s sole-child status (adding a sibling element inside the <div>, so the block is no longer controlled), or adding an out: transition to <span>, both avoid the fast path and the crash does not occur — consistent with the analysis above.
Guarding the fast path rather than destroy_effects keeps the offscreen items that pending batches need:
- var fast_path = transitions.length === 0 && controlled_anchor !== null;
+ var fast_path =
+ transitions.length === 0 && controlled_anchor !== null && state.pending.size === 0;
With this applied, the reproduction no longer throws and the <p> correctly reads A0/B1.
Guarding at line 145 instead (const item = state.items.get(key); if (item) preserved_effects.add(item.e);) also stops the throw, but leaves the pending batch's items destroyed rather than preserved offscreen, so it seems like the wrong layer to fix it at.
Happy to open a PR with this plus a test if the approach looks right.
An annoyance but no workaround — the throw corrupts the batch commit, and avoiding it means restructuring markup to defeat the controlled-each optimisation.
System:
OS: macOS 27.0
CPU: (10) arm64 Apple M1 Max
Binaries:
Node: 26.5.0
bun: 1.3.14
Browsers:
Chrome: 151.0.7922.71
Safari: 27.0
npmPackages:
@sveltejs/kit: 3.0.0-next.14
svelte: 5.56.8
vite: 8.2.0
Line numbers above are from svelte@5.56.8; the code is unchanged on main as of writing.
`
App.svelte, in a project with compilerOptions.experimental.async: true. Click the button.
<script>
let base = $state([1, 2]);
let extraKey = $state(null);
let tickA = $state(0);
let tickB = $state(0);
// Reads two independent sources so the two batches below touch disjoint
// source sets and are therefore not merged into one batch.
const items = $derived(extraKey === null ? base : [...base, extraKey]);
let resolveB;
const gate = (name, tick) =>
tick === 0
? Promise.resolve(`${name}0`)
: new Promise((r) => {
if (name === 'B') resolveB = r;
});
const a = $derived(await gate('A', tickA));
const b = $derived(await gate('B', tickB));
const nextTask = () => new Promise((r) => setTimeout(r, 0));
async function run() {
// Batch A: add key 9, then block on gate A (never settles).
extraKey = 9;
tickA = 1;
await nextTask();
// Batch B: empty the collection, then block on gate B.
base = [];
tickB = 1;
await nextTask();
// Settle B first, so B commits while A is still pending.
resolveB('B1');
}
</script>
<button onclick={run}>reproduce</button>
<p>{a}/{b}</p>
<!-- Must be the ONLY child of its parent element, so the each block is
"controlled" and the fast path in `pause_effects` is taken. -->
<div>
{#each items as item (item)}
<span>{item}</span>
{/each}
</div>
Expected: the list empties and the <p> reads A0/B1.
Actual: TypeError: Cannot read properties of undefined (reading 'e'), the list empties, and the <p> is stuck at A0/B0.
I verified this in a vitest-browser test (Chromium) and instrumented each.js to confirm the state at the moment of the throw:
[each block run] defer=true batch=2 keys=[1,2,9] ← batch A, pending
[each block run] defer=true batch=3 keys=[] ← batch B, pending
[pause_effects] fast_path=true controlled=true state.pending.size=1
[destroy_effects] pending keys=[[1,2,9]] state.items=[] ← cleared on line 116
[destroy_effects] missing key: 1
TypeError: Cannot read properties of undefined (reading 'e')
Removing the {#each}'s sole-child status (adding a sibling element inside the <div>, so the block is no longer controlled), or adding an out: transition to <span>, both avoid the fast path and the crash does not occur — consistent with the analysis above.
Vitest browser logs:
[each block run] defer=true batch=2 keys=[1,2,9] ← batch A, pending
[each block run] defer=true batch=3 keys=[] ← batch B, pending
[pause_effects] fast_path=true controlled=true state.pending.size=1
[destroy_effects] pending keys=[[1,2,9]] state.items=[] ← cleared on line 116
[destroy_effects] missing key: 1
TypeError: Cannot read properties of undefined (reading 'e')
Real browser logs:
index-client-CWWpCFEA.js?v=53423141:818 Uncaught TypeError: Cannot read properties of undefined (reading 'e')
at destroy_effects (index-client-CWWpCFE…s?v=53423141:818:24)
at pause_effects (index-client-CWWpCFE…js?v=53423141:797:3)
at reconcile (index-client-CWWpCFE…s?v=53423141:1102:4)
at commit (index-client-CWWpCFE…js?v=53423141:870:3)
at #process (runtime-BeeFzqNP.js?v=53423141:2879:44)
at Batch.flush (runtime-BeeFzqNP.js?v=53423141:3025:17)
at Array.<anonymous> (runtime-BeeFzqNP.js?v=53423141:3160:32)
at run_all (runtime-BeeFzqNP.js?v=53423141:40:45)
at run_micro_tasks (runtime-BeeFzqNP.js?v=53423141:1109:2)
at runtime-BeeFzqNP.js?v=53423141:1118:31
destroy_effects @ index-client-CWWpCFEA.js?v=53423141:818
pause_effects @ index-client-CWWpCFEA.js?v=53423141:797
reconcile @ index-client-CWWpCFEA.js?v=53423141:1102
commit @ index-client-CWWpCFEA.js?v=53423141:870
#process @ runtime-BeeFzqNP.js?v=53423141:2879
flush @ runtime-BeeFzqNP.js?v=53423141:3025
(anonymous) @ runtime-BeeFzqNP.js?v=53423141:3160
run_all @ runtime-BeeFzqNP.js?v=53423141:40
run_micro_tasks @ runtime-BeeFzqNP.js?v=53423141:1109
(anonymous) @ runtime-BeeFzqNP.js?v=53423141:1118
postMessage
post_to_main @ :3000/@fs/Users/nico…file&type=module:13
post_loaded_media @ :3000/@fs/Users/nico…le&type=module:2002
drain_decode_results @ :3000/@fs/Users/nico…le&type=module:1964
(anonymous) @ :3000/@fs/Users/nico…le&type=module:2030
setInterval
start_render_loop @ :3000/@fs/Users/nico…le&type=module:2022
self.onmessage @ :3000/@fs/Users/nico…ile&type=module:361
System:
OS: macOS 27.0
CPU: (10) arm64 Apple M1 Max
Memory: 3.48 GB / 64.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 26.5.0 - /opt/homebrew/bin/node
npm: 11.11.0 - /Users/nicolasmontavon/code/Sonica/node_modules/.bin/npm
pnpm: 10.21.0 - /Users/nicolasmontavon/Library/pnpm/pnpm
bun: 1.3.14 - /Users/nicolasmontavon/.bun/bin/bun
Deno: 2.9.4 - /opt/homebrew/bin/deno
Browsers:
Chrome: 151.0.7922.71
Firefox: 153.0.1
Safari: 27.0
npmPackages:
svelte: ^5.56.8 => 5.56.8
@sveltejs/kit: ^3.0.0-next.14 => 3.0.0-next.14
vite: ^8.2.0 => 8.2.0
annoyance
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.