handle errors in the correct boundary

pull/16171/head
Rich Harris 3 months ago
parent 3ad0519444
commit 942cda7f64

@ -158,3 +158,23 @@ let odd = $derived(!even);
``` ```
If side-effects are unavoidable, use [`$effect`]($effect) instead. If side-effects are unavoidable, use [`$effect`]($effect) instead.
### svelte_boundary_reset_onerror
```
A `<svelte:boundary>` `reset` function cannot be called while an error is still being handled
```
If a [`<svelte:boundary>`](https://svelte.dev/docs/svelte/svelte-boundary) has an `onerror` function, it must not call the provided `reset` function synchronously since the boundary is still in a broken state. Typically, `reset()` is called later, once the error has been resolved.
If it's possible to resolve the error inside the `onerror` callback, you must at least wait for the boundary to settle before calling `reset()`, for example using [`tick`](https://svelte.dev/docs/svelte/lifecycle-hooks#tick):
```svelte
<svelte:boundary onerror={async (error, reset) => {
fixTheError();
+++await tick();+++
reset();
}}>
</svelte:boundary>
```

@ -200,74 +200,6 @@ Consider the following code:
To fix it, either create callback props to communicate changes, or mark `person` as [`$bindable`]($bindable). To fix it, either create callback props to communicate changes, or mark `person` as [`$bindable`]($bindable).
### reset_misuse
```
reset() was invoked and the `<svelte:boundary>` template threw during flush. Calling `reset` inside the `onerror` handler while the app state is still broken can cause the fresh template to crash during its first render; the error bypassed the <svelte:boundary> to avoid an infinite loop `error``reset``error`
```
When you call `reset()` Svelte tears down the template inside `<svelte:boundary>` and renders a fresh one. If the same bad state that caused the first error in the first place is still present, that fresh mount crashes immediately. To break a potential `error → reset → error` loop, Svelte lets such render-time errors bubble past the boundary.
Sometimes this happens because you might have called `reset` before the error was thrown (perhaps in the `onclick` handler of the button that will then trigger the error) or inside the `onerror` handler.
`reset()` should preferably be called **after** the boundary has entered its error state. A common pattern is to call it from a "Try again" button in the fallback UI.
If you need to call `reset` inside the `onerror` handler, ensure you fix the broken state first, *then* invoke `reset()`.
The examples below show do's and don'ts:
```svelte
<!-- ❌ Don't call reset before errors occur -->
<button onclick={() => {
showComponent = true;
if (reset) reset(); // Called before knowing if error will occur
}}>
Update
</button>
<svelte:boundary>
{#if showComponent}
<!-- ... -->
{/if}
</svelte:boundary>
```
```svelte
<!-- ❌ Don't call reset without fixing the problematic state -->
<svelte:boundary onerror={() => {
// Fix the problematic state first
reset(); // This will cause the error to be thrown again and bypass the boundary
}}>
<!-- ... -->
</svelte:boundary>
```
```svelte
<!-- ✅ Call reset from error UI -->
<svelte:boundary>
<!-- ... -->
{#snippet failed(error)}
<button onclick={() => {
// Fix the problematic state first
selectedItem = null;
userInput = '';
reset(); // Now safe to retry
}}>Try Again</button>
{/snippet}
</svelte:boundary>
```
```svelte
<!-- ✅ Or fix the problematic state first and call reset in the onerror for immediate recovery -->
<svelte:boundary onerror={() => {
componentState = initialComponentState; // Fix/reset the problematic state first
reset(); // Now the regular template will show without errors
}}>
<!-- ... -->
</svelte:boundary>
```
### select_multiple_invalid_value ### select_multiple_invalid_value
``` ```

@ -114,3 +114,21 @@ let odd = $derived(!even);
``` ```
If side-effects are unavoidable, use [`$effect`]($effect) instead. If side-effects are unavoidable, use [`$effect`]($effect) instead.
## svelte_boundary_reset_onerror
> A `<svelte:boundary>` `reset` function cannot be called while an error is still being handled
If a [`<svelte:boundary>`](https://svelte.dev/docs/svelte/svelte-boundary) has an `onerror` function, it must not call the provided `reset` function synchronously since the boundary is still in a broken state. Typically, `reset()` is called later, once the error has been resolved.
If it's possible to resolve the error inside the `onerror` callback, you must at least wait for the boundary to settle before calling `reset()`, for example using [`tick`](https://svelte.dev/docs/svelte/lifecycle-hooks#tick):
```svelte
<svelte:boundary onerror={async (error, reset) => {
fixTheError();
+++await tick();+++
reset();
}}>
</svelte:boundary>
```

@ -168,72 +168,6 @@ Consider the following code:
To fix it, either create callback props to communicate changes, or mark `person` as [`$bindable`]($bindable). To fix it, either create callback props to communicate changes, or mark `person` as [`$bindable`]($bindable).
## reset_misuse
> reset() was invoked and the `<svelte:boundary>` template threw during flush. Calling `reset` inside the `onerror` handler while the app state is still broken can cause the fresh template to crash during its first render; the error bypassed the <svelte:boundary> to avoid an infinite loop `error``reset``error`
When you call `reset()` Svelte tears down the template inside `<svelte:boundary>` and renders a fresh one. If the same bad state that caused the first error in the first place is still present, that fresh mount crashes immediately. To break a potential `error → reset → error` loop, Svelte lets such render-time errors bubble past the boundary.
Sometimes this happens because you might have called `reset` before the error was thrown (perhaps in the `onclick` handler of the button that will then trigger the error) or inside the `onerror` handler.
`reset()` should preferably be called **after** the boundary has entered its error state. A common pattern is to call it from a "Try again" button in the fallback UI.
If you need to call `reset` inside the `onerror` handler, ensure you fix the broken state first, *then* invoke `reset()`.
The examples below show do's and don'ts:
```svelte
<!-- ❌ Don't call reset before errors occur -->
<button onclick={() => {
showComponent = true;
if (reset) reset(); // Called before knowing if error will occur
}}>
Update
</button>
<svelte:boundary>
{#if showComponent}
<!-- ... -->
{/if}
</svelte:boundary>
```
```svelte
<!-- ❌ Don't call reset without fixing the problematic state -->
<svelte:boundary onerror={() => {
// Fix the problematic state first
reset(); // This will cause the error to be thrown again and bypass the boundary
}}>
<!-- ... -->
</svelte:boundary>
```
```svelte
<!-- ✅ Call reset from error UI -->
<svelte:boundary>
<!-- ... -->
{#snippet failed(error)}
<button onclick={() => {
// Fix the problematic state first
selectedItem = null;
userInput = '';
reset(); // Now safe to retry
}}>Try Again</button>
{/snippet}
</svelte:boundary>
```
```svelte
<!-- ✅ Or fix the problematic state first and call reset in the onerror for immediate recovery -->
<svelte:boundary onerror={() => {
componentState = initialComponentState; // Fix/reset the problematic state first
reset(); // Now the regular template will show without errors
}}>
<!-- ... -->
</svelte:boundary>
```
## select_multiple_invalid_value ## select_multiple_invalid_value
> The `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is. > The `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is.

@ -1,6 +1,6 @@
/** @import { Effect, TemplateNode, } from '#client' */ /** @import { Effect, TemplateNode, } from '#client' */
import { BOUNDARY_EFFECT, EFFECT_TRANSPARENT } from '#client/constants'; import { BOUNDARY_EFFECT, EFFECT_RAN, EFFECT_TRANSPARENT } from '#client/constants';
import { component_context, set_component_context } from '../../context.js'; import { component_context, set_component_context } from '../../context.js';
import { handle_error, invoke_error_boundary } from '../../error-handling.js'; import { handle_error, invoke_error_boundary } from '../../error-handling.js';
import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js'; import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js';
@ -20,6 +20,7 @@ import {
} from '../hydration.js'; } from '../hydration.js';
import { queue_micro_task } from '../task.js'; import { queue_micro_task } from '../task.js';
import * as w from '../../warnings.js'; import * as w from '../../warnings.js';
import * as e from '../../errors.js';
/** /**
* @param {Effect} boundary * @param {Effect} boundary
@ -96,8 +97,7 @@ export function boundary(node, props, boundary_fn) {
did_reset = true; did_reset = true;
if (calling_on_error) { if (calling_on_error) {
w.reset_misuse(); e.svelte_boundary_reset_onerror();
throw error;
} }
pause_effect(boundary_effect); pause_effect(boundary_effect);
@ -115,6 +115,12 @@ export function boundary(node, props, boundary_fn) {
calling_on_error = true; calling_on_error = true;
onerror?.(error, reset); onerror?.(error, reset);
calling_on_error = false; calling_on_error = false;
} catch (error) {
if ((boundary.f & EFFECT_RAN) !== 0) {
invoke_error_boundary(error, /** @type {Effect} */ (boundary.parent));
} else {
throw error;
}
} finally { } finally {
set_active_reaction(previous_reaction); set_active_reaction(previous_reaction);
} }

