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