-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat: add error boundaries #14211
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
Changes from 2 commits
007d5f3
f683ce2
51e49be
11b2ecd
10897ac
e6b6a68
dc95bd3
b9bc80d
cf11f58
4d8ad24
2c8dea6
5be2334
c468c6d
3ae0c80
3441004
75a01a5
371f118
36dcb7d
3afd1bb
1564640
acb3cd0
bc72ed2
a77bf50
f82a59b
6aa714e
b53cfc8
1ec18a3
a7ee520
3070f67
fbbb7d9
3322856
08b82f9
2d27f50
702adf9
69877ae
8e74719
b4a30a4
a81dc34
0fe5274
62e1af8
dfdcf02
b3d1d92
dc36557
2b0778e
93b16b1
4509d3b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'svelte': patch | ||
| --- | ||
|
|
||
| feat: adds error boundaries | ||
Rich-Harris marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2026,6 +2026,10 @@ export interface SvelteHTMLElements { | |
| [name: string]: any; | ||
| }; | ||
| 'svelte:head': { [name: string]: any }; | ||
| 'svelte:boundary': { | ||
| onerror?: (error: Error, reset: () => void) => void; | ||
|
||
| failed?: import('svelte').Snippet; | ||
trueadm marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| [name: string]: { [name: string]: any }; | ||
| } | ||
| 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 |
|---|---|---|
| @@ -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)); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /** @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, | ||
| set_active_effect, | ||
| set_active_reaction, | ||
| set_component_context | ||
| } from '../../runtime.js'; | ||
| import { hydrate_next, hydrate_node, hydrating } 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); | ||
|
|
||
| // 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 (boundary_effect) { | ||
| destroy_effect(boundary_effect); | ||
| } | ||
|
|
||
| // If we have nothing to capture the error then rethrow the error | ||
| // for another boundary to handle | ||
| if (!onerror && !failed_snippet) { | ||
| throw error; | ||
| } | ||
|
|
||
| // Handle resetting the error boundary | ||
| var reset = () => { | ||
| if (boundary_effect) { | ||
| pause_effect(boundary_effect); | ||
| } | ||
trueadm marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| with_boundary(boundary, () => { | ||
| boundary_effect = null; | ||
trueadm marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| boundary_effect = branch(() => boundary_fn(anchor)); | ||
| }); | ||
| }; | ||
|
|
||
| // Handle the `onerror` event handler | ||
| if (onerror) { | ||
| onerror(error, reset); | ||
| } | ||
|
|
||
| // 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.
Outdated
Show resolved
Hide resolved
|
||
| boundary_effect = branch(() => | ||
| failed_snippet( | ||
| anchor, | ||
| () => error, | ||
| () => reset | ||
| ) | ||
| ); | ||
| }); | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| if (hydrating) { | ||
| hydrate_next(); | ||
| } | ||
|
|
||
| boundary_effect = branch(() => boundary_fn(anchor)); | ||
| }, EFFECT_TRANSPARENT | BOUNDARY_EFFECT); | ||
|
|
||
| if (hydrating) { | ||
| anchor = hydrate_node; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.