Skip to content

Commit

Permalink
feat: add error boundaries (#14211)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update .changeset/polite-peaches-do.md

Co-authored-by: Simon H <[email protected]>

* 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 <[email protected]>

* Update packages/svelte/src/internal/client/dom/blocks/boundary.js

Co-authored-by: Rich Harris <[email protected]>

* Update packages/svelte/src/internal/client/dom/blocks/boundary.js

Co-authored-by: Rich Harris <[email protected]>

* fix type

* fix link

* explain what happens if onerror throws

---------

Co-authored-by: Simon H <[email protected]>
Co-authored-by: Simon Holthausen <[email protected]>
Co-authored-by: Rich Harris <[email protected]>
  • Loading branch information
4 people authored Dec 1, 2024
1 parent f2eed15 commit ed7ebcd
Show file tree
Hide file tree
Showing 78 changed files with 1,199 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/polite-peaches-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add error boundaries with `<svelte:boundary>`
79 changes: 79 additions & 0 deletions documentation/docs/05-special-elements/01-svelte-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
title: <svelte:boundary>
---

```svelte
<svelte:boundary onerror={handler}>...</svelte:boundary>
```

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.

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.

Errors occurring outside the rendering process (for example, in event handlers) are _not_ caught by error boundaries.

## Properties

For the boundary to do anything, one or both of `failed` and `onerror` must be provided.

### `failed`

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)):

```svelte
<svelte:boundary>
<FlakyComponent />
{#snippet failed(error, reset)}
<button onclick={reset}>oops! try again</button>
{/snippet}
</svelte:boundary>
```

> [!NOTE]
> As with [snippets passed to components](snippet#Passing-snippets-to-components), the `failed` snippet can be passed explicitly as a property...
>
> ```svelte
> <svelte:boundary {failed}>...</svelte:boundary>
> ```
>
> ...or implicitly by declaring it directly inside the boundary, as in the example above.
### `onerror`
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...
```svelte
<svelte:boundary onerror={(e) => report(e)}>
...
</svelte:boundary>
```
...or using `error` and `reset` outside the boundary itself:

```svelte
<script>
let error = $state(null);
let reset = $state(() => {});
function onerror(e, r) {
error = e;
reset = r;
}
</script>
<svelte:boundary {onerror}>
<FlakyComponent />
</svelte:boundary>
{#if error}
<button onclick={() => {
error = null;
reset();
}}>
oops! try again
</button>
{/if}
```

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.
12 changes: 12 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,18 @@ A component can have a single top-level `<style>` element
`<svelte:body>` does not support non-event attributes or spread attributes
```

### svelte_boundary_invalid_attribute

```
Valid attributes on `<svelte:boundary>` are `onerror` and `failed`
```

### svelte_boundary_invalid_attribute_value

```
Attribute value must be a non-string expression
```

### svelte_component_invalid_this

```
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/elements.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2044,6 +2044,10 @@ export interface SvelteHTMLElements {
[name: string]: any;
};
'svelte:head': { [name: string]: any };
'svelte:boundary': {
onerror?: (error: unknown, reset: () => void) => void;
failed?: import('svelte').Snippet<[error: unknown, reset: () => void]>;
};

[name: string]: { [name: string]: any };
}
8 changes: 8 additions & 0 deletions packages/svelte/messages/compile-errors/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@ HTML restricts where certain elements can appear. In case of a violation the bro

> `<svelte:body>` does not support non-event attributes or spread attributes
## svelte_boundary_invalid_attribute

> Valid attributes on `<svelte:boundary>` are `onerror` and `failed`
## svelte_boundary_invalid_attribute_value

> Attribute value must be a non-string expression
## svelte_component_invalid_this

> Invalid component definition — must be an `{expression}`
Expand Down
18 changes: 18 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,24 @@ export function svelte_body_illegal_attribute(node) {
e(node, "svelte_body_illegal_attribute", "`<svelte:body>` does not support non-event attributes or spread attributes");
}

/**
* Valid attributes on `<svelte:boundary>` are `onerror` and `failed`
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function svelte_boundary_invalid_attribute(node) {
e(node, "svelte_boundary_invalid_attribute", "Valid attributes on `<svelte:boundary>` are `onerror` and `failed`");
}

/**
* Attribute value must be a non-string expression
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function svelte_boundary_invalid_attribute_value(node) {
e(node, "svelte_boundary_invalid_attribute_value", "Attribute value must be a non-string expression");
}

/**
* Invalid component definition — must be an `{expression}`
* @param {null | number | NodeLike} node
Expand Down
14 changes: 14 additions & 0 deletions packages/svelte/src/compiler/legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,20 @@ export function convert(source, ast) {
children: node.body.nodes.map((child) => visit(child))
};
},
// @ts-expect-error
SvelteBoundary(node, { visit }) {
remove_surrounding_whitespace_nodes(node.fragment.nodes);
return {
type: 'SvelteBoundary',
name: 'svelte:boundary',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map((child) => visit(child))
};
},
RegularElement(node, { visit }) {
return {
type: 'Element',
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/compiler/phases/1-parse/state/element.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const meta_tags = new Map([
['svelte:element', 'SvelteElement'],
['svelte:component', 'SvelteComponent'],
['svelte:self', 'SvelteSelf'],
['svelte:fragment', 'SvelteFragment']
['svelte:fragment', 'SvelteFragment'],
['svelte:boundary', 'SvelteBoundary']
]);

/** @param {Parser} parser */
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { TaggedTemplateExpression } from './visitors/TaggedTemplateExpression.js';
import { Text } from './visitors/Text.js';
import { TitleElement } from './visitors/TitleElement.js';
Expand Down Expand Up @@ -171,6 +172,7 @@ const visitors = {
SvelteHead,
SvelteSelf,
SvelteWindow,
SvelteBoundary,
TaggedTemplateExpression,
Text,
TransitionDirective,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';

const valid = ['onerror', 'failed'];

/**
* @param {AST.SvelteBoundary} node
* @param {Context} context
*/
export function SvelteBoundary(node, context) {
for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute' || !valid.includes(attribute.name)) {
e.svelte_boundary_invalid_attribute(attribute);
}

if (
attribute.value === true ||
(Array.isArray(attribute.value) &&
(attribute.value.length !== 1 || attribute.value[0].type !== 'ExpressionTag'))
) {
e.svelte_boundary_invalid_attribute_value(attribute);
}
}

