feat: add error boundaries ()

* 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>
pull/14486/head
Dominic Gannaway 5 months ago committed by GitHub
parent f2eed15c02
commit ed7ebcde1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add error boundaries with `<svelte:boundary>`

@ -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.

@ -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
```

@ -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 };
}

@ -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}`

@ -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

@ -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',

@ -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 */

@ -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';
@ -171,6 +172,7 @@ const visitors = {
SvelteHead,
SvelteSelf,
SvelteWindow,
SvelteBoundary,
TaggedTemplateExpression,
Text,
TransitionDirective,

@ -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();
}

@ -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';
@ -122,6 +123,7 @@ const visitors = {
SvelteDocument,
SvelteElement,
SvelteFragment,
SvelteBoundary,
SvelteHead,
SvelteSelf,
SvelteWindow,

@ -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
);
}

@ -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 = {
@ -75,7 +76,8 @@ const template_visitors = {
SvelteFragment,
SvelteHead,
SvelteSelf,
TitleElement
TitleElement,
SvelteBoundary
};
/**

@ -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)
);
}

@ -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 &&

@ -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';
@ -520,7 +525,8 @@ export type ElementLike =
| AST.SvelteHead
| AST.SvelteOptionsRaw
| AST.SvelteSelf
| AST.SvelteWindow;
| AST.SvelteWindow
| AST.SvelteBoundary;
export type TemplateNode =
| AST.Root

@ -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');

@ -0,0 +1,134 @@
/** @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,
reset_is_throwing_error
} 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 {{
* onerror?: (error: unknown, reset: () => void) => void,
* failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void
* }} props
* @param {((anchor: Node) => void)} boundary_fn
* @returns {void}
*/
export function boundary(node, props, boundary_fn) {
var anchor = node;
/** @type {Effect} */
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 {unknown}} */ error) => {
var onerror = props.onerror;
let failed = props.failed;
// If we have nothing to capture the error, or if we hit an error while
// rendering the fallback, re-throw for another boundary to handle
if ((!onerror && !failed) || is_creating_fallback) {
throw error;
}
var reset = () => {
pause_effect(boundary_effect);
with_boundary(boundary, () => {
is_creating_fallback = false;
boundary_effect = branch(() => boundary_fn(anchor));
reset_is_throwing_error();
});
};
onerror?.(error, reset);
if (boundary_effect) {
destroy_effect(boundary_effect);
} else if (hydrating) {
set_hydrate_node(hydrate_open);
next();
set_hydrate_node(remove_nodes());
}
if (failed) {
// Render the `failed` snippet in a microtask
queue_micro_task(() => {
with_boundary(boundary, () => {
is_creating_fallback = true;
try {
boundary_effect = branch(() => {
failed(
anchor,
() => error,
() => reset
);
});
} catch (error) {
handle_error(error, boundary, null, boundary.ctx);
}
reset_is_throwing_error();
is_creating_fallback = false;
});
});
}
};
if (hydrating) {
hydrate_next();
}
boundary_effect = branch(() => boundary_fn(anchor));
reset_is_throwing_error();
}, EFFECT_TRANSPARENT | BOUNDARY_EFFECT);
if (hydrating) {
anchor = hydrate_node;
}
}

