diff --git a/.changeset/fluffy-eggs-do.md b/.changeset/fluffy-eggs-do.md new file mode 100644 index 000000000000..95520ab8f4ff --- /dev/null +++ b/.changeset/fluffy-eggs-do.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: throw runtime warning when template returns different html diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index 284e9a7c3e57..1d934c5a8a7a 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -140,6 +140,12 @@ This warning is thrown when Svelte encounters an error while hydrating the HTML During development, this error is often preceeded by a `console.error` detailing the offending HTML, which needs fixing. +### invalid_html_structure + +``` +This html structure `%html_input%` would be corrected like this `%html_output%` by the browser making this component impossible to hydrate properly +``` + ### invalid_raw_snippet_render ``` diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 943cf6f01f4f..a5b48b159944 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -118,6 +118,10 @@ This warning is thrown when Svelte encounters an error while hydrating the HTML During development, this error is often preceeded by a `console.error` detailing the offending HTML, which needs fixing. +## invalid_html_structure + +> This html structure `%html_input%` would be corrected like this `%html_output%` by the browser making this component impossible to hydrate properly + ## invalid_raw_snippet_render > The `render` function passed to `createRawSnippet` should return HTML for a single element diff --git a/packages/svelte/src/internal/client/dom/reconciler.js b/packages/svelte/src/internal/client/dom/reconciler.js index 9897e08d5314..7e3b1ebddef5 100644 --- a/packages/svelte/src/internal/client/dom/reconciler.js +++ b/packages/svelte/src/internal/client/dom/reconciler.js @@ -1,6 +1,28 @@ -/** @param {string} html */ +import { DEV } from 'esm-env'; +import * as w from '../warnings.js'; + +/** + * @param {string} html + */ export function create_fragment_from_html(html) { var elem = document.createElement('template'); elem.innerHTML = html; + if (DEV) { + let replace_comments = html.replaceAll('', ''); + let remove_attributes_and_text_input = replace_comments + // we remove every attribute since the template automatically adds ="" after boolean attributes + .replace(/<([a-z0-9]+)(\s+[^>]+?)?>/g, '<$1>') + // we remove the text within the elements because the template change & to & (and similar) + .replace(/>([^<>]*)/g, '>'); + let remove_attributes_and_text_output = elem.innerHTML + // we remove every attribute since the template automatically adds ="" after boolean attributes + .replace(/<([a-z0-9]+)(\s+[^>]+?)?>/g, '<$1>') + // we remove the text within the elements because the template change & to & (and similar) + .replace(/>([^<>]*)/g, '>'); + if (remove_attributes_and_text_input !== remove_attributes_and_text_output) { + w.invalid_html_structure(remove_attributes_and_text_input, remove_attributes_and_text_output); + } + } + return elem.content; } diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index de2df62c927f..fd9d20713dcc 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -4,6 +4,7 @@ import { create_text, get_first_child, is_firefox } from './operations.js'; import { create_fragment_from_html } from './reconciler.js'; import { active_effect } from '../runtime.js'; import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../constants.js'; +import { DEV } from 'esm-env'; /** * @param {TemplateNode} start @@ -36,6 +37,18 @@ export function template(content, flags) { */ var has_start = !content.startsWith(''); + function create_node() { + node = create_fragment_from_html(has_start ? content : '' + content); + } + + let eagerly_created = false; + + if (DEV) { + eagerly_created = true; + // in dev we eagerly create the node to provide warnings in case of mismatches + create_node(); + } + return () => { if (hydrating) { assign_nodes(hydrate_node, null); @@ -43,8 +56,11 @@ export function template(content, flags) { } if (node === undefined) { - node = create_fragment_from_html(has_start ? content : '' + content); + create_node(); if (!is_fragment) node = /** @type {Node} */ (get_first_child(node)); + } else if (eagerly_created && !is_fragment) { + eagerly_created = false; + node = /** @type {Node} */ (get_first_child(node)); } var clone = /** @type {TemplateNode} */ ( diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index 250c6eca2fe9..0d31a8da5127 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -94,6 +94,19 @@ export function hydration_mismatch(location) { } } +/** + * This html structure `%html_input%` would be corrected like this `%html_output%` by the browser making this component impossible to hydrate properly + * @param {string} html_input + * @param {string} html_output + */ +export function invalid_html_structure(html_input, html_output) { + if (DEV) { + console.warn(`%c[svelte] invalid_html_structure\n%cThis html structure \`${html_input}\` would be corrected like this \`${html_output}\` by the browser making this component impossible to hydrate properly\nhttps://svelte.dev/e/invalid_html_structure`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/invalid_html_structure`); + } +} + /** * The `render` function passed to `createRawSnippet` should return HTML for a single element */ diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index fc748ce6b299..0dba6f453319 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -37,6 +37,7 @@ export interface RuntimeTest = Record void; after_test?: () => void; test?: (args: { @@ -174,6 +175,8 @@ async function common_setup(cwd: string, runes: boolean | undefined, config: Run return compileOptions; } +let import_logs = new Map(); + async function run_test_variant( cwd: string, config: RuntimeTest, @@ -276,6 +279,13 @@ async function run_test_variant( let mod = await import(`${cwd}/_output/client/main.svelte.js`); + if (config.needs_import_logs && !import_logs.has(`${cwd}/_output/client/main.svelte.js`)) { + import_logs.set(`${cwd}/_output/client/main.svelte.js`, { + logs: [...logs], + warnings: [...warnings] + }); + } + const target = window.document.querySelector('main') as HTMLElement; let snapshot = undefined; @@ -336,6 +346,13 @@ async function run_test_variant( } } else { logs.length = warnings.length = 0; + if (config.needs_import_logs) { + const { logs: import_logs_logs, warnings: import_logs_warnings } = import_logs.get( + `${cwd}/_output/client/main.svelte.js` + ); + logs.push(...import_logs_logs); + warnings.push(...import_logs_warnings); + } config.before_test?.(); diff --git a/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/_config.js b/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/_config.js new file mode 100644 index 000000000000..288bf6adfecc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/_config.js @@ -0,0 +1,18 @@ +import { test } from '../../test'; + +export default test({ + mode: ['hydrate', 'client'], + recover: true, + needs_import_logs: true, + test({ warnings, assert, variant }) { + const expected_warnings = [ + 'This html structure `

` would be corrected like this `

` by the browser making this component impossible to hydrate properly' + ]; + if (variant === 'hydrate') { + expected_warnings.push( + 'Hydration failed because the initial UI does not match what was rendered on the server' + ); + } + assert.deepEqual(warnings, expected_warnings); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/main.svelte b/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/main.svelte new file mode 100644 index 000000000000..ce131ae22a31 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/main.svelte @@ -0,0 +1,3 @@ + +

+ \ No newline at end of file