Skip to content
Merged
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