context.next();
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { SvelteComponent } from './visitors/SvelteComponent.js';
import { SvelteDocument } from './visitors/SvelteDocument.js';
import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
Expand Down Expand Up @@ -122,6 +123,7 @@ const visitors = {
SvelteDocument,
SvelteElement,
SvelteFragment,
SvelteBoundary,
SvelteHead,
SvelteSelf,
SvelteWindow,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** @import { BlockStatement, Statement, Expression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '../../../../utils/builders.js';

/**
* @param {AST.SvelteBoundary} node
* @param {ComponentContext} context
*/
export function SvelteBoundary(node, context) {
const props = b.object([]);

for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute' || attribute.value === true) {
// these can't exist, because they would have caused validation
// to fail, but typescript doesn't know that
continue;
}

const chunk = Array.isArray(attribute.value)
? /** @type {AST.ExpressionTag} */ (attribute.value[0])
: attribute.value;

const expression = /** @type {Expression} */ (context.visit(chunk.expression, context.state));

if (attribute.metadata.expression.has_state) {
props.properties.push(b.get(attribute.name, [b.return(expression)]));
} else {
props.properties.push(b.init(attribute.name, expression));
}
}

const nodes = [];

/** @type {Statement[]} */
const snippet_statements = [];

// Capture the `failed` implicit snippet prop
for (const child of node.fragment.nodes) {
if (child.type === 'SnippetBlock' && child.expression.name === 'failed') {
/** @type {Statement[]} */
const init = [];
context.visit(child, { ...context.state, init });
props.properties.push(b.prop('init', child.expression, child.expression));
snippet_statements.push(...init);
} else {
nodes.push(child);
}
}

const block = /** @type {BlockStatement} */ (context.visit({ ...node.fragment, nodes }));

const boundary = b.stmt(
b.call('$.boundary', context.state.node, props, b.arrow([b.id('$$anchor')], block))
);

context.state.template.push('<!>');
context.state.init.push(
snippet_statements.length > 0 ? b.block([...snippet_statements, boundary]) : boundary
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { SvelteSelf } from './visitors/SvelteSelf.js';
import { TitleElement } from './visitors/TitleElement.js';
import { UpdateExpression } from './visitors/UpdateExpression.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';

/** @type {Visitors} */
const global_visitors = {
Expand Down Expand Up @@ -75,7 +76,8 @@ const template_visitors = {
SvelteFragment,
SvelteHead,
SvelteSelf,
TitleElement
TitleElement,
SvelteBoundary
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** @import { BlockStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { BLOCK_CLOSE, BLOCK_OPEN } from '../../../../../internal/server/hydration.js';
import * as b from '../../../../utils/builders.js';

/**
* @param {AST.SvelteBoundary} node
* @param {ComponentContext} context
*/
export function SvelteBoundary(node, context) {
context.state.template.push(
b.literal(BLOCK_OPEN),
/** @type {BlockStatement} */ (context.visit(node.fragment)),
b.literal(BLOCK_CLOSE)
);
}
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/phases/3-transform/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export function clean_nodes(
parent.type === 'SnippetBlock' ||
parent.type === 'EachBlock' ||
parent.type === 'SvelteComponent' ||
parent.type === 'SvelteBoundary' ||
parent.type === 'Component' ||
parent.type === 'SvelteSelf') &&
first &&
Expand Down
8 changes: 7 additions & 1 deletion packages/svelte/src/compiler/types/template.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,11 @@ export namespace AST {
name: 'svelte:fragment';
}

export interface SvelteBoundary extends BaseElement {
type: 'SvelteBoundary';
name: 'svelte:boundary';
}

export interface SvelteHead extends BaseElement {
type: 'SvelteHead';
name: 'svelte:head';
Expand Down Expand Up @@ -520,7 +525,8 @@ export type ElementLike =
| AST.SvelteHead
| AST.SvelteOptionsRaw
| AST.SvelteSelf
| AST.SvelteWindow;
| AST.SvelteWindow
| AST.SvelteBoundary;

export type TemplateNode =
| AST.Root
Expand Down
Loading

0 comments on commit ed7ebcd

Please sign in to comment.