Skip to content

fix: add a warning when the misuse of reset in an error:boundary causes an error to be thrown when flushing the boundary content #16171

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/new-dogs-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

Add a warning when the misuse of `reset` in an `error:boundary` causes an error to be thrown when re-mounting the boundary content
5 changes: 5 additions & 0 deletions .changeset/polite-toys-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: make `<svelte:boundary>` reset function a noop after the first call
94 changes: 94 additions & 0 deletions documentation/docs/98-reference/.generated/client-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,74 @@ Consider the following code:

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

```
Expand Down Expand Up @@ -232,6 +300,32 @@ Reactive `$state(...)` proxies and the values they proxy have different identiti

To resolve this, ensure you're comparing values where both values were created with `$state(...)`, or neither were. Note that `$state.raw(...)` will _not_ create a state proxy.

### svelte_boundary_reset_noop

```
A `<svelte:boundary>` `reset` function only resets the boundary the first time it is called
```

When an error occurs while rendering the contents of a [`<svelte:boundary>`](https://svelte.dev/docs/svelte/svelte-boundary), the `onerror` handler is called with the error plus a `reset` function that attempts to re-render the contents.

This `reset` function should only be called once. After that, it has no effect — in a case like this, where a reference to `reset` is stored outside the boundary, clicking the button while `<Contents />` is rendered will _not_ cause the contents to be rendered again.

```svelte
<script>
let reset;
</script>

<button onclick={reset}>reset</button>

<svelte:boundary onerror={(e, r) => (reset = r)}>
<!-- contents >

{#snippet failed(e)}
<p>oops! {e.message}</p>
{/snippet}
</svelte:boundary>
```

### transition_slide_display

```
Expand Down
90 changes: 90 additions & 0 deletions packages/svelte/messages/client-warnings/warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,72 @@ Consider the following code:

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

> 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.
Expand Down Expand Up @@ -196,6 +262,30 @@ To silence the warning, ensure that `value`:

To resolve this, ensure you're comparing values where both values were created with `$state(...)`, or neither were. Note that `$state.raw(...)` will _not_ create a state proxy.

## svelte_boundary_reset_noop

> A `<svelte:boundary>` `reset` function only resets the boundary the first time it is called

When an error occurs while rendering the contents of a [`<svelte:boundary>`](https://svelte.dev/docs/svelte/svelte-boundary), the `onerror` handler is called with the error plus a `reset` function that attempts to re-render the contents.

This `reset` function should only be called once. After that, it has no effect — in a case like this, where a reference to `reset` is stored outside the boundary, clicking the button while `<Contents />` is rendered will _not_ cause the contents to be rendered again.

```svelte
<script>
let reset;
</script>

<button onclick={reset}>reset</button>

<svelte:boundary onerror={(e, r) => (reset = r)}>
<!-- contents >

{#snippet failed(e)}
<p>oops! {e.message}</p>
{/snippet}
</svelte:boundary>
```

## transition_slide_display

> The `slide` transition does not work correctly for elements with `display: %value%`
Expand Down
38 changes: 29 additions & 9 deletions packages/svelte/src/internal/client/dom/blocks/boundary.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { BOUNDARY_EFFECT, EFFECT_TRANSPARENT } from '#client/constants';
import { component_context, set_component_context } from '../../context.js';
import { 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 {
active_effect,
Expand All @@ -19,6 +19,7 @@ import {
set_hydrate_node
} from '../hydration.js';
import { queue_micro_task } from '../task.js';
import * as w from '../../warnings.js';

/**
* @param {Effect} boundary
Expand All @@ -35,6 +36,8 @@ function with_boundary(boundary, fn) {

try {
fn();
} catch (e) {
handle_error(e);
} finally {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
Expand Down Expand Up @@ -73,7 +76,30 @@ export function boundary(node, props, boundary_fn) {
throw error;
}

if (boundary_effect) {
destroy_effect(boundary_effect);
} else if (hydrating) {
set_hydrate_node(hydrate_open);
next();
set_hydrate_node(remove_nodes());
}

var did_reset = false;
var calling_on_error = false;

var reset = () => {
if (did_reset) {
w.svelte_boundary_reset_noop();
return;
}

did_reset = true;

if (calling_on_error) {
w.reset_misuse();
throw error;
}

pause_effect(boundary_effect);

with_boundary(boundary, () => {
Expand All @@ -86,19 +112,13 @@ export function boundary(node, props, boundary_fn) {

try {
set_active_reaction(null);
calling_on_error = true;
onerror?.(error, reset);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rich-Harris People might want to auto-fix the state of the application inside the onerror for instance they might decide to show an error snackbar and reset a form without any confirmation from the user

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It won't work — the update is still in progress and everything gets torn. The svelte_boundary_reset_onerror message has an example of fixing it by waiting for a tick() before calling reset()

calling_on_error = false;
} finally {
set_active_reaction(previous_reaction);
}

if (boundary_effect) {
destroy_effect(boundary_effect);
} else if (hydrating) {
set_hydrate_node(hydrate_open);
next();
set_hydrate_node(remove_nodes());
}

if (failed) {
// Render the `failed` snippet in a microtask
queue_micro_task(() => {
Expand Down
22 changes: 22 additions & 0 deletions packages/svelte/src/internal/client/warnings.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ 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%?
* @param {string} handler
Expand Down Expand Up @@ -158,6 +169,17 @@ 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.
*/
Expand Down
7 changes: 4 additions & 3 deletions packages/svelte/tests/runtime-legacy/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,11 @@ async function run_test_variant(
'Expected component to unmount and leave nothing behind after it was destroyed'
);

// TODO: This seems useless, unhandledRejection is only triggered on the next task
// by which time the test has already finished and the next test resets it to null above
// uncaught errors like during template effects flush
if (unhandled_rejection) {
throw unhandled_rejection; // eslint-disable-line no-unsafe-finally
if (!config.expect_unhandled_rejections) {
throw unhandled_rejection; // eslint-disable-line no-unsafe-finally
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';

export default test({
test({ assert, target, warnings }) {
const btn = target.querySelector('button');

btn?.click();

assert.throws(() => {
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
assert.htmlEqual(target.innerHTML, `<button>trigger throw</button>`);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script>
let must_throw = $state(false);

function throw_error() {
throw new Error("error on template render");
}
</script>

<svelte:boundary onerror={(_, reset) => reset()}>
{must_throw ? throw_error() : 'normal content'}

{#snippet failed()}
<div>err</div>
{/snippet}
</svelte:boundary>

<button onclick={() => must_throw = true}>trigger throw</button>
Loading
Loading