Skip to content

Commit ed7ebcd

Browse files
trueadmdummdidummRich-Harris
authored
feat: add error boundaries (#14211)
* feat: add error boundary support tweak tweak again retry -> reset tweaks add tests tweaks tweaks tweaks more tests more tests and tweaks comments tweak tweak tweak tweak tweak * tweak tweak tweak tweak more fixes tweak tweak more fixes changeset * Update packages/svelte/elements.d.ts Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> * Update .changeset/polite-peaches-do.md Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> * fix issue with rethrowing * handle fallback error * handle fallback error * add more test coverage * more tests * more bug fixes * guard against non-errors * add component_stack to error * alternative approach * remove spread support * lint * add to legacy ast * add to svelte-html * disallow anything but attributes on the boundary element * fix error * more validation * only create block when necessary * swap argument order - results in nicer-looking code in many cases * Update .changeset/polite-peaches-do.md * simplify a bit * simplify * move declaration closer to usage * push once * unused * tweaks * consistent naming * simplify * add a couple newlines * tweak comments * simplify * newlines * placeholder documentation * add some docs * Update packages/svelte/src/internal/client/dom/blocks/boundary.js Co-authored-by: Rich Harris <rich.harris@vercel.com> * Update packages/svelte/src/internal/client/dom/blocks/boundary.js Co-authored-by: Rich Harris <rich.harris@vercel.com> * Update packages/svelte/src/internal/client/dom/blocks/boundary.js Co-authored-by: Rich Harris <rich.harris@vercel.com> * fix type * fix link * explain what happens if onerror throws --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com> Co-authored-by: Rich Harris <rich.harris@vercel.com>
1 parent f2eed15 commit ed7ebcd

File tree

78 files changed

+1199
-49
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+1199
-49
lines changed

.changeset/polite-peaches-do.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: add error boundaries with `<svelte:boundary>`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
title: <svelte:boundary>
3+
---
4+
5+
```svelte
6+
<svelte:boundary onerror={handler}>...</svelte:boundary>
7+
```
8+
9+
Boundaries allow you to guard against errors in part of your app from breaking the app as a whole, and to recover from those errors.
10+
11+
If an error occurs while rendering or updating the children of a `<svelte:boundary>`, or running any [`$effect`]($effect) functions contained therein, the contents will be removed.
12+
13+
Errors occurring outside the rendering process (for example, in event handlers) are _not_ caught by error boundaries.
14+
15+
## Properties
16+
17+
For the boundary to do anything, one or both of `failed` and `onerror` must be provided.
18+
19+
### `failed`
20+
21+
If an `failed` snippet is provided, it will be rendered with the error that was thrown, and a `reset` function that recreates the contents ([demo](/playground/hello-world#H4sIAAAAAAAAE3VRy26DMBD8lS2tFCIh6JkAUlWp39Cq9EBg06CAbdlLArL87zWGKk8ORnhmd3ZnrD1WtOjFXqKO2BDGW96xqpBD5gXerm5QefG39mgQY9EIWHxueRMinLosti0UPsJLzggZKTeilLWgLGc51a3gkuCjKQ7DO7cXZotgJ3kLqzC6hmex1SZnSXTWYHcrj8LJjWTk0PHoZ8VqIdCOKayPykcpuQxAokJaG1dGybYj4gw4K5u6PKTasSbjXKgnIDlA8VvUdo-pzonraBY2bsH7HAl78mKSHZpgIcuHjq9jXSpZSLixRlveKYQUXhQVhL6GPobXAAb7BbNeyvNUs4qfRg3OnELLj5hqH9eQZqCnoBwR9lYcQxuVXeBzc8kMF8yXY4yNJ5oGiUzP_aaf_waTRGJib5_Ad3P_vbCuaYxzeNpbU0eUMPAOKh7Yw1YErgtoXyuYlPLzc10_xo_5A91zkQL_AgAA)):
22+
23+
```svelte
24+
<svelte:boundary>
25+
<FlakyComponent />
26+
27+
{#snippet failed(error, reset)}
28+
<button onclick={reset}>oops! try again</button>
29+
{/snippet}
30+
</svelte:boundary>
31+
```
32+
33+
> [!NOTE]
34+
> As with [snippets passed to components](snippet#Passing-snippets-to-components), the `failed` snippet can be passed explicitly as a property...
35+
>
36+
> ```svelte
37+
> <svelte:boundary {failed}>...</svelte:boundary>
38+
> ```
39+
>
40+
> ...or implicitly by declaring it directly inside the boundary, as in the example above.
41+
42+
### `onerror`
43+
44+
If an `onerror` function is provided, it will be called with the same two `error` and `reset` arguments. This is useful for tracking the error with an error reporting service...
45+
46+
```svelte
47+
<svelte:boundary onerror={(e) => report(e)}>
48+
...
49+
</svelte:boundary>
50+
```
51+
52+
...or using `error` and `reset` outside the boundary itself:
53+
54+
```svelte
55+
<script>
56+
let error = $state(null);
57+
let reset = $state(() => {});
58+
59+
function onerror(e, r) {
60+
error = e;
61+
reset = r;
62+
}
63+
</script>
64+
65+
<svelte:boundary {onerror}>
66+
<FlakyComponent />
67+
</svelte:boundary>
68+
69+
{#if error}
70+
<button onclick={() => {
71+
error = null;
72+
reset();
73+
}}>
74+
oops! try again
75+
</button>
76+
{/if}
77+
```
78+
79+
If an error occurs inside the `onerror` function (or if you rethrow the error), it will be handled by a parent boundary if such exists.

documentation/docs/98-reference/.generated/compile-errors.md

+12
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,18 @@ A component can have a single top-level `<style>` element
762762
`<svelte:body>` does not support non-event attributes or spread attributes
763763
```
764764

765+
### svelte_boundary_invalid_attribute
766+
767+
```
768+
Valid attributes on `<svelte:boundary>` are `onerror` and `failed`
769+
```
770+
771+
### svelte_boundary_invalid_attribute_value
772+
773+
```
774+
Attribute value must be a non-string expression
775+
```
776+
765777
### svelte_component_invalid_this
766778

767779
```

packages/svelte/elements.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -2044,6 +2044,10 @@ export interface SvelteHTMLElements {
20442044
[name: string]: any;
20452045
};
20462046
'svelte:head': { [name: string]: any };
2047+
'svelte:boundary': {
2048+
onerror?: (error: unknown, reset: () => void) => void;
2049+
failed?: import('svelte').Snippet<[error: unknown, reset: () => void]>;
2050+
};
20472051

20482052
[name: string]: { [name: string]: any };
20492053
}

packages/svelte/messages/compile-errors/template.md

+8
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,14 @@ HTML restricts where certain elements can appear. In case of a violation the bro
282282

283283
> `<svelte:body>` does not support non-event attributes or spread attributes
284284
285+
## svelte_boundary_invalid_attribute
286+
287+
> Valid attributes on `<svelte:boundary>` are `onerror` and `failed`
288+
289+
## svelte_boundary_invalid_attribute_value
290+
291+
> Attribute value must be a non-string expression
292+
285293
## svelte_component_invalid_this
286294

287295
> Invalid component definition — must be an `{expression}`

packages/svelte/src/compiler/errors.js

+18
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,24 @@ export function svelte_body_illegal_attribute(node) {
12281228
e(node, "svelte_body_illegal_attribute", "`<svelte:body>` does not support non-event attributes or spread attributes");
12291229
}
12301230

1231+
/**
1232+
* Valid attributes on `<svelte:boundary>` are `onerror` and `failed`
1233+
* @param {null | number | NodeLike} node
1234+
* @returns {never}
1235+
*/
1236+
export function svelte_boundary_invalid_attribute(node) {
1237+
e(node, "svelte_boundary_invalid_attribute", "Valid attributes on `<svelte:boundary>` are `onerror` and `failed`");
1238+
}
1239+
1240+
/**
1241+
* Attribute value must be a non-string expression
1242+
* @param {null | number | NodeLike} node
1243+
* @returns {never}
1244+
*/
1245+
export function svelte_boundary_invalid_attribute_value(node) {
1246+
e(node, "svelte_boundary_invalid_attribute_value", "Attribute value must be a non-string expression");
1247+
}
1248+
12311249
/**
12321250
* Invalid component definition — must be an `{expression}`
12331251
* @param {null | number | NodeLike} node

packages/svelte/src/compiler/legacy.js

+14
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,20 @@ export function convert(source, ast) {
381381
children: node.body.nodes.map((child) => visit(child))
382382
};
383383
},
384+
// @ts-expect-error
385+
SvelteBoundary(node, { visit }) {
386+
remove_surrounding_whitespace_nodes(node.fragment.nodes);
387+
return {
388+
type: 'SvelteBoundary',
389+
name: 'svelte:boundary',
390+
start: node.start,
391+
end: node.end,
392+
attributes: node.attributes.map(
393+
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
394+
),
395+
children: node.fragment.nodes.map((child) => visit(child))
396+
};
397+
},
384398
RegularElement(node, { visit }) {
385399
return {
386400
type: 'Element',

packages/svelte/src/compiler/phases/1-parse/state/element.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ const meta_tags = new Map([
4343
['svelte:element', 'SvelteElement'],
4444
['svelte:component', 'SvelteComponent'],
4545
['svelte:self', 'SvelteSelf'],
46-
['svelte:fragment', 'SvelteFragment']
46+
['svelte:fragment', 'SvelteFragment'],
47+
['svelte:boundary', 'SvelteBoundary']
4748
]);
4849

4950
/** @param {Parser} parser */

packages/svelte/src/compiler/phases/2-analyze/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { SvelteFragment } from './visitors/SvelteFragment.js';
6060
import { SvelteHead } from './visitors/SvelteHead.js';
6161
import { SvelteSelf } from './visitors/SvelteSelf.js';
6262
import { SvelteWindow } from './visitors/SvelteWindow.js';
63+
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
6364
import { TaggedTemplateExpression } from './visitors/TaggedTemplateExpression.js';
6465
import { Text } from './visitors/Text.js';
6566
import { TitleElement } from './visitors/TitleElement.js';
@@ -171,6 +172,7 @@ const visitors = {
171172
SvelteHead,
172173
SvelteSelf,
173174
SvelteWindow,
175+
SvelteBoundary,
174176
TaggedTemplateExpression,
175177
Text,
176178
TransitionDirective,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/** @import { AST } from '#compiler' */
2+
/** @import { Context } from '../types' */
3+
import * as e from '../../../errors.js';
4+
5+
const valid = ['onerror', 'failed'];
6+
7+
/**
8+
* @param {AST.SvelteBoundary} node
9+
* @param {Context} context
10+
*/
11+
export function SvelteBoundary(node, context) {
12+
for (const attribute of node.attributes) {
13+
if (attribute.type !== 'Attribute' || !valid.includes(attribute.name)) {
14+
e.svelte_boundary_invalid_attribute(attribute);
15+
}
16+
17+
if (
18+
attribute.value === true ||
19+
(Array.isArray(attribute.value) &&
20+
(attribute.value.length !== 1 || attribute.value[0].type !== 'ExpressionTag'))
21+
) {
22+
e.svelte_boundary_invalid_attribute_value(attribute);
23+
}
24+
}
25+
26+
context.next();
27+
}

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

+2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { SvelteComponent } from './visitors/SvelteComponent.js';
4848
import { SvelteDocument } from './visitors/SvelteDocument.js';
4949
import { SvelteElement } from './visitors/SvelteElement.js';
5050
import { SvelteFragment } from './visitors/SvelteFragment.js';
51+
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
5152
import { SvelteHead } from './visitors/SvelteHead.js';
5253
import { SvelteSelf } from './visitors/SvelteSelf.js';
5354
import { SvelteWindow } from './visitors/SvelteWindow.js';
@@ -122,6 +123,7 @@ const visitors = {
122123
SvelteDocument,
123124
SvelteElement,
124125
SvelteFragment,
126+
SvelteBoundary,
125127
SvelteHead,
126128
SvelteSelf,
127129
SvelteWindow,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/** @import { BlockStatement, Statement, Expression } from 'estree' */
2+
/** @import { AST } from '#compiler' */
3+
/** @import { ComponentContext } from '../types' */
4+
import * as b from '../../../../utils/builders.js';
5+
6+
/**
7+
* @param {AST.SvelteBoundary} node
8+
* @param {ComponentContext} context
9+
*/
10+
export function SvelteBoundary(node, context) {
11+
const props = b.object([]);
12+
13+
for (const attribute of node.attributes) {
14+
if (attribute.type !== 'Attribute' || attribute.value === true) {
15+
// these can't exist, because they would have caused validation
16+
// to fail, but typescript doesn't know that
17+
continue;
18+
}
19+
20+
const chunk = Array.isArray(attribute.value)
21+
? /** @type {AST.ExpressionTag} */ (attribute.value[0])
22+
: attribute.value;
23+
24+
const expression = /** @type {Expression} */ (context.visit(chunk.expression, context.state));
25+
26+
if (attribute.metadata.expression.has_state) {
27+
props.properties.push(b.get(attribute.name, [b.return(expression)]));
28+
} else {
29+
props.properties.push(b.init(attribute.name, expression));
30+
}
31+
}
32+
33+
const nodes = [];
34+
35+
/** @type {Statement[]} */
36+
const snippet_statements = [];
37+
38+
// Capture the `failed` implicit snippet prop
39+
for (const child of node.fragment.nodes) {
40+
if (child.type === 'SnippetBlock' && child.expression.name === 'failed') {
41+
/** @type {Statement[]} */
42+
const init = [];
43+
context.visit(child, { ...context.state, init });
44+
props.properties.push(b.prop('init', child.expression, child.expression));
45+
snippet_statements.push(...init);
46+
} else {
47+
nodes.push(child);
48+
}
49+
}
50+
51+
const block = /** @type {BlockStatement} */ (context.visit({ ...node.fragment, nodes }));
52+
53+
const boundary = b.stmt(
54+
b.call('$.boundary', context.state.node, props, b.arrow([b.id('$$anchor')], block))
55+
);
56+
57+
context.state.template.push('<!>');
58+
context.state.init.push(
59+
snippet_statements.length > 0 ? b.block([...snippet_statements, boundary]) : boundary
60+
);
61+
}

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { SvelteSelf } from './visitors/SvelteSelf.js';
3838
import { TitleElement } from './visitors/TitleElement.js';
3939
import { UpdateExpression } from './visitors/UpdateExpression.js';
4040
import { VariableDeclaration } from './visitors/VariableDeclaration.js';
41+
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
4142

4243
/** @type {Visitors} */
4344
const global_visitors = {
@@ -75,7 +76,8 @@ const template_visitors = {
7576
SvelteFragment,
7677
SvelteHead,
7778
SvelteSelf,
78-
TitleElement
79+
TitleElement,
80+
SvelteBoundary
7981
};
8082

8183
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/** @import { BlockStatement } from 'estree' */
2+
/** @import { AST } from '#compiler' */
3+
/** @import { ComponentContext } from '../types' */
4+
import { BLOCK_CLOSE, BLOCK_OPEN } from '../../../../../internal/server/hydration.js';
5+
import * as b from '../../../../utils/builders.js';
6+
7+
/**
8+
* @param {AST.SvelteBoundary} node
9+
* @param {ComponentContext} context
10+
*/
11+
export function SvelteBoundary(node, context) {
12+
context.state.template.push(
13+
b.literal(BLOCK_OPEN),
14+
/** @type {BlockStatement} */ (context.visit(node.fragment)),
15+
b.literal(BLOCK_CLOSE)
16+
);
17+
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ export function clean_nodes(
305305
parent.type === 'SnippetBlock' ||
306306
parent.type === 'EachBlock' ||
307307
parent.type === 'SvelteComponent' ||
308+
parent.type === 'SvelteBoundary' ||
308309
parent.type === 'Component' ||
309310
parent.type === 'SvelteSelf') &&
310311
first &&

packages/svelte/src/compiler/types/template.d.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,11 @@ export namespace AST {
363363
name: 'svelte:fragment';
364364
}
365365

366+
export interface SvelteBoundary extends BaseElement {
367+
type: 'SvelteBoundary';
368+
name: 'svelte:boundary';
369+
}
370+
366371
export interface SvelteHead extends BaseElement {
367372
type: 'SvelteHead';
368373
name: 'svelte:head';
@@ -520,7 +525,8 @@ export type ElementLike =
520525
| AST.SvelteHead
521526
| AST.SvelteOptionsRaw
522527
| AST.SvelteSelf
523-
| AST.SvelteWindow;
528+
| AST.SvelteWindow
529+
| AST.SvelteBoundary;
524530

525531
export type TemplateNode =
526532
| AST.Root

0 commit comments

Comments
 (0)