From 7fe14a508adbfa85ba53e43b9e9c6700d6fde588 Mon Sep 17 00:00:00 2001 From: "svelte-triage-bot[bot]" <316883489+svelte-triage-bot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:15:35 +0200 Subject: [PATCH] fix: ignore stale boundary resets after destruction (#18886) - `destroy_effect` cleared a boundary's component context while retained reset callbacks and queued error work could still re-enter its run path. - Reset handling is intentionally shared with hydration by [#18556](https://github.com/sveltejs/svelte/pull/18556), while `onerror` is deferred for safe state mutation by [#17561](https://github.com/sveltejs/svelte/pull/17561); neither path checked the boundary lifetime. - Make retained and deferred boundary continuations inert during destruction instead of masking null contexts in `bind:this` or `#run`, which would still let dead effects and callbacks restart. - Cover resets from `onerror` and failed snippets, cleanup ordering, delayed error transforms, DOM rendering, and hydration. Fixes https://github.com/sveltejs/svelte/issues/18885 --- .changeset/stale-boundary-reset.md | 5 ++ .../internal/client/dom/blocks/boundary.js | 26 ++++++++-- .../error-boundary-reset-destroyed/_config.js | 50 +++++++++++++++++++ .../main.svelte | 47 +++++++++++++++++ 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 .changeset/stale-boundary-reset.md create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-reset-destroyed/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/error-boundary-reset-destroyed/main.svelte diff --git a/.changeset/stale-boundary-reset.md b/.changeset/stale-boundary-reset.md new file mode 100644 index 0000000000..1f158c5713 --- /dev/null +++ b/.changeset/stale-boundary-reset.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ignore stale boundary reset callbacks diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 979d3e50b4..7ca0c47a81 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,5 +1,11 @@ /** @import { Effect, Source, TemplateNode, } from '#client' */ -import { BOUNDARY_EFFECT, EFFECT_PRESERVED, EFFECT_TRANSPARENT } from '#client/constants'; +import { + BOUNDARY_EFFECT, + DESTROYED, + DESTROYING, + EFFECT_PRESERVED, + EFFECT_TRANSPARENT +} from '#client/constants'; import { HYDRATION_ERROR, HYDRATION_START_ELSE, @@ -224,6 +230,8 @@ export class Boundary { var calling_on_error = false; const reset = () => { + if (this.#is_destroyed()) return; + if (did_reset) { w.svelte_boundary_reset_noop(); return; @@ -247,6 +255,8 @@ export class Boundary { }; const invoke_onerror = () => { + if (this.#is_destroyed()) return; + try { calling_on_error = true; this.#props.onerror?.(error, reset); @@ -259,6 +269,10 @@ export class Boundary { return { reset, invoke_onerror }; } + #is_destroyed() { + return (this.#effect.f & (DESTROYED | DESTROYING)) !== 0; + } + #hydrate_pending_content() { const pending = this.#props.pending; if (!pending) return; @@ -267,6 +281,8 @@ export class Boundary { this.#pending_effect = branch(() => pending(this.#anchor)); queue_micro_task(() => { + if (this.#is_destroyed()) return; + var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); var anchor = create_text(); var handled = false; @@ -465,7 +481,7 @@ export class Boundary { if (this.#failed_effect) current_batch.skip_effect(this.#failed_effect); current_batch.oncommit(() => { - this.#handle_error(error); + if (!this.#is_destroyed()) this.#handle_error(error); }); } else { this.#handle_error(error); @@ -501,11 +517,13 @@ export class Boundary { /** @param {unknown} transformed_error */ const handle_error_result = (transformed_error) => { + if (this.#is_destroyed()) return; + const { reset, invoke_onerror } = this.#create_reset(transformed_error); invoke_onerror(); - if (failed) { + if (failed && !this.#is_destroyed()) { this.#failed_effect = this.#run(() => { try { return branch(() => { @@ -531,6 +549,8 @@ export class Boundary { }; queue_micro_task(() => { + if (this.#is_destroyed()) return; + // Run the error through the API-level transformError transform (e.g. SvelteKit's handleError) /** @type {unknown} */ var result; diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-destroyed/_config.js b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-destroyed/_config.js new file mode 100644 index 0000000000..01d57158bf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-destroyed/_config.js @@ -0,0 +1,50 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +/** @type {Array<() => void>} */ +const resolvers = []; + +export default test({ + transformError: (error) => new Promise((resolve) => resolvers.push(() => resolve(error))), + + async test({ assert, target, logs }) { + const [error, toggle, reset, destroy] = target.querySelectorAll('button'); + const paragraph = /** @type {HTMLParagraphElement} */ (target.querySelector('p')); + + error.click(); + await tick(); + resolvers.shift()?.(); + await tick(); + assert.htmlEqual(paragraph.innerHTML, 'boom'); + + // A retained reset is inert after its boundary has been destroyed + toggle.click(); + await tick(); + reset.click(); + await tick(); + assert.htmlEqual(paragraph.innerHTML, 'boom'); + + // Resolving an error transform cannot resume a destroyed boundary + toggle.click(); + await tick(); + error.click(); + await tick(); + toggle.click(); + await tick(); + resolvers.shift()?.(); + await tick(); + assert.htmlEqual(paragraph.innerHTML, 'boom'); + + // A failed snippet's reset is also inert while the boundary is being destroyed + toggle.click(); + await tick(); + error.click(); + await tick(); + resolvers.shift()?.(); + await tick(); + destroy.click(); + await tick(); + assert.htmlEqual(paragraph.innerHTML, 'boom,boom'); + assert.deepEqual(logs, ['render', 'render', 'render']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-destroyed/main.svelte b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-destroyed/main.svelte new file mode 100644 index 0000000000..e8a1db83d8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-reset-destroyed/main.svelte @@ -0,0 +1,47 @@ + + + + + + +
{errors.join(',')}
+ +{#if show} +