diff --git a/.changeset/silent-apes-report.md b/.changeset/silent-apes-report.md new file mode 100644 index 0000000000..f7f96440be --- /dev/null +++ b/.changeset/silent-apes-report.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: handle sole empty expression tags diff --git a/packages/svelte/src/internal/client/each.js b/packages/svelte/src/internal/client/each.js index 68b5f446c2..25df8dce73 100644 --- a/packages/svelte/src/internal/client/each.js +++ b/packages/svelte/src/internal/client/each.js @@ -13,9 +13,8 @@ import { hydrate_block_anchor, set_current_hydration_fragment } from './hydration.js'; -import { clear_text_content, map_get, map_set } from './operations.js'; +import { clear_text_content, empty, map_get, map_set } from './operations.js'; import { insert, remove } from './reconciler.js'; -import { empty } from './render.js'; import { destroy_signal, execute_effect, diff --git a/packages/svelte/src/internal/client/hydration.js b/packages/svelte/src/internal/client/hydration.js index cd2e84e4a0..a8067ff89f 100644 --- a/packages/svelte/src/internal/client/hydration.js +++ b/packages/svelte/src/internal/client/hydration.js @@ -1,5 +1,6 @@ // Handle hydration +import { empty } from './operations.js'; import { schedule_task } from './runtime.js'; /** @type {null | Array} */ @@ -16,9 +17,10 @@ export function set_current_hydration_fragment(fragment) { /** * Returns all nodes between the first `` comment tag pair encountered. * @param {Node | null} node + * @param {boolean} [insert_text] Whether to insert an empty text node if the fragment is empty * @returns {Array | null} */ -export function get_hydration_fragment(node) { +export function get_hydration_fragment(node, insert_text = false) { /** @type {Array} */ const fragment = []; @@ -37,6 +39,11 @@ export function get_hydration_fragment(node) { if (target_depth === null) { target_depth = depth; } else if (depth === target_depth) { + if (insert_text && fragment.length === 0) { + const text = empty(); + fragment.push(text); + /** @type {Node} */ (current_node.parentNode).insertBefore(text, current_node); + } return fragment; } else { fragment.push(/** @type {Text | Comment | Element} */ (current_node)); diff --git a/packages/svelte/src/internal/client/operations.js b/packages/svelte/src/internal/client/operations.js index 372b1e510a..5b6b3f93e1 100644 --- a/packages/svelte/src/internal/client/operations.js +++ b/packages/svelte/src/internal/client/operations.js @@ -158,6 +158,11 @@ export function clone_node(node, deep) { return /** @type {N} */ (clone_node_method.call(node, deep)); } +/** @returns {Text} */ +export function empty() { + return document.createTextNode(''); +} + /** * @template {Node} N * @param {N} node @@ -169,7 +174,7 @@ export function child(node) { if (current_hydration_fragment !== null) { // Child can be null if we have an element with a single child, like `

{text}

`, where `text` is empty if (child === null) { - const text = document.createTextNode(''); + const text = empty(); node.appendChild(text); return text; } else { @@ -193,7 +198,7 @@ export function child_frag(node, is_text) { // 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(''); + const text = empty(); current_hydration_fragment.unshift(text); if (first_node) { /** @type {DocumentFragment} */ (first_node.parentNode).insertBefore(text, first_node); @@ -221,8 +226,10 @@ export function child_frag(node, is_text) { export function sibling(node, is_text = false) { const next_sibling = next_sibling_get.call(node); if (current_hydration_fragment !== null) { + // if a sibling {expression} is empty during SSR, there might be no + // text node to hydrate — we must therefore create one if (is_text && next_sibling?.nodeType !== 3) { - const text = document.createTextNode(''); + const text = empty(); if (next_sibling) { const index = current_hydration_fragment.indexOf( /** @type {Text | Comment | Element} */ (next_sibling) diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index eb51d41530..458f4ec684 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -4,6 +4,7 @@ import { child, clone_node, create_element, + empty, init_operations, map_get, map_set, @@ -75,11 +76,6 @@ const all_registerd_events = new Set(); /** @type {Set<(events: Array) => void>} */ const root_event_handles = new Set(); -/** @returns {Text} */ -export function empty() { - return document.createTextNode(''); -} - /** * @param {string} html * @param {boolean} return_fragment @@ -212,11 +208,22 @@ const space_template = template(' ', false); const comment_template = template('', true); /** - * @param {null | Text | Comment | Element} anchor + * @param {Text | Comment | Element | null} anchor */ /*#__NO_SIDE_EFFECTS__*/ export function space(anchor) { - return open(anchor, true, space_template); + /** @type {Node | null} */ + var node = /** @type {any} */ (open(anchor, true, space_template)); + // 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 (current_hydration_fragment !== null && node?.nodeType !== 3) { + 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); + } + return node; } /** @@ -228,6 +235,8 @@ export function comment(anchor) { } /** + * Assign the created (or in hydration mode, traversed) dom elements to the current block + * and insert the elements into the dom (in client mode). * @param {Element | Text} dom * @param {boolean} is_fragment * @param {null | Text | Comment | Element} anchor @@ -2866,7 +2875,9 @@ export function mount(component, options) { const container = options.target; const block = create_root_block(options.intro || false); const first_child = /** @type {ChildNode} */ (container.firstChild); - const hydration_fragment = get_hydration_fragment(first_child); + // Call with insert_text == true to prevent empty {expressions} resulting in an empty + // fragment array, resulting in a hydration error down the line + const hydration_fragment = get_hydration_fragment(first_child, true); const previous_hydration_fragment = current_hydration_fragment; /** @type {Exports} */ diff --git a/packages/svelte/src/internal/client/transitions.js b/packages/svelte/src/internal/client/transitions.js index f9d0133719..08d09192d0 100644 --- a/packages/svelte/src/internal/client/transitions.js +++ b/packages/svelte/src/internal/client/transitions.js @@ -10,8 +10,7 @@ import { ROOT_BLOCK } from './block.js'; import { destroy_each_item_block, get_first_element } from './each.js'; -import { append_child } from './operations.js'; -import { empty } from './render.js'; +import { append_child, empty } from './operations.js'; import { current_block, current_effect, diff --git a/packages/svelte/tests/hydration/samples/if-block-empty/_after.html b/packages/svelte/tests/hydration/samples/if-block-empty/_after.html new file mode 100644 index 0000000000..9c71a6ffee --- /dev/null +++ b/packages/svelte/tests/hydration/samples/if-block-empty/_after.html @@ -0,0 +1,4 @@ + + +x + diff --git a/packages/svelte/tests/hydration/samples/if-block-empty/_before.html b/packages/svelte/tests/hydration/samples/if-block-empty/_before.html new file mode 100644 index 0000000000..88c34c8f29 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/if-block-empty/_before.html @@ -0,0 +1,4 @@ + + + + diff --git a/packages/svelte/tests/hydration/samples/if-block-empty/_config.js b/packages/svelte/tests/hydration/samples/if-block-empty/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/hydration/samples/if-block-empty/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/hydration/samples/if-block-empty/main.svelte b/packages/svelte/tests/hydration/samples/if-block-empty/main.svelte new file mode 100644 index 0000000000..0037e21829 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/if-block-empty/main.svelte @@ -0,0 +1,7 @@ + + +{#if true} + {foo} +{/if} diff --git a/packages/svelte/tests/hydration/samples/text-empty/_after.html b/packages/svelte/tests/hydration/samples/text-empty/_after.html new file mode 100644 index 0000000000..5592a725f3 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/text-empty/_after.html @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/packages/svelte/tests/hydration/samples/text-empty/_before.html b/packages/svelte/tests/hydration/samples/text-empty/_before.html new file mode 100644 index 0000000000..a8cad39ae7 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/text-empty/_before.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/hydration/samples/text-empty/_config.js b/packages/svelte/tests/hydration/samples/text-empty/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/hydration/samples/text-empty/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/hydration/samples/text-empty/main.svelte b/packages/svelte/tests/hydration/samples/text-empty/main.svelte new file mode 100644 index 0000000000..d88ec4833c --- /dev/null +++ b/packages/svelte/tests/hydration/samples/text-empty/main.svelte @@ -0,0 +1,5 @@ + + +{x}