Skip to content

feat: single-pass hydration #12335

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

Merged
merged 91 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
0c37a40
WIP towards single-pass hydration
Rich-Harris Jul 3, 2024
a7b658a
fix
Rich-Harris Jul 4, 2024
cbeb564
fixes
Rich-Harris Jul 4, 2024
9607f7e
fix
Rich-Harris Jul 4, 2024
bf75fd8
fix
Rich-Harris Jul 4, 2024
e23d992
Merge branch 'main' into single-pass-hydration
Rich-Harris Jul 4, 2024
ee9008b
fixes
Rich-Harris Jul 5, 2024
86492e5
fix
Rich-Harris Jul 5, 2024
6b3a3d6
fixes
Rich-Harris Jul 5, 2024
9a80016
fix
Rich-Harris Jul 5, 2024
eb9824e
fix, tidy up
Rich-Harris Jul 5, 2024
840c572
update script (it currently fails)
Rich-Harris Jul 5, 2024
10f02f4
fix
Rich-Harris Jul 5, 2024
2bfd5b2
fix
Rich-Harris Jul 5, 2024
a761d88
hmm
Rich-Harris Jul 5, 2024
c8bad17
fix
Rich-Harris Jul 5, 2024
75ab257
fix
Rich-Harris Jul 5, 2024
1074bfd
fix
Rich-Harris Jul 5, 2024
64d8acd
fix
Rich-Harris Jul 5, 2024
dc45164
all hydration tests passing
Rich-Harris Jul 5, 2024
cafefce
drive-by fix
Rich-Harris Jul 5, 2024
32bea02
fix
Rich-Harris Jul 5, 2024
f39c0f0
update snapshot tests
Rich-Harris Jul 5, 2024
1dd0575
fix
Rich-Harris Jul 5, 2024
79755e3
recover: false
Rich-Harris Jul 6, 2024
2486b95
fix invalid HTML message
Rich-Harris Jul 6, 2024
0343fc3
note to self
Rich-Harris Jul 6, 2024
c23e5ea
fix
Rich-Harris Jul 7, 2024
16533e1
fix
Rich-Harris Jul 7, 2024
65f8675
update snapshot tests
Rich-Harris Jul 7, 2024
65d2053
fix
Rich-Harris Jul 7, 2024
c440445
fix
Rich-Harris Jul 7, 2024
2aa9c67
fix
Rich-Harris Jul 7, 2024
ee95533
update test
Rich-Harris Jul 7, 2024
f263956
fix
Rich-Harris Jul 7, 2024
32f6c4f
fix
Rich-Harris Jul 7, 2024
8a4883f
fix
Rich-Harris Jul 7, 2024
9f6c032
ALL TESTS PASSING THIS IS NOT A DRILL
Rich-Harris Jul 7, 2024
fa4d373
merge
Rich-Harris Jul 7, 2024
ee76cd7
optimise each blocks
Rich-Harris Jul 7, 2024
7be9add
changeset
Rich-Harris Jul 7, 2024
ce14d06
type stuff
Rich-Harris Jul 7, 2024
19579e1
fix comment
Rich-Harris Jul 7, 2024
4f2b26a
tidy up
Rich-Harris Jul 7, 2024
aee8d0d
tidy up
Rich-Harris Jul 7, 2024
99b2dca
tidy up
Rich-Harris Jul 7, 2024
4de3f2e
tidy up
Rich-Harris Jul 7, 2024
11a9ada
tidy up
Rich-Harris Jul 7, 2024
c7e3fb8
remove comment, turns out we do need it
Rich-Harris Jul 8, 2024
667a1a2
revert
Rich-Harris Jul 8, 2024
412ac8a
merge main
Rich-Harris Jul 8, 2024
7bfc74f
reinstate standalone optimisation
Rich-Harris Jul 8, 2024
0bdb35e
improve <svelte:element> SSR
Rich-Harris Jul 8, 2024
1d9fd22
merge main
Rich-Harris Jul 8, 2024
56d8708
reset more conservatively
Rich-Harris Jul 8, 2024
f5d36b8
tweak
Rich-Harris Jul 8, 2024
2e468eb
DRY/fix
Rich-Harris Jul 8, 2024
aa45aa4
revert
Rich-Harris Jul 8, 2024
f77b10a
simplify
Rich-Harris Jul 8, 2024
4a20760
add comment
Rich-Harris Jul 8, 2024
357c5f5
tweak
Rich-Harris Jul 8, 2024
1de3caa
simplify
Rich-Harris Jul 8, 2024
50d9536
simplify
Rich-Harris Jul 9, 2024
2436cbc
answer: yes, at least for now, because otherwise empty components are…
Rich-Harris Jul 9, 2024
81d6c3f
tweak
Rich-Harris Jul 9, 2024
93076bb
unused
Rich-Harris Jul 9, 2024
6f31b62
comment is answered by https://github.com/sveltejs/svelte/pull/12356
Rich-Harris Jul 9, 2024
09a3537
tweak
Rich-Harris Jul 9, 2024
d71dd5f
handle `<template>` edge case at compile time
Rich-Harris Jul 9, 2024
3079878
this is no longer a possibility, because of is_text_first
Rich-Harris Jul 9, 2024
4a2cb4b
unused
Rich-Harris Jul 9, 2024
bdf8946
merge main
Rich-Harris Jul 9, 2024
b763654
tweak
Rich-Harris Jul 9, 2024
2d41103
merge main
Rich-Harris Jul 9, 2024
e52bf4b
fix
Rich-Harris Jul 9, 2024
5676a84
move annotations to properties
Rich-Harris Jul 9, 2024
d79d503
Update packages/svelte/src/constants.js
Rich-Harris Jul 9, 2024
8bdcee6
Update packages/svelte/src/compiler/phases/3-transform/client/visitor…
Rich-Harris Jul 9, 2024
44efc18
Update packages/svelte/src/internal/client/dom/blocks/each.js
Rich-Harris Jul 9, 2024
360ea32
Update packages/svelte/src/internal/client/dom/hydration.js
Rich-Harris Jul 9, 2024
d2a30c5
Update playgrounds/demo/vite.config.js
Rich-Harris Jul 9, 2024
85ae369
add a comment
Rich-Harris Jul 9, 2024
c023a15
Merge branch 'single-pass-hydration' of github.com:sveltejs/svelte in…
Rich-Harris Jul 9, 2024
e7c8d61
prettier
Rich-Harris Jul 9, 2024
9192bbb
tweak
Rich-Harris Jul 9, 2024
ba62a07
tighten up hydration tests, add test for standalone component
Rich-Harris Jul 9, 2024
b66f8bc
test for standalone snippet
Rich-Harris Jul 9, 2024
fd43c85
fix
Rich-Harris Jul 9, 2024
b62c321
add some comments
Rich-Harris Jul 9, 2024
9268126
tidy up
Rich-Harris Jul 9, 2024
4557344
avoid mutating `arguments`
Rich-Harris Jul 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spotty-shrimps-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

