Skip to content

fix: route $derived teardown errors through invoke_error_boundary#18486

Open
BonchitoSky wants to merge 3 commits into
sveltejs:mainfrom
BonchitoSky:fix/boundary-teardown-error-handling
Open

fix: route $derived teardown errors through invoke_error_boundary#18486
BonchitoSky wants to merge 3 commits into
sveltejs:mainfrom
BonchitoSky:fix/boundary-teardown-error-handling

Conversation

@BonchitoSky

Copy link
Copy Markdown

Fixes #18485

Problem

Two related bugs in error handling during effect teardown:

Bug 1 — invoke_error_boundary crashes with null effect.b

The DESTROYED guard was only applied to the initial effect, not to ancestor effects encountered while walking up the tree. A boundary effect that has been fully destroyed has effect.b = null (nulled at the end of destroy_effect), so calling effect.b.error(error) inside the loop throws:

TypeError: Cannot read properties of null (reading 'error')

This masks the original error and makes debugging very hard.

Bug 2 — teardown errors escape <svelte:boundary> entirely

execute_effect_teardown let errors from teardown functions propagate raw up the JS call stack through destroy_effectdestroy_effect_children → ..., bypassing invoke_error_boundary completely. The <svelte:boundary onerror> handler was never called.

Fix

error-handling.js: Move the DESTROYED guard inside the while loop and extend it to also skip DESTROYING effects (mid-teardown, children being destroyed but not yet fully done). This prevents the null dereference and correctly skips boundaries that can no longer handle errors.

effects.js: Wrap teardown.call(null) in a catch and route errors through invoke_error_boundary(error, effect.parent). Combined with the loop fix, destroyed/destroying ancestor boundaries are transparently skipped — the error either reaches a live ancestor boundary or surfaces at the top level.

Test

Added error-boundary-28 — covers the minimal reproduction from the issue: a $derived that becomes dirty (without throwing) while mounted, then re-executes and throws during teardown when a child effect reads it.

Before this fix: TypeError: Cannot read properties of null (reading 'error') was thrown, masking the real error.
After this fix: the real error propagates cleanly.

Two bugs fixed:

1. invoke_error_boundary only guarded the initial effect against DESTROYED,
   not ancestors walked in the loop. A fully-destroyed boundary has effect.b
   nulled, causing "Cannot read properties of null (reading 'error')" which
   masks the real error. Fix: move the guard inside the loop and also skip
   DESTROYING effects (mid-teardown).

2. execute_effect_teardown let teardown errors escape raw up the call stack,
   bypassing invoke_error_boundary entirely so <svelte:boundary onerror>
   was never called. Fix: catch and route through invoke_error_boundary.

Fixes sveltejs#18485

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c3fc325

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
svelte Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@BonchitoSky

Copy link
Copy Markdown
Author

Summary of changes

Root causes

This PR fixes two distinct but related bugs that manifest when a $derived re-executes and throws while being read during effect teardown (e.g. a library like bits-ui reading reactive state in a cleanup callback).


Bug 1 — invoke_error_boundary crashes with null effect.b

File: packages/svelte/src/internal/client/error-handling.js

The original code only applied the DESTROYED guard to the first effect passed in:

if (effect !== null && (effect.f & DESTROYED) !== 0) {
    return; // only guards the entry point
}
while (effect !== null) {
    if ((effect.f & BOUNDARY_EFFECT) !== 0) {
        effect.b.error(error); // 💥 effect.b is null for destroyed ancestors
    }
    effect = effect.parent;
}

When walking up the parent chain, it could reach an ancestor boundary that was already fully destroyed. destroy_effect nulls out effect.b after setting DESTROYED — so calling effect.b.error(error) on it throws:

TypeError: Cannot read properties of null (reading 'error')

This secondary crash masked the original error entirely.

Fix: Move the guard inside the while loop and extend it to also skip DESTROYING effects (those whose children are being torn down but which aren't marked DESTROYED yet):

while (effect !== null) {
    if ((effect.f & (DESTROYED | DESTROYING)) !== 0) {
        effect = effect.parent; // skip, can't handle errors
        continue;
    }
    // ...
}

Bug 2 — teardown errors escape <svelte:boundary> entirely

File: packages/svelte/src/internal/client/reactivity/effects.js

execute_effect_teardown did not catch errors from the teardown function. They propagated raw up the JS call stack through destroy_effectdestroy_effect_children → ..., completely bypassing invoke_error_boundary. The <svelte:boundary onerror> handler was never invoked.

Fix: Wrap the teardown call in a catch and route the error through invoke_error_boundary starting from effect.parent:

try {
    teardown.call(null);
} catch (error) {
    invoke_error_boundary(error, effect.parent);
}

Combined with the Bug 1 fix, any destroyed/destroying boundaries in the ancestor chain are transparently skipped. The error either reaches a live ancestor boundary or surfaces cleanly at the top level — no more silent swallowing or misleading TypeError.


Test

packages/svelte/tests/runtime-runes/samples/error-boundary-28/ reproduces the minimal case from the issue: a $derived that becomes dirty while mounted (without throwing), then re-executes and throws when a child effect reads it during teardown. Before this fix the test would crash with the null dereference; after, the real error surfaces correctly.

Comment thread packages/svelte/tests/runtime-runes/samples/error-boundary-28/main.svelte Outdated
…sertion

- Replace `onerror={(e) => errors.push(e.message)}` with `onerror={() => {}}`
  in main.svelte; `errors` was an undeclared global (undefined at runtime),
  disconnected from the dead `const errors = []` in _config.js.
- Remove the dead `const errors = []` declaration and its stale comment.
- Wrap the unmount flushSync in try/catch: when the boundary is torn down
  together with the component all ancestor boundary effects carry DESTROYING,
  so invoke_error_boundary finds no live handler and re-throws synchronously.
  The real TypeError (null.value) escaping is acceptable.
- Tighten the assertion to match only "reading 'error'" — the specific
  null-dereference crash (sveltejs#18485) this test guards against — rather than
  the overly broad "Cannot read properties of null" which also matched
  the original application error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error thrown by a $derived re-executing during teardown escapes <svelte:boundary> (and can mask the real error via null effect.b)

1 participant