Skip to content

Commit 2d2772a

Browse files
authored
fix: invoke parent boundary of deriveds that throw (#16091)
* remove unused handle_error arguments * move error handling code into separate module * tidy up * simplify * remove unnecessary handle_error invocation * WIP * WIP * WIP * note to self * WIP * WIP * unused * WIP * turns out we need this * fix * all tests passing * unused * unused * tidy up * unused * unused * tweak * WIP * tweak * asked and answered * tweak * unused * changeset * lint
1 parent 2342c87 commit 2d2772a

File tree

13 files changed

+218
-257
lines changed

13 files changed

+218
-257
lines changed

.changeset/early-tips-prove.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: invoke parent boundary of deriveds that throw
Lines changed: 40 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { BlockStatement, Statement, Expression } from 'estree' */
1+
/** @import { BlockStatement, Statement, Expression, FunctionDeclaration, VariableDeclaration, ArrowFunctionExpression } from 'estree' */
22
/** @import { AST } from '#compiler' */
33
/** @import { ComponentContext } from '../types' */
44
import { dev } from '../../../../state.js';
@@ -34,62 +34,61 @@ export function SvelteBoundary(node, context) {
3434
const nodes = [];
3535

3636
/** @type {Statement[]} */
37-
const external_statements = [];
37+
const const_tags = [];
3838

3939
/** @type {Statement[]} */
40-
const internal_statements = [];
40+
const hoisted = [];
4141

42-
const snippets_visits = [];
42+
// const tags need to live inside the boundary, but might also be referenced in hoisted snippets.
43+
// to resolve this we cheat: we duplicate const tags inside snippets
44+
for (const child of node.fragment.nodes) {
45+
if (child.type === 'ConstTag') {
46+
context.visit(child, { ...context.state, init: const_tags });
47+
}
48+
}
4349

44-
// Capture the `failed` implicit snippet prop
4550
for (const child of node.fragment.nodes) {
46-
if (child.type === 'SnippetBlock' && child.expression.name === 'failed') {
47-
// we need to delay the visit of the snippets in case they access a ConstTag that is declared
48-
// after the snippets so that the visitor for the const tag can be updated
49-
snippets_visits.push(() => {
50-
/** @type {Statement[]} */
51-
const init = [];
52-
context.visit(child, { ...context.state, init });
53-
props.properties.push(b.prop('init', child.expression, child.expression));
54-
external_statements.push(...init);
55-
});
56-
} else if (child.type === 'ConstTag') {
51+
if (child.type === 'ConstTag') {
52+
continue;
53+
}
54+
55+
if (child.type === 'SnippetBlock') {
5756
/** @type {Statement[]} */
58-
const init = [];
59-
context.visit(child, { ...context.state, init });
60-
61-
if (dev) {
62-
// In dev we must separate the declarations from the code
63-
// that eagerly evaluate the expression...
64-
for (const statement of init) {
65-
if (statement.type === 'VariableDeclaration') {
66-
external_statements.push(statement);
67-
} else {
68-
internal_statements.push(statement);
69-
}
70-
}
71-
} else {
72-
external_statements.push(...init);
57+
const statements = [];
58+
59+
context.visit(child, { ...context.state, init: statements });
60+
61+
const snippet = /** @type {VariableDeclaration} */ (statements[0]);
62+
63+
const snippet_fn = dev
64+
? // @ts-expect-error we know this shape is correct
65+
snippet.declarations[0].init.arguments[1]
66+
: snippet.declarations[0].init;
67+
68+
snippet_fn.body.body.unshift(
69+
...const_tags.filter((node) => node.type === 'VariableDeclaration')
70+
);
71+
72+
hoisted.push(snippet);
73+
74+
if (child.expression.name === 'failed') {
75+
props.properties.push(b.prop('init', child.expression, child.expression));
7376
}
74-
} else {
75-
nodes.push(child);
77+
78+
continue;
7679
}
77-
}
7880

79-
snippets_visits.forEach((visit) => visit());
81+
nodes.push(child);
82+
}
8083

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

83-
if (dev && internal_statements.length) {
84-
block.body.unshift(...internal_statements);
85-
}
86+
block.body.unshift(...const_tags);
8687

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

9192
context.state.template.push_comment();
92-
context.state.init.push(
93-
external_statements.length > 0 ? b.block([...external_statements, boundary]) : boundary
94-
);
93+
context.state.init.push(hoisted.length > 0 ? b.block([...hoisted, boundary]) : boundary);
9594
}

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22

33
import { BOUNDARY_EFFECT, EFFECT_TRANSPARENT } from '#client/constants';
44
import { component_context, set_component_context } from '../../context.js';
5+
import { invoke_error_boundary } from '../../error-handling.js';
56
import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js';
67
import {
78
active_effect,
89
active_reaction,
9-
handle_error,
1010
set_active_effect,
11-
set_active_reaction,
12-
reset_is_throwing_error
11+
set_active_reaction
1312
} from '../../runtime.js';
1413
import {
1514
hydrate_next,
@@ -80,11 +79,17 @@ export function boundary(node, props, boundary_fn) {
8079
with_boundary(boundary, () => {
8180
is_creating_fallback = false;
8281
boundary_effect = branch(() => boundary_fn(anchor));
83-
reset_is_throwing_error();
8482
});
8583
};
8684

87-
onerror?.(error, reset);
85+
var previous_reaction = active_reaction;
86+
87+
try {
88+
set_active_reaction(null);
89+
onerror?.(error, reset);
90+
} finally {
91+
set_active_reaction(previous_reaction);
92+
}
8893

8994
if (boundary_effect) {
9095
destroy_effect(boundary_effect);
@@ -109,10 +114,9 @@ export function boundary(node, props, boundary_fn) {
109114
);
110115
});
111116
} catch (error) {
112-
handle_error(error, boundary, null, boundary.ctx);
117+
invoke_error_boundary(error, /** @type {Effect} */ (boundary.parent));
113118
}
114119

115-
reset_is_throwing_error();
116120
is_creating_fallback = false;
117121
});
118122
});
@@ -124,7 +128,6 @@ export function boundary(node, props, boundary_fn) {
124128
}
125129

126130
boundary_effect = branch(() => boundary_fn(anchor));
127-
reset_is_throwing_error();
128131
}, EFFECT_TRANSPARENT | BOUNDARY_EFFECT);
129132

130133
if (hydrating) {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/** @import { Effect } from '#client' */
2+
import { DEV } from 'esm-env';
3+
import { FILENAME } from '../../constants.js';
4+
import { is_firefox } from './dom/operations.js';
5+
import { BOUNDARY_EFFECT, EFFECT_RAN } from './constants.js';
6+
import { define_property } from '../shared/utils.js';
7+
import { active_effect } from './runtime.js';
8+
9+
/**
10+
* @param {unknown} error
11+
*/
12+
export function handle_error(error) {
13+
var effect = /** @type {Effect} */ (active_effect);
14+
15+
if (DEV && error instanceof Error) {
16+
adjust_error(error, effect);
17+
}
18+
19+
if ((effect.f & EFFECT_RAN) === 0) {
20+
// if the error occurred while creating this subtree, we let it
21+
// bubble up until it hits a boundary that can handle it
22+
if ((effect.f & BOUNDARY_EFFECT) === 0) {
23+
throw error;
24+
}
25+
26+
// @ts-expect-error
27+
effect.fn(error);
28+
} else {
29+
// otherwise we bubble up the effect tree ourselves
30+
invoke_error_boundary(error, effect);
31+
}
32+
}
33+
34+
/**
35+
* @param {unknown} error
36+
* @param {Effect | null} effect
37+
*/
38+
export function invoke_error_boundary(error, effect) {
39+
while (effect !== null) {
40+
if ((effect.f & BOUNDARY_EFFECT) !== 0) {
41+
try {
42+
// @ts-expect-error
43+
effect.fn(error);
44+
return;
45+
} catch {}
46+
}
47+
48+
effect = effect.parent;
49+
}
50+
51+
throw error;
52+
}
53+
54+
/** @type {WeakSet<Error>} */
55+
const adjusted_errors = new WeakSet();
56+
57+
/**
58+
* Add useful information to the error message/stack in development
59+
* @param {Error} error
60+
* @param {Effect} effect
61+
*/
62+
function adjust_error(error, effect) {
63+
if (adjusted_errors.has(error)) return;
64+
adjusted_errors.add(error);
65+
66+
var indent = is_firefox ? ' ' : '\t';
67+
var component_stack = `\n${indent}in ${effect.fn?.name || '<unknown>'}`;
68+
var context = effect.ctx;
69+
70+
while (context !== null) {
71+
component_stack += `\n${indent}in ${context.function?.[FILENAME].split('/').pop()}`;
72+
context = context.p;
73+
}
74+
75+
define_property(error, 'message', {
76+
value: error.message + `\n${component_stack}\n`
77+
});
78+
79+
if (error.stack) {
80+
// Filter out internal modules
81+
define_property(error, 'stack', {
82+
value: error.stack
83+
.split('\n')
84+
.filter((line) => !line.includes('svelte/src/internal'))
85+
.join('\n')
86+
});
87+
}
88+
}

packages/svelte/src/internal/client/reactivity/effects.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -332,16 +332,23 @@ export function render_effect(fn) {
332332
* @returns {Effect}
333333
*/
334334
export function template_effect(fn, thunks = [], d = derived) {
335-
const deriveds = thunks.map(d);
336-
const effect = () => fn(...deriveds.map(get));
337-
338335
if (DEV) {
339-
define_property(effect, 'name', {
340-
value: '{expression}'
336+
// wrap the effect so that we can decorate stack trace with `in {expression}`
337+
// (TODO maybe there's a better approach?)
338+
return render_effect(() => {
339+
var outer = /** @type {Effect} */ (active_effect);
340+
var inner = () => fn(...deriveds.map(get));
341+
342+
define_property(outer.fn, 'name', { value: '{expression}' });
343+
define_property(inner, 'name', { value: '{expression}' });
344+
345+
const deriveds = thunks.map(d);
346+
block(inner);
341347
});
342348
}
343349

344-
return block(effect);
350+
const deriveds = thunks.map(d);
351+
return block(() => fn(...deriveds.map(get)));
345352
}
346353

347354
/**
@@ -426,7 +433,11 @@ export function destroy_block_effect_children(signal) {
426433
export function destroy_effect(effect, remove_dom = true) {
427434
var removed = false;
428435

429-
if ((remove_dom || (effect.f & HEAD_EFFECT) !== 0) && effect.nodes_start !== null) {
436+
if (
437+
(remove_dom || (effect.f & HEAD_EFFECT) !== 0) &&
438+
effect.nodes_start !== null &&
439+
effect.nodes_end !== null
440+
) {
430441
remove_effect_dom(effect.nodes_start, /** @type {TemplateNode} */ (effect.nodes_end));
431442
removed = true;
432443
}

0 commit comments

Comments
 (0)