fix: preserve original boundary errors when keyed each rows are removed during async updates (#17843)

Fixes a runtime edge case where keyed #each reconciliation can hit a
missing item during deferred async updates, causing an internal crash
and masking the original boundary error.

Fixes #17841

### Before submitting the PR, please make sure you do the following

- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [x] Ideally, include a test that fails without this PR but passes with
it.
- [x] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).

### Tests and linting

- [] Run the tests with `pnpm test` and lint the project with `pnpm
lint`

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/17842/head
Philip Breuer 1 week ago committed by GitHub
parent 0965028d3b
commit b7bc1309aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: preserve original boundary errors when keyed each rows are removed during async updates

@ -35,7 +35,7 @@ import {
} from '../../reactivity/effects.js';
import { source, mutable_source, internal_set } from '../../reactivity/sources.js';
import { array_from, is_array } from '../../../shared/utils.js';
import { BRANCH_EFFECT, COMMENT_NODE, EFFECT_OFFSCREEN, INERT } from '#client/constants';
import { BRANCH_EFFECT, COMMENT_NODE, DESTROYED, EFFECT_OFFSCREEN, INERT } from '#client/constants';
import { queue_micro_task } from '../task.js';
import { get } from '../../runtime.js';
import { DEV } from 'esm-env';
@ -217,6 +217,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
* @param {Batch} batch
*/
function commit(batch) {
if ((state.effect.f & DESTROYED) !== 0) {
return;
}
state.pending.delete(batch);
state.fallback = fallback;

@ -0,0 +1,28 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
// this test doesn't fail without the associated fix — the error gets
// swallowed somewhere. but keeping it around for illustration
skip: true,
mode: ['client'],
async test({ assert, target, errors, logs }) {
const button = target.querySelector('button');
button?.click();
await tick();
await tick();
assert.deepEqual(logs, ['Simulated TypeError']);
assert.deepEqual(errors, []);
assert.htmlEqual(
target.innerHTML,
`
<button>Trigger</button>
<p>Error Caught: Simulated TypeError</p>
`
);
}
});

@ -0,0 +1,29 @@
<script>
let index = $state(0);
async function fn(id) {
if (id === 2) throw new Error('Simulated TypeError');
return id;
}
function onerror(error) {
console.log(error.message);
}
</script>
<button onclick={() => (index = 1)}>Trigger</button>
<svelte:boundary {onerror}>
{#snippet pending()}
<p>Loading...</p>
{/snippet}
{#snippet failed(error)}
<p>Error Caught: {error.message}</p>
{/snippet}
{#each [[1], [2]][index] as id (id)}
{@const result = await fn(id)}
<p>{result}</p>
{/each}
</svelte:boundary>
Loading…
Cancel
Save