mirror of https://github.com/sveltejs/svelte
fix: route $derived teardown errors through invoke_error_boundary (#18486)
Fixes #18485 Two parts to this: 1. explicitly invoke error boundary during teardown errors, else they go missing/bubble up outside the render tree 2. skip destroying/destroyed boundaries will searching a handler --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com> Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>pull/18685/head
parent
8bf9ec8700
commit
224fcadbd5
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: route $derived teardown errors through invoke_error_boundary
|
||||
@ -0,0 +1,39 @@
|
||||
import { assert, test } from 'vitest';
|
||||
import { BOUNDARY_EFFECT, DESTROYED, REACTION_RAN } from './constants';
|
||||
import { invoke_error_boundary } from './error-handling';
|
||||
import type { Effect } from './types';
|
||||
|
||||
test('ignores errors from a destroyed entry effect', () => {
|
||||
const error = new Error('original');
|
||||
let handled = null;
|
||||
const boundary = {
|
||||
f: BOUNDARY_EFFECT | REACTION_RAN,
|
||||
b: { error: (error: unknown) => (handled = error) },
|
||||
parent: null
|
||||
} as unknown as Effect;
|
||||
const effect = { f: DESTROYED, parent: boundary } as Effect;
|
||||
|
||||
invoke_error_boundary(error, effect);
|
||||
|
||||
assert.equal(handled, null);
|
||||
});
|
||||
|
||||
test('skips destroyed boundary ancestors without masking the error', () => {
|
||||
const error = new Error('original');
|
||||
let handled = null;
|
||||
const live_boundary = {
|
||||
f: BOUNDARY_EFFECT | REACTION_RAN,
|
||||
b: { error: (error: unknown) => (handled = error) },
|
||||
parent: null
|
||||
} as unknown as Effect;
|
||||
const destroyed_boundary = {
|
||||
f: BOUNDARY_EFFECT | DESTROYED | REACTION_RAN,
|
||||
b: null,
|
||||
parent: live_boundary
|
||||
} as unknown as Effect;
|
||||
const effect = { f: 0, parent: destroyed_boundary } as Effect;
|
||||
|
||||
invoke_error_boundary(error, effect);
|
||||
|
||||
assert.equal(handled, error);
|
||||
});
|
||||
@ -0,0 +1,12 @@
|
||||
<script>
|
||||
let { getValue } = $props();
|
||||
|
||||
// Reads reactive state during teardown
|
||||
$effect(() => {
|
||||
return () => {
|
||||
getValue();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<span>trigger</span>
|
||||
@ -0,0 +1,23 @@
|
||||
import { flushSync, tick } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
// Regression test for https://github.com/sveltejs/svelte/issues/18485.
|
||||
// A $derived that re-executes and throws during teardown should route the
|
||||
// error to a live ancestor boundary.
|
||||
mode: ['client'],
|
||||
async test({ assert, target }) {
|
||||
const [break_it, unmount] = target.querySelectorAll('button');
|
||||
|
||||
break_it.click();
|
||||
flushSync();
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
unmount.click();
|
||||
flushSync();
|
||||
});
|
||||
|
||||
await tick();
|
||||
assert.htmlEqual(target.innerHTML, '<p>caught</p>');
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import Trigger from './Trigger.svelte';
|
||||
|
||||
let mounted = $state(true);
|
||||
let broken = $state(false);
|
||||
|
||||
// Throwing derived — nothing in the live template reads `appContext`, so
|
||||
// invalidating it does NOT throw immediately (becomes dirty-and-unread).
|
||||
let data = $derived(broken ? null : { value: 'ok' });
|
||||
let appContext = $derived(data.value);
|
||||
</script>
|
||||
|
||||
<svelte:boundary>
|
||||
<button onclick={() => (broken = true)}>break</button>
|
||||
<button onclick={() => (mounted = false)}>unmount</button>
|
||||
|
||||
{#if mounted}
|
||||
<Trigger getValue={() => appContext} />
|
||||
{/if}
|
||||
|
||||
{#snippet failed()}
|
||||
<p>caught</p>
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
Loading…
Reference in new issue