From 4e8d1c8c52a4b1aaa10bbf79aa02e0d44df63e50 Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Thu, 11 Jul 2024 01:35:28 +0200 Subject: [PATCH] feat: runtime dev warn for mismatched `@html` (#12396) * feat: runtime dev warn for mismatched `@html` * fix: limit the length of the client value shown in the error * put logic inside a helper * remove $.hash, no longer needed * fix * tweak * update changeset * fix --------- Co-authored-by: Rich Harris --- .changeset/few-badgers-guess.md | 5 +++ .../messages/client-warnings/warnings.md | 6 ++++ .../src/compiler/phases/2-analyze/index.js | 2 +- .../3-transform/server/transform-server.js | 2 +- .../src/internal/client/dom/blocks/html.js | 31 +++++++++++++++++++ .../svelte/src/internal/client/warnings.js | 13 ++++++++ .../svelte/src/internal/server/blocks/html.js | 10 ++++++ packages/svelte/src/internal/server/index.js | 2 ++ .../{compiler/phases/2-analyze => }/utils.js | 0 .../samples/html-tag-hydration-2/_config.js | 19 ++++++++++++ .../samples/html-tag-hydration-2/main.svelte | 5 +++ .../samples/raw-anchor-first-child/_config.js | 2 +- 12 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 .changeset/few-badgers-guess.md create mode 100644 packages/svelte/src/internal/server/blocks/html.js rename packages/svelte/src/{compiler/phases/2-analyze => }/utils.js (100%) create mode 100644 packages/svelte/tests/hydration/samples/html-tag-hydration-2/_config.js create mode 100644 packages/svelte/tests/hydration/samples/html-tag-hydration-2/main.svelte diff --git a/.changeset/few-badgers-guess.md b/.changeset/few-badgers-guess.md new file mode 100644 index 0000000000..b721433c96 --- /dev/null +++ b/.changeset/few-badgers-guess.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: warn in dev on `{@html ...}` block hydration mismatch diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 73dba661b3..2543a2065a 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -2,6 +2,12 @@ > The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value +## hydration_html_changed + +> The value of an `{@html ...}` block changed between server and client renders. The client value will be ignored in favour of the server value + +> The value of an `{@html ...}` block %location% changed between server and client renders. The client value will be ignored in favour of the server value + ## hydration_mismatch > Hydration failed because the initial UI does not match what was rendered on the server diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 5a44c30d17..d48fa34561 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -27,7 +27,7 @@ import { import { should_proxy_or_freeze } from '../3-transform/client/utils.js'; import { analyze_css } from './css/css-analyze.js'; import { prune } from './css/css-prune.js'; -import { hash } from './utils.js'; +import { hash } from '../../../utils.js'; import { warn_unused } from './css/css-warn.js'; import { extract_svelte_ignore } from '../../utils/extract_svelte_ignore.js'; import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js'; diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 0e5c584591..687984b395 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -1169,7 +1169,7 @@ const template_visitors = { }, HtmlTag(node, context) { const expression = /** @type {import('estree').Expression} */ (context.visit(node.expression)); - context.state.template.push(empty_comment, expression, empty_comment); + context.state.template.push(b.call('$.html', expression)); }, ConstTag(node, { state, visit }) { const declaration = node.declaration.declarations[0]; diff --git a/packages/svelte/src/internal/client/dom/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js index 4d0fc38e67..f0a4d2bc79 100644 --- a/packages/svelte/src/internal/client/dom/blocks/html.js +++ b/packages/svelte/src/internal/client/dom/blocks/html.js @@ -5,6 +5,32 @@ import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from '../hydr import { create_fragment_from_html } from '../reconciler.js'; import { assign_nodes } from '../template.js'; import * as w from '../../warnings.js'; +import { hash } from '../../../../utils.js'; +import { DEV } from 'esm-env'; +import { dev_current_component_function } from '../../runtime.js'; + +/** + * @param {Element} element + * @param {string | null} server_hash + * @param {string} value + */ +function check_hash(element, server_hash, value) { + if (!server_hash || server_hash === hash(String(value ?? ''))) return; + + let location; + + // @ts-expect-error + const loc = element.__svelte_meta?.loc; + if (loc) { + location = `near ${loc.file}:${loc.line}:${loc.column}`; + } else if (dev_current_component_function.filename) { + location = `in ${dev_current_component_function.filename}`; + } + + w.hydration_html_changed( + location?.replace(/\//g, '/\u200b') // prevent devtools trying to make it a clickable link by inserting a zero-width space + ); +} /** * @param {Element | Text | Comment} node @@ -33,6 +59,7 @@ export function html(node, get_value, svg, mathml) { effect = branch(() => { if (hydrating) { + var hash = /** @type {Comment} */ (hydrate_node).data; var next = hydrate_next(); var last = next; @@ -49,6 +76,10 @@ export function html(node, get_value, svg, mathml) { throw HYDRATION_ERROR; } + if (DEV) { + check_hash(/** @type {Element} */ (next.parentNode), hash, value); + } + assign_nodes(hydrate_node, last); anchor = set_hydrate_node(next); return; diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index 576453e525..e339cf1a8c 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -20,6 +20,19 @@ export function hydration_attribute_changed(attribute, html, value) { } } +/** + * The value of an `{@html ...}` block %location% changed between server and client renders. The client value will be ignored in favour of the server value + * @param {string | undefined | null} [location] + */ +export function hydration_html_changed(location) { + if (DEV) { + console.warn(`%c[svelte] hydration_html_changed\n%c${location ? `The value of an \`{@html ...}\` block ${location} changed between server and client renders. The client value will be ignored in favour of the server value` : "The value of an `{@html ...}` block changed between server and client renders. The client value will be ignored in favour of the server value"}`, bold, normal); + } else { + // TODO print a link to the documentation + console.warn("hydration_html_changed"); + } +} + /** * Hydration failed because the initial UI does not match what was rendered on the server. The error occurred near %location% * @param {string | undefined | null} [location] diff --git a/packages/svelte/src/internal/server/blocks/html.js b/packages/svelte/src/internal/server/blocks/html.js new file mode 100644 index 0000000000..09efbc78fe --- /dev/null +++ b/packages/svelte/src/internal/server/blocks/html.js @@ -0,0 +1,10 @@ +import { DEV } from 'esm-env'; +import { hash } from '../../../utils.js'; + +/** + * @param {string} value + */ +export function html(value) { + var open = DEV ? `` : ''; + return `${open}${value}`; +} diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index c52b6e4151..841794e544 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -545,6 +545,8 @@ export function once(get_value) { }; } +export { html } from './blocks/html.js'; + export { push, pop } from './context.js'; export { push_element, pop_element } from './dev.js'; diff --git a/packages/svelte/src/compiler/phases/2-analyze/utils.js b/packages/svelte/src/utils.js similarity index 100% rename from packages/svelte/src/compiler/phases/2-analyze/utils.js rename to packages/svelte/src/utils.js diff --git a/packages/svelte/tests/hydration/samples/html-tag-hydration-2/_config.js b/packages/svelte/tests/hydration/samples/html-tag-hydration-2/_config.js new file mode 100644 index 0000000000..7713578002 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/html-tag-hydration-2/_config.js @@ -0,0 +1,19 @@ +import { test } from '../../test'; + +export default test({ + server_props: { + browser: false + }, + + props: { + browser: true + }, + + compileOptions: { + dev: true + }, + + errors: [ + 'The value of an `{@html ...}` block in packages/​svelte/​tests/​hydration/​samples/​html-tag-hydration-2/​main.svelte changed between server and client renders. The client value will be ignored in favour of the server value' + ] +}); diff --git a/packages/svelte/tests/hydration/samples/html-tag-hydration-2/main.svelte b/packages/svelte/tests/hydration/samples/html-tag-hydration-2/main.svelte new file mode 100644 index 0000000000..6794452c67 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/html-tag-hydration-2/main.svelte @@ -0,0 +1,5 @@ + + +{@html browser ? 'browser' : 'server'} diff --git a/packages/svelte/tests/runtime-legacy/samples/raw-anchor-first-child/_config.js b/packages/svelte/tests/runtime-legacy/samples/raw-anchor-first-child/_config.js index 0f47f3c114..76b2446812 100644 --- a/packages/svelte/tests/runtime-legacy/samples/raw-anchor-first-child/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/raw-anchor-first-child/_config.js @@ -11,7 +11,7 @@ export default test({ if (variant === 'dom') { assert.ok(!span.previousSibling); } else { - assert.ok(span.previousSibling?.textContent === ''); // ssr commment node + assert.equal(span.previousSibling?.textContent, '1tbe2lq'); // hash of the value } component.raw = 'bar';