@ -4,7 +4,6 @@ import { create_text, get_first_child } from './operations.js';
import { create_fragment_from_html } from './reconciler.js';
import { active_effect } from '../runtime.js';
import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../constants.js';
import { queue_micro_task } from './task.js';
/**
* @param {TemplateNode} start

@ -124,6 +124,7 @@ export {
update_store,
mark_store_binding
} from './reactivity/store.js';
export { boundary } from './dom/blocks/boundary.js';
export { set_text } from './render.js';
export {
get,

@ -464,12 +464,12 @@ export function destroy_effect(effect, remove_dom = true) {
}
// `first` and `child` are nulled out in destroy_effect_children
// we don't null out `parent` so that error propagation can work correctly
effect.next =
effect.prev =
effect.teardown =
effect.ctx =
effect.deps =
effect.parent =
effect.fn =
effect.nodes_start =
effect.nodes_end =

@ -24,7 +24,8 @@ import {
BLOCK_EFFECT,
ROOT_EFFECT,
LEGACY_DERIVED_PROP,
DISCONNECTED
DISCONNECTED,
BOUNDARY_EFFECT
} from './constants.js';
import { flush_tasks } from './dom/task.js';
import { add_owner } from './dev/ownership.js';
@ -37,10 +38,11 @@ import { legacy_mode_flag } from '../flags/index.js';
const FLUSH_MICROTASK = 0;
const FLUSH_SYNC = 1;
// Used for DEV time error handling
/** @param {WeakSet<Error>} value */
const handled_errors = new WeakSet();
export let is_throwing_error = false;
// Used for controlling the flush of effects.
let scheduler_mode = FLUSH_MICROTASK;
// Used for handling scheduling
@ -229,16 +231,81 @@ export function check_dirtiness(reaction) {
}
/**
* @param {Error} error
* @param {unknown} error
* @param {Effect} effect
*/
function propagate_error(error, effect) {
/** @type {Effect | null} */
var current = effect;
while (current !== null) {
if ((current.f & BOUNDARY_EFFECT) !== 0) {
try {
// @ts-expect-error
current.fn(error);
return;
} catch {
// Remove boundary flag from effect
current.f ^= BOUNDARY_EFFECT;
}
}
current = current.parent;
}
is_throwing_error = false;
throw error;
}
/**
* @param {Effect} effect
*/
function should_rethrow_error(effect) {
return (
(effect.f & DESTROYED) === 0 &&
(effect.parent === null || (effect.parent.f & BOUNDARY_EFFECT) === 0)
);
}
export function reset_is_throwing_error() {
is_throwing_error = false;
}
/**
* @param {unknown} error
* @param {Effect} effect
* @param {Effect | null} previous_effect
* @param {ComponentContext | null} component_context
*/
function handle_error(error, effect, component_context) {
// Given we don't yet have error boundaries, we will just always throw.
if (!DEV || handled_errors.has(error) || component_context === null) {
throw error;
export function handle_error(error, effect, previous_effect, component_context) {
if (is_throwing_error) {
if (previous_effect === null) {
is_throwing_error = false;
}
if (should_rethrow_error(effect)) {
throw error;
}
return;
}
if (previous_effect !== null) {
is_throwing_error = true;
}
if (
!DEV ||
component_context === null ||
!(error instanceof Error) ||
handled_errors.has(error)
) {
propagate_error(error, effect);
return;
}
handled_errors.add(error);
const component_stack = [];
const effect_name = effect.fn?.name;
@ -268,6 +335,9 @@ function handle_error(error, effect, component_context) {
define_property(error, 'message', {
value: error.message + `\n${component_stack.map((name) => `\n${indent}in ${name}`).join('')}\n`
});
define_property(error, 'component_stack', {
value: component_stack
});
const stack = error.stack;
@ -287,8 +357,11 @@ function handle_error(error, effect, component_context) {
});
}
handled_errors.add(error);
throw error;
propagate_error(error, effect);
if (should_rethrow_error(effect)) {
throw error;
}
}
/**
@ -449,7 +522,7 @@ export function update_effect(effect) {
dev_effect_stack.push(effect);
}
} catch (error) {
handle_error(/** @type {Error} */ (error), effect, previous_component_context);
handle_error(error, effect, previous_effect, previous_component_context || effect.ctx);
} finally {
active_effect = previous_effect;
@ -529,22 +602,28 @@ function flush_queued_effects(effects) {
for (var i = 0; i < length; i++) {
var effect = effects[i];
if ((effect.f & (DESTROYED | INERT)) === 0 && check_dirtiness(effect)) {
update_effect(effect);
// Effects with no dependencies or teardown do not get added to the effect tree.
// Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we
// don't know if we need to keep them until they are executed. Doing the check
// here (rather than in `update_effect`) allows us to skip the work for
// immediate effects.
if (effect.deps === null && effect.first === null && effect.nodes_start === null) {
if (effect.teardown === null) {
// remove this effect from the graph
unlink_effect(effect);
} else {
// keep the effect in the graph, but free up some memory
effect.fn = null;
if ((effect.f & (DESTROYED | INERT)) === 0) {
try {
if (check_dirtiness(effect)) {
update_effect(effect);
// Effects with no dependencies or teardown do not get added to the effect tree.
// Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we
// don't know if we need to keep them until they are executed. Doing the check
// here (rather than in `update_effect`) allows us to skip the work for
// immediate effects.
if (effect.deps === null && effect.first === null && effect.nodes_start === null) {
if (effect.teardown === null) {
// remove this effect from the graph
unlink_effect(effect);
} else {
// keep the effect in the graph, but free up some memory
effect.fn = null;
}
}
}
} catch (error) {
handle_error(error, effect, null, effect.ctx);
}
}
}
@ -612,13 +691,20 @@ function process_effects(effect, collected_effects) {
var flags = current_effect.f;
var is_branch = (flags & BRANCH_EFFECT) !== 0;
var is_skippable_branch = is_branch && (flags & CLEAN) !== 0;
var sibling = current_effect.next;
if (!is_skippable_branch && (flags & INERT) === 0) {
if ((flags & RENDER_EFFECT) !== 0) {
if (is_branch) {
current_effect.f ^= CLEAN;
} else if (check_dirtiness(current_effect)) {
update_effect(current_effect);
} else {
try {
if (check_dirtiness(current_effect)) {
update_effect(current_effect);
}
} catch (error) {
handle_error(error, current_effect, null, current_effect.ctx);
}
}
var child = current_effect.first;
@ -632,8 +718,6 @@ function process_effects(effect, collected_effects) {
}
}
var sibling = current_effect.next;
if (sibling === null) {
let parent = current_effect.parent;

@ -244,6 +244,10 @@ declare global {
'svelte:document': HTMLProps<'svelte:document', HTMLAttributes>;
'svelte:fragment': { slot?: string };
'svelte:head': { [name: string]: any };
'svelte:boundary': {
onerror?: (error: unknown, reset: () => void) => void;
failed?: import('svelte').Snippet<[error: unknown, reset: () => void]>;
};
// don't type svelte:options, it would override the types in svelte/elements and it isn't extendable anyway
[name: string]: { [name: string]: any };

@ -4,7 +4,7 @@ export default test({
error: {
code: 'svelte_meta_invalid_tag',
message:
'Valid `<svelte:...>` tag names are svelte:head, svelte:options, svelte:window, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self or svelte:fragment',
'Valid `<svelte:...>` tag names are svelte:head, svelte:options, svelte:window, svelte:document, svelte:body, svelte:element, svelte:component, svelte:self, svelte:fragment or svelte:boundary',
position: [10, 32]
}
});

@ -0,0 +1,14 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
const [btn1, btn2] = target.querySelectorAll('button');
btn2?.click();
btn1?.click();
flushSync();
assert.deepEqual(logs, ['error caught!!!']);
}
});

@ -0,0 +1,17 @@
<script>
function throw_error() {
throw new Error('test')
}
let count = $state(0);
let onerror = $state((e) => console.log('error caught'));
</script>
<svelte:boundary {onerror}>
{count > 0 ? throw_error() : null}
</svelte:boundary>
<button onclick={() => count++}>+</button>
<button onclick={() => onerror = () => console.log('error caught!!!')}>change error message</button>

@ -0,0 +1,14 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
const [btn1, btn2] = target.querySelectorAll('button');
btn2?.click();
btn1?.click();
flushSync();
assert.deepEqual(logs, ['error caught!!!']);
}
});

@ -0,0 +1,17 @@
<script>
function throw_error() {
throw new Error('test')
}
let count = $state(0);
let props = $state({ onerror: (e) => console.log('error caught') });
</script>
<svelte:boundary onerror={props.onerror}>
{count > 0 ? throw_error() : null}
</svelte:boundary>
<button onclick={() => count++}>+</button>
<button onclick={() => props = { onerror: () => console.log('error caught!!!') }}>change error message</button>

@ -0,0 +1,13 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target }) {
const btn = target.querySelector('button');
btn?.click();
flushSync();
assert.htmlEqual(target.innerHTML, `<button>change</button><p>Error occured</p>`);
}
});