feat: single-pass hydration
2 changes: 1 addition & 1 deletion packages/svelte/scripts/check-treeshakeability.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const bundle = await bundle_code(
).js.code
);

if (!bundle.includes('hydrate_nodes') && !bundle.includes('hydrate_anchor')) {
if (!bundle.includes('hydrate_node') && !bundle.includes('hydrate_next')) {
// eslint-disable-next-line no-console
console.error(`✅ Hydration code treeshakeable`);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,8 @@ function serialize_inline_component(node, component_name, context, anchor = cont

statements.push(
b.stmt(b.call('$.css_props', anchor, b.thunk(b.object(custom_css_props)))),
b.stmt(fn(b.member(anchor, b.id('lastChild'))))
b.stmt(fn(b.member(anchor, b.id('lastChild')))),
b.stmt(b.call('$.reset', anchor))
);
} else {
context.state.template.push('<!>');
Expand Down Expand Up @@ -1441,6 +1442,12 @@ function process_children(nodes, expression, is_element, { visit, state }) {
}

if (sequence.length > 0) {
// if the final item in a fragment is static text,
// we need to force `hydrate_node` to advance
if (sequence.length === 1 && sequence[0].type === 'Text' && nodes.length > 1) {
state.init.push(b.stmt(b.call('$.next')));
}

flush_sequence(sequence);
}
}
Expand Down Expand Up @@ -1569,7 +1576,7 @@ export const template_visitors = {

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

const { hoisted, trimmed, is_standalone } = clean_nodes(
const { hoisted, trimmed, is_standalone, is_text_first } = clean_nodes(
parent,
node.nodes,
context.path,
Expand Down Expand Up @@ -1619,6 +1626,11 @@ export const template_visitors = {
context.visit(node, state);
}

if (is_text_first) {
// skip over inserted comment
body.push(b.stmt(b.call('$.next')));
}

/**
* @param {import('estree').Identifier} template_name
* @param {import('estree').Expression[]} args
Expand Down Expand Up @@ -1677,20 +1689,15 @@ export const template_visitors = {
state
});

body.push(
b.var(id, b.call('$.text', b.id('$$anchor'))),
...state.before_init,
...state.init
);
body.push(b.var(id, b.call('$.text')), ...state.before_init, ...state.init);
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
} else {
if (is_standalone) {
// no need to create a template, we can just use the existing block's anchor
process_children(trimmed, () => b.id('$$anchor'), false, { ...context, state });
} else {
/** @type {(is_text: boolean) => import('estree').Expression} */
const expression = (is_text) =>
is_text ? b.call('$.first_child', id, b.true) : b.call('$.first_child', id);
const expression = (is_text) => b.call('$.first_child', id, is_text && b.true);

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

Expand Down Expand Up @@ -2180,18 +2187,30 @@ export const template_visitors = {
context.visit(node, child_state);
}

process_children(
trimmed,
() =>
b.call(
'$.child',
node.name === 'template'
? b.member(context.state.node, b.id('content'))
: context.state.node
),
true,
{ ...context, state: child_state }
);
/** @type {import('estree').Expression} */
let arg = context.state.node;

// If `hydrate_node` is set inside the element, we need to reset it
// after the element has been hydrated
let needs_reset = trimmed.some((node) => node.type !== 'Text');

// The same applies if it's a `<template>` element, since we need to
// set the value of `hydrate_node` to `node.content`
if (node.name === 'template') {
needs_reset = true;

arg = b.member(arg, b.id('content'));
child_state.init.push(b.stmt(b.call('$.reset', arg)));
}

process_children(trimmed, () => b.call('$.child', arg), true, {
...context,
state: child_state
});

if (needs_reset) {
child_state.init.push(b.stmt(b.call('$.reset', context.state.node)));
}

if (has_declaration) {
context.state.init.push(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,21 @@ import {
import { escape_html } from '../../../../escaping.js';
import { sanitize_template_string } from '../../../utils/sanitize_template_string.js';
import {
BLOCK_ANCHOR,
EMPTY_COMMENT,
BLOCK_CLOSE,
BLOCK_CLOSE_ELSE,
BLOCK_OPEN
BLOCK_OPEN,
BLOCK_OPEN_ELSE
} from '../../../../internal/server/hydration.js';
import { filename, locator } from '../../../state.js';

export const block_open = b.literal(BLOCK_OPEN);
export const block_close = b.literal(BLOCK_CLOSE);
export const block_anchor = b.literal(BLOCK_ANCHOR);
/** Opens an if/each block, so that we can remove nodes in the case of a mismatch */
const block_open = b.literal(BLOCK_OPEN);

/** 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 */
const block_close = b.literal(BLOCK_CLOSE);

/** Empty comment to keep text nodes separate, or provide an anchor node for blocks */
const empty_comment = b.literal(EMPTY_COMMENT);

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

const dynamic =
node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic);

if (custom_css_props.length > 0) {
statement = b.stmt(
b.call(
'$.css_props',
b.id('$$payload'),
b.literal(context.state.namespace === 'svg' ? false : true),
b.object(custom_css_props),
b.thunk(b.block([statement]))
context.state.template.push(
b.stmt(
b.call(
'$.css_props',
b.id('$$payload'),
b.literal(context.state.namespace === 'svg' ? false : true),
b.object(custom_css_props),
b.thunk(b.block([statement])),
dynamic && b.true
)
)
);
} else {
if (dynamic) {
context.state.template.push(empty_comment);
}

context.state.template.push(statement);
} else if (context.state.skip_hydration_boundaries) {
context.state.template.push(statement);
} else {
context.state.template.push(block_open, statement, block_close);

if (!context.state.skip_hydration_boundaries) {
context.state.template.push(empty_comment);
}
}
}

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

const { hoisted, trimmed, is_standalone } = clean_nodes(
const { hoisted, trimmed, is_standalone, is_text_first } = clean_nodes(
parent,
node.nodes,
context.path,
Expand All @@ -1142,13 +1157,18 @@ const template_visitors = {
context.visit(node, state);
}

if (is_text_first) {
// insert `<!---->` to prevent this from being glued to the previous fragment
state.template.push(empty_comment);
}

process_children(trimmed, { ...context, state });

return b.block([...state.init, ...serialize_template(state.template)]);
},
HtmlTag(node, context) {
const expression = /** @type {import('estree').Expression} */ (context.visit(node.expression));
context.state.template.push(block_open, expression, block_close);
context.state.template.push(empty_comment, expression, empty_comment);
},
ConstTag(node, { state, visit }) {
const declaration = node.declaration.declarations[0];
Expand Down Expand Up @@ -1188,10 +1208,6 @@ const template_visitors = {
return /** @type {import('estree').Expression} */ (context.visit(arg));
});

if (!context.state.skip_hydration_boundaries) {
context.state.template.push(block_open);
}

context.state.template.push(
b.stmt(
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
Expand All @@ -1203,7 +1219,7 @@ const template_visitors = {
);

if (!context.state.skip_hydration_boundaries) {
context.state.template.push(block_close);
context.state.template.push(empty_comment);
}
},
ClassDirective() {
Expand Down Expand Up @@ -1353,7 +1369,6 @@ const template_visitors = {
},
EachBlock(node, context) {
const state = context.state;
state.template.push(block_open);

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

each.push(b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_OPEN))));

each.push(.../** @type {import('estree').BlockStatement} */ (context.visit(node.body)).body);

each.push(b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE))));

const for_loop = b.for(
b.let(index, b.literal(0)),
b.binary('<', index, b.member(array_id, b.id('length'))),
b.update('++', index, false),
b.block(each)
);

const close = b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE)));

if (node.fallback) {
const open = b.stmt(b.assignment('+=', b.id('$$payload.out'), block_open));

const fallback = /** @type {import('estree').BlockStatement} */ (
context.visit(node.fallback)
);

fallback.body.push(
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE_ELSE)))
fallback.body.unshift(
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_OPEN_ELSE)))
);

