diff --git a/.changeset/heavy-comics-move.md b/.changeset/heavy-comics-move.md new file mode 100644 index 0000000000..3fe8e3e9df --- /dev/null +++ b/.changeset/heavy-comics-move.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: better handling of empty text node hydration diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index 7b672502b9..754aec21b4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -1127,7 +1127,7 @@ function create_block(parent, name, nodes, context) { 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 + // special case — we can use `$.space_frag` instead of creating a unique template const id = b.id(context.state.scope.generate('text')); process_children(trimmed, () => id, false, { @@ -1135,7 +1135,7 @@ function create_block(parent, name, nodes, context) { state }); - body.push(b.var(id, b.call('$.space', b.id('$$anchor'))), ...state.init); + body.push(b.var(id, b.call('$.space_frag', b.id('$$anchor'))), ...state.init); close = b.stmt(b.call('$.close', b.id('$$anchor'), id)); } else { /** @type {(is_text: boolean) => import('estree').Expression} */ @@ -1495,7 +1495,7 @@ function process_children(nodes, expression, is_element, { visit, state }) { state.template.push(' '); - const text_id = get_node_id(expression(true), state, 'text'); + const text_id = get_node_id(b.call('$.space', expression(true)), state, 'text'); const singular = b.stmt( b.call( diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index a0efcd8e6b..a4a0e5265b 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -206,7 +206,7 @@ const comment_template = template('', true); * @param {Text | Comment | Element | null} anchor */ /*#__NO_SIDE_EFFECTS__*/ -export function space(anchor) { +export function space_frag(anchor) { /** @type {Node | null} */ var node = /** @type {any} */ (open(anchor, true, space_template)); // if an {expression} is empty during SSR, there might be no @@ -216,11 +216,27 @@ export function space(anchor) { node = empty(); // @ts-ignore in this case the anchor should always be a comment, // if not something more fundamental is wrong and throwing here is better to bail out early - anchor.parentElement.insertBefore(node, anchor); + anchor.before(node); } return node; } +/** + * @param {Text | Comment | Element} anchor + */ +/*#__NO_SIDE_EFFECTS__*/ +export function space(anchor) { + // if an {expression} is empty during SSR, there might be no + // text node to hydrate (or an anchor comment is falsely detected instead) + // — we must therefore create one + if (hydrating && anchor.nodeType !== 3) { + const node = empty(); + anchor.before(node); + return node; + } + return anchor; +} + /** * @param {null | Text | Comment | Element} anchor */ diff --git a/packages/svelte/tests/runtime-runes/samples/state-space/_config.js b/packages/svelte/tests/runtime-runes/samples/state-space/_config.js new file mode 100644 index 0000000000..2f7c0d88ac --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-space/_config.js @@ -0,0 +1,15 @@ +import { test } from '../../test'; + +export default test({ + html: `
`, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + + await btn?.click(); + assert.htmlEqual( + target.innerHTML, + `