@ -0,0 +1,22 @@
<script>
let count = $state(0);
const things = $derived.by(() => {
if (count === 1) {
throw new Error('123')
}
return [1, 2 ,3]
})
</script>
<button onclick={() => count++}>change</button>
<svelte:boundary>
{#each things as thing}
<p>{thing}</p>
{/each}
{#snippet failed()}
<p>Error occured</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,7 @@
<script>
const { things } = $props();
$effect(() => {
things
})
</script>

@ -0,0 +1,13 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target }) {
const btn = target.querySelector('button');
btn?.click();
flushSync();
assert.htmlEqual(target.innerHTML, `<button>change</button><p>Error occured</p>`);
}
});

@ -0,0 +1,22 @@
<script>
import Child from './Child.svelte';
let count = $state(0);
const things = $derived.by(() => {
if (count === 1) {
throw new Error('123')
}
return [1, 2 ,3]
})
</script>
<button onclick={() => count++}>change</button>
<svelte:boundary>
<Child {things} />
{#snippet failed()}
<p>Error occured</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,9 @@
<script>
function throw_error() {
throw new Error('throw_error');
}
</script>
{throw_error()}
<div>Foo</div>

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
mode: ['client'],
test({ assert, logs }) {
assert.deepEqual(logs, ['error caught']);
}
});

