fix: insert empty text nodes during hydration, where necessary (#9729)

* Revert "fix: improve template text node serialization (#9722)"

This reverts commit 2fa06447cf.

* 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 <rich.harris@vercel.com>
pull/10354/head
Rich Harris 11 months ago committed by GitHub
parent 396bab3628
commit 0aedab8d5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: insert empty text nodes while hydrating, if necessary

@ -1099,27 +1099,34 @@ function create_block(parent, name, nodes, context) {
body.push(...state.init); body.push(...state.init);
} else if (trimmed.length > 0) { } else if (trimmed.length > 0) {
const id = b.id(context.state.scope.generate('fragment')); const id = b.id(context.state.scope.generate('fragment'));
const node_id = b.id(context.state.scope.generate('node'));
process_children(trimmed, node_id, { const use_space_template =
trimmed.some((node) => node.type === 'ExpressionTag') &&
trimmed.every((node) => node.type === 'Text' || node.type === 'ExpressionTag');
if (use_space_template) {
// special case — we can use `$.space` instead of creating a unique template
const id = b.id(context.state.scope.generate('text'));
process_children(trimmed, () => id, false, {
...context, ...context,
state state
}); });
const template = state.template[0]; body.push(b.var(id, b.call('$.space', b.id('$$anchor'))), ...state.init);
close = b.stmt(b.call('$.close', b.id('$$anchor'), id));
if (state.template.length === 1 && (template === ' ' || template === '<!>')) {
if (template === ' ') {
body.push(b.var(node_id, b.call('$.space', b.id('$$anchor'))), ...state.init);
close = b.stmt(b.call('$.close', b.id('$$anchor'), node_id));
} else { } else {
body.push( /** @type {(is_text: boolean) => import('estree').Expression} */
b.var(id, b.call('$.comment', b.id('$$anchor'))), const expression = (is_text) =>
b.var(node_id, b.call('$.child_frag', id)), is_text ? b.call('$.child_frag', id, b.true) : b.call('$.child_frag', id);
...state.init
); process_children(trimmed, expression, false, { ...context, state });
close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id));
} const use_comment_template = state.template.length === 1 && state.template[0] === '<!>';
if (use_comment_template) {
// special case — we can use `$.comment` instead of creating a unique template
body.push(b.var(id, b.call('$.comment', b.id('$$anchor'))));
} else { } else {
const callee = namespace === 'svg' ? '$.svg_template' : '$.template'; const callee = namespace === 'svg' ? '$.svg_template' : '$.template';
@ -1139,10 +1146,11 @@ function create_block(parent, name, nodes, context) {
b.literal(!state.metadata.template_needs_import_node), b.literal(!state.metadata.template_needs_import_node),
template_name template_name
) )
), )
b.var(node_id, b.call('$.child_frag', id)),
...state.init
); );
}
body.push(...state.init);
close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id)); close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id));
} }
@ -1418,10 +1426,11 @@ function serialize_event_attribute(node, context) {
* (e.g. `{a} b {c}`) into a single update function. Along the way it creates * (e.g. `{a} b {c}`) into a single update function. Along the way it creates
* corresponding template node references these updates are applied to. * corresponding template node references these updates are applied to.
* @param {import('#compiler').SvelteNode[]} nodes * @param {import('#compiler').SvelteNode[]} nodes
* @param {import('estree').Expression} parent * @param {(is_text: boolean) => import('estree').Expression} expression
* @param {boolean} is_element
* @param {import('../types.js').ComponentContext} context * @param {import('../types.js').ComponentContext} context
*/ */
function process_children(nodes, parent, { visit, state }) { function process_children(nodes, expression, is_element, { visit, state }) {
const within_bound_contenteditable = state.metadata.bound_contenteditable; const within_bound_contenteditable = state.metadata.bound_contenteditable;
/** @typedef {Array<import('#compiler').Text | import('#compiler').ExpressionTag>} Sequence */ /** @typedef {Array<import('#compiler').Text | import('#compiler').ExpressionTag>} Sequence */
@ -1429,28 +1438,24 @@ function process_children(nodes, parent, { visit, state }) {
/** @type {Sequence} */ /** @type {Sequence} */
let sequence = []; let sequence = [];
let expression = parent;
/** /**
* @param {Sequence} sequence * @param {Sequence} sequence
* @param {boolean} in_fragment
*/ */
function flush_sequence(sequence, in_fragment) { function flush_sequence(sequence) {
if (sequence.length === 1) { if (sequence.length === 1) {
const node = sequence[0]; const node = sequence[0];
if ((in_fragment && node.type === 'ExpressionTag') || node.type === 'Text') {
expression = b.call('$.sibling', expression);
}
if (node.type === 'Text') { if (node.type === 'Text') {
let prev = expression;
expression = () => b.call('$.sibling', prev(true));
state.template.push(node.raw); state.template.push(node.raw);
return; return;
} }
state.template.push(' '); state.template.push(' ');
const text_id = get_node_id(expression, state, 'text'); const text_id = get_node_id(expression(true), state, 'text');
const singular = b.stmt( const singular = b.stmt(
b.call( b.call(
'$.text_effect', '$.text_effect',
@ -1487,12 +1492,13 @@ function process_children(nodes, parent, { visit, state }) {
); );
} }
return; expression = (is_text) =>
} is_text ? b.call('$.sibling', text_id, b.true) : b.call('$.sibling', text_id);
} else {
const text_id = get_node_id(expression(true), state, 'text');
state.template.push(' '); state.template.push(' ');
const text_id = get_node_id(expression, state, 'text');
const contains_call_expression = sequence.some( const contains_call_expression = sequence.some(
(n) => n.type === 'ExpressionTag' && n.metadata.contains_call_expression (n) => n.type === 'ExpressionTag' && n.metadata.contains_call_expression
); );
@ -1514,10 +1520,11 @@ function process_children(nodes, parent, { visit, state }) {
state.init.push(init); state.init.push(init);
} }
expression = b.call('$.sibling', text_id); expression = (is_text) =>
is_text ? b.call('$.sibling', text_id, b.true) : b.call('$.sibling', text_id);
}
} }
let is_fragment = false;
for (let i = 0; i < nodes.length; i += 1) { for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]; const node = nodes[i];
@ -1525,12 +1532,7 @@ function process_children(nodes, parent, { visit, state }) {
sequence.push(node); sequence.push(node);
} else { } else {
if (sequence.length > 0) { if (sequence.length > 0) {
flush_sequence(sequence, is_fragment); flush_sequence(sequence);
// Ensure we move to the next sibling for the case where we move reference within a fragment
if (!is_fragment && sequence.length === 1 && sequence[0].type === 'ExpressionTag') {
expression = b.call('$.sibling', expression);
is_fragment = true;
}
sequence = []; sequence = [];
} }
@ -1544,23 +1546,18 @@ function process_children(nodes, parent, { visit, state }) {
// get hoisted inside clean_nodes? // get hoisted inside clean_nodes?
visit(node, state); visit(node, state);
} else { } else {
if ( if (node.type === 'EachBlock' && nodes.length === 1 && is_element) {
node.type === 'EachBlock' &&
nodes.length === 1 &&
parent.type === 'CallExpression' &&
parent.callee.type === 'Identifier' &&
parent.callee.name === '$.child'
) {
node.metadata.is_controlled = true; node.metadata.is_controlled = true;
visit(node, state); visit(node, state);
} else { } else {
const id = get_node_id( const id = get_node_id(
expression, expression(false),
state, state,
node.type === 'RegularElement' ? node.name : 'node' node.type === 'RegularElement' ? node.name : 'node'
); );
expression = b.call('$.sibling', id); expression = (is_text) =>
is_text ? b.call('$.sibling', id, b.true) : b.call('$.sibling', id);
visit(node, { visit(node, {
...state, ...state,
@ -1572,7 +1569,7 @@ function process_children(nodes, parent, { visit, state }) {
} }
if (sequence.length > 0) { if (sequence.length > 0) {
flush_sequence(sequence, false); flush_sequence(sequence);
} }
} }
@ -2041,12 +2038,14 @@ export const template_visitors = {
process_children( process_children(
trimmed, trimmed,
() =>
b.call( b.call(
'$.child', '$.child',
node.name === 'template' node.name === 'template'
? b.member(context.state.node, b.id('content')) ? b.member(context.state.node, b.id('content'))
: context.state.node : context.state.node
), ),
true,
{ ...context, state } { ...context, state }
); );

@ -167,7 +167,7 @@ function process_children(nodes, parent, { visit, state }) {
} }
const expression = b.call( const expression = b.call(
'$.escape_text', '$.escape',
/** @type {import('estree').Expression} */ (visit(node.expression)) /** @type {import('estree').Expression} */ (visit(node.expression))
); );
state.template.push(t_expression(expression)); state.template.push(t_expression(expression));

@ -182,31 +182,64 @@ export function child(node) {
/** /**
* @template {Node | Node[]} N * @template {Node | Node[]} N
* @param {N} node * @param {N} node
* @param {boolean} is_text
* @returns {Node | null} * @returns {Node | null}
*/ */
/*#__NO_SIDE_EFFECTS__*/ /*#__NO_SIDE_EFFECTS__*/
export function child_frag(node) { export function child_frag(node, is_text) {
if (current_hydration_fragment !== null) { if (current_hydration_fragment !== null) {
const first_node = /** @type {Node[]} */ (node)[0]; const first_node = /** @type {Node[]} */ (node)[0];
if (current_hydration_fragment !== null && first_node !== null) {
// if an {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (is_text && first_node?.nodeType !== 3) {
const text = document.createTextNode('');
current_hydration_fragment.unshift(text);
if (first_node) {
/** @type {DocumentFragment} */ (first_node.parentNode).insertBefore(text, first_node);
}
return text;
}
if (first_node !== null) {
return capture_fragment_from_node(first_node); return capture_fragment_from_node(first_node);
} }
return first_node; return first_node;
} }
return first_child_get.call(/** @type {Node} */ (node)); return first_child_get.call(/** @type {Node} */ (node));
} }
/** /**
* @template {Node} N * @template {Node} N
* @param {N} node * @param {N} node
* @param {boolean} is_text
* @returns {Node | null} * @returns {Node | null}
*/ */
/*#__NO_SIDE_EFFECTS__*/ /*#__NO_SIDE_EFFECTS__*/
export function sibling(node) { export function sibling(node, is_text = false) {
const next_sibling = next_sibling_get.call(node); const next_sibling = next_sibling_get.call(node);
if (current_hydration_fragment !== null && next_sibling !== null) { if (current_hydration_fragment !== null) {
if (is_text && next_sibling?.nodeType !== 3) {
const text = document.createTextNode('');
if (next_sibling) {
const index = current_hydration_fragment.indexOf(
/** @type {Text | Comment | Element} */ (next_sibling)
);
current_hydration_fragment.splice(index, 0, text);
/** @type {DocumentFragment} */ (next_sibling.parentNode).insertBefore(text, next_sibling);
} else {
current_hydration_fragment.push(text);
}
return text;
}
if (next_sibling !== null) {
return capture_fragment_from_node(next_sibling); return capture_fragment_from_node(next_sibling);
} }
}
return next_sibling; return next_sibling;
} }

@ -149,17 +149,6 @@ export function escape(value, is_attr = false) {
return escaped + str.substring(last); return escaped + str.substring(last);
} }
/**
* @template V
* @param {V} value
* @returns {string}
*/
export function escape_text(value) {
const escaped = escape(value);
// If the value is empty, then ensure we put a space so that it creates a text node on the client
return escaped === '' ? ' ' : escaped;
}
/** /**
* @param {Payload} payload * @param {Payload} payload
* @param {(head_payload: Payload['head']) => void} fn * @param {(head_payload: Payload['head']) => void} fn

@ -1,2 +1 @@
<!--ssr:0--><p></p> <!--ssr:0--><hr><hr> <p></p> <p></p><!--ssr:0-->
<p></p><!--ssr:0-->

@ -3,5 +3,6 @@
let maybeUndefined = undefined; let maybeUndefined = undefined;
</script> </script>
{maybeNull}<hr>{maybeUndefined}<hr>
<p>{maybeNull}</p> <p>{maybeNull}</p>
<p>{maybeUndefined}</p> <p>{maybeUndefined}</p>

@ -1,20 +1,16 @@
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
import { log } from './log.js';
export default test({ export default test({
before_test() { html: `<button>0</button><button>0</button>`,
log.length = 0;
},
async test({ assert, target }) { async test({ assert, target }) {
const [b1] = target.querySelectorAll('button'); const [b1, b2] = target.querySelectorAll('button');
flushSync(() => { flushSync(() => b1?.click());
b1?.click(); assert.htmlEqual(target.innerHTML, `<button>1</button><button>0</button>`);
});
await Promise.resolve(); flushSync(() => b2?.click());
assert.deepEqual(log, ['onclick']); assert.htmlEqual(target.innerHTML, `<button>1</button><button>1</button>`);
} }
}); });

@ -1,10 +1,6 @@
<script> <script>
import { log } from './log.js'; let a = $state(0);
let b = $state(0);
function send() {
log.push("onclick")
}
</script> </script>
{undefined}<hr/> {undefined}<button onclick={() => a += 1}>{a}</button>{undefined}<button onclick={() => b += 1}>{b}</button>
<button onclick={send}>Send Event</button>

@ -13,25 +13,25 @@ export default function Main($$anchor, $$props) {
let y = () => 'test'; let y = () => 'test';
/* Init */ /* Init */
var fragment = $.open_frag($$anchor, false, frag); var fragment = $.open_frag($$anchor, false, frag);
var node = $.child_frag(fragment); var div = $.child_frag(fragment);
var svg = $.sibling($.sibling(node)); var svg = $.sibling($.sibling(div, true));
var custom_element = $.sibling($.sibling(svg)); var custom_element = $.sibling($.sibling(svg, true));
var div = $.sibling($.sibling(custom_element)); var div_1 = $.sibling($.sibling(custom_element, true));
var svg_1 = $.sibling($.sibling(div)); var svg_1 = $.sibling($.sibling(div_1, true));
var custom_element_1 = $.sibling($.sibling(svg_1)); var custom_element_1 = $.sibling($.sibling(svg_1, true));
/* Update */ /* Update */
$.attr_effect(div, "foobar", y); $.attr_effect(div_1, "foobar", y);
$.attr_effect(svg_1, "viewBox", y); $.attr_effect(svg_1, "viewBox", y);
$.set_custom_element_data_effect(custom_element_1, "fooBar", y); $.set_custom_element_data_effect(custom_element_1, "fooBar", y);
var node_foobar; var div_foobar;
var svg_viewBox; var svg_viewBox;
var custom_element_fooBar; var custom_element_fooBar;
$.render_effect(() => { $.render_effect(() => {
if (node_foobar !== (node_foobar = x)) { if (div_foobar !== (div_foobar = x)) {
$.attr(node, "foobar", node_foobar); $.attr(div, "foobar", div_foobar);
} }
if (svg_viewBox !== (svg_viewBox = x)) { if (svg_viewBox !== (svg_viewBox = x)) {

@ -16,11 +16,11 @@ export default function Each_string_template($$anchor, $$props) {
1, 1,
($$anchor, thing, $$index) => { ($$anchor, thing, $$index) => {
/* Init */ /* Init */
var node_1 = $.space($$anchor); var text = $.space($$anchor);
/* Update */ /* Update */
$.text_effect(node_1, () => `${$.stringify($.unwrap(thing))}, `); $.text_effect(text, () => `${$.stringify($.unwrap(thing))}, `);
$.close($$anchor, node_1); $.close($$anchor, text);
}, },
null null
); );

@ -23,11 +23,11 @@ export default function Function_prop_no_getter($$anchor, $$props) {
onmouseenter: () => $.set(count, $.proxy(plusOne($.get(count)))), onmouseenter: () => $.set(count, $.proxy(plusOne($.get(count)))),
children: ($$anchor, $$slotProps) => { children: ($$anchor, $$slotProps) => {
/* Init */ /* Init */
var node_1 = $.space($$anchor); var text = $.space($$anchor);
/* Update */ /* Update */
$.text_effect(node_1, () => `clicks: ${$.stringify($.get(count))}`); $.text_effect(text, () => `clicks: ${$.stringify($.get(count))}`);
$.close($$anchor, node_1); $.close($$anchor, text);
} }
}); });

@ -19,18 +19,18 @@ export default function State_proxy_literal($$anchor, $$props) {
let tpl = $.source(``); let tpl = $.source(``);
/* Init */ /* Init */
var fragment = $.open_frag($$anchor, true, frag); var fragment = $.open_frag($$anchor, true, frag);
var node = $.child_frag(fragment); var input = $.child_frag(fragment);
$.remove_input_attr_defaults(node); $.remove_input_attr_defaults(input);
var input = $.sibling($.sibling(node)); var input_1 = $.sibling($.sibling(input, true));
$.remove_input_attr_defaults(input); $.remove_input_attr_defaults(input_1);
var button = $.sibling($.sibling(input)); var button = $.sibling($.sibling(input_1, true));
$.bind_value(node, () => $.get(str), ($$value) => $.set(str, $$value)); $.bind_value(input, () => $.get(str), ($$value) => $.set(str, $$value));
$.bind_value(input, () => $.get(tpl), ($$value) => $.set(tpl, $$value)); $.bind_value(input_1, () => $.get(tpl), ($$value) => $.set(tpl, $$value));
button.__click = [reset, str, tpl]; button.__click = [reset, str, tpl];
$.close_frag($$anchor, fragment); $.close_frag($$anchor, fragment);
$.pop(); $.pop();

Loading…
Cancel
Save