Skip to content

Commit 2789a3c

Browse files
feat: single-pass hydration (#12335)
* WIP towards single-pass hydration * fix * fixes * fix * fix * fixes * fix * fixes * fix * fix, tidy up * update script (it currently fails) * fix * fix * hmm * fix * fix * fix * fix * all hydration tests passing * drive-by fix * fix * update snapshot tests * fix * recover: false * fix invalid HTML message * note to self * fix * fix * update snapshot tests * fix * fix * fix * update test * fix * fix * fix * ALL TESTS PASSING THIS IS NOT A DRILL * optimise each blocks * changeset * type stuff * fix comment * tidy up * tidy up * tidy up * tidy up * tidy up * remove comment, turns out we do need it * revert * reinstate standalone optimisation * improve <svelte:element> SSR * reset more conservatively * tweak * DRY/fix * revert * simplify * add comment * tweak * simplify * simplify * answer: yes, at least for now, because otherwise empty components are a nuisance * tweak * unused * comment is answered by #12356 * tweak * handle `<template>` edge case at compile time * this is no longer a possibility, because of is_text_first * unused * tweak * fix * move annotations to properties * Update packages/svelte/src/constants.js Co-authored-by: Simon H <[email protected]> * Update packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js Co-authored-by: Simon H <[email protected]> * Update packages/svelte/src/internal/client/dom/blocks/each.js Co-authored-by: Simon H <[email protected]> * Update packages/svelte/src/internal/client/dom/hydration.js Co-authored-by: Simon H <[email protected]> * Update playgrounds/demo/vite.config.js Co-authored-by: Simon H <[email protected]> * add a comment * prettier * tweak * tighten up hydration tests, add test for standalone component * test for standalone snippet * fix * add some comments * tidy up * avoid mutating `arguments` --------- Co-authored-by: Simon H <[email protected]>
1 parent 3dbb220 commit 2789a3c

File tree

57 files changed

+606
-406
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+606
-406
lines changed

.changeset/spotty-shrimps-hug.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: single-pass hydration

packages/svelte/scripts/check-treeshakeability.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ const bundle = await bundle_code(
113113
).js.code
114114
);
115115

