Skip to content

Commit 1dcced5

Browse files
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 <[email protected]> --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 04836a8 commit 1dcced5

File tree

11 files changed

+131
-44
lines changed

11 files changed

+131
-44
lines changed

.changeset/silent-suns-whisper.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: properly catch top level await errors

packages/svelte/src/compiler/phases/3-transform/client/transform-client.js

Lines changed: 20 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -359,16 +359,31 @@ export function client_component(analysis, options) {
359359
if (dev) push_args.push(b.id(analysis.name));
360360

361361
let component_block = b.block([
362+
store_init,
362363
...store_setup,
363364
...legacy_reactive_declarations,
364365
...group_binding_declarations,
365-
...state.instance_level_snippets,
366-
.../** @type {ESTree.Statement[]} */ (instance.body),
367-
analysis.runes || !analysis.needs_context
368-
? b.empty
369-
: b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined))
366+
...state.instance_level_snippets
370367
]);
371368

369+
if (analysis.instance.has_await) {
370+
const body = b.block([
371+
.../** @type {ESTree.Statement[]} */ (instance.body),
372+
b.if(b.call('$.aborted'), b.return()),
373+
.../** @type {ESTree.Statement[]} */ (template.body)
374+
]);
375+
376+
component_block.body.push(b.stmt(b.call(`$.async_body`, b.arrow([], body, true))));
377+
} else {
378+
component_block.body.push(.../** @type {ESTree.Statement[]} */ (instance.body));
379+
380+
if (!analysis.runes && analysis.needs_context) {
381+
component_block.body.push(b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)));
382+
}
383+
384+
component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body));
385+
}
386+
372387
if (analysis.needs_mutation_validation) {
373388
component_block.body.unshift(
374389
b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props')))
@@ -389,41 +404,6 @@ export function client_component(analysis, options) {
389404
analysis.uses_slots ||
390405
analysis.slot_names.size > 0;
391406

392-
if (analysis.instance.has_await) {
393-
const params = [b.id('$$anchor')];
394-
if (should_inject_props) {
395-
params.push(b.id('$$props'));
396-
}
397-
if (store_setup.length > 0) {
398-
params.push(b.id('$$stores'));
399-
}
400-
const body = b.function_declaration(
401-
b.id('$$body'),
402-
params,
403-
b.block([
404-
b.var('$$unsuspend', b.call('$.suspend')),
405-
...component_block.body,
406-
b.if(b.call('$.aborted'), b.return()),
407-
.../** @type {ESTree.Statement[]} */ (template.body),
408-
b.stmt(b.call('$$unsuspend'))
409-
]),
410-
true
411-
);
412-
413-
state.hoisted.push(body);
414-
415-
component_block = b.block([
416-
b.var('fragment', b.call('$.comment')),
417-
b.var('node', b.call('$.first_child', b.id('fragment'))),
418-
store_init,
419-
b.stmt(b.call(body.id, b.id('node'), ...params.slice(1))),
420-
b.stmt(b.call('$.append', b.id('$$anchor'), b.id('fragment')))
421-
]);
422-
} else {
423-
component_block.body.unshift(store_init);
424-
component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body));
425-
}
426-
427407
// trick esrap into including comments
428408
component_block.loc = instance.loc;
429409

packages/svelte/src/internal/client/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export {
9999
with_script
100100
} from './dom/template.js';
101101
export {
102+
async_body,
102103
for_await_track_reactivity_loss,
103104
save,
104105
track_reactivity_loss
@@ -151,7 +152,8 @@ export {
151152
untrack,
152153
exclude_from_object,
153154
deep_read,
154-
deep_read_state
155+
deep_read_state,
156+
active_effect
155157
} from './runtime.js';
156158
export { validate_binding, validate_each_keys } from './validate.js';
157159
export { raf } from './timing.js';
@@ -176,3 +178,4 @@ export {
176178
} from '../shared/validate.js';
177179
export { strict_equals, equals } from './dev/equality.js';
178180
export { log_if_contains_state } from './dev/console-log.js';
181+
export { invoke_error_boundary } from './error-handling.js';

packages/svelte/src/internal/client/reactivity/async.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ import {
1111
set_active_effect,
1212
set_active_reaction
1313
} from '../runtime.js';
14-
import { current_batch } from './batch.js';
14+
import { current_batch, suspend } from './batch.js';
1515
import {
1616
async_derived,
1717
current_async_effect,
1818
derived,
1919
derived_safe_equal,
2020
set_from_async_derived
2121
} from './deriveds.js';
22+
import { aborted } from './effects.js';
2223

2324
/**
2425
*
@@ -170,3 +171,21 @@ export function unset_context() {
170171
set_component_context(null);
171172
if (DEV) set_from_async_derived(null);
172173
}
174+
175+
/**
176+
* @param {() => Promise<void>} fn
177+
*/
178+
export async function async_body(fn) {
179+
const unsuspend = suspend();
180+
const active = /** @type {Effect} */ (active_effect);
181+
182+
try {
183+
await fn();
184+
} catch (error) {
185+
if (!aborted(active)) {
186+
invoke_error_boundary(error, active);
187+
}
188+
} finally {
189+
unsuspend();
190+
}
191+
}

packages/svelte/src/internal/client/reactivity/effects.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,6 @@ function resume_children(effect, local) {
648648
}
649649
}
650650

651-
export function aborted() {
652-
var effect = /** @type {Effect} */ (active_effect);
651+
export function aborted(effect = /** @type {Effect} */ (active_effect)) {
653652
return (effect.f & DESTROYED) !== 0;
654653
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
import { route } from "./main.svelte";
3+
4+
await new Promise(async (_, reject) => {
5+
await Promise.resolve();
6+
route.current = 'other'
7+
route.reject = reject;
8+
});
9+
</script>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
html: `<button>reject</button> <p>pending</p>`,
6+
7+
async test({ assert, target }) {
8+
const [reject] = target.querySelectorAll('button');
9+
10+
await tick();
11+
reject.click();
12+
await tick();
13+
assert.htmlEqual(target.innerHTML, '<button>reject</button> <p>route: other</p>');
14+
}
15+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script module>
2+
import Child from './Child.svelte';
3+
export let route = $state({ current: 'home' });
4+
</script>
5+
6+
<button onclick={() => route.reject()}>reject</button>
7+
8+
<svelte:boundary>
9+
{#if route.current === 'home'}
10+
<Child />
11+
{:else}
12+
<p>route: {route.current}</p>
13+
{/if}
14+
15+
{#snippet pending()}
16+
<p>pending</p>
17+
{/snippet}
18+
</svelte:boundary>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script>
2+
import { route } from "./main.svelte";
3+
4+
await new Promise(async (_, reject) => {
5+
route.reject = reject;
6+
});
7+
</script>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
html: `<button>reject</button> <p>pending</p>`,
6+
7+
async test({ assert, target }) {
8+
const [reject] = target.querySelectorAll('button');
9+
10+
reject.click();
11+
await tick();
12+
assert.htmlEqual(target.innerHTML, '<button>reject</button> <p>failed</p>');
13+
}
14+
});

0 commit comments

Comments
 (0)