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);
} else if (trimmed.length > 0) {
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,
state
});
const template = state.template[0];
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));
body.push(b.var(id, b.call('$.space', b.id('$$anchor'))), ...state.init);
close = b.stmt(b.call('$.close', b.id('$$anchor'), id));
} else {
body.push(
b.var(id, b.call('$.comment', b.id('$$anchor'))),
b.var(node_id, b.call('$.child_frag', id)),
...state.init
);
close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id));
}
/** @type {(is_text: boolean) => import('estree').Expression} */
const expression = (is_text) =>
is_text ? b.call('$.child_frag', id, b.true) : b.call('$.child_frag', id);
process_children(trimmed, expression, false, { ...context, state });
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 {
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),
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));
}
@ -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
* corresponding template node references these updates are applied to.
* @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
*/
function process_children(nodes, parent, { visit, state }) {
function process_children(nodes, expression, is_element, { visit, state }) {
const within_bound_contenteditable = state.metadata.bound_contenteditable;
/** @typedef {Array<import('#compiler').Text | import('#compiler').ExpressionTag>} Sequence */
@ -1429,28 +1438,24 @@ function process_children(nodes, parent, { visit, state }) {
/** @type {Sequence} */
let sequence = [];
let expression = parent;
/**
* @param {Sequence} sequence
* @param {boolean} in_fragment
*/
function flush_sequence(sequence, in_fragment) {
function flush_sequence(sequence) {
if (sequence.length === 1) {
const node = sequence[0];
if ((in_fragment && node.type === 'ExpressionTag') || node.type === 'Text') {
expression = b.call('$.sibling', expression);
}
if (node.type === 'Text') {
let prev = expression;
expression = () => b.call('$.sibling', prev(true));
state.template.push(node.raw);
return;
}
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(
b.call(
'$.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(' ');
const text_id = get_node_id(expression, state, 'text');
const contains_call_expression = sequence.some(
(n) => n.type === 'ExpressionTag' && n.metadata.contains_call_expression
);
@ -1514,10 +1520,11 @@ function process_children(nodes, parent, { visit, state }) {
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) {
const node = nodes[i];
@ -1525,12 +1532,7 @@ function process_children(nodes, parent, { visit, state }) {
sequence.push(node);
} else {
if (sequence.length > 0) {
flush_sequence(sequence, is_fragment);
// 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;
}
flush_sequence(sequence);
sequence = [];
}
@ -1544,23 +1546,18 @@ function process_children(nodes, parent, { visit, state }) {
// get hoisted inside clean_nodes?
visit(node, state);
} else {
if (
node.type === 'EachBlock' &&
nodes.length === 1 &&
parent.type === 'CallExpression' &&
parent.callee.type === 'Identifier' &&
parent.callee.name === '$.child'
) {
if (node.type === 'EachBlock' && nodes.length === 1 && is_element) {
node.metadata.is_controlled = true;
visit(node, state);
} else {
const id = get_node_id(
expression,
expression(false),
state,
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, {
...state,
@ -1572,7 +1569,7 @@ function process_children(nodes, parent, { visit, state }) {
}
if (sequence.length > 0) {
flush_sequence(sequence, false);
flush_sequence(sequence);
}
}
@ -2041,12 +2038,14 @@ export const template_visitors = {
process_children(
trimmed,
() =>
b.call(
'$.child',
node.name === 'template'
? b.member(context.state.node, b.id('content'))
: context.state.node
),
true,
{ ...context, state }
);

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

@ -182,31 +182,64 @@ export function child(node) {
/**
* @template {Node | Node[]} N
* @param {N} node
* @param {boolean} is_text
* @returns {Node | null}
*/
/*#__NO_SIDE_EFFECTS__*/
export function child_frag(node) {
export function child_frag(node, is_text) {
if (current_hydration_fragment !== null) {
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 first_node;
}
return first_child_get.call(/** @type {Node} */ (node));
}
/**
* @template {Node} N
* @param {N} node
* @param {boolean} is_text
* @returns {Node | null}
*/
/*#__NO_SIDE_EFFECTS__*/
export function sibling(node) {
export function sibling(node, is_text = false) {
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 next_sibling;
}

@ -149,17 +149,6 @@ export function escape(value, is_attr = false) {
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 {(head_payload: Payload['head']) => void} fn

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

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

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

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

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

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

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

@ -19,18 +19,18 @@ export default function State_proxy_literal($$anchor, $$props) {
let tpl = $.source(``);
/* Init */
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(tpl), ($$value) => $.set(tpl, $$value));
$.bind_value(input, () => $.get(str), ($$value) => $.set(str, $$value));
$.bind_value(input_1, () => $.get(tpl), ($$value) => $.set(tpl, $$value));
button.__click = [reset, str, tpl];
$.close_frag($$anchor, fragment);
$.pop();

Loading…
Cancel
Save