From 66378e51834cf143bd0f84bfe8510babbd54f235 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 14 Aug 2025 13:48:27 +0200 Subject: [PATCH 1/3] fix: properly catch top level await errors async errors within the template and derived etc are properly handled because they know about the last active effect and invoke the error boundary correctly as a response. This logic was missing for our top level await output. Fixes #16613 --- .changeset/silent-suns-whisper.md | 5 +++++ .../3-transform/client/transform-client.js | 17 ++++++++++++++--- packages/svelte/src/compiler/utils/builders.js | 18 ++++++++++++++++++ packages/svelte/src/internal/client/index.js | 4 +++- .../src/internal/client/reactivity/effects.js | 3 +-- 5 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 .changeset/silent-suns-whisper.md diff --git a/.changeset/silent-suns-whisper.md b/.changeset/silent-suns-whisper.md new file mode 100644 index 000000000000..7ee7d74abc3f --- /dev/null +++ b/.changeset/silent-suns-whisper.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: properly catch top level await errors diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 166207f66aa7..eb32f9c98b31 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -402,9 +402,20 @@ export function client_component(analysis, options) { params, b.block([ b.var('$$unsuspend', b.call('$.suspend')), - ...component_block.body, - b.if(b.call('$.aborted'), b.return()), - .../** @type {ESTree.Statement[]} */ (template.body), + b.var('$$active', b.id('$.active_effect')), + b.try_catch( + b.block([ + ...component_block.body, + b.if(b.call('$.aborted'), b.return()), + .../** @type {ESTree.Statement[]} */ (template.body) + ]), + b.block([ + b.if( + b.unary('!', b.call('$.aborted', b.id('$$active'))), + b.stmt(b.call('$.invoke_error_boundary', b.id('$$error'), b.id('$$active'))) + ) + ]) + ), b.stmt(b.call('$$unsuspend')) ]), true diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 56a5f31ffe82..c77cd7eee782 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -659,6 +659,24 @@ export function throw_error(str) { }; } +/** + * @param {ESTree.BlockStatement} body + * @param {ESTree.BlockStatement} handler + * @returns {ESTree.TryStatement} + */ +export function try_catch(body, handler) { + return { + type: 'TryStatement', + block: body, + handler: { + type: 'CatchClause', + param: id('$$error'), + body: handler + }, + finalizer: null + }; +} + export { await_builder as await, let_builder as let, diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index c094c9e04449..4089401a7ef2 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -151,7 +151,8 @@ export { untrack, exclude_from_object, deep_read, - deep_read_state + deep_read_state, + active_effect } from './runtime.js'; export { validate_binding, validate_each_keys } from './validate.js'; export { raf } from './timing.js'; @@ -176,3 +177,4 @@ export { } from '../shared/validate.js'; export { strict_equals, equals } from './dev/equality.js'; export { log_if_contains_state } from './dev/console-log.js'; +export { invoke_error_boundary } from './error-handling.js'; diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 68a155503237..df3dd7580896 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -648,7 +648,6 @@ function resume_children(effect, local) { } } -export function aborted() { - var effect = /** @type {Effect} */ (active_effect); +export function aborted(effect = /** @type {Effect} */ (active_effect)) { return (effect.f & DESTROYED) !== 0; } From af11ee5a47ab17cb82c505ffcf78982443edcfb3 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sun, 17 Aug 2025 22:13:05 +0200 Subject: [PATCH 2/3] test --- .../async-top-level-error-nested/Child.svelte | 9 +++++++++ .../async-top-level-error-nested/_config.js | 15 +++++++++++++++ .../async-top-level-error-nested/main.svelte | 18 ++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte new file mode 100644 index 000000000000..f7ba132acee1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js new file mode 100644 index 000000000000..298e33e9a299 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js @@ -0,0 +1,15 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

pending

`, + + async test({ assert, target }) { + const [reject] = target.querySelectorAll('button'); + + await tick(); + reject.click(); + await tick(); + assert.htmlEqual(target.innerHTML, '

route: other

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte new file mode 100644 index 000000000000..2f461e96c856 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte @@ -0,0 +1,18 @@ + + + + + + {#if route.current === 'home'} + + {:else} +

route: {route.current}

+ {/if} + + {#snippet pending()} +

pending

+ {/snippet} +
From ab5aedaddd168e98713fd708d86ec6a5c0344f3c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 18 Aug 2025 12:58:36 -0400 Subject: [PATCH 3/3] use helper for async bodies (#16641) * use helper for async bodies * unused * fix * failing test + fix --------- Co-authored-by: Simon Holthausen --- .../3-transform/client/transform-client.js | 71 ++++++------------- .../svelte/src/compiler/utils/builders.js | 18 ----- packages/svelte/src/internal/client/index.js | 1 + .../src/internal/client/reactivity/async.js | 21 +++++- .../Child.svelte | 9 +++ .../_config.js | 15 ++++ .../main.svelte | 18 +++++ .../async-top-level-error-nested/Child.svelte | 2 - .../async-top-level-error-nested/_config.js | 3 +- .../async-top-level-error-nested/main.svelte | 12 ++-- 10 files changed, 90 insertions(+), 80 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/main.svelte diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index eb32f9c98b31..940d6a9e004d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -359,16 +359,31 @@ export function client_component(analysis, options) { if (dev) push_args.push(b.id(analysis.name)); let component_block = b.block([ + store_init, ...store_setup, ...legacy_reactive_declarations, ...group_binding_declarations, - ...state.instance_level_snippets, - .../** @type {ESTree.Statement[]} */ (instance.body), - analysis.runes || !analysis.needs_context - ? b.empty - : b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)) + ...state.instance_level_snippets ]); + if (analysis.instance.has_await) { + const body = b.block([ + .../** @type {ESTree.Statement[]} */ (instance.body), + b.if(b.call('$.aborted'), b.return()), + .../** @type {ESTree.Statement[]} */ (template.body) + ]); + + component_block.body.push(b.stmt(b.call(`$.async_body`, b.arrow([], body, true)))); + } else { + component_block.body.push(.../** @type {ESTree.Statement[]} */ (instance.body)); + + if (!analysis.runes && analysis.needs_context) { + component_block.body.push(b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined))); + } + + component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body)); + } + if (analysis.needs_mutation_validation) { component_block.body.unshift( b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props'))) @@ -389,52 +404,6 @@ export function client_component(analysis, options) { analysis.uses_slots || analysis.slot_names.size > 0; - if (analysis.instance.has_await) { - const params = [b.id('$$anchor')]; - if (should_inject_props) { - params.push(b.id('$$props')); - } - if (store_setup.length > 0) { - params.push(b.id('$$stores')); - } - const body = b.function_declaration( - b.id('$$body'), - params, - b.block([ - b.var('$$unsuspend', b.call('$.suspend')), - b.var('$$active', b.id('$.active_effect')), - b.try_catch( - b.block([ - ...component_block.body, - b.if(b.call('$.aborted'), b.return()), - .../** @type {ESTree.Statement[]} */ (template.body) - ]), - b.block([ - b.if( - b.unary('!', b.call('$.aborted', b.id('$$active'))), - b.stmt(b.call('$.invoke_error_boundary', b.id('$$error'), b.id('$$active'))) - ) - ]) - ), - b.stmt(b.call('$$unsuspend')) - ]), - true - ); - - state.hoisted.push(body); - - component_block = b.block([ - b.var('fragment', b.call('$.comment')), - b.var('node', b.call('$.first_child', b.id('fragment'))), - store_init, - b.stmt(b.call(body.id, b.id('node'), ...params.slice(1))), - b.stmt(b.call('$.append', b.id('$$anchor'), b.id('fragment'))) - ]); - } else { - component_block.body.unshift(store_init); - component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body)); - } - // trick esrap into including comments component_block.loc = instance.loc; diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index c77cd7eee782..56a5f31ffe82 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -659,24 +659,6 @@ export function throw_error(str) { }; } -/** - * @param {ESTree.BlockStatement} body - * @param {ESTree.BlockStatement} handler - * @returns {ESTree.TryStatement} - */ -export function try_catch(body, handler) { - return { - type: 'TryStatement', - block: body, - handler: { - type: 'CatchClause', - param: id('$$error'), - body: handler - }, - finalizer: null - }; -} - export { await_builder as await, let_builder as let, diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 4089401a7ef2..c5b7bb845c4a 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -99,6 +99,7 @@ export { with_script } from './dom/template.js'; export { + async_body, for_await_track_reactivity_loss, save, track_reactivity_loss diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 2b133e5f4492..1ea1bbe56160 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -11,7 +11,7 @@ import { set_active_effect, set_active_reaction } from '../runtime.js'; -import { current_batch } from './batch.js'; +import { current_batch, suspend } from './batch.js'; import { async_derived, current_async_effect, @@ -19,6 +19,7 @@ import { derived_safe_equal, set_from_async_derived } from './deriveds.js'; +import { aborted } from './effects.js'; /** * @@ -170,3 +171,21 @@ export function unset_context() { set_component_context(null); if (DEV) set_from_async_derived(null); } + +/** + * @param {() => Promise} fn + */ +export async function async_body(fn) { + const unsuspend = suspend(); + const active = /** @type {Effect} */ (active_effect); + + try { + await fn(); + } catch (error) { + if (!aborted(active)) { + invoke_error_boundary(error, active); + } + } finally { + unsuspend(); + } +} diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/Child.svelte new file mode 100644 index 000000000000..f7ba132acee1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/Child.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/_config.js b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/_config.js new file mode 100644 index 000000000000..298e33e9a299 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/_config.js @@ -0,0 +1,15 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

pending

`, + + async test({ assert, target }) { + const [reject] = target.querySelectorAll('button'); + + await tick(); + reject.click(); + await tick(); + assert.htmlEqual(target.innerHTML, '

route: other

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/main.svelte new file mode 100644 index 000000000000..2f461e96c856 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/main.svelte @@ -0,0 +1,18 @@ + + + + + + {#if route.current === 'home'} + + {:else} +

route: {route.current}

+ {/if} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte index f7ba132acee1..11c9ebd6532a 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte @@ -2,8 +2,6 @@ import { route } from "./main.svelte"; await new Promise(async (_, reject) => { - await Promise.resolve(); - route.current = 'other' route.reject = reject; }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js index 298e33e9a299..57005b41120c 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js @@ -7,9 +7,8 @@ export default test({ async test({ assert, target }) { const [reject] = target.querySelectorAll('button'); - await tick(); reject.click(); await tick(); - assert.htmlEqual(target.innerHTML, '

route: other

'); + assert.htmlEqual(target.innerHTML, '

failed

'); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte index 2f461e96c856..2fdf4c0d2f65 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte @@ -1,18 +1,18 @@ - {#if route.current === 'home'} - - {:else} -

route: {route.current}

- {/if} + {#snippet pending()}

pending

{/snippet} + + {#snippet failed()} +

failed

+ {/snippet}