fix: check boundary exists before calling error handler in async derived (#18384)

fixes #18383

I traced the issue to
`packages/svelte/src/internal/client/error-handling.js`, specifically
the `invoke_error_boundary` function. The problem is at line 56 where
`effect.b.error(error)` is called without checking if `effect.b` exists.

What happens is: when an async `$derived` rejects inside a
`<svelte:boundary>`, the error is handled correctly the first time. But
if the boundary is destroyed before the rejection fully settles (e.g.
component unmounts), a subsequent settle tries to call
`effect.b.error()` on a destroyed boundary where `effect.b` is `null`,
causing a `TypeError`.

The fix adds a null check for `effect.b` before calling `error()`. If
the boundary has been destroyed, we skip it and continue bubbling up to
the parent boundary. This way the error still gets handled properly
instead of crashing.

This could also be tested by creating a component with an async
`$derived` that rejects after the component unmounts, but I wanted to
get the fix up first for review.

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/18389/head
sijie-Z 3 months ago committed by GitHub
parent 3d83c9abe6
commit 71a6515bd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: ignore errors that occur in destroyed effects

@ -3,7 +3,7 @@
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 } from './constants.js';
import { ERROR_VALUE, BOUNDARY_EFFECT, REACTION_RAN, EFFECT, DESTROYED } from './constants.js';
import { define_property, get_descriptor } from '../shared/utils.js';
import { active_effect, active_reaction } from './runtime.js';
@ -45,6 +45,10 @@ export function handle_error(error) {
* @param {Effect | null} effect
*/
export function invoke_error_boundary(error, effect) {
if (effect !== null && (effect.f & DESTROYED) !== 0) {
return;
}
while (effect !== null) {
if ((effect.f & BOUNDARY_EFFECT) !== 0) {
if ((effect.f & REACTION_RAN) === 0) {

@ -180,4 +180,3 @@ export {
} from '../shared/validate.js';
export { strict_equals, equals } from './dev/equality.js';
export { log_if_contains_state } from './dev/console-log.js';
export { invoke_error_boundary } from './error-handling.js';

@ -0,0 +1,6 @@
<script>
const data = $derived(await Promise.reject(new Error('oops')));
const model = $derived({ title: data.title });
</script>
<h4>{model.title}</h4>

@ -0,0 +1,13 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [button] = target.querySelectorAll('button');
button.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>show</button><p>error was contained</p>');
}
});

@ -0,0 +1,27 @@
<script>
import Child from './Child.svelte';
let open = $state(false);
</script>
<svelte:boundary>
<button onclick={() => (open = true)}>show</button>
{#if open}
<svelte:boundary>
<Child />
{#snippet pending()}
<p>loading…</p>
{/snippet}
{#snippet failed()}
<p>error was contained</p>
{/snippet}
</svelte:boundary>
{/if}
{#snippet failed()}
<p>error escaped containment</p>
{/snippet}
</svelte:boundary>
Loading…
Cancel
Save