Skip to content

Commit fbb7da7

Browse files
authored
feat: simpler effect DOM boundaries (#12258)
* simpler effect dom boundaries * remove unused argument * tidy up * simplify * skip redundant comment templates for components (and others TODO) * same optimisation for render tags * DRY out * appease typescript * changeset * tighten up, leave note to self * reinstate $.comment optimisation * add explanation * comments
1 parent dcc7ed4 commit fbb7da7

File tree

26 files changed

+203
-235
lines changed

26 files changed

+203
-235
lines changed

.changeset/beige-gifts-appear.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+
feat: simpler effect DOM boundaries

packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import {
3636
EACH_KEYED,
3737
is_capture_event,
3838
TEMPLATE_FRAGMENT,
39-
TEMPLATE_UNSET_START,
4039
TEMPLATE_USE_IMPORT_NODE,
4140
TRANSITION_GLOBAL,
4241
TRANSITION_IN,
@@ -1561,7 +1560,7 @@ export const template_visitors = {
15611560

15621561
const namespace = infer_namespace(context.state.metadata.namespace, parent, node.nodes);
15631562

1564-
const { hoisted, trimmed } = clean_nodes(
1563+
const { hoisted, trimmed, is_standalone } = clean_nodes(
15651564
parent,
15661565
node.nodes,
15671566
context.path,
@@ -1676,56 +1675,38 @@ export const template_visitors = {
16761675
);
16771676
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
16781677
} else {
1679-
/** @type {(is_text: boolean) => import('estree').Expression} */
1680-
const expression = (is_text) =>
1681-
is_text ? b.call('$.first_child', id, b.true) : b.call('$.first_child', id);
1682-
1683-
process_children(trimmed, expression, false, { ...context, state });
1684-
1685-
var first = trimmed[0];
1686-
1687-
/**
1688-
* If the first item in an effect is a static slot or render tag, it will clone
1689-
* a template but without creating a child effect. In these cases, we need to keep
1690-
* the current `effect.nodes.start` undefined, so that it can be populated by
1691-
* the item in question
1692-
* TODO come up with a better name than `unset`
1693-
*/
1694-
var unset = false;
1695-
1696-
if (first.type === 'SlotElement') unset = true;
1697-
if (first.type === 'RenderTag' && !first.metadata.dynamic) unset = true;
1698-
if (first.type === 'Component' && !first.metadata.dynamic && !context.state.options.hmr) {
1699-
unset = true;
1700-
}
1678+
if (is_standalone) {
1679+
// no need to create a template, we can just use the existing block's anchor
1680+
process_children(trimmed, () => b.id('$$anchor'), false, { ...context, state });
1681+
} else {
1682+
/** @type {(is_text: boolean) => import('estree').Expression} */
1683+
const expression = (is_text) =>
1684+
is_text ? b.call('$.first_child', id, b.true) : b.call('$.first_child', id);
17011685

1702-
const use_comment_template = state.template.length === 1 && state.template[0] === '<!>';
1686+
process_children(trimmed, expression, false, { ...context, state });
17031687

1704-
if (use_comment_template) {
1705-
// special case — we can use `$.comment` instead of creating a unique template
1706-
body.push(b.var(id, b.call('$.comment', unset && b.literal(unset))));
1707-
} else {
17081688
let flags = TEMPLATE_FRAGMENT;
17091689

1710-
if (unset) {
1711-
flags |= TEMPLATE_UNSET_START;
1712-
}
1713-
17141690
if (state.metadata.context.template_needs_import_node) {
17151691
flags |= TEMPLATE_USE_IMPORT_NODE;
17161692
}
17171693

1718-
add_template(template_name, [
1719-
b.template([b.quasi(state.template.join(''), true)], []),
1720-
b.literal(flags)
1721-
]);
1694+
if (state.template.length === 1 && state.template[0] === '<!>') {
1695+
// special case — we can use `$.comment` instead of creating a unique template
1696+
body.push(b.var(id, b.call('$.comment')));
1697+
} else {
1698+
add_template(template_name, [
1699+
b.template([b.quasi(state.template.join(''), true)], []),
1700+
b.literal(flags)
1701+
]);
1702+
1703+
body.push(b.var(id, b.call(template_name)));
1704+
}
17221705

1723-
body.push(b.var(id, b.call(template_name)));
1706+
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
17241707
}
17251708

17261709
body.push(...state.before_init, ...state.init);
1727-
1728-
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
17291710
}
17301711
} else {
17311712
body.push(...state.before_init, ...state.init);

packages/svelte/src/compiler/phases/3-transform/server/transform-server.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,8 @@ function serialize_inline_component(node, expression, context) {
10021002
)
10031003
);
10041004

1005+
context.state.template.push(statement);
1006+
} else if (context.state.skip_hydration_boundaries) {
10051007
context.state.template.push(statement);
10061008
} else {
10071009
context.state.template.push(block_open, statement, block_close);
@@ -1112,7 +1114,7 @@ const template_visitors = {
11121114
const parent = context.path.at(-1) ?? node;
11131115
const namespace = infer_namespace(context.state.namespace, parent, node.nodes);
11141116

1115-
const { hoisted, trimmed } = clean_nodes(
1117+
const { hoisted, trimmed, is_standalone } = clean_nodes(
11161118
parent,
11171119
node.nodes,
11181120
context.path,
@@ -1127,7 +1129,8 @@ const template_visitors = {
11271129
...context.state,
11281130
init: [],
11291131
template: [],
1130-
namespace
1132+
namespace,
1133+
skip_hydration_boundaries: is_standalone
11311134
};
11321135

11331136
for (const node of hoisted) {
@@ -1180,17 +1183,23 @@ const template_visitors = {
11801183
return /** @type {import('estree').Expression} */ (context.visit(arg));
11811184
});
11821185

1186+
if (!context.state.skip_hydration_boundaries) {
1187+
context.state.template.push(block_open);
1188+
}
1189+
11831190
context.state.template.push(
1184-
block_open,
11851191
b.stmt(
11861192
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
11871193
snippet_function,
11881194
b.id('$$payload'),
11891195
...snippet_args
11901196
)
1191-
),
1192-
block_close
1197+
)
11931198
);
1199+
1200+
if (!context.state.skip_hydration_boundaries) {
1201+
context.state.template.push(block_close);
1202+
}
11941203
},
11951204
ClassDirective() {
11961205
throw new Error('Node should have been handled elsewhere');
@@ -1925,7 +1934,8 @@ export function server_component(analysis, options) {
19251934
template: /** @type {any} */ (null),
19261935
namespace: options.namespace,
19271936
preserve_whitespace: options.preserveWhitespace,
1928-
private_derived: new Map()
1937+
private_derived: new Map(),
1938+
skip_hydration_boundaries: false
19291939
};
19301940

19311941
const module = /** @type {import('estree').Program} */ (

packages/svelte/src/compiler/phases/3-transform/server/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface ComponentServerTransformState extends ServerTransformState {
2222
readonly template: Array<Statement | Expression>;
2323
readonly namespace: Namespace;
2424
readonly preserve_whitespace: boolean;
25+
readonly skip_hydration_boundaries: boolean;
2526
}
2627

2728
export type Context = import('zimmerframe').Context<SvelteNode, ServerTransformState>;

packages/svelte/src/compiler/phases/3-transform/utils.js

Lines changed: 87 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -185,83 +185,105 @@ export function clean_nodes(
185185
}
186186
}
187187

188-
if (preserve_whitespace) {
189-
return { hoisted, trimmed: regular };
190-
}
188+
let trimmed = regular;
191189

192-
let first, last;
190+
if (!preserve_whitespace) {
191+
trimmed = [];
193192

194-
while ((first = regular[0]) && first.type === 'Text' && !regex_not_whitespace.test(first.data)) {
195-
regular.shift();
196-
}
193+
let first, last;
197194

198-
if (first?.type === 'Text') {
199-
first.raw = first.raw.replace(regex_starts_with_whitespaces, '');
200-
first.data = first.data.replace(regex_starts_with_whitespaces, '');
201-
}
195+
while (
196+
(first = regular[0]) &&
197+
first.type === 'Text' &&
198+
!regex_not_whitespace.test(first.data)
199+
) {
200+
regular.shift();
201+
}
202202

203-
while ((last = regular.at(-1)) && last.type === 'Text' && !regex_not_whitespace.test(last.data)) {
204-
regular.pop();
205-
}
203+
if (first?.type === 'Text') {
204+
first.raw = first.raw.replace(regex_starts_with_whitespaces, '');
205+
first.data = first.data.replace(regex_starts_with_whitespaces, '');
206+
}
206207

207-
if (last?.type === 'Text') {
208-
last.raw = last.raw.replace(regex_ends_with_whitespaces, '');
209-
last.data = last.data.replace(regex_ends_with_whitespaces, '');
210-
}
208+
while (
209+
(last = regular.at(-1)) &&
210+
last.type === 'Text' &&
211+
!regex_not_whitespace.test(last.data)
212+
) {
213+
regular.pop();
214+
}
211215

212-
const can_remove_entirely =
213-
(namespace === 'svg' &&
214-
(parent.type !== 'RegularElement' || parent.name !== 'text') &&
215-
!path.some((n) => n.type === 'RegularElement' && n.name === 'text')) ||
216-
(parent.type === 'RegularElement' &&
217-
// TODO others?
218-
(parent.name === 'select' ||
219-
parent.name === 'tr' ||
220-
parent.name === 'table' ||
221-
parent.name === 'tbody' ||
222-
parent.name === 'thead' ||
223-
parent.name === 'tfoot' ||
224-
parent.name === 'colgroup' ||
225-
parent.name === 'datalist'));
216+
if (last?.type === 'Text') {
217+
last.raw = last.raw.replace(regex_ends_with_whitespaces, '');
218+
last.data = last.data.replace(regex_ends_with_whitespaces, '');
219+
}
226220

227-
/** @type {Compiler.SvelteNode[]} */
228-
const trimmed = [];
229-
230-
// Replace any whitespace between a text and non-text node with a single spaceand keep whitespace
231-
// as-is within text nodes, or between text nodes and expression tags (because in the end they count
232-
// as one text). This way whitespace is mostly preserved when using CSS with `white-space: pre-line`
233-
// and default slot content going into a pre tag (which we can't see).
234-
for (let i = 0; i < regular.length; i++) {
235-
const prev = regular[i - 1];
236-
const node = regular[i];
237-
const next = regular[i + 1];
238-
239-
if (node.type === 'Text') {
240-
if (prev?.type !== 'ExpressionTag') {
241-
const prev_is_text_ending_with_whitespace =
242-
prev?.type === 'Text' && regex_ends_with_whitespaces.test(prev.data);
243-
node.data = node.data.replace(
244-
regex_starts_with_whitespaces,
245-
prev_is_text_ending_with_whitespace ? '' : ' '
246-
);
247-
node.raw = node.raw.replace(
248-
regex_starts_with_whitespaces,
249-
prev_is_text_ending_with_whitespace ? '' : ' '
250-
);
251-
}
252-
if (next?.type !== 'ExpressionTag') {
253-
node.data = node.data.replace(regex_ends_with_whitespaces, ' ');
254-
node.raw = node.raw.replace(regex_ends_with_whitespaces, ' ');
255-
}
256-
if (node.data && (node.data !== ' ' || !can_remove_entirely)) {
221+
const can_remove_entirely =
222+
(namespace === 'svg' &&
223+
(parent.type !== 'RegularElement' || parent.name !== 'text') &&
224+
!path.some((n) => n.type === 'RegularElement' && n.name === 'text')) ||
225+
(parent.type === 'RegularElement' &&
226+
// TODO others?
227+
(parent.name === 'select' ||
228+
parent.name === 'tr' ||
229+
parent.name === 'table' ||
230+
parent.name === 'tbody' ||
231+
parent.name === 'thead' ||
232+
parent.name === 'tfoot' ||
233+
parent.name === 'colgroup' ||
234+
parent.name === 'datalist'));
235+
236+
// Replace any whitespace between a text and non-text node with a single spaceand keep whitespace
237+
// as-is within text nodes, or between text nodes and expression tags (because in the end they count
238+
// as one text). This way whitespace is mostly preserved when using CSS with `white-space: pre-line`
239+
// and default slot content going into a pre tag (which we can't see).
240+
for (let i = 0; i < regular.length; i++) {
241+
const prev = regular[i - 1];
242+
const node = regular[i];
243+
const next = regular[i + 1];
244+
245+
if (node.type === 'Text') {
246+
if (prev?.type !== 'ExpressionTag') {
247+
const prev_is_text_ending_with_whitespace =
248+
prev?.type === 'Text' && regex_ends_with_whitespaces.test(prev.data);
249+
node.data = node.data.replace(
250+
regex_starts_with_whitespaces,
251+
prev_is_text_ending_with_whitespace ? '' : ' '
252+
);
253+
node.raw = node.raw.replace(
254+
regex_starts_with_whitespaces,
255+
prev_is_text_ending_with_whitespace ? '' : ' '
256+
);
257+
}
258+
if (next?.type !== 'ExpressionTag') {
259+
node.data = node.data.replace(regex_ends_with_whitespaces, ' ');
260+
node.raw = node.raw.replace(regex_ends_with_whitespaces, ' ');
261+
}
262+
if (node.data && (node.data !== ' ' || !can_remove_entirely)) {
263+
trimmed.push(node);
264+
}
265+
} else {
257266
trimmed.push(node);
258267
}
259-
} else {
260-
trimmed.push(node);
261268
}
262269
}
263270

264-
return { hoisted, trimmed };
271+
var first = trimmed[0];
272+
273+
/**
274+
* In a case like `{#if x}<Foo />{/if}`, we don't need to wrap the child in
275+
* comments — we can just use the parent block's anchor for the component.
276+
* TODO extend this optimisation to other cases
277+
*/
278+
const is_standalone =
279+
trimmed.length === 1 &&
280+
((first.type === 'RenderTag' && !first.metadata.dynamic) ||
281+
(first.type === 'Component' &&
282+
!first.attributes.some(
283+
(attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--')
284+
)));
285+
286+
return { hoisted, trimmed, is_standalone };
265287
}
266288

267289
/**

packages/svelte/src/constants.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export const TRANSITION_GLOBAL = 1 << 2;
1818

1919
export const TEMPLATE_FRAGMENT = 1;
2020
export const TEMPLATE_USE_IMPORT_NODE = 1 << 1;
21-
export const TEMPLATE_UNSET_START = 1 << 2;
2221

2322
export const HYDRATION_START = '[';
2423
export const HYDRATION_END = ']';

packages/svelte/src/internal/client/dev/hmr.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** @import { Source, Effect } from '#client' */
2+
import { empty } from '../dom/operations.js';
23
import { block, branch, destroy_effect } from '../reactivity/effects.js';
34
import { set_should_intro } from '../render.js';
45
import { get } from '../runtime.js';
@@ -19,7 +20,7 @@ export function hmr(source) {
1920
/** @type {Effect} */
2021
let effect;
2122

22-
block(anchor, 0, () => {
23+
block(() => {
2324
const component = get(source);
2425

2526
if (effect) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export function await_block(anchor, get_input, pending_fn, then_fn, catch_fn) {
105105
}
106106
}
107107

108-
var effect = block(anchor, 0, () => {
108+
var effect = block(() => {
109109
if (input === (input = get_input())) return;
110110

111111
if (is_promise(input)) {

0 commit comments

Comments
 (0)