From 997ccefea96820934e0cf53d07f079635fd29922 Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 15 Jun 2025 17:32:14 +0100 Subject: [PATCH 1/9] Add a warning when the misuse of `reset` in an `error:boundary` causes an error to be thrown when flushing the boundary content --- .../.generated/client-warnings.md | 68 +++++++++++++++++++ .../messages/client-warnings/warnings.md | 66 ++++++++++++++++++ .../internal/client/dom/blocks/boundary.js | 9 ++- .../svelte/src/internal/client/warnings.js | 11 +++ 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index fe90b0db3815..e11d73e819f4 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -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 `` 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 to avoid an infinite loop `error` → `reset` → `error` +``` + +When you call `reset()` Svelte tears down the template inside `` 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 + + + + + {#if showComponent} + + {/if} + +``` + +```svelte + + { + // Fix the problematic state first + reset(); // This will cause the error to be thrown again and bypass the boundary +}}> + + +``` + +```svelte + + + + + {#snippet failed(error)} + + {/snippet} + +``` + +```svelte + + { + componentState = initialComponentState; // Fix/reset the problematic state first + reset(); // Now the regular template will show without errors +}}> + + +``` + ### select_multiple_invalid_value ``` diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index f1901271d11c..e161fe4bd909 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -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 `` 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 to avoid an infinite loop `error` → `reset` → `error` + +When you call `reset()` Svelte tears down the template inside `` 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 + + + + + {#if showComponent} + + {/if} + +``` + +```svelte + + { + // Fix the problematic state first + reset(); // This will cause the error to be thrown again and bypass the boundary +}}> + + +``` + +```svelte + + + + + {#snippet failed(error)} + + {/snippet} + +``` + +```svelte + + { + componentState = initialComponentState; // Fix/reset the problematic state first + reset(); // Now the regular template will show without errors +}}> + + +``` + ## select_multiple_invalid_value > The `value` property of a `` element should be an array, but it received a non-array value. The selection will be kept as is. */ From 625530168f1b165d08206a2f322e4ad68d45f5c4 Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 15 Jun 2025 20:01:21 +0100 Subject: [PATCH 2/9] Prevent uncaught errors to make test fails when they are expected and are fired during template effects flush --- packages/svelte/tests/runtime-legacy/shared.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 11ea9f6dda89..b2b850901c34 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -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 + } } } } From 0554c512d9821429c5ccbd029ae49bb36b9cd4c4 Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 15 Jun 2025 20:01:47 +0100 Subject: [PATCH 3/9] Add tests --- .../error-boundary-reset-onerror/_config.js | 20 +++++++ .../error-boundary-reset-onerror/main.svelte | 17 ++++++ .../error-boundary-reset-premature/_config.js | 58 +++++++++++++++++++ .../main.svelte | 26 +++++++++ 4 files changed, 121 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-reset-premature/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-reset-premature/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/_config.js b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/_config.js new file mode 100644 index 000000000000..2a18790c611e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/_config.js @@ -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, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/main.svelte b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/main.svelte new file mode 100644 index 000000000000..f91048a9e778 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/main.svelte @@ -0,0 +1,17 @@ + + + reset()}> + {must_throw ? throw_error() : 'normal content'} + + {#snippet failed()} +
err
+ {/snippet} +
+ + diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-premature/_config.js b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-premature/_config.js new file mode 100644 index 000000000000..048413ad7b81 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-premature/_config.js @@ -0,0 +1,58 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + normal content + + `, + + expect_unhandled_rejections: true, + + async test({ assert, target, warnings, window }) { + // @ts-expect-error + const __expected_error = (window.__expected_error = { v: false }); + + window.addEventListener('error', (e) => { + // @ts-expect-error when in hydrate mode we can't access variables in the scope + const __expected_error = window.__expected_error; + + if (__expected_error.v) { + assert.include(e.error.message, 'error on template render'); + } else { + assert.fail('Error was not expected: ' + e.error.message); + } + e.preventDefault(); + }); + + const btn = target.querySelector('button'); + + // 1st click — error caught, fallback visible + btn?.click(); + flushSync(); + assert.htmlEqual(target.innerHTML, `
err
`); + + // 2nd click — reset succeeds, normal render + btn?.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ` + normal content + + ` + ); + + // 3rd click — mount-time crash escapes, boundary empty + __expected_error.v = true; + btn?.click(); + flushSync(); + __expected_error.v = false; + + // 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, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-premature/main.svelte b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-premature/main.svelte new file mode 100644 index 000000000000..c1462eaf09c2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-premature/main.svelte @@ -0,0 +1,26 @@ + + + + (reset = fn)}> + {must_throw ? throw_error() : 'normal content'} + + {#snippet failed()} +
err
+ {/snippet} +
+
+ + From 28b0310e359467c792931360ef74b7bf7385f895 Mon Sep 17 00:00:00 2001 From: raythurnvoid <53383860+raythurnvoid@users.noreply.github.com> Date: Sun, 15 Jun 2025 20:02:15 +0100 Subject: [PATCH 4/9] Add changeset --- .changeset/new-dogs-obey.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/new-dogs-obey.md diff --git a/.changeset/new-dogs-obey.md b/.changeset/new-dogs-obey.md new file mode 100644 index 000000000000..088d7a946236 --- /dev/null +++ b/.changeset/new-dogs-obey.md @@ -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 From 0ebb878166bbe0e354177ae93fe730524956d9a7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Jun 2025 08:41:47 -0400 Subject: [PATCH 5/9] reset should just be a noop after the first call --- .../.generated/client-warnings.md | 26 +++++++++ .../messages/client-warnings/warnings.md | 24 +++++++++ .../internal/client/dom/blocks/boundary.js | 9 ++++ .../svelte/src/internal/client/warnings.js | 11 ++++ .../error-boundary-reset-premature/_config.js | 54 +++++-------------- 5 files changed, 82 insertions(+), 42 deletions(-) diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index e11d73e819f4..948f4b9b2f97 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -300,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 `` `reset` function only resets the boundary the first time it is called +``` + +When an error occurs while rendering the contents of a [``](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 `` is rendered will _not_ cause the contents to be rendered again. + +```svelte + + + + + (reset = r)}> + - - - - {#if showComponent} - - {/if} - -``` - -```svelte - - { - // Fix the problematic state first - reset(); // This will cause the error to be thrown again and bypass the boundary -}}> - - -``` - -```svelte - - - - - {#snippet failed(error)} - - {/snippet} - -``` - -```svelte - - { - componentState = initialComponentState; // Fix/reset the problematic state first - reset(); // Now the regular template will show without errors -}}> - - -``` - ### select_multiple_invalid_value ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index c4e68f8fee80..32c4e620e320 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -114,3 +114,21 @@ let odd = $derived(!even); ``` If side-effects are unavoidable, use [`$effect`]($effect) instead. + +## svelte_boundary_reset_onerror + +> A `` `reset` function cannot be called while an error is still being handled + +If a [``](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 + { + fixTheError(); + +++await tick();+++ + reset(); +}}> + + +``` diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 0c75803433bf..6a3c79daa563 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -168,72 +168,6 @@ 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 `` 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 to avoid an infinite loop `error` → `reset` → `error` - -When you call `reset()` Svelte tears down the template inside `` 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 - - - - - {#if showComponent} - - {/if} - -``` - -```svelte - - { - // Fix the problematic state first - reset(); // This will cause the error to be thrown again and bypass the boundary -}}> - - -``` - -```svelte - - - - - {#snippet failed(error)} - - {/snippet} - -``` - -```svelte - - { - componentState = initialComponentState; // Fix/reset the problematic state first - reset(); // Now the regular template will show without errors -}}> - - -``` - ## select_multiple_invalid_value > The `value` property of a `` 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 `` `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 \`\` \`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%` * @param {string} value diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/_config.js b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/_config.js index 2a18790c611e..092d7ad37d57 100644 --- a/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-onerror/_config.js @@ -2,17 +2,12 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ - test({ assert, target, warnings }) { + test({ assert, target }) { 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'); + assert.throws(flushSync, 'svelte_boundary_reset_onerror'); // boundary content empty; only button remains assert.htmlEqual(target.innerHTML, ``); From f686128f348e44bc0950ff960d7e497fc2d607bf Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Jun 2025 11:22:35 -0400 Subject: [PATCH 9/9] update changeset --- .changeset/new-dogs-obey.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/new-dogs-obey.md b/.changeset/new-dogs-obey.md index 088d7a946236..aa9a3d73b958 100644 --- a/.changeset/new-dogs-obey.md +++ b/.changeset/new-dogs-obey.md @@ -2,4 +2,4 @@ '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 +fix: handle error in correct boundary after reset