@ -42,7 +42,13 @@ export function invoke_error_boundary(error, effect) {
// @ts-expect-error // @ts-expect-error
effect.fn(error); effect.fn(error);
return; return;
} catch {} } catch (e) {
if (DEV && e instanceof Error) {
adjust_error(e, effect);
}
error = e;
}
} }
effect = effect.parent; effect = effect.parent;

@ -319,4 +319,19 @@ export function state_unsafe_mutation() {
} else { } else {
throw new Error(`https://svelte.dev/e/state_unsafe_mutation`); throw new Error(`https://svelte.dev/e/state_unsafe_mutation`);
} }
}
/**
* A `<svelte:boundary>` `reset` function cannot be called while an error is still being handled
* @returns {never}
*/
export function svelte_boundary_reset_onerror() {
if (DEV) {
const error = new Error(`svelte_boundary_reset_onerror\nA \`<svelte:boundary>\` \`reset\` function cannot be called while an error is still being handled\nhttps://svelte.dev/e/svelte_boundary_reset_onerror`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`);
}
} }

@ -43,17 +43,6 @@ export function console_log_state(method) {
} }
} }
/**
* A `<svelte:boundary>` `reset` function only resets the boundary the first time it is called
*/
export function svelte_boundary_reset_noop() {
if (DEV) {
console.warn(`%c[svelte] svelte_boundary_reset_noop\n%cA \`<svelte:boundary>\` \`reset\` function only resets the boundary the first time it is called\nhttps://svelte.dev/e/svelte_boundary_reset_noop`, bold, normal);
} else {
console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`);
}
}
/** /**
* %handler% should be a function. Did you mean to %suggestion%? * %handler% should be a function. Did you mean to %suggestion%?
* @param {string} handler * @param {string} handler
@ -169,17 +158,6 @@ export function ownership_invalid_mutation(name, location, prop, parent) {
} }
} }
/**
* reset() was invoked and the `<svelte:boundary>` template threw during flush. Calling `reset` inside the `onerror` handler while the app state is still broken can cause the fresh template to crash during its first render; the error bypassed the <svelte:boundary> to avoid an infinite loop `error` `reset` `error`
*/
export function reset_misuse() {
if (DEV) {
console.warn(`%c[svelte] reset_misuse\n%creset() was invoked and the \`<svelte:boundary>\` template threw during flush. Calling \`reset\` inside the \`onerror\` handler while the app state is still broken can cause the fresh template to crash during its first render; the error bypassed the <svelte:boundary> to avoid an infinite loop \`error\`\`reset\`\`error\`\nhttps://svelte.dev/e/reset_misuse`, bold, normal);
} else {
console.warn(`https://svelte.dev/e/reset_misuse`);
}
}
/** /**
* The `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is. * The `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is.
*/ */
@ -203,6 +181,17 @@ export function state_proxy_equality_mismatch(operator) {
} }
} }
/**
* A `<svelte:boundary>` `reset` function only resets the boundary the first time it is called
*/
export function svelte_boundary_reset_noop() {
if (DEV) {
console.warn(`%c[svelte] svelte_boundary_reset_noop\n%cA \`<svelte:boundary>\` \`reset\` function only resets the boundary the first time it is called\nhttps://svelte.dev/e/svelte_boundary_reset_noop`, bold, normal);
} else {
console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`);
}
}
/** /**
* The `slide` transition does not work correctly for elements with `display: %value%` * The `slide` transition does not work correctly for elements with `display: %value%`
* @param {string} value * @param {string} value

@ -2,17 +2,12 @@ import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
test({ assert, target, warnings }) { test({ assert, target }) {
const btn = target.querySelector('button'); const btn = target.querySelector('button');
btn?.click(); btn?.click();
assert.throws(() => { assert.throws(flushSync, 'svelte_boundary_reset_onerror');
flushSync();
}, 'error on template render');
// Check that the warning is being showed to the user
assert.include(warnings[0], 'reset() was invoked');
// boundary content empty; only button remains // boundary content empty; only button remains
assert.htmlEqual(target.innerHTML, `<button>trigger throw</button>`); assert.htmlEqual(target.innerHTML, `<button>trigger throw</button>`);

Loading…
Cancel
Save