@ -0,0 +1,9 @@
<script>
import Child from "./Child.svelte";
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
<Child />
</svelte:boundary>

@ -0,0 +1,9 @@
<script>
function throw_error() {
throw new Error('throw_error');
}
</script>
{throw_error()}
<div>Foo</div>

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
mode: ['client'],
test({ assert, logs }) {
assert.deepEqual(logs, ['error caught 1', 'error caught 2']);
}
});

@ -0,0 +1,19 @@
<script>
import Child from "./Child.svelte";
function throw_error() {
throw new Error('test')
}
</script>
<svelte:boundary onerror={(e) => console.log('error caught 2')}>
<svelte:boundary onerror={(e) => console.log('error caught 1')}>
<Child />
{#snippet failed()}
{throw_error()}
{/snippet}
</svelte:boundary>
</svelte:boundary>

@ -0,0 +1,13 @@
<script>
let count = $state(0);
$effect.pre(() => {
if (count > 1) {
throw new Error('too high');
}
});
</script>
{count}
<button onclick={() => count++}>+</button>

@ -0,0 +1,24 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
let btn = target.querySelector('button');
btn?.click();
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught']);
assert.htmlEqual(target.innerHTML, `<div>An error occurred!</div>\n0\n<button>+</button>`);
btn = target.querySelector('button');
btn?.click();
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught', 'error caught']);
assert.htmlEqual(target.innerHTML, `<div>An error occurred!</div>\n0\n<button>+</button>`);
}
});

@ -0,0 +1,12 @@
<script>
import Child from './Child.svelte';
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
<Child />
{#snippet failed(err, reset)}
<div>An error occurred!</div>
<Child />
{/snippet}
</svelte:boundary>

@ -0,0 +1,15 @@
<script>
const { initial = 0 } = $props();
let count = $state(initial);
$effect.pre(() => {
if (count > 1) {
throw 'too high';
}
});
</script>
{count}
<button onclick={() => count++}>+</button>

@ -0,0 +1,15 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
let btn = target.querySelector('button');
btn?.click();
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught 1', 'error caught 2']);
assert.htmlEqual(target.innerHTML, `<div>content before</div><div>content after</div>`);
}
});

