Skip to content

Commit 0aedab8

Browse files
authored
fix: insert empty text nodes during hydration, where necessary (#9729)
* Revert "fix: improve template text node serialization (#9722)" This reverts commit 2fa0644. * regression test for #9722 * add back failing test * better test * create text nodes during hydration * partial fix * partial fix * remove nasty brittle logic * simplify * refactor * rename * thunkify * fix * add back changeset * changeset * lint --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 396bab3 commit 0aedab8

File tree

14 files changed

+171
-155
lines changed

14 files changed

+171
-155
lines changed

.changeset/forty-dolls-wave.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: insert empty text nodes while hydrating, if necessary

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

Lines changed: 93 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,50 +1099,58 @@ function create_block(parent, name, nodes, context) {
10991099
body.push(...state.init);
11001100
} else if (trimmed.length > 0) {
11011101
const id = b.id(context.state.scope.generate('fragment'));
1102-
const node_id = b.id(context.state.scope.generate('node'));
11031102

1104-
process_children(trimmed, node_id, {
1105-
...context,
1106-
state
1107-
});
1103+
const use_space_template =
1104+
trimmed.some((node) => node.type === 'ExpressionTag') &&
1105+
trimmed.every((node) => node.type === 'Text' || node.type === 'ExpressionTag');
1106+
1107+
if (use_space_template) {
1108+
// special case — we can use `$.space` instead of creating a unique template
1109+
const id = b.id(context.state.scope.generate('text'));
1110+
1111+
process_children(trimmed, () => id, false, {
1112+
...context,
1113+
state
1114+
});
1115+
1116+
body.push(b.var(id, b.call('$.space', b.id('$$anchor'))), ...state.init);
1117+
close = b.stmt(b.call('$.close', b.id('$$anchor'), id));
1118+
} else {
1119+
/** @type {(is_text: boolean) => import('estree').Expression} */
1120+
const expression = (is_text) =>
1121+
is_text ? b.call('$.child_frag', id, b.true) : b.call('$.child_frag', id);
1122+
1123+
process_children(trimmed, expression, false, { ...context, state });
11081124

1109-
const template = state.template[0];
1125+
const use_comment_template = state.template.length === 1 && state.template[0] === '<!>';
11101126

1111-
if (state.template.length === 1 && (template === ' ' || template === '<!>')) {
1112-
if (template === ' ') {
1113-
body.push(b.var(node_id, b.call('$.space', b.id('$$anchor'))), ...state.init);
1114-
close = b.stmt(b.call('$.close', b.id('$$anchor'), node_id));
1127+
if (use_comment_template) {
1128+
// special case — we can use `$.comment` instead of creating a unique template
1129+
body.push(b.var(id, b.call('$.comment', b.id('$$anchor'))));
11151130
} else {
1131+
const callee = namespace === 'svg' ? '$.svg_template' : '$.template';
1132+
1133+
state.hoisted.push(
1134+
b.var(
1135+
template_name,
1136+
b.call(callee, b.template([b.quasi(state.template.join(''), true)], []), b.true)
1137+
)
1138+
);
1139+
11161140
body.push(
1117-
b.var(id, b.call('$.comment', b.id('$$anchor'))),
1118-
b.var(node_id, b.call('$.child_frag', id)),
1119-
...state.init
1141+
b.var(
1142+
id,
1143+
b.call(
1144+
'$.open_frag',
1145+
b.id('$$anchor'),
1146+
b.literal(!state.metadata.template_needs_import_node),
1147+
template_name
1148+
)
1149+
)
11201150
);
1121-
close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id));
11221151
}
1123-
} else {
1124-
const callee = namespace === 'svg' ? '$.svg_template' : '$.template';
11251152

1126-
state.hoisted.push(
1127-
b.var(
1128-
template_name,
1129-
b.call(callee, b.template([b.quasi(state.template.join(''), true)], []), b.true)
1130-
)
1131-
);
1132-
1133-
body.push(
1134-
b.var(
1135-
id,
1136-
b.call(
1137-
'$.open_frag',
1138-
b.id('$$anchor'),
1139-
b.literal(!state.metadata.template_needs_import_node),
1140-
template_name
1141-
)
1142-
),
1143-
b.var(node_id, b.call('$.child_frag', id)),
1144-
...state.init
1145-
);
1153+
body.push(...state.init);
11461154

11471155
close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id));
11481156
}
@@ -1418,39 +1426,36 @@ function serialize_event_attribute(node, context) {
14181426
* (e.g. `{a} b {c}`) into a single update function. Along the way it creates
14191427
* corresponding template node references these updates are applied to.
14201428
* @param {import('#compiler').SvelteNode[]} nodes
1421-
* @param {import('estree').Expression} parent
1429+
* @param {(is_text: boolean) => import('estree').Expression} expression
1430+
* @param {boolean} is_element
14221431
* @param {import('../types.js').ComponentContext} context
14231432
*/
1424-
function process_children(nodes, parent, { visit, state }) {
1433+
function process_children(nodes, expression, is_element, { visit, state }) {
14251434
const within_bound_contenteditable = state.metadata.bound_contenteditable;
14261435

14271436
/** @typedef {Array<import('#compiler').Text | import('#compiler').ExpressionTag>} Sequence */
14281437

14291438
/** @type {Sequence} */
14301439
let sequence = [];
14311440

1432-
let expression = parent;
1433-
14341441
/**
14351442
* @param {Sequence} sequence
1436-
* @param {boolean} in_fragment
14371443
*/
1438-
function flush_sequence(sequence, in_fragment) {
1444+
function flush_sequence(sequence) {
14391445
if (sequence.length === 1) {
14401446
const node = sequence[0];
14411447

1442-
if ((in_fragment && node.type === 'ExpressionTag') || node.type === 'Text') {
1443-
expression = b.call('$.sibling', expression);
1444-
}
1445-
14461448
if (node.type === 'Text') {
1449+
let prev = expression;
1450+
expression = () => b.call('$.sibling', prev(true));
14471451
state.template.push(node.raw);
14481452
return;
14491453
}
14501454

14511455
state.template.push(' ');
14521456

1453-
const text_id = get_node_id(expression, state, 'text');
1457+
const text_id = get_node_id(expression(true), state, 'text');
1458+
14541459
const singular = b.stmt(
14551460
b.call(
14561461
'$.text_effect',
@@ -1487,50 +1492,47 @@ function process_children(nodes, parent, { visit, state }) {
14871492
);
14881493
}
14891494

1490-
return;
1491-
}
1495+
expression = (is_text) =>
1496+
is_text ? b.call('$.sibling', text_id, b.true) : b.call('$.sibling', text_id);
1497+
} else {
1498+
const text_id = get_node_id(expression(true), state, 'text');
14921499

1493-
state.template.push(' ');
1500+
state.template.push(' ');
14941501

1495-
const text_id = get_node_id(expression, state, 'text');
1496-
const contains_call_expression = sequence.some(
1497-
(n) => n.type === 'ExpressionTag' && n.metadata.contains_call_expression
1498-
);
1499-
const assignment = serialize_template_literal(sequence, visit, state)[1];
1500-
const init = b.stmt(b.assignment('=', b.member(text_id, b.id('nodeValue')), assignment));
1501-
const singular = b.stmt(b.call('$.text_effect', text_id, b.thunk(assignment)));
1502+
const contains_call_expression = sequence.some(
1503+
(n) => n.type === 'ExpressionTag' && n.metadata.contains_call_expression
1504+
);
1505+
const assignment = serialize_template_literal(sequence, visit, state)[1];
1506+
const init = b.stmt(b.assignment('=', b.member(text_id, b.id('nodeValue')), assignment));
1507+
const singular = b.stmt(b.call('$.text_effect', text_id, b.thunk(assignment)));
15021508

1503-
if (contains_call_expression && !within_bound_contenteditable) {
1504-
state.update_effects.push(singular);
1505-
} else if (
1506-
sequence.some((node) => node.type === 'ExpressionTag' && node.metadata.dynamic) &&
1507-
!within_bound_contenteditable
1508-
) {
1509-
state.update.push({
1510-
singular,
1511-
grouped: b.stmt(b.call('$.text', text_id, assignment))
1512-
});
1513-
} else {
1514-
state.init.push(init);
1515-
}
1509+
if (contains_call_expression && !within_bound_contenteditable) {
1510+
state.update_effects.push(singular);
1511+
} else if (
1512+
sequence.some((node) => node.type === 'ExpressionTag' && node.metadata.dynamic) &&
1513+
!within_bound_contenteditable
1514+
) {
1515+
state.update.push({
1516+
singular,
1517+
grouped: b.stmt(b.call('$.text', text_id, assignment))
1518+
});
1519+
} else {
1520+
state.init.push(init);
1521+
}
15161522

1517-
expression = b.call('$.sibling', text_id);
1523+
expression = (is_text) =>
1524+
is_text ? b.call('$.sibling', text_id, b.true) : b.call('$.sibling', text_id);
1525+
}
15181526
}
15191527

1520-
let is_fragment = false;
15211528
for (let i = 0; i < nodes.length; i += 1) {
15221529
const node = nodes[i];
15231530

15241531
if (node.type === 'Text' || node.type === 'ExpressionTag') {
15251532
sequence.push(node);
15261533
} else {
15271534
if (sequence.length > 0) {
1528-
flush_sequence(sequence, is_fragment);
1529-
// Ensure we move to the next sibling for the case where we move reference within a fragment
1530-
if (!is_fragment && sequence.length === 1 && sequence[0].type === 'ExpressionTag') {
1531-
expression = b.call('$.sibling', expression);
1532-
is_fragment = true;
1533-
}
1535+
flush_sequence(sequence);
15341536
sequence = [];
15351537
}
15361538

@@ -1544,23 +1546,18 @@ function process_children(nodes, parent, { visit, state }) {
15441546
// get hoisted inside clean_nodes?
15451547
visit(node, state);
15461548
} else {
1547-
if (
1548-
node.type === 'EachBlock' &&
1549-
nodes.length === 1 &&
1550-
parent.type === 'CallExpression' &&
1551-
parent.callee.type === 'Identifier' &&
1552-
parent.callee.name === '$.child'
1553-
) {
1549+
if (node.type === 'EachBlock' && nodes.length === 1 && is_element) {
15541550
node.metadata.is_controlled = true;
15551551
visit(node, state);
15561552
} else {
15571553
const id = get_node_id(
1558-
expression,
1554+
expression(false),
15591555
state,
15601556
node.type === 'RegularElement' ? node.name : 'node'
15611557
);
15621558

1563-
expression = b.call('$.sibling', id);
1559+
expression = (is_text) =>
1560+
is_text ? b.call('$.sibling', id, b.true) : b.call('$.sibling', id);
15641561

15651562
visit(node, {
15661563
...state,
@@ -1572,7 +1569,7 @@ function process_children(nodes, parent, { visit, state }) {
15721569
}
15731570

15741571
if (sequence.length > 0) {
1575-
flush_sequence(sequence, false);
1572+
flush_sequence(sequence);
15761573
}
15771574
}
15781575

@@ -2041,12 +2038,14 @@ export const template_visitors = {
20412038

20422039
process_children(
20432040
trimmed,
2044-
b.call(
2045-
'$.child',
2046-
node.name === 'template'
2047-
? b.member(context.state.node, b.id('content'))
2048-
: context.state.node
2049-
),
2041+
() =>
2042+
b.call(
2043+
'$.child',
2044+
node.name === 'template'
2045+
? b.member(context.state.node, b.id('content'))
2046+
: context.state.node
2047+
),
2048+
true,
20502049
{ ...context, state }
20512050
);
20522051

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ function process_children(nodes, parent, { visit, state }) {
167167
}
168168

169169
const expression = b.call(
170-
'$.escape_text',
170+
'$.escape',
171171
/** @type {import('estree').Expression} */ (visit(node.expression))
172172
);
173173
state.template.push(t_expression(expression));

packages/svelte/src/internal/client/operations.js

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,30 +182,63 @@ export function child(node) {
182182
/**
183183
* @template {Node | Node[]} N
184184
* @param {N} node
185+
* @param {boolean} is_text
185186
* @returns {Node | null}
186187
*/
187188
/*#__NO_SIDE_EFFECTS__*/
188-
export function child_frag(node) {
189+
export function child_frag(node, is_text) {
189190
if (current_hydration_fragment !== null) {
190191
const first_node = /** @type {Node[]} */ (node)[0];
191-
if (current_hydration_fragment !== null && first_node !== null) {
192+
193+
// if an {expression} is empty during SSR, there might be no
194+
// text node to hydrate — we must therefore create one
195+
if (is_text && first_node?.nodeType !== 3) {
196+
const text = document.createTextNode('');
197+
current_hydration_fragment.unshift(text);
198+
if (first_node) {
199+
/** @type {DocumentFragment} */ (first_node.parentNode).insertBefore(text, first_node);
200+
}
201+
return text;
202+
}
203+
204+
if (first_node !== null) {
192205
return capture_fragment_from_node(first_node);
193206
}
207+
194208
return first_node;
195209
}
210+
196211
return first_child_get.call(/** @type {Node} */ (node));
197212
}
198213

199214
/**
200215
* @template {Node} N
201216
* @param {N} node
217+
* @param {boolean} is_text
202218
* @returns {Node | null}
203219
*/
204220
/*#__NO_SIDE_EFFECTS__*/
205-
export function sibling(node) {
221+
export function sibling(node, is_text = false) {
206222
const next_sibling = next_sibling_get.call(node);
207-
if (current_hydration_fragment !== null && next_sibling !== null) {
208-
return capture_fragment_from_node(next_sibling);
223+
if (current_hydration_fragment !== null) {
224+
if (is_text && next_sibling?.nodeType !== 3) {
225+
const text = document.createTextNode('');
226+
if (next_sibling) {
227+
const index = current_hydration_fragment.indexOf(
228+
/** @type {Text | Comment | Element} */ (next_sibling)
229+
);
230+
current_hydration_fragment.splice(index, 0, text);
231+
/** @type {DocumentFragment} */ (next_sibling.parentNode).insertBefore(text, next_sibling);
232+
} else {
233+
current_hydration_fragment.push(text);
234+
}
235+
236+
return text;
237+
}
238+
239+
if (next_sibling !== null) {
240+
return capture_fragment_from_node(next_sibling);
241+
}
209242
}
210243
return next_sibling;
211244
}

packages/svelte/src/internal/server/index.js

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,17 +149,6 @@ export function escape(value, is_attr = false) {
149149
return escaped + str.substring(last);
150150
}
151151

152-
/**
153-
* @template V
154-
* @param {V} value
155-
* @returns {string}
156-
*/
157-
export function escape_text(value) {
158-
const escaped = escape(value);
159-
// If the value is empty, then ensure we put a space so that it creates a text node on the client
160-
return escaped === '' ? ' ' : escaped;
161-
}
162-
163152
/**
164153
* @param {Payload} payload
165154
* @param {(head_payload: Payload['head']) => void} fn
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
<!--ssr:0--><p></p>
2-
<p></p><!--ssr:0-->
1+
<!--ssr:0--><hr><hr> <p></p> <p></p><!--ssr:0-->

packages/svelte/tests/hydration/samples/dynamic-text-nil/main.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
let maybeUndefined = undefined;
44
</script>
55

6+
{maybeNull}<hr>{maybeUndefined}<hr>
67
<p>{maybeNull}</p>
78
<p>{maybeUndefined}</p>

0 commit comments

Comments
 (0)