mirror of https://github.com/sveltejs/svelte
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
parent
44a7813730
commit
26786e9298
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: skip controlled each fast path while another batch is pending
|
||||
@ -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…
Reference in new issue