state.template.push(
b.if(
b.binary('!==', b.member(array_id, b.id('length')), b.literal(0)),
b.block([for_loop, close]),
b.block([open, for_loop]),
fallback
)
),
block_close
);
} else {
state.template.push(for_loop, close);
state.template.push(block_open, for_loop, block_close);
}
},
IfBlock(node, context) {
Expand All @@ -1422,16 +1434,17 @@ const template_visitors = {
? /** @type {import('estree').BlockStatement} */ (context.visit(node.alternate))
: b.block([]);

consequent.body.push(b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE))));
alternate.body.push(
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_CLOSE_ELSE)))
consequent.body.unshift(b.stmt(b.assignment('+=', b.id('$$payload.out'), block_open)));

alternate.body.unshift(
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_OPEN_ELSE)))
);

context.state.template.push(block_open, b.if(test, consequent, alternate));
context.state.template.push(b.if(test, consequent, alternate), block_close);
},
AwaitBlock(node, context) {
context.state.template.push(
block_open,
empty_comment,
b.stmt(
b.call(
'$.await',
Expand All @@ -1455,12 +1468,12 @@ const template_visitors = {
)
)
),
block_close
empty_comment
);
},
KeyBlock(node, context) {
const block = /** @type {import('estree').BlockStatement} */ (context.visit(node.fragment));
context.state.template.push(block_open, block, block_close);
context.state.template.push(empty_comment, block, empty_comment);
},
SnippetBlock(node, context) {
const fn = b.function_declaration(
Expand Down Expand Up @@ -1594,7 +1607,7 @@ const template_visitors = {

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

context.state.template.push(block_open, b.stmt(slot), block_close);
context.state.template.push(empty_comment, b.stmt(slot), empty_comment);
},
SvelteHead(node, context) {
const block = /** @type {import('estree').BlockStatement} */ (context.visit(node.fragment));
Expand Down
37 changes: 22 additions & 15 deletions packages/svelte/src/compiler/phases/3-transform/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,21 +270,28 @@ export function clean_nodes(

var first = trimmed[0];

/**
* In a case like `{#if x}<Foo />{/if}`, we don't need to wrap the child in
* comments — we can just use the parent block's anchor for the component.
* TODO extend this optimisation to other cases
*/
const is_standalone =
trimmed.length === 1 &&
((first.type === 'RenderTag' && !first.metadata.dynamic) ||
(first.type === 'Component' &&
!state.options.hmr &&
!first.attributes.some(
(attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--')
)));

return { hoisted, trimmed, is_standalone };
return {
hoisted,
trimmed,
/**
* In a case like `{#if x}<Foo />{/if}`, we don't need to wrap the child in
* comments — we can just use the parent block's anchor for the component.
* TODO extend this optimisation to other cases
*/
is_standalone:
trimmed.length === 1 &&
((first.type === 'RenderTag' && !first.metadata.dynamic) ||
(first.type === 'Component' &&
!state.options.hmr &&
!first.attributes.some(
(attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--')
))),
/** 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 */
is_text_first:
(parent.type === 'Fragment' || parent.type === 'SnippetBlock') &&
first &&
(first?.type === 'Text' || first?.type === 'ExpressionTag')
};
}

/**
Expand Down
Loading
Loading