@ -0,0 +1,18 @@
<script>
import Child from './Child.svelte';
</script>
<div>content before</div>
<svelte:boundary onerror={(e) => console.log('error caught 2')}>
<svelte:boundary onerror={(e) => console.log('error caught 1')}>
<Child />
{#snippet failed(err, reset)}
<div>An error occurred! {err}</div>
<Child initial={2} />
{/snippet}
</svelte:boundary>
</svelte:boundary>
<div>content after</div>

@ -0,0 +1,15 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target }) {
let btn = target.querySelector('button');
btn?.click();
btn?.click();
assert.throws(() => {
flushSync();
}, /test\n\n\tin {expression}\n/);
}
});

@ -0,0 +1,16 @@
<script>
let count = $state(0);
let test = $derived.by(() => {
if (count > 1) {
throw new Error('test');
}
});
</script>
<svelte:boundary onerror={(e) => { throw(e) }}>
<div>Count: {count}</div>
<button onclick={() => count++}>Increment</button>
{count} / {test}
</svelte:boundary>

@ -0,0 +1,14 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
let btn = target.querySelector('button');
btn?.click();
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught 1', 'error caught 2']);
}
});

@ -0,0 +1,22 @@
<script>
let count = $state(0);
let test = $derived.by(() => {
if (count > 1) {
throw new Error('test');
}
});
</script>
<svelte:boundary onerror={(e) => {console.log('error caught 1')}}>
<div>Count: {count}</div>
<button onclick={() => count++}>Increment</button>
{count} / {test}
</svelte:boundary>
<svelte:boundary onerror={(e) => {console.log('error caught 2')}}>
<div>Count: {count}</div>
<button onclick={() => count++}>Increment</button>
{count} / {test}
</svelte:boundary>

@ -0,0 +1,13 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
const btn = target.querySelector('button');
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught']);
}
});

@ -0,0 +1,15 @@
<script>
function throw_error() {
throw new Error('test')
}
let count = $state(0);
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
{count > 0 ? throw_error() : null}
</svelte:boundary>
<button onclick={() => count++}>+</button>

@ -0,0 +1,14 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
const btn = target.querySelector('button');
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught']);
assert.htmlEqual(target.innerHTML, `<div>Fallback!</div><button>+</button>`);
}
});

@ -0,0 +1,17 @@
<script>
function throw_error() {
throw new Error('test')
}
let count = $state(0);
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
{count > 0 ? throw_error() : null}
{#snippet failed()}
<div>Fallback!</div>
{/snippet}
</svelte:boundary>
<button onclick={() => count++}>+</button>

@ -0,0 +1,14 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
const btn = target.querySelector('button');
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught']);
assert.htmlEqual(target.innerHTML, `<div>Fallback!</div><button>+</button>`);
}
});

@ -0,0 +1,17 @@
<script>
function throw_error() {
throw new Error('test')
}
let count = $state(0);
</script>
{#snippet failed()}
<div>Fallback!</div>
{/snippet}
<svelte:boundary {failed} onerror={(e) => console.log('error caught')}>
{count > 0 ? throw_error() : null}
</svelte:boundary>
<button onclick={() => count++}>+</button>

@ -0,0 +1,13 @@
<script>
let count = $state(0);
$effect.pre(() => {
if (count > 1) {
throw new Error('too high');
}
});
</script>
{count}
<button onclick={() => count++}>+</button>

@ -0,0 +1,31 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
let btn = target.querySelector('button');
btn?.click();
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught']);
assert.htmlEqual(target.innerHTML, `<div>too high</div><button>Retry</button>`);
const [btn2] = target.querySelectorAll('button');
btn2?.click();
flushSync();
assert.htmlEqual(target.innerHTML, `0\n<button>+</button>`);
btn = target.querySelector('button');
btn?.click();
btn?.click();
flushSync();
assert.deepEqual(logs, ['error caught', 'error caught']);
assert.htmlEqual(target.innerHTML, `<div>too high</div><button>Retry</button>`);
}
});

