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.
pull/18629/head
Sankalp Thakur 3 weeks ago committed by GitHub
parent 44a7813730
commit 26786e9298
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: skip controlled each fast path while another batch is pending

@ -103,8 +103,12 @@ function pause_effects(state, to_destroy, controlled_anchor) {
if (remaining === 0) { if (remaining === 0) {
// If we're in a controlled each block (i.e. the block is the only child of an // 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, // 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 // we can use the fast path — emptying the element and replacing the anchor.
var fast_path = transitions.length === 0 && controlled_anchor !== null; // 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) { if (fast_path) {
var anchor = /** @type {Element} */ (controlled_anchor); var anchor = /** @type {Element} */ (controlled_anchor);

@ -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,
`
<button>startA</button>
<button>startB</button>
<button>settleB</button>
<p>A0/B0</p>
<div><span>1</span><span>2</span></div>
`
);
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,
`
<button>startA</button>
<button>startB</button>
<button>settleB</button>
<p>A0/B1</p>
<div></div>
`
);
}
});

@ -0,0 +1,54 @@
<script>
let base = $state([1, 2]);
let extraKey = $state(/** @type {number | null} */ (null));
let tickA = $state(0);
let tickB = $state(0);
// Two independent sources so the batches touch disjoint source sets
// and are not merged.
const items = $derived(extraKey === null ? base : [...base, extraKey]);
/** @type {((value: string) => void) | undefined} */
let resolveB;
/**
* @param {string} name
* @param {number} n
*/
const gate = (name, n) =>
n === 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));
function startA() {
extraKey = 9;
tickA = 1;
}
function startB() {
base = [];
tickB = 1;
}
function settleB() {
resolveB?.('B1');
}
</script>
<button onclick={startA}>startA</button>
<button onclick={startB}>startB</button>
<button onclick={settleB}>settleB</button>
<p>{a}/{b}</p>
<!-- Sole child so the each block is controlled and the fast path applies. -->
<div>
{#each items as item (item)}
<span>{item}</span>
{/each}
</div>
Loading…
Cancel
Save