Skip to content

fix: improve hydration of altered html #16226

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/angry-pigs-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: improve hydration of altered html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,15 +26,19 @@ 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);
}

return b.call(
'$.sibling',
prev(false),
b.literal(node_type),
(is_text || skipped !== 1) && b.literal(skipped),
is_text && b.true
);
Expand All @@ -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') {
Expand Down
41 changes: 40 additions & 1 deletion packages/svelte/src/internal/client/dom/blocks/svelte-head.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
}
28 changes: 22 additions & 6 deletions packages/svelte/src/internal/client/dom/operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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'));
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!--[--><meta name="count" content="1"><!----><!--]--><!--[--><meta name="count" content="1"><!----><!--]-->
<!----><meta name="count" content="1"> <meta name="will-be-missing"> <meta name="count" content="1">
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!--[--><meta name="count" content="1" /><!----><!--]--><!--[--><meta name="count" content="1" />
<meta name="count" content="1" /><!----><!--]--><!--[--><meta
name="count"
content="1"
/><!----><!--]-->
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
let { children } = $props();
</script>
<svelte:head>
{@render children()}
</svelte:head>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script>
import Head from './head.svelte';

let count = $state(1);
</script>

<Head>
<meta name="count" content={count}>
</Head>
<Head>
<meta name="count" content={count}>
<meta name="will-be-missing">
<meta name="count" content={count}>
</Head>
<Head>
<meta name="count" content={count}>
</Head>

<button onclick={() => count++}>inc</button>
Original file line number Diff line number Diff line change
@@ -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);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<meta name="description" content="some description"> <meta name="keywords" content="some keywords">
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!--[--><meta name="description" content="some description" />
<meta name="foreign" content="alien" /> <meta name="keywords" content="some keywords" /><!--]-->
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script>
let content = "some keywords"
</script>

<svelte:head>
<meta name="description" content="some description" />
<meta name="keywords" {content} />
</svelte:head>

<div>Just a dummy page.</div>
Original file line number Diff line number Diff line change
@@ -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);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!--[--><meta name="description" content="some description"> <meta name="keywords" content="some keywords">
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!--[--><meta name="description" content="some description" /><!--]-->
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<svelte:head>
<meta name="description" content="some description" />
<meta name="keywords" content="some keywords" />
</svelte:head>

<div>Just a dummy page.</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ''}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@ 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];

var text = $.child(button);

$.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) ?? ''}`));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading