fix: properly catch top level await errors (#16619)

* 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

* test

* use helper for async bodies (#16641)

* use helper for async bodies

* unused

* fix

* failing test + fix

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/16643/head
Simon H 3 weeks ago committed by GitHub
parent 04836a8799
commit 1dcced59e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: properly catch top level await errors

@ -359,16 +359,31 @@ export function client_component(analysis, options) {
if (dev) push_args.push(b.id(analysis.name)); if (dev) push_args.push(b.id(analysis.name));
let component_block = b.block([ let component_block = b.block([
store_init,
...store_setup, ...store_setup,
...legacy_reactive_declarations, ...legacy_reactive_declarations,
...group_binding_declarations, ...group_binding_declarations,
...state.instance_level_snippets, ...state.instance_level_snippets
]);
if (analysis.instance.has_await) {
const body = b.block([
.../** @type {ESTree.Statement[]} */ (instance.body), .../** @type {ESTree.Statement[]} */ (instance.body),
analysis.runes || !analysis.needs_context b.if(b.call('$.aborted'), b.return()),
? b.empty .../** @type {ESTree.Statement[]} */ (template.body)
: b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined))
]); ]);
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) { if (analysis.needs_mutation_validation) {
component_block.body.unshift( component_block.body.unshift(
b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props'))) b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props')))
@ -389,41 +404,6 @@ export function client_component(analysis, options) {
analysis.uses_slots || analysis.uses_slots ||
analysis.slot_names.size > 0; 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')),
...component_block.body,
b.if(b.call('$.aborted'), b.return()),
.../** @type {ESTree.Statement[]} */ (template.body),
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 // trick esrap into including comments
component_block.loc = instance.loc; component_block.loc = instance.loc;

@ -99,6 +99,7 @@ export {
with_script with_script
} from './dom/template.js'; } from './dom/template.js';
export { export {
async_body,
for_await_track_reactivity_loss, for_await_track_reactivity_loss,
save, save,
track_reactivity_loss track_reactivity_loss
@ -151,7 +152,8 @@ export {
untrack, untrack,
exclude_from_object, exclude_from_object,
deep_read, deep_read,
deep_read_state deep_read_state,
active_effect
} from './runtime.js'; } from './runtime.js';
export { validate_binding, validate_each_keys } from './validate.js'; export { validate_binding, validate_each_keys } from './validate.js';
export { raf } from './timing.js'; export { raf } from './timing.js';
@ -176,3 +178,4 @@ export {
} from '../shared/validate.js'; } from '../shared/validate.js';
export { strict_equals, equals } from './dev/equality.js'; export { strict_equals, equals } from './dev/equality.js';
export { log_if_contains_state } from './dev/console-log.js'; export { log_if_contains_state } from './dev/console-log.js';
export { invoke_error_boundary } from './error-handling.js';

@ -11,7 +11,7 @@ import {
set_active_effect, set_active_effect,
set_active_reaction set_active_reaction
} from '../runtime.js'; } from '../runtime.js';
import { current_batch } from './batch.js'; import { current_batch, suspend } from './batch.js';
import { import {
async_derived, async_derived,
current_async_effect, current_async_effect,
@ -19,6 +19,7 @@ import {
derived_safe_equal, derived_safe_equal,
set_from_async_derived set_from_async_derived
} from './deriveds.js'; } from './deriveds.js';
import { aborted } from './effects.js';
/** /**
* *
@ -170,3 +171,21 @@ export function unset_context() {
set_component_context(null); set_component_context(null);
if (DEV) set_from_async_derived(null); if (DEV) set_from_async_derived(null);
} }
/**
* @param {() => Promise<void>} 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();
}
}

@ -648,7 +648,6 @@ function resume_children(effect, local) {
} }
} }
export function aborted() { export function aborted(effect = /** @type {Effect} */ (active_effect)) {
var effect = /** @type {Effect} */ (active_effect);
return (effect.f & DESTROYED) !== 0; return (effect.f & DESTROYED) !== 0;
} }

@ -0,0 +1,9 @@
<script>
import { route } from "./main.svelte";
await new Promise(async (_, reject) => {
await Promise.resolve();
route.current = 'other'
route.reject = reject;
});
</script>

@ -0,0 +1,15 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>reject</button> <p>pending</p>`,
async test({ assert, target }) {
const [reject] = target.querySelectorAll('button');
await tick();
reject.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>reject</button> <p>route: other</p>');
}
});

@ -0,0 +1,18 @@
<script module>
import Child from './Child.svelte';
export let route = $state({ current: 'home' });
</script>
<button onclick={() => route.reject()}>reject</button>
<svelte:boundary>
{#if route.current === 'home'}
<Child />
{:else}
<p>route: {route.current}</p>
{/if}
{#snippet pending()}
<p>pending</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,7 @@
<script>
import { route } from "./main.svelte";
await new Promise(async (_, reject) => {
route.reject = reject;
});
</script>

@ -0,0 +1,14 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>reject</button> <p>pending</p>`,
async test({ assert, target }) {
const [reject] = target.querySelectorAll('button');
reject.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>reject</button> <p>failed</p>');
}
});

@ -0,0 +1,18 @@
<script module>
import Child from './Child.svelte';
export let route = $state({});
</script>
<button onclick={() => route.reject()}>reject</button>
<svelte:boundary>
<Child />
{#snippet pending()}
<p>pending</p>
{/snippet}
{#snippet failed()}
<p>failed</p>
{/snippet}
</svelte:boundary>
Loading…
Cancel
Save