116-
if (!bundle.includes('hydrate_nodes') && !bundle.includes('hydrate_anchor')) {
116+
if (!bundle.includes('hydrate_node') && !bundle.includes('hydrate_next')) {
117117
// eslint-disable-next-line no-console
118118
console.error(`✅ Hydration code treeshakeable`);
119119
} else {

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

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -980,7 +980,8 @@ function serialize_inline_component(node, component_name, context, anchor = cont
980980

981981
statements.push(
982982
b.stmt(b.call('$.css_props', anchor, b.thunk(b.object(custom_css_props)))),
983-
b.stmt(fn(b.member(anchor, b.id('lastChild'))))
983+
b.stmt(fn(b.member(anchor, b.id('lastChild')))),
984+
b.stmt(b.call('$.reset', anchor))
984985
);
985986
} else {
986987
context.state.template.push('<!>');
@@ -1441,6 +1442,12 @@ function process_children(nodes, expression, is_element, { visit, state }) {
14411442
}
14421443

14431444
if (sequence.length > 0) {
1445+
// if the final item in a fragment is static text,
1446+
// we need to force `hydrate_node` to advance
1447+
if (sequence.length === 1 && sequence[0].type === 'Text' && nodes.length > 1) {
1448+
state.init.push(b.stmt(b.call('$.next')));
1449+
}
1450+
14441451
flush_sequence(sequence);
14451452
}
14461453
}
@@ -1569,7 +1576,7 @@ export const template_visitors = {
15691576

15701577
const namespace = infer_namespace(context.state.metadata.namespace, parent, node.nodes);
15711578

1572-
const { hoisted, trimmed, is_standalone } = clean_nodes(
1579+
const { hoisted, trimmed, is_standalone, is_text_first } = clean_nodes(
15731580
parent,
15741581
node.nodes,
15751582
context.path,
@@ -1619,6 +1626,11 @@ export const template_visitors = {
16191626
context.visit(node, state);
16201627
}
16211628

1629+
if (is_text_first) {
1630+
// skip over inserted comment
1631+
body.push(b.stmt(b.call('$.next')));
1632+
}
1633+
16221634
/**
16231635
* @param {import('estree').Identifier} template_name
16241636
* @param {import('estree').Expression[]} args
@@ -1677,20 +1689,15 @@ export const template_visitors = {
16771689
state
16781690
});
16791691

1680-
body.push(
1681-
b.var(id, b.call('$.text', b.id('$$anchor'))),
1682-
...state.before_init,
1683-
...state.init
1684-
);
1692+
body.push(b.var(id, b.call('$.text')), ...state.before_init, ...state.init);
16851693
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
16861694
} else {
16871695
if (is_standalone) {
16881696
// no need to create a template, we can just use the existing block's anchor
16891697
process_children(trimmed, () => b.id('$$anchor'), false, { ...context, state });
16901698
} else {
16911699
/** @type {(is_text: boolean) => import('estree').Expression} */
1692-
const expression = (is_text) =>
1693-
is_text ? b.call('$.first_child', id, b.true) : b.call('$.first_child', id);
1700+
const expression = (is_text) => b.call('$.first_child', id, is_text && b.true);
16941701

16951702
process_children(trimmed, expression, false, { ...context, state });
16961703

@@ -2180,18 +2187,30 @@ export const template_visitors = {
21802187
context.visit(node, child_state);
21812188
}
21822189

2183-
process_children(
2184-
trimmed,
2185-
() =>
2186-
b.call(
2187-
'$.child',
2188-
node.name === 'template'
2189-
? b.member(context.state.node, b.id('content'))
2190-
: context.state.node
2191-
),
2192-
true,
2193-
{ ...context, state: child_state }
2194-
);
2190+
/** @type {import('estree').Expression} */
2191+
let arg = context.state.node;
2192+
2193+
// If `hydrate_node` is set inside the element, we need to reset it
2194+
// after the element has been hydrated
2195+
let needs_reset = trimmed.some((node) => node.type !== 'Text');
2196+
2197+
// The same applies if it's a `<template>` element, since we need to
2198+
// set the value of `hydrate_node` to `node.content`
2199+
if (node.name === 'template') {
2200+
needs_reset = true;
2201+
2202+
arg = b.member(arg, b.id('content'));
2203+
child_state.init.push(b.stmt(b.call('$.reset', arg)));
2204+
}
2205+
2206+
process_children(trimmed, () => b.call('$.child', arg), true, {
2207+
...context,
2208+
state: child_state
2209+
});
2210+
2211+
if (needs_reset) {
2212+
child_state.init.push(b.stmt(b.call('$.reset', context.state.node)));
2213+
}
21952214

21962215
if (has_declaration) {
21972216
context.state.init.push(

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

Lines changed: 57 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,21 @@ import {
3333
import { escape_html } from '../../../../escaping.js';
3434
import { sanitize_template_string } from '../../../utils/sanitize_template_string.js';
3535
import {
36-
BLOCK_ANCHOR,
36+
EMPTY_COMMENT,
3737
BLOCK_CLOSE,
38-
BLOCK_CLOSE_ELSE,
39-
BLOCK_OPEN
38+
BLOCK_OPEN,
39+
BLOCK_OPEN_ELSE
4040
} from '../../../../internal/server/hydration.js';
4141
import { filename, locator } from '../../../state.js';
4242

43-
export const block_open = b.literal(BLOCK_OPEN);
44-
export const block_close = b.literal(BLOCK_CLOSE);
45-
export const block_anchor = b.literal(BLOCK_ANCHOR);
43+
/** Opens an if/each block, so that we can remove nodes in the case of a mismatch */
44+
const block_open = b.literal(BLOCK_OPEN);
45+
46+
/** Closes an if/each block, so that we can remove nodes in the case of a mismatch. Also serves as an anchor for these blocks */
47+
const block_close = b.literal(BLOCK_CLOSE);
48+
49+
/** Empty comment to keep text nodes separate, or provide an anchor node for blocks */
50+
const empty_comment = b.literal(EMPTY_COMMENT);
4651

4752
/**
4853
* @param {import('estree').Node} node
@@ -996,22 +1001,32 @@ function serialize_inline_component(node, expression, context) {
9961001
statement = b.block([...snippet_declarations, statement]);
9971002
}
9981003

1004+
const dynamic =
1005+
node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic);
1006+
9991007
if (custom_css_props.length > 0) {
1000-
statement = b.stmt(
1001-
b.call(
1002-
'$.css_props',
1003-
b.id('$$payload'),
1004-
b.literal(context.state.namespace === 'svg' ? false : true),
1005-
b.object(custom_css_props),
1006-
b.thunk(b.block([statement]))
1008+
context.state.template.push(
1009+
b.stmt(
1010+
b.call(
1011+
'$.css_props',
1012+
b.id('$$payload'),
1013+
b.literal(context.state.namespace === 'svg' ? false : true),
1014+
b.object(custom_css_props),
1015+
b.thunk(b.block([statement])),
1016+
dynamic && b.true
1017+
)
10071018
)
10081019
);
1020+
} else {
1021+
if (dynamic) {
1022+
context.state.template.push(empty_comment);
1023+
}
10091024

10101025
context.state.template.push(statement);
1011-
} else if (context.state.skip_hydration_boundaries) {
1012-
context.state.template.push(statement);
1013-
} else {
1014-
context.state.template.push(block_open, statement, block_close);
1026+
1027+
if (!context.state.skip_hydration_boundaries) {
1028+
context.state.template.push(empty_comment);
1029+
}
10151030
}
10161031
}
10171032

@@ -1119,7 +1134,7 @@ const template_visitors = {
11191134
const parent = context.path.at(-1) ?? node;
11201135
const namespace = infer_namespace(context.state.namespace, parent, node.nodes);
11211136

1122-
const { hoisted, trimmed, is_standalone } = clean_nodes(
1137+
const { hoisted, trimmed, is_standalone, is_text_first } = clean_nodes(
11231138
parent,
11241139
node.nodes,
11251140
context.path,
@@ -1142,13 +1157,18 @@ const template_visitors = {
11421157
context.visit(node, state);
11431158
}
11441159

1160+
if (is_text_first) {
1161+
// insert `<!---->` to prevent this from being glued to the previous fragment
1162+
state.template.push(empty_comment);
1163+
}
1164+
11451165
process_children(trimmed, { ...context, state });
11461166

11471167
return b.block([...state.init, ...serialize_template(state.template)]);
11481168
},
11491169
HtmlTag(node, context) {
11501170
const expression = /** @type {import('estree').Expression} */ (context.visit(node.expression));
1151-
context.state.template.push(block_open, expression, block_close);
1171+
context.state.template.push(empty_comment, expression, empty_comment);
11521172
},
11531173
ConstTag(node, { state, visit }) {
11541174
const declaration = node.declaration.declarations[0];
@@ -1188,10 +1208,6 @@ const template_visitors = {
11881208
return /** @type {import('estree').Expression} */ (context.visit(arg));
11891209
});
11901210

1191-
if (!context.state.skip_hydration_boundaries) {
1192-
context.state.template.push(block_open);
1193-
}
1194-
11951211
context.state.template.push(
11961212
b.stmt(
11971213
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
@@ -1203,7 +1219,7 @@ const template_visitors = {
12031219
);
12041220

12051221
if (!context.state.skip_hydration_boundaries) {
1206-
context.state.template.push(block_close);
1222+
context.state.template.push(empty_comment);
12071223
}
12081224
},
12091225
ClassDirective() {
@@ -1353,7 +1369,6 @@ const template_visitors = {
13531369
},
13541370
EachBlock(node, context) {
13551371
const state = context.state;
1356-
state.template.push(block_open);
13571372

13581373
const each_node_meta = node.metadata;
13591374
const collection = /** @type {import('estree').Expression} */ (context.visit(node.expression));
@@ -1376,39 +1391,36 @@ const template_visitors = {
13761391
each.push(b.let(node.index, index));
13771392
}
13781393

1379-
each.push(b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_OPEN))));
1380-
13811394
each.push(.../** @type {import('estree').BlockStatement} */ (context.visit(node.body)).body);
13821395

1383-
each.push(b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE))));
1384-
13851396
const for_loop = b.for(
13861397
b.let(index, b.literal(0)),
13871398
b.binary('<', index, b.member(array_id, b.id('length'))),
13881399
b.update('++', index, false),
13891400
b.block(each)
13901401
);
13911402

1392-
const close = b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE)));
1393-
13941403
if (node.fallback) {
1404+
const open = b.stmt(b.assignment('+=', b.id('$$payload.out'), block_open));
1405+
13951406
const fallback = /** @type {import('estree').BlockStatement} */ (
13961407
context.visit(node.fallback)
13971408
);
13981409

1399-
fallback.body.push(
1400-
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE_ELSE)))
1410+
fallback.body.unshift(
1411+
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_OPEN_ELSE)))
14011412
);
14021413