@ -0,0 +1,18 @@
<script>
import Child from './Child.svelte';
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
<svelte:boundary onerror={(e) => { throw e }}>
<svelte:boundary>
<Child />
</svelte:boundary>
</svelte:boundary>
{#snippet failed(e, retry)}
<div>too high</div>
<button onclick={retry}>Retry</button>
{/snippet}
</svelte:boundary>

@ -0,0 +1,13 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target }) {
const btn = target.querySelector('button');
btn?.click();
flushSync();
assert.htmlEqual(target.innerHTML, `<button>+</button><div>There is an error!</div>`);
}
});

@ -0,0 +1,20 @@
<script>
function throw_error() {
throw new Error('test')
}
let count = $state(0);
let error = $state();
</script>
<svelte:boundary onerror={(e) => error = e}>
{count > 0 ? throw_error() : null}
</svelte:boundary>
<button onclick={() => count++}>+</button>
{#if error}
<div>There is an error!</div>
{/if}

@ -0,0 +1,5 @@
<script>
$effect.pre(() => {
throw new Error('oh noes');
});
</script>

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
assert.deepEqual(logs, ['error caught']);
assert.htmlEqual(target.innerHTML, `<div>Error!</div><button>Retry</button>`);
}
});

@ -0,0 +1,14 @@
<script>
import Child from './Child.svelte';
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
<Child />
{#snippet failed(e, retry)}
<div>Error!</div>
<button onclick={retry}>Retry</button>
{/snippet}
</svelte:boundary>

@ -0,0 +1,5 @@
<script>
$effect.pre(() => {
throw new Error('oh noes');
});
</script>

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
assert.deepEqual(logs, ['error caught']);
assert.htmlEqual(target.innerHTML, `<div>Error!</div><button>Retry</button>`);
}
});

@ -0,0 +1,17 @@
<script>
import Child from './Child.svelte';
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
<!-- boundary should rethrow error as there's no control flow -->
<svelte:boundary>
<Child />
</svelte:boundary>
{#snippet failed(e, retry)}
<div>Error!</div>
<button onclick={retry}>Retry</button>
{/snippet}
</svelte:boundary>

@ -0,0 +1,5 @@
<script>
$effect.pre(() => {
throw new Error('oh noes');
});
</script>

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
assert.deepEqual(logs, ['error caught']);
assert.htmlEqual(target.innerHTML, `<div>Error!</div><button>Retry</button>`);
}
});

@ -0,0 +1,20 @@
<script>
import Child from './Child.svelte';
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
<!-- boundary should rethrow error as there's no control flow -->
<svelte:boundary>
<!-- rethrow the error in the handler -->
<svelte:boundary onerror={e => { throw e }}>
<Child />
</svelte:boundary>
</svelte:boundary>
{#snippet failed(e, retry)}
<div>Error!</div>
<button onclick={retry}>Retry</button>
{/snippet}
</svelte:boundary>

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
mode: ['client'],
test({ assert, logs }) {
assert.deepEqual(logs, ['error caught']);
}
});

@ -0,0 +1,11 @@
<script>
function throw_error() {
throw new Error('test')
}
</script>
<svelte:boundary onerror={(e) => console.log('error caught')}>
{throw_error()}
</svelte:boundary>

@ -1163,6 +1163,11 @@ declare module 'svelte/compiler' {
name: 'svelte:fragment';
}
export interface SvelteBoundary extends BaseElement {
type: 'SvelteBoundary';
name: 'svelte:boundary';
}
export interface SvelteHead extends BaseElement {
type: 'SvelteHead';
name: 'svelte:head';
@ -1280,7 +1285,8 @@ declare module 'svelte/compiler' {
| AST.SvelteHead
| AST.SvelteOptionsRaw
| AST.SvelteSelf
| AST.SvelteWindow;
| AST.SvelteWindow
| AST.SvelteBoundary;
/**
* The preprocess function provides convenient hooks for arbitrarily transforming component source code.
* For example, it can be used to convert a `<style lang="sass">` block into vanilla CSS.

Loading…
Cancel
Save