From a9b26c3ffede6dadb09469b07e30f98141edde32 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Sun, 16 Mar 2025 12:22:51 +0100 Subject: [PATCH 1/2] fix: throw runtime error when template returns different html --- .changeset/fluffy-eggs-do.md | 5 ++++ .../98-reference/.generated/client-errors.md | 6 +++++ .../svelte/messages/client-errors/errors.md | 4 +++ .../src/internal/client/dom/blocks/html.js | 2 +- .../src/internal/client/dom/reconciler.js | 27 +++++++++++++++++-- packages/svelte/src/internal/client/errors.js | 17 ++++++++++++ .../samples/invalid-html-structure/_config.js | 7 +++++ .../invalid-html-structure/main.svelte | 3 +++ 8 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 .changeset/fluffy-eggs-do.md create mode 100644 packages/svelte/tests/runtime-runes/samples/invalid-html-structure/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/invalid-html-structure/main.svelte diff --git a/.changeset/fluffy-eggs-do.md b/.changeset/fluffy-eggs-do.md new file mode 100644 index 000000000000..b36927376a86 --- /dev/null +++ b/.changeset/fluffy-eggs-do.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: throw runtime error when template returns different html diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 0beb3cb9a96c..6f05d339d181 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -80,6 +80,12 @@ Maximum update depth exceeded. This can happen when a reactive block or effect r Failed to hydrate the application ``` +### 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_snippet ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index ab4d1519c18c..d4d3d7a8b98d 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -52,6 +52,10 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long > Failed to hydrate the application +## 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_snippet > Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}` diff --git a/packages/svelte/src/internal/client/dom/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js index b3fc5a9c725d..c002ea608dcd 100644 --- a/packages/svelte/src/internal/client/dom/blocks/html.js +++ b/packages/svelte/src/internal/client/dom/blocks/html.js @@ -99,7 +99,7 @@ export function html(node, get_value, svg, mathml, skip_warning) { // Don't use create_fragment_with_script_from_html here because that would mean script tags are executed. // @html is basically `.innerHTML = ...` and that doesn't execute scripts either due to security reasons. /** @type {DocumentFragment | Element} */ - var node = create_fragment_from_html(html); + var node = create_fragment_from_html(html, false); if (svg || mathml) { node = /** @type {Element} */ (get_first_child(node)); diff --git a/packages/svelte/src/internal/client/dom/reconciler.js b/packages/svelte/src/internal/client/dom/reconciler.js index 9897e08d5314..b0e3a2048159 100644 --- a/packages/svelte/src/internal/client/dom/reconciler.js +++ b/packages/svelte/src/internal/client/dom/reconciler.js @@ -1,6 +1,29 @@ -/** @param {string} html */ -export function create_fragment_from_html(html) { +import { DEV } from 'esm-env'; +import * as e from '../errors.js'; + +/** + * @param {string} html + * @param {boolean} [check_structure] + */ +export function create_fragment_from_html(html, check_structure = true) { var elem = document.createElement('template'); elem.innerHTML = html; + if (DEV && check_structure) { + 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) { + e.invalid_html_structure(remove_attributes_and_text_input, remove_attributes_and_text_output); + } + } + return elem.content; } diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 682816e1d64b..2cbef310cd7f 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -198,6 +198,23 @@ export function hydration_failed() { } } +/** + * 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 + * @returns {never} + */ +export function invalid_html_structure(html_input, html_output) { + if (DEV) { + const error = new Error(`invalid_html_structure\nThis 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`); + + error.name = 'Svelte error'; + throw error; + } else { + throw new Error(`https://svelte.dev/e/invalid_html_structure`); + } +} + /** * Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}` * @returns {never} 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..fd6f4c03c4a6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + mode: ['client', 'hydrate'], + recover: true, + runtime_error: 'invalid_html_structure' +}); 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 From 62a6a15b8a6fd6ec9f4dfd1e96d9a1ffc0cb9b6c Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 17 Mar 2025 11:31:44 +0100 Subject: [PATCH 2/2] fix: move the error to a warning --- .changeset/fluffy-eggs-do.md | 2 +- .../98-reference/.generated/client-errors.md | 6 ------ .../98-reference/.generated/client-warnings.md | 6 ++++++ .../svelte/messages/client-errors/errors.md | 4 ---- .../messages/client-warnings/warnings.md | 4 ++++ .../src/internal/client/dom/blocks/html.js | 2 +- .../src/internal/client/dom/reconciler.js | 9 ++++----- .../svelte/src/internal/client/dom/template.js | 18 +++++++++++++++++- packages/svelte/src/internal/client/errors.js | 17 ----------------- .../svelte/src/internal/client/warnings.js | 13 +++++++++++++ packages/svelte/tests/runtime-legacy/shared.ts | 17 +++++++++++++++++ .../samples/invalid-html-structure/_config.js | 15 +++++++++++++-- 12 files changed, 76 insertions(+), 37 deletions(-) diff --git a/.changeset/fluffy-eggs-do.md b/.changeset/fluffy-eggs-do.md index b36927376a86..95520ab8f4ff 100644 --- a/.changeset/fluffy-eggs-do.md +++ b/.changeset/fluffy-eggs-do.md @@ -2,4 +2,4 @@ 'svelte': patch --- -fix: throw runtime error when template returns different html +fix: throw runtime warning when template returns different html diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 6f05d339d181..0beb3cb9a96c 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -80,12 +80,6 @@ Maximum update depth exceeded. This can happen when a reactive block or effect r Failed to hydrate the application ``` -### 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_snippet ``` 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-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index d4d3d7a8b98d..ab4d1519c18c 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -52,10 +52,6 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long > Failed to hydrate the application -## 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_snippet > Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}` 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/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js index c002ea608dcd..b3fc5a9c725d 100644 --- a/packages/svelte/src/internal/client/dom/blocks/html.js +++ b/packages/svelte/src/internal/client/dom/blocks/html.js @@ -99,7 +99,7 @@ export function html(node, get_value, svg, mathml, skip_warning) { // Don't use create_fragment_with_script_from_html here because that would mean script tags are executed. // @html is basically `.innerHTML = ...` and that doesn't execute scripts either due to security reasons. /** @type {DocumentFragment | Element} */ - var node = create_fragment_from_html(html, false); + var node = create_fragment_from_html(html); if (svg || mathml) { node = /** @type {Element} */ (get_first_child(node)); diff --git a/packages/svelte/src/internal/client/dom/reconciler.js b/packages/svelte/src/internal/client/dom/reconciler.js index b0e3a2048159..7e3b1ebddef5 100644 --- a/packages/svelte/src/internal/client/dom/reconciler.js +++ b/packages/svelte/src/internal/client/dom/reconciler.js @@ -1,14 +1,13 @@ import { DEV } from 'esm-env'; -import * as e from '../errors.js'; +import * as w from '../warnings.js'; /** * @param {string} html - * @param {boolean} [check_structure] */ -export function create_fragment_from_html(html, check_structure = true) { +export function create_fragment_from_html(html) { var elem = document.createElement('template'); elem.innerHTML = html; - if (DEV && check_structure) { + 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 @@ -21,7 +20,7 @@ export function create_fragment_from_html(html, check_structure = true) { // 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) { - e.invalid_html_structure(remove_attributes_and_text_input, remove_attributes_and_text_output); + w.invalid_html_structure(remove_attributes_and_text_input, remove_attributes_and_text_output); } } 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/errors.js b/packages/svelte/src/internal/client/errors.js index 2cbef310cd7f..682816e1d64b 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -198,23 +198,6 @@ export function hydration_failed() { } } -/** - * 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 - * @returns {never} - */ -export function invalid_html_structure(html_input, html_output) { - if (DEV) { - const error = new Error(`invalid_html_structure\nThis 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`); - - error.name = 'Svelte error'; - throw error; - } else { - throw new Error(`https://svelte.dev/e/invalid_html_structure`); - } -} - /** * Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}` * @returns {never} 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 index fd6f4c03c4a6..288bf6adfecc 100644 --- a/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/invalid-html-structure/_config.js @@ -1,7 +1,18 @@ import { test } from '../../test'; export default test({ - mode: ['client', 'hydrate'], + mode: ['hydrate', 'client'], recover: true, - runtime_error: 'invalid_html_structure' + 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); + } });