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
Rupankar Dutta 3 days ago committed by GitHub
parent 8bf9ec8700
commit 224fcadbd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: route $derived teardown errors through invoke_error_boundary

@ -3,7 +3,14 @@
import { DEV } from 'esm-env';
import { FILENAME } from '../../constants.js';
import { is_firefox } from './dom/operations.js';
import { ERROR_VALUE, BOUNDARY_EFFECT, REACTION_RAN, EFFECT, DESTROYED } from './constants.js';
import {
ERROR_VALUE,
BOUNDARY_EFFECT,
REACTION_RAN,
EFFECT,
DESTROYED,
DESTROYING
} from './constants.js';
import { define_property, get_descriptor } from '../shared/utils.js';
import { active_effect, active_reaction } from './runtime.js';
@ -50,7 +57,8 @@ export function invoke_error_boundary(error, effect) {
}
while (effect !== null) {
if ((effect.f & BOUNDARY_EFFECT) !== 0) {
// Skip boundaries that are destroyed/destroying and cannot meaningfully handle the error.
if ((effect.f & BOUNDARY_EFFECT) !== 0 && (effect.f & (DESTROYED | DESTROYING)) === 0) {
if ((effect.f & REACTION_RAN) === 0) {
// we are still creating the boundary effect
throw error;

@ -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);
});

@ -36,6 +36,7 @@ import {
MANAGED_EFFECT,
DESTROYING
} from '#client/constants';
import { invoke_error_boundary } from '../error-handling.js';
import * as e from '../errors.js';
import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
@ -449,6 +450,11 @@ export function execute_effect_teardown(effect) {
set_active_reaction(null);
try {
teardown.call(null);
} catch (error) {
// Route teardown errors through the boundary system so that a live
// ancestor <svelte:boundary> can handle them. Boundaries that are
// themselves mid-teardown are skipped by invoke_error_boundary.
invoke_error_boundary(error, effect.parent);
} finally {
set_is_destroying_effect(previously_destroying_effect);
set_active_reaction(previous_reaction);

@ -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…
Cancel
Save