From 224fcadbd5034f0ac6d89b6f8525a58acde8eed4 Mon Sep 17 00:00:00 2001 From: Rupankar Dutta Date: Fri, 21 Aug 2026 19:24:15 +0530 Subject: [PATCH] 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 Co-authored-by: Simon Holthausen Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/boundary-teardown-error-routing.md | 5 +++ .../src/internal/client/error-handling.js | 12 +++++- .../internal/client/error-handling.test.ts | 39 +++++++++++++++++++ .../src/internal/client/reactivity/effects.js | 6 +++ .../samples/error-boundary-28/Trigger.svelte | 12 ++++++ .../samples/error-boundary-28/_config.js | 23 +++++++++++ .../samples/error-boundary-28/main.svelte | 24 ++++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 .changeset/boundary-teardown-error-routing.md create mode 100644 packages/svelte/src/internal/client/error-handling.test.ts create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-28/Trigger.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-28/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-28/main.svelte diff --git a/.changeset/boundary-teardown-error-routing.md b/.changeset/boundary-teardown-error-routing.md new file mode 100644 index 0000000000..0b2249bf22 --- /dev/null +++ b/.changeset/boundary-teardown-error-routing.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: route $derived teardown errors through invoke_error_boundary diff --git a/packages/svelte/src/internal/client/error-handling.js b/packages/svelte/src/internal/client/error-handling.js index 7c404dd933..a46281d36c 100644 --- a/packages/svelte/src/internal/client/error-handling.js +++ b/packages/svelte/src/internal/client/error-handling.js @@ -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; diff --git a/packages/svelte/src/internal/client/error-handling.test.ts b/packages/svelte/src/internal/client/error-handling.test.ts new file mode 100644 index 0000000000..2eb6093a2c --- /dev/null +++ b/packages/svelte/src/internal/client/error-handling.test.ts @@ -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); +}); diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index c5d195dfae..7e53138aae 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -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 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); diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-28/Trigger.svelte b/packages/svelte/tests/runtime-runes/samples/error-boundary-28/Trigger.svelte new file mode 100644 index 0000000000..46b4bd1c30 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-28/Trigger.svelte @@ -0,0 +1,12 @@ + + +trigger diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-28/_config.js b/packages/svelte/tests/runtime-runes/samples/error-boundary-28/_config.js new file mode 100644 index 0000000000..4802089124 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-28/_config.js @@ -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, '

caught

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-28/main.svelte b/packages/svelte/tests/runtime-runes/samples/error-boundary-28/main.svelte new file mode 100644 index 0000000000..e100be2169 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-28/main.svelte @@ -0,0 +1,24 @@ + + + + + + + {#if mounted} + appContext} /> + {/if} + + {#snippet failed()} +

caught

+ {/snippet} +