diff --git a/.changeset/angry-pigs-float.md b/.changeset/angry-pigs-float.md new file mode 100644 index 000000000000..59f186f4966e --- /dev/null +++ b/.changeset/angry-pigs-float.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: improve hydration of altered html diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js index 62d07014eea4..1dd2d04f73a6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js @@ -6,6 +6,7 @@ import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast. import * as b from '#compiler/builders'; import { is_custom_element_node } from '../../../../nodes.js'; import { build_template_chunk } from './utils.js'; +import { ELEMENT_NODE, TEXT_NODE } from '#client/constants'; /** * Processes an array of template nodes, joining sibling text/expression nodes @@ -25,8 +26,11 @@ export function process_children(nodes, initial, is_element, context) { /** @type {Sequence} */ let sequence = []; - /** @param {boolean} is_text */ - function get_node(is_text) { + /** + * @param {boolean} is_text + * @param {number} node_type + **/ + function get_node(is_text, node_type) { if (skipped === 0) { return prev(is_text); } @@ -34,6 +38,7 @@ export function process_children(nodes, initial, is_element, context) { return b.call( '$.sibling', prev(false), + b.literal(node_type), (is_text || skipped !== 1) && b.literal(skipped), is_text && b.true ); @@ -44,7 +49,10 @@ export function process_children(nodes, initial, is_element, context) { * @param {string} name */ function flush_node(is_text, name) { - const expression = get_node(is_text); + const expression = get_node( + is_text, + name === 'text' ? TEXT_NODE : name === 'node' ? 0 : ELEMENT_NODE + ); let id = expression; if (id.type !== 'Identifier') { diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index 66d337183637..02c4f622106e 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -3,7 +3,8 @@ import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hyd import { create_text, get_first_child, get_next_sibling } from '../operations.js'; import { block } from '../../reactivity/effects.js'; import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants'; -import { HYDRATION_START } from '../../../../constants.js'; +import { HYDRATION_END, HYDRATION_ERROR, HYDRATION_START } from '../../../../constants.js'; +import { hydration_mismatch } from '../../warnings.js'; /** * @type {Node | undefined} @@ -58,6 +59,36 @@ export function head(render_fn) { try { block(() => render_fn(anchor), HEAD_EFFECT); + check_end(); + } catch (error) { + // re-mount only this svelte:head + if (was_hydrating && head_anchor && error === HYDRATION_ERROR) { + // Here head_anchor is the node next after HYDRATION_START + /** @type {Node | null} */ + let prev = head_anchor.previousSibling; + /** @type {Node | null} */ + let next = head_anchor; + // remove nodes that failed to hydrate + while ( + prev !== null && + (prev.nodeType !== COMMENT_NODE || /** @type {Comment} */ (prev).data !== HYDRATION_END) + ) { + document.head.removeChild(prev); + prev = next; + next = get_next_sibling(/** @type {Node} */ (next)); + } + if (prev?.parentNode) document.head.removeChild(prev); + if (next !== null) { + // allow the next head block try to hydrate + head_anchor = set_hydrate_node(/** @type {TemplateNode} */ (next)); + } + + set_hydrating(false); + anchor = document.head.appendChild(create_text()); + block(() => render_fn(anchor), HEAD_EFFECT); + } else { + throw error; + } } finally { if (was_hydrating) { set_hydrating(true); @@ -66,3 +97,11 @@ export function head(render_fn) { } } } + +// treeshaking of hydrate node fails when this is directly in the try-catch +function check_end() { + if (hydrating && /** @type {Comment|null} */ (hydrate_node)?.data !== HYDRATION_END) { + hydration_mismatch(); + throw HYDRATION_ERROR; + } +} diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 4b35f0802f4a..099c22b8d774 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -3,7 +3,9 @@ import { hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; import { DEV } from 'esm-env'; import { init_array_prototype_warnings } from '../dev/equality.js'; import { get_descriptor, is_extensible } from '../../shared/utils.js'; -import { TEXT_NODE } from '#client/constants'; +import { COMMENT_NODE, TEXT_NODE } from '#client/constants'; +import { HYDRATION_END, HYDRATION_ERROR } from '../../../constants.js'; +import { hydration_mismatch } from '../warnings.js'; // export these for reference in the compiled code, making global name deduplication unnecessary /** @type {Window} */ @@ -158,26 +160,40 @@ export function first_child(fragment, is_text) { /** * Don't mark this as side-effect-free, hydration needs to walk all nodes * @param {TemplateNode} node + * @param {number} node_type * @param {number} count - * @param {boolean} is_text + * @param {boolean} add_text * @returns {Node | null} */ -export function sibling(node, count = 1, is_text = false) { - let next_sibling = hydrating ? hydrate_node : node; +export function sibling(node, node_type, count = 1, add_text = false) { + var next_sibling = hydrating ? hydrate_node : node; var last_sibling; while (count--) { last_sibling = next_sibling; next_sibling = /** @type {TemplateNode} */ (get_next_sibling(next_sibling)); + if ( + (next_sibling === null && !add_text) || + (next_sibling?.nodeType === COMMENT_NODE && + /** @type {Comment} */ (next_sibling).data === HYDRATION_END) + ) { + hydration_mismatch(); + throw HYDRATION_ERROR; + } } if (!hydrating) { return next_sibling; } + if (hydrating && node_type !== 0 && !add_text && next_sibling?.nodeType !== node_type) { + hydration_mismatch(); + throw HYDRATION_ERROR; + } + // 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 !== TEXT_NODE) { + if (add_text && next_sibling?.nodeType !== TEXT_NODE) { var text = create_text(); // If the next sibling is `null` and we're handling text then it's because // the SSR content was empty for the text, so we need to generate a new text @@ -192,7 +208,7 @@ export function sibling(node, count = 1, is_text = false) { } set_hydrate_node(next_sibling); - return /** @type {TemplateNode} */ (next_sibling); + return next_sibling; } /** diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-2/_config.js b/packages/svelte/tests/hydration/samples/head-corrupted-2/_config.js new file mode 100644 index 000000000000..91470f805211 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-2/_config.js @@ -0,0 +1,18 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + expect_hydration_error: true, + test(assert, target, snapshot, component, window) { + assert.equal(window.document.querySelectorAll('meta').length, 5); + + const [button] = target.getElementsByTagName('button'); + button.click(); + flushSync(); + + /** @type {NodeList} */ + const metas = window.document.querySelectorAll('meta[name=count]'); + assert.equal(metas.length, 4); + metas.forEach((meta) => assert.equal(/** @type {HTMLMetaElement} */ (meta).content, '2')); + } +}); diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-2/_expected_head.html b/packages/svelte/tests/hydration/samples/head-corrupted-2/_expected_head.html new file mode 100644 index 000000000000..3ce4f3237b46 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-2/_expected_head.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-2/_override_head.html b/packages/svelte/tests/hydration/samples/head-corrupted-2/_override_head.html new file mode 100644 index 000000000000..f7404d045e06 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-2/_override_head.html @@ -0,0 +1,5 @@ + + diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-2/head.svelte b/packages/svelte/tests/hydration/samples/head-corrupted-2/head.svelte new file mode 100644 index 000000000000..07f1acef653f --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-2/head.svelte @@ -0,0 +1,6 @@ + + + {@render children()} + diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-2/main.svelte b/packages/svelte/tests/hydration/samples/head-corrupted-2/main.svelte new file mode 100644 index 000000000000..471ebd12cf94 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-2/main.svelte @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-3/_config.js b/packages/svelte/tests/hydration/samples/head-corrupted-3/_config.js new file mode 100644 index 000000000000..29cd156f927a --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-3/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + expect_hydration_error: true, + test(assert, target, snapshot, component, window) { + assert.equal(window.document.querySelectorAll('meta').length, 2); + } +}); diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-3/_expected_head.html b/packages/svelte/tests/hydration/samples/head-corrupted-3/_expected_head.html new file mode 100644 index 000000000000..ae8f27c2d2e0 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-3/_expected_head.html @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-3/_override_head.html b/packages/svelte/tests/hydration/samples/head-corrupted-3/_override_head.html new file mode 100644 index 000000000000..104301cd90ad --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-3/_override_head.html @@ -0,0 +1,2 @@ + + diff --git a/packages/svelte/tests/hydration/samples/head-corrupted-3/main.svelte b/packages/svelte/tests/hydration/samples/head-corrupted-3/main.svelte new file mode 100644 index 000000000000..57b03a95a487 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted-3/main.svelte @@ -0,0 +1,10 @@ + + + + + + + +
Just a dummy page.
diff --git a/packages/svelte/tests/hydration/samples/head-corrupted/_config.js b/packages/svelte/tests/hydration/samples/head-corrupted/_config.js new file mode 100644 index 000000000000..29cd156f927a --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + expect_hydration_error: true, + test(assert, target, snapshot, component, window) { + assert.equal(window.document.querySelectorAll('meta').length, 2); + } +}); diff --git a/packages/svelte/tests/hydration/samples/head-corrupted/_expected_head.html b/packages/svelte/tests/hydration/samples/head-corrupted/_expected_head.html new file mode 100644 index 000000000000..c3aeef232a69 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted/_expected_head.html @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/hydration/samples/head-corrupted/_override_head.html b/packages/svelte/tests/hydration/samples/head-corrupted/_override_head.html new file mode 100644 index 000000000000..6e11ce19d413 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted/_override_head.html @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/hydration/samples/head-corrupted/main.svelte b/packages/svelte/tests/hydration/samples/head-corrupted/main.svelte new file mode 100644 index 000000000000..fee6ed54a3a9 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/head-corrupted/main.svelte @@ -0,0 +1,6 @@ + + + + + +
Just a dummy page.
diff --git a/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client/index.svelte.js index 9bb45ebf78e6..5fe156e51a7f 100644 --- a/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client/index.svelte.js @@ -19,11 +19,11 @@ export default function Await_block_scope($$anchor) { $.reset(button); - var node = $.sibling(button, 2); + var node = $.sibling(button, 0, 2); $.await(node, () => $.get(promise), null, ($$anchor, counter) => {}); - var text_1 = $.sibling(node); + var text_1 = $.sibling(node, 3); $.template_effect(() => { $.set_text(text, `clicks: ${counter.count ?? ''}`); diff --git a/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js index ba3f4b155a31..d6425a4df3f9 100644 --- a/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js @@ -27,7 +27,7 @@ export default function Bind_component_snippet($$anchor) { } }); - var text_1 = $.sibling(node); + var text_1 = $.sibling(node, 3); $.template_effect(() => $.set_text(text_1, ` value: ${$.get(value) ?? ''}`)); $.append($$anchor, fragment); diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js index 28bb01fb18df..ff3ec03fc220 100644 --- a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js +++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js @@ -12,17 +12,17 @@ export default function Main($$anchor) { $.set_attribute(div, 'foobar', x); - var svg = $.sibling(div, 2); + var svg = $.sibling(div, 1, 2); $.set_attribute(svg, 'viewBox', x); - var custom_element = $.sibling(svg, 2); + var custom_element = $.sibling(svg, 1, 2); $.set_custom_element_data(custom_element, 'fooBar', x); - var div_1 = $.sibling(custom_element, 2); - var svg_1 = $.sibling(div_1, 2); - var custom_element_1 = $.sibling(svg_1, 2); + var div_1 = $.sibling(custom_element, 1, 2); + var svg_1 = $.sibling(div_1, 1, 2); + var custom_element_1 = $.sibling(svg_1, 1, 2); $.template_effect(() => $.set_custom_element_data(custom_element_1, 'fooBar', y())); diff --git a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js index b46acee82e14..704d8080fde7 100644 --- a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js @@ -12,11 +12,11 @@ export default function Nullish_coallescence_omittance($$anchor) { h1.textContent = 'Hello, world!'; - var b = $.sibling(h1, 2); + var b = $.sibling(h1, 1, 2); b.textContent = '123'; - var button = $.sibling(b, 2); + var button = $.sibling(b, 1, 2); button.__click = [on_click, count]; @@ -24,7 +24,7 @@ export default function Nullish_coallescence_omittance($$anchor) { $.reset(button); - var h1_1 = $.sibling(button, 2); + var h1_1 = $.sibling(button, 1, 2); h1_1.textContent = 'Hello, world'; $.template_effect(() => $.set_text(text, `Count is ${$.get(count) ?? ''}`)); diff --git a/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js index da6fdf44d881..118dac12c469 100644 --- a/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js @@ -12,11 +12,11 @@ export default function Purity($$anchor) { $.untrack(() => Math.max(0, Math.min(0, 100))) ); - var p_1 = $.sibling(p, 2); + var p_1 = $.sibling(p, 1, 2); p_1.textContent = ($.untrack(() => location.href)); - var node = $.sibling(p_1, 2); + var node = $.sibling(p_1, 0, 2); Child(node, { prop: encodeURIComponent('hello') }); $.append($$anchor, fragment); diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js index 78147659ff40..f28b0addee36 100644 --- a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js @@ -5,43 +5,43 @@ var root = $.from_html(`