sveltejs / svelte · Issue No. 18485
When a $derived re-executes while a component is being torn down and throws, the error is mishandled:
Minimal case: the error escapes the enclosing <svelte:boundary onerror> completely (uncaught), thrown out of execute_effect_teardown → destroy_effect.
Real-world case (bits-ui + SvelteKit): when the same teardown-time read comes from a $props proxy that is read by a still-live reaction, active_effect is not flagged DESTROYED, so the DESTROYED guard added in 5.56.3 doesn't fire. invoke_error_boundary then walks up to an ancestor boundary effect whose effect.b is already null (it is mid-teardown) and throws:
TypeError: Cannot read properties of null (reading 'error')
at invoke_error_boundary (.../svelte/src/internal/client/error-handling.js)
This masks the original error, making it very hard to debug (the real error was Cannot read properties of undefined (reading 'id') from a $derived reading page data that had gone stale during a cross-layout navigation).
The 5.56.3 fix (fix: ignore errors that occur in destroyed effects) only guards the entry effect:
// invoke_error_boundary
if (effect !== null && (effect.f & DESTROYED) !== 0) {
return;
}
while (effect !== null) {
if ((effect.f & BOUNDARY_EFFECT) !== 0) {
...
try {
/** @type {Boundary} */ (effect.b).error(error); // <-- effect.b can be null here
The boundary effects reached via effect = effect.parent inside the loop are not checked for DESTROYED, and effect.b is not null-checked.
Steps:
appContext dirty. Nothing in the live template reads it, so it does not throw yet (mirrors page data going stale mid-navigation).$derived, which re-executes and throws.Components:
<!-- App.svelte -->
<script>
import Trigger from './Trigger.svelte';
let mounted = $state(true);
let broken = $state(false);
// Throwing derived, owned by App (stays alive). Nothing in the live template
// reads `appContext`, so invalidating it does NOT throw immediately — it just
// becomes dirty-and-unread, like page data going stale mid-navigation.
let data = $derived(broken ? null : { orgApp: { id: "x" } });
let appContext = $derived(data.orgApp.id);
</script>
<button onclick={() => (broken = true)}>1. break (invalidate derived)</button>
<button onclick={() => (mounted = false)}>2. unmount (teardown)</button>
{#if mounted}
<svelte:boundary onerror={(e) => console.log("BOUNDARY onerror caught:", e)}>
<Trigger getValue={() => appContext} />
</svelte:boundary>
{/if}
<!-- Trigger.svelte -->
<script>
let { getValue } = $props();
// Simulates a library (e.g. bits-ui popover/tooltip) that reads reactive
// trigger state during its own teardown.
$effect(() => {
return () => {
console.log('teardown read ->', getValue());
};
});
</script>
<span>trigger</span>
Console after step 2:
TypeError: Cannot read properties of null (reading 'orgApp')
at execute_derived
at get
at Object.getValue
at execute_effect_teardown
at destroy_effect
at destroy_effect_children
at destroy_effect
Note: BOUNDARY onerror caught: is never logged — the <svelte:boundary> does not contain the teardown-phase error.
annoyance
A $derived that throws while being read during teardown should either be contained by the enclosing <svelte:boundary>, or at minimum surface the real error — not a masked Cannot read properties of null (reading 'error') originating inside invoke_error_boundary due to an unchecked null effect.b on a boundary effect that is itself being destroyed.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.