Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add error boundaries #14211

Merged
merged 46 commits into from
Dec 1, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
007d5f3
feat: add error boundary support
trueadm Nov 5, 2024
f683ce2
tweak
trueadm Nov 7, 2024
51e49be
Update packages/svelte/elements.d.ts
trueadm Nov 7, 2024
11b2ecd
Update .changeset/polite-peaches-do.md
trueadm Nov 7, 2024
10897ac
fix issue with rethrowing
trueadm Nov 7, 2024
e6b6a68
handle fallback error
trueadm Nov 8, 2024
dc95bd3
handle fallback error
trueadm Nov 8, 2024
b9bc80d
add more test coverage
trueadm Nov 8, 2024
cf11f58
more tests
trueadm Nov 8, 2024
4d8ad24
more bug fixes
trueadm Nov 8, 2024
2c8dea6
guard against non-errors
trueadm Nov 9, 2024
5be2334
add component_stack to error
trueadm Nov 9, 2024
c468c6d
alternative approach
trueadm Nov 11, 2024
3ae0c80
Merge branch 'main' into error-boundaries
trueadm Nov 15, 2024
3441004
remove spread support
trueadm Nov 15, 2024
75a01a5
lint
trueadm Nov 15, 2024
371f118
add to legacy ast
dummdidumm Nov 20, 2024
36dcb7d
Merge branch 'main' into error-boundaries
dummdidumm Nov 20, 2024
3afd1bb
add to svelte-html
dummdidumm Nov 20, 2024
1564640
disallow anything but attributes on the boundary element
dummdidumm Nov 20, 2024
acb3cd0
Merge branch 'main' into error-boundaries
Rich-Harris Nov 26, 2024
bc72ed2
fix error
Rich-Harris Nov 26, 2024
a77bf50
more validation
Rich-Harris Nov 26, 2024
f82a59b
only create block when necessary
Rich-Harris Nov 26, 2024
6aa714e
swap argument order - results in nicer-looking code in many cases
Rich-Harris Nov 26, 2024
b53cfc8
Update .changeset/polite-peaches-do.md
Rich-Harris Nov 26, 2024
1ec18a3
simplify a bit
Rich-Harris Nov 26, 2024
a7ee520
simplify
Rich-Harris Nov 26, 2024
3070f67
move declaration closer to usage
Rich-Harris Nov 26, 2024
fbbb7d9
push once
Rich-Harris Nov 26, 2024
3322856
unused
Rich-Harris Nov 26, 2024
08b82f9
tweaks
Rich-Harris Nov 27, 2024
2d27f50
consistent naming
Rich-Harris Nov 27, 2024
702adf9
simplify
Rich-Harris Nov 27, 2024
69877ae
add a couple newlines
Rich-Harris Nov 27, 2024
8e74719
tweak comments
Rich-Harris Nov 27, 2024
b4a30a4
simplify
Rich-Harris Nov 27, 2024
a81dc34
newlines
Rich-Harris Nov 27, 2024
0fe5274
placeholder documentation
Rich-Harris Nov 27, 2024
62e1af8
add some docs
Rich-Harris Nov 27, 2024
dfdcf02
Update packages/svelte/src/internal/client/dom/blocks/boundary.js
trueadm Nov 27, 2024
b3d1d92
Update packages/svelte/src/internal/client/dom/blocks/boundary.js
trueadm Nov 27, 2024
dc36557
Update packages/svelte/src/internal/client/dom/blocks/boundary.js
trueadm Nov 27, 2024
2b0778e
fix type
trueadm Nov 27, 2024
93b16b1
fix link
Rich-Harris Dec 1, 2024
4509d3b
explain what happens if onerror throws
Rich-Harris Dec 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: adds error boundaries
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 4 additions & 0 deletions packages/svelte/elements.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2026,6 +2026,10 @@ export interface SvelteHTMLElements {
[name: string]: any;
};
'svelte:head': { [name: string]: any };
'svelte:boundary': {
onerror?: (error: Error, reset: () => void) => void;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about adding a onreset prop here as well? There are some scenarios where executing a side effect could be useful.

I know users can reach in and wrap the reset method, but that feels like it introduces friction.

Copy link
Contributor Author

@trueadm trueadm Nov 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already provide component stack traces on our errors during DEV, this PR actually extends this so the stack happens in more places too.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, I didn't realize we had the component stack on error.message, seems like it was implemented earlier! That's great, any error logging sdk can parse that and use right away.

Right now this component stack is attached to an error with define_property(error, 'message'... in handle_error. Could we evaluate defining it as a separate field on the error? This would make it easier for error logging to just grab and parse the field instead of having to read through error.message. It would also be nice to pass the component_stack into onerror callback so it can be used in userland (users don't have to parse error.message to get access to it).

failed?: import('svelte').Snippet<[error: Error, reset: () => void]>;
};

[name: string]: { [name: string]: any };
}
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
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,108 @@
/** @import { BlockStatement, Statement, Property, 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 nodes = [];
/** @type {Statement[]} */
const snippet_statements = [];
/** @type {Array<Property[] | Expression>} */
const props_and_spreads = [];

let has_spread = false;

const push_prop = (/** @type {Property} */ prop) => {
let current = props_and_spreads.at(-1);
if (Array.isArray(current)) {
current.push(prop);
}
const arr = [prop];
props_and_spreads.push(arr);
};

for (const attribute of node.attributes) {
if (attribute.type === 'SpreadAttribute') {
const value = /** @type {Expression} */ (context.visit(attribute.expression, context.state));
has_spread = true;

if (attribute.metadata.expression.has_state) {
props_and_spreads.push(b.thunk(value));
} else {
props_and_spreads.push(value);
}
continue;
}

// Skip non-attributes with a single value
if (
attribute.type !== 'Attribute' ||
attribute.value === true ||
Array.isArray(attribute.value)
) {
continue;
}

// Currently we only support `onerror` and `failed` props
if (attribute.name === 'onerror' || attribute.name === 'failed') {
const value = /** @type {Expression} */ (
context.visit(attribute.value.expression, context.state)
);

if (attribute.metadata.expression.has_state) {
push_prop(
b.prop('get', b.id(attribute.name), b.function(null, [], b.block([b.return(value)])))
);
} else {
push_prop(b.prop('init', b.id(attribute.name), value));
}
}
}

// 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 = [];
const block_state = { ...context.state, init };
context.visit(child, block_state);
push_prop(b.prop('init', b.id('failed'), b.id('failed')));
snippet_statements.push(...init);
} else {
nodes.push(child);
}
}

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

const props_expression =
!has_spread && Array.isArray(props_and_spreads[0])
? b.object(props_and_spreads[0])
: props_and_spreads.length === 0
? b.object([])
: b.call(
'$.spread_props',
...props_and_spreads.map((p) => (Array.isArray(p) ? b.object(p) : p))
);

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

context.state.template.push('<!>');
context.state.init.push(
snippet_statements ? 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,20 @@
/** @import { BlockStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */

import {
BLOCK_CLOSE,
BLOCK_OPEN,
EMPTY_COMMENT
} 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));
context.state.template.push(/** @type {BlockStatement} */ (context.visit(node.fragment)));
context.state.template.push(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 @@ -306,6 +306,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 @@ -352,6 +352,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 @@ -499,7 +504,8 @@ export type ElementLike =
| AST.SvelteHead
| AST.SvelteOptionsRaw
| AST.SvelteSelf
| AST.SvelteWindow;
| AST.SvelteWindow
| AST.SvelteBoundary;

export type TemplateNode =
| AST.Root
Expand Down
27 changes: 14 additions & 13 deletions packages/svelte/src/internal/client/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@ export const RENDER_EFFECT = 1 << 3;
export const BLOCK_EFFECT = 1 << 4;
export const BRANCH_EFFECT = 1 << 5;
export const ROOT_EFFECT = 1 << 6;
export const UNOWNED = 1 << 7;
export const DISCONNECTED = 1 << 8;
export const CLEAN = 1 << 9;
export const DIRTY = 1 << 10;
export const MAYBE_DIRTY = 1 << 11;
export const INERT = 1 << 12;
export const DESTROYED = 1 << 13;
export const EFFECT_RAN = 1 << 14;
export const BOUNDARY_EFFECT = 1 << 7;
export const UNOWNED = 1 << 8;
export const DISCONNECTED = 1 << 9;
export const CLEAN = 1 << 10;
export const DIRTY = 1 << 11;
export const MAYBE_DIRTY = 1 << 12;
export const INERT = 1 << 13;
export const DESTROYED = 1 << 14;
export const EFFECT_RAN = 1 << 15;
/** 'Transparent' effects do not create a transition boundary */
export const EFFECT_TRANSPARENT = 1 << 15;
export const EFFECT_TRANSPARENT = 1 << 16;
/** Svelte 4 legacy mode props need to be handled with deriveds and be recognized elsewhere, hence the dedicated flag */
export const LEGACY_DERIVED_PROP = 1 << 16;
export const INSPECT_EFFECT = 1 << 17;
export const HEAD_EFFECT = 1 << 18;
export const EFFECT_HAS_DERIVED = 1 << 19;
export const LEGACY_DERIVED_PROP = 1 << 17;
export const INSPECT_EFFECT = 1 << 18;
export const HEAD_EFFECT = 1 << 19;
export const EFFECT_HAS_DERIVED = 1 << 20;

export const STATE_SYMBOL = Symbol('$state');
export const STATE_SYMBOL_METADATA = Symbol('$state metadata');
Expand Down
135 changes: 135 additions & 0 deletions packages/svelte/src/internal/client/dom/blocks/boundary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/** @import { Effect, TemplateNode, } from '#client' */

import { BOUNDARY_EFFECT, EFFECT_TRANSPARENT } from '../../constants.js';
import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js';
import {
active_effect,
active_reaction,
component_context,
handle_error,
set_active_effect,
set_active_reaction,
set_component_context
} from '../../runtime.js';
import {
hydrate_next,
hydrate_node,
hydrating,
next,
remove_nodes,
set_hydrate_node
} from '../hydration.js';
import { queue_micro_task } from '../task.js';

/**
* @param {Effect} boundary
* @param {() => void} fn
*/
function with_boundary(boundary, fn) {
var previous_effect = active_effect;
var previous_reaction = active_reaction;
var previous_ctx = component_context;
set_active_effect(boundary);
set_active_reaction(boundary);
set_component_context(boundary.ctx);
try {
fn();
} finally {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
set_component_context(previous_ctx);
}
}

/**
* @param {TemplateNode} node
* @param {((anchor: Node) => void)} boundary_fn
* @param {{
* onerror?: (error: Error, reset: () => void) => void,
* failed?: (anchor: Node, error: () => Error, reset: () => () => void) => void
* }} props
* @returns {void}
*/
export function boundary(node, boundary_fn, props) {
var anchor = node;

/** @type {Effect | null} */
var boundary_effect;

block(() => {
var boundary = /** @type {Effect} */ (active_effect);
var hydrate_open = hydrate_node;
var is_creating_fallback = false;

// We re-use the effect's fn property to avoid allocation of an additional field
boundary.fn = (/** @type { Error }} */ error) => {
var onerror = props.onerror;
let failed_snippet = props.failed;

// If we have nothing to capture the error then re-throw the error
// for another boundary to handle, additionaly, if we're creating
// the fallback and that too fails, then re-throw the error
if ((!onerror && !failed_snippet) || is_creating_fallback) {
throw error;
}

// Handle resetting the error boundary
var reset = () => {
if (boundary_effect) {
pause_effect(boundary_effect);
}
trueadm marked this conversation as resolved.
Show resolved Hide resolved
with_boundary(boundary, () => {
boundary_effect = null;
trueadm marked this conversation as resolved.
Show resolved Hide resolved
is_creating_fallback = false;
boundary_effect = branch(() => boundary_fn(anchor));
});
};

// Handle the `onerror` event handler
if (onerror) {
onerror(error, reset);
}

if (boundary_effect) {
destroy_effect(boundary_effect);
} else if (hydrating) {
set_hydrate_node(hydrate_open);
next();
set_hydrate_node(remove_nodes());
}

// Handle the `failed` snippet fallback
if (failed_snippet) {
// Ensure we create the boundary branch after the catch event cycle finishes
queue_micro_task(() => {
with_boundary(boundary, () => {
boundary_effect = null;
trueadm marked this conversation as resolved.
Show resolved Hide resolved
is_creating_fallback = true;
try {
boundary_effect = branch(() => {
failed_snippet(
anchor,
() => error,
() => reset
);
});
} catch (error) {
handle_error(/** @type {Error} */ (error), boundary, boundary.ctx);
}
is_creating_fallback = false;
});
});
}
};

if (hydrating) {
hydrate_next();
}

boundary_effect = branch(() => boundary_fn(anchor));
}, EFFECT_TRANSPARENT | BOUNDARY_EFFECT);

if (hydrating) {
anchor = hydrate_node;
}
}
Loading
Loading