14031414
state.template.push(
14041415
b.if(
14051416
b.binary('!==', b.member(array_id, b.id('length')), b.literal(0)),
1406-
b.block([for_loop, close]),
1417+
b.block([open, for_loop]),
14071418
fallback
1408-
)
1419+
),
1420+
block_close
14091421
);
14101422
} else {
1411-
state.template.push(for_loop, close);
1423+
state.template.push(block_open, for_loop, block_close);
14121424
}
14131425
},
14141426
IfBlock(node, context) {
@@ -1422,16 +1434,17 @@ const template_visitors = {
14221434
? /** @type {import('estree').BlockStatement} */ (context.visit(node.alternate))
14231435
: b.block([]);
14241436

1425-
consequent.body.push(b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE))));
1426-
alternate.body.push(
1427-
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE_ELSE)))
1437+
consequent.body.unshift(b.stmt(b.assignment('+=', b.id('$$payload.out'), block_open)));
1438+
1439+
alternate.body.unshift(
1440+
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_OPEN_ELSE)))
14281441
);
14291442

1430-
context.state.template.push(block_open, b.if(test, consequent, alternate));
1443+
context.state.template.push(b.if(test, consequent, alternate), block_close);
14311444
},
14321445
AwaitBlock(node, context) {
14331446
context.state.template.push(
1434-
block_open,
1447+
empty_comment,
14351448
b.stmt(
14361449
b.call(
14371450
'$.await',
@@ -1455,12 +1468,12 @@ const template_visitors = {
14551468
)
14561469
)
14571470
),
1458-
block_close
1471+
empty_comment
14591472
);
14601473
},
14611474
KeyBlock(node, context) {
14621475
const block = /** @type {import('estree').BlockStatement} */ (context.visit(node.fragment));
1463-
context.state.template.push(block_open, block, block_close);
1476+
context.state.template.push(empty_comment, block, empty_comment);
14641477
},
14651478
SnippetBlock(node, context) {
14661479
const fn = b.function_declaration(
@@ -1594,7 +1607,7 @@ const template_visitors = {
15941607

15951608
const slot = b.call('$.slot', b.id('$$payload'), expression, props_expression, fallback);
15961609

1597-
context.state.template.push(block_open, b.stmt(slot), block_close);
1610+
context.state.template.push(empty_comment, b.stmt(slot), empty_comment);
15981611
},
15991612
SvelteHead(node, context) {
16001613
const block = /** @type {import('estree').BlockStatement} */ (context.visit(node.fragment));

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

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -270,21 +270,28 @@ export function clean_nodes(
270270

271271
var first = trimmed[0];
272272

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-
!state.options.hmr &&
283-
!first.attributes.some(
284-
(attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--')
285-
)));
286-
287-
return { hoisted, trimmed, is_standalone };
273+
return {
274+
hoisted,
275+
trimmed,
276+
/**
277+
* In a case like `{#if x}<Foo />{/if}`, we don't need to wrap the child in
278+
* comments — we can just use the parent block's anchor for the component.
279+
* TODO extend this optimisation to other cases
280+
*/
281+
is_standalone:
282+
trimmed.length === 1 &&
283+
((first.type === 'RenderTag' && !first.metadata.dynamic) ||
284+
(first.type === 'Component' &&
285+
!state.options.hmr &&
286+
!first.attributes.some(
287+
(attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--')
288+
))),
289+
/** if a component or snippet starts with text, we need to add an anchor comment so that its text node doesn't get fused with its surroundings */
290+
is_text_first:
291+
(parent.type === 'Fragment' || parent.type === 'SnippetBlock') &&
292+
first &&
293+
(first?.type === 'Text' || first?.type === 'ExpressionTag')
294+
};
288295
}
289296

290297
/**

0 commit comments

Comments
 (0)