From a4ce7760705bc58454e954488719e2ef6d17cafa Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:57:59 +0200 Subject: [PATCH] fix: defer error boundary rendering in forks (#18076) RIght now, when an async error occurs inside a fork, the error UI will show immediately. This change defers the removal of the current content etc until the fork is committed. The BranchManager logic cannot be reused here since this is not about pending work, and we cannot wait for other pending work to complete if the fork commits. Instead, batches now have a "on fork commit" callback set which the boundary pushes to. The boundary's effects are skipped in the fork until it's committing, at which point we "resume" the error logic (calling onerror, transforming it, etc) Fixes #18060 --- .changeset/slimy-pears-throw.md | 5 +++ .../internal/client/dom/blocks/boundary.js | 26 ++++++++++++--- .../src/internal/client/reactivity/async.js | 3 +- .../src/internal/client/reactivity/batch.js | 21 ++++++++++++ .../async-fork-failure-escapes/_config.js | 33 +++++++++++++++++++ .../async-fork-failure-escapes/main.svelte | 18 ++++++++++ 6 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 .changeset/slimy-pears-throw.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-failure-escapes/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-failure-escapes/main.svelte diff --git a/.changeset/slimy-pears-throw.md b/.changeset/slimy-pears-throw.md new file mode 100644 index 0000000000..c9954586e5 --- /dev/null +++ b/.changeset/slimy-pears-throw.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: defer error boundary rendering in forks diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index b440bb3ba4..028b82ab92 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -35,7 +35,7 @@ import { queue_micro_task } from '../task.js'; import * as e from '../../errors.js'; import * as w from '../../warnings.js'; import { DEV } from 'esm-env'; -import { Batch, current_batch, schedule_effect } from '../../reactivity/batch.js'; +import { Batch, current_batch, previous_batch, schedule_effect } from '../../reactivity/batch.js'; import { internal_set, source } from '../../reactivity/sources.js'; import { tag } from '../../dev/tracing.js'; import { createSubscriber } from '../../../../reactivity/create-subscriber.js'; @@ -386,15 +386,29 @@ export class Boundary { /** @param {unknown} error */ error(error) { - var onerror = this.#props.onerror; - let failed = this.#props.failed; - // If we have nothing to capture the error, or if we hit an error while // rendering the fallback, re-throw for another boundary to handle - if (!onerror && !failed) { + if (!this.#props.onerror && !this.#props.failed) { throw error; } + if (current_batch?.is_fork) { + if (this.#main_effect) current_batch.skip_effect(this.#main_effect); + if (this.#pending_effect) current_batch.skip_effect(this.#pending_effect); + if (this.#failed_effect) current_batch.skip_effect(this.#failed_effect); + + current_batch.on_fork_commit(() => { + this.#handle_error(error); + }); + } else { + this.#handle_error(error); + } + } + + /** + * @param {unknown} error + */ + #handle_error(error) { if (this.#main_effect) { destroy_effect(this.#main_effect); this.#main_effect = null; @@ -416,6 +430,8 @@ export class Boundary { set_hydrate_node(skip_nodes()); } + var onerror = this.#props.onerror; + let failed = this.#props.failed; var did_reset = false; var calling_on_error = false; diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 7b0b108e4c..18e4f71c88 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -265,6 +265,8 @@ export function run(thunks) { for (const fn of thunks.slice(1)) { promise = promise .then(() => { + restore(); + if (errored) { throw errored.error; } @@ -273,7 +275,6 @@ export function run(thunks) { throw STALE_REACTION; } - restore(); return fn(); }) .catch(handle_error); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 9c0832150a..68aeb66f93 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -120,6 +120,12 @@ export class Batch { */ #discard_callbacks = new Set(); + /** + * Callbacks that should run only when a fork is committed. + * @type {Set<(batch: Batch) => void>} + */ + #fork_commit_callbacks = new Set(); + /** * Async effects that are currently in flight * @type {Map} @@ -489,6 +495,7 @@ export class Batch { discard() { for (const fn of this.#discard_callbacks) fn(this); this.#discard_callbacks.clear(); + this.#fork_commit_callbacks.clear(); batches.delete(this); } @@ -686,6 +693,16 @@ export class Batch { this.#discard_callbacks.add(fn); } + /** @param {(batch: Batch) => void} fn */ + on_fork_commit(fn) { + this.#fork_commit_callbacks.add(fn); + } + + run_fork_commit_callbacks() { + for (const fn of this.#fork_commit_callbacks) fn(this); + this.#fork_commit_callbacks.clear(); + } + settled() { return (this.#deferred ??= deferred()).promise; } @@ -1212,6 +1229,10 @@ export function fork(fn) { source.wv = increment_write_version(); } + batch.activate(); + batch.run_fork_commit_callbacks(); + batch.deactivate(); + // trigger any `$state.eager(...)` expressions with the new state. // eager effects don't get scheduled like other effects, so we // can't just encounter them during traversal, we need to diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-failure-escapes/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-failure-escapes/_config.js new file mode 100644 index 0000000000..1a9cd322dd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-failure-escapes/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [show, commit] = target.querySelectorAll('button'); + + show.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + commit.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + failed + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-failure-escapes/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-failure-escapes/main.svelte new file mode 100644 index 0000000000..d598bd6f47 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-failure-escapes/main.svelte @@ -0,0 +1,18 @@ + + + + + + + + {#if show} + {await Promise.reject('boom')} + {/if} + {#snippet failed()} + failed + {/snippet} + \ No newline at end of file