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));
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,41 +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')),
...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
component_block.loc = instance.loc;

@ -99,6 +99,7 @@ export {
with_script
} from './dom/template.js';
export {
async_body,
for_await_track_reactivity_loss,
save,
track_reactivity_loss
@ -151,7 +152,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 +178,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';

@ -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<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() {
var effect = /** @type {Effect} */ (active_effect);
export function aborted(effect = /** @type {Effect} */ (active_effect)) {
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