fix: better error messages for invalid HTML trees (#14445)

* fix: better error messages for invalid HTML trees

closes #13331

* fix test

* more concise

* tweak

* tweak messages

* adjust tests

* tweak message slightly, so it doesn't sound like the bad element is the one we're currently encountering

* put locations in generated message

* tidy up

* consistency

* fix

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/14548/head
Simon H 2 months ago committed by GitHub
parent 4e77bdef74
commit 86ae349bcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: better error messages for invalid HTML trees

@ -487,12 +487,12 @@ A component cannot have a default export
### node_invalid_placement
```
%thing% is invalid inside `<%parent%>`
%message%. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.
```
HTML restricts where certain elements can appear. In case of a violation the browser will 'repair' the HTML in a way that breaks Svelte's assumptions about the structure of your components. Some examples:
- `<p>hello <div>world</div></p>` will result in `<p>hello </p><div>world</div><p></p>` for example (the `<div>` autoclosed the `<p>` because `<p>` cannot contain block-level elements)
- `<p>hello <div>world</div></p>` will result in `<p>hello </p><div>world</div><p></p>` (the `<div>` autoclosed the `<p>` because `<p>` cannot contain block-level elements)
- `<option><div>option a</div></option>` will result in `<option>option a</option>` (the `<div>` is removed)
- `<table><tr><td>cell</td></tr></table>` will result in `<table><tbody><tr><td>cell</td></tr></tbody></table>` (a `<tbody>` is auto-inserted)

@ -643,12 +643,12 @@ Svelte 5 components are no longer classes. Instantiate them using `mount` or `hy
### node_invalid_placement_ssr
```
%thing% is invalid inside `<%parent%>`. When rendering this component on the server, the resulting HTML will be modified by the browser, likely resulting in a `hydration_mismatch` warning
%message%. When rendering this component on the server, the resulting HTML will be modified by the browser (by moving, removing, or inserting elements), likely resulting in a `hydration_mismatch` warning
```
HTML restricts where certain elements can appear. In case of a violation the browser will 'repair' the HTML in a way that breaks Svelte's assumptions about the structure of your components. Some examples:
- `<p>hello <div>world</div></p>` will result in `<p>hello </p><div>world</div><p></p>` for example (the `<div>` autoclosed the `<p>` because `<p>` cannot contain block-level elements)
- `<p>hello <div>world</div></p>` will result in `<p>hello </p><div>world</div><p></p>` (the `<div>` autoclosed the `<p>` because `<p>` cannot contain block-level elements)
- `<option><div>option a</div></option>` will result in `<option>option a</option>` (the `<div>` is removed)
- `<table><tr><td>cell</td></tr></table>` will result in `<table><tbody><tr><td>cell</td></tr></tbody></table>` (a `<tbody>` is auto-inserted)

@ -190,11 +190,11 @@
## node_invalid_placement
> %thing% is invalid inside `<%parent%>`
> %message%. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.
HTML restricts where certain elements can appear. In case of a violation the browser will 'repair' the HTML in a way that breaks Svelte's assumptions about the structure of your components. Some examples:
- `<p>hello <div>world</div></p>` will result in `<p>hello </p><div>world</div><p></p>` for example (the `<div>` autoclosed the `<p>` because `<p>` cannot contain block-level elements)
- `<p>hello <div>world</div></p>` will result in `<p>hello </p><div>world</div><p></p>` (the `<div>` autoclosed the `<p>` because `<p>` cannot contain block-level elements)
- `<option><div>option a</div></option>` will result in `<option>option a</option>` (the `<div>` is removed)
- `<table><tr><td>cell</td></tr></table>` will result in `<table><tbody><tr><td>cell</td></tr></tbody></table>` (a `<tbody>` is auto-inserted)

@ -40,11 +40,11 @@
## node_invalid_placement_ssr
> %thing% is invalid inside `<%parent%>`. When rendering this component on the server, the resulting HTML will be modified by the browser, likely resulting in a `hydration_mismatch` warning
> %message%. When rendering this component on the server, the resulting HTML will be modified by the browser (by moving, removing, or inserting elements), likely resulting in a `hydration_mismatch` warning
HTML restricts where certain elements can appear. In case of a violation the browser will 'repair' the HTML in a way that breaks Svelte's assumptions about the structure of your components. Some examples:
- `<p>hello <div>world</div></p>` will result in `<p>hello </p><div>world</div><p></p>` for example (the `<div>` autoclosed the `<p>` because `<p>` cannot contain block-level elements)
- `<p>hello <div>world</div></p>` will result in `<p>hello </p><div>world</div><p></p>` (the `<div>` autoclosed the `<p>` because `<p>` cannot contain block-level elements)
- `<option><div>option a</div></option>` will result in `<option>option a</option>` (the `<div>` is removed)
- `<table><tr><td>cell</td></tr></table>` will result in `<table><tbody><tr><td>cell</td></tr></tbody></table>` (a `<tbody>` is auto-inserted)

@ -1043,14 +1043,13 @@ export function mixed_event_handler_syntaxes(node, name) {
}
/**
* %thing% is invalid inside `<%parent%>`
* %message%. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.
* @param {null | number | NodeLike} node
* @param {string} thing
* @param {string} parent
* @param {string} message
* @returns {never}
*/
export function node_invalid_placement(node, thing, parent) {
e(node, "node_invalid_placement", `${thing} is invalid inside \`<${parent}>\``);
export function node_invalid_placement(node, message) {
e(node, "node_invalid_placement", `${message}. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.`);
}
/**

@ -12,8 +12,9 @@ export function ExpressionTag(node, context) {
const in_template = context.path.at(-1)?.type === 'Fragment';
if (in_template && context.state.parent_element) {
if (!is_tag_valid_with_parent('#text', context.state.parent_element)) {
e.node_invalid_placement(node, '`{expression}`', context.state.parent_element);
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
if (message) {
e.node_invalid_placement(node, message);
}
}

@ -114,15 +114,12 @@ export function RegularElement(node, context) {
if (!past_parent) {
if (ancestor.type === 'RegularElement' && ancestor.name === context.state.parent_element) {
if (!is_tag_valid_with_parent(node.name, context.state.parent_element)) {
const message = is_tag_valid_with_parent(node.name, context.state.parent_element);
if (message) {
if (only_warn) {
w.node_invalid_placement_ssr(
node,
`\`<${node.name}>\``,
context.state.parent_element
);
w.node_invalid_placement_ssr(node, message);
} else {
e.node_invalid_placement(node, `\`<${node.name}>\``, context.state.parent_element);
e.node_invalid_placement(node, message);
}
}
@ -131,11 +128,12 @@ export function RegularElement(node, context) {
} else if (ancestor.type === 'RegularElement') {
ancestors.push(ancestor.name);
if (!is_tag_valid_with_ancestor(node.name, ancestors)) {
const message = is_tag_valid_with_ancestor(node.name, ancestors);
if (message) {
if (only_warn) {
w.node_invalid_placement_ssr(node, `\`<${node.name}>\``, ancestor.name);
w.node_invalid_placement_ssr(node, message);
} else {
e.node_invalid_placement(node, `\`<${node.name}>\``, ancestor.name);
e.node_invalid_placement(node, message);
}
}
} else if (

@ -12,8 +12,9 @@ export function Text(node, context) {
const in_template = context.path.at(-1)?.type === 'Fragment';
if (in_template && context.state.parent_element && regex_not_whitespace.test(node.data)) {
if (!is_tag_valid_with_parent('#text', context.state.parent_element)) {
e.node_invalid_placement(node, 'Text node', context.state.parent_element);
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
if (message) {
e.node_invalid_placement(node, message);
}
}
}

@ -754,13 +754,12 @@ export function event_directive_deprecated(node, name) {
}
/**
* %thing% is invalid inside `<%parent%>`. When rendering this component on the server, the resulting HTML will be modified by the browser, likely resulting in a `hydration_mismatch` warning
* %message%. When rendering this component on the server, the resulting HTML will be modified by the browser (by moving, removing, or inserting elements), likely resulting in a `hydration_mismatch` warning
* @param {null | NodeLike} node
* @param {string} thing
* @param {string} parent
* @param {string} message
*/
export function node_invalid_placement_ssr(node, thing, parent) {
w(node, "node_invalid_placement_ssr", `${thing} is invalid inside \`<${parent}>\`. When rendering this component on the server, the resulting HTML will be modified by the browser, likely resulting in a \`hydration_mismatch\` warning`);
export function node_invalid_placement_ssr(node, message) {
w(node, "node_invalid_placement_ssr", `${message}. When rendering this component on the server, the resulting HTML will be modified by the browser (by moving, removing, or inserting elements), likely resulting in a \`hydration_mismatch\` warning`);
}
/**

@ -135,59 +135,85 @@ const disallowed_children = {
};
/**
* Returns false if the tag is not allowed inside the ancestor tag (which is grandparent and above) such that it will result
* Returns an error message if the tag is not allowed inside the ancestor tag (which is grandparent and above) such that it will result
* in the browser repairing the HTML, which will likely result in an error during hydration.
* @param {string} tag
* @param {string} child_tag
* @param {string[]} ancestors All nodes starting with the parent, up until the ancestor, which means two entries minimum
* @returns {boolean}
* @param {string} [child_loc]
* @param {string} [ancestor_loc]
* @returns {string | null}
*/
export function is_tag_valid_with_ancestor(tag, ancestors) {
if (tag.includes('-')) return true; // custom elements can be anything
export function is_tag_valid_with_ancestor(child_tag, ancestors, child_loc, ancestor_loc) {
if (child_tag.includes('-')) return null; // custom elements can be anything
const target = ancestors[ancestors.length - 1];
const disallowed = disallowed_children[target];
if (!disallowed) return true;
const ancestor_tag = ancestors[ancestors.length - 1];
const disallowed = disallowed_children[ancestor_tag];
if (!disallowed) return null;
if ('reset_by' in disallowed && disallowed.reset_by) {
for (let i = ancestors.length - 2; i >= 0; i--) {
const ancestor = ancestors[i];
if (ancestor.includes('-')) return true; // custom elements can be anything
if (ancestor.includes('-')) return null; // custom elements can be anything
// A reset means that forbidden descendants are allowed again
if (disallowed.reset_by.includes(ancestors[i])) {
return true;
return null;
}
}
}
if ('descendant' in disallowed && disallowed.descendant.includes(child_tag)) {
const child = child_loc ? `\`<${child_tag}>\` (${child_loc})` : `\`<${child_tag}>\``;
const ancestor = ancestor_loc
? `\`<${ancestor_tag}>\` (${ancestor_loc})`
: `\`<${ancestor_tag}>\``;
return `${child} cannot be a descendant of ${ancestor}`;
}
return 'descendant' in disallowed ? !disallowed.descendant.includes(tag) : true;
return null;
}
/**
* Returns false if the tag is not allowed inside the parent tag such that it will result
* Returns an error message if the tag is not allowed inside the parent tag such that it will result
* in the browser repairing the HTML, which will likely result in an error during hydration.
* @param {string} tag
* @param {string} child_tag
* @param {string} parent_tag
* @returns {boolean}
* @param {string} [child_loc]
* @param {string} [parent_loc]
* @returns {string | null}
*/
export function is_tag_valid_with_parent(tag, parent_tag) {
if (tag.includes('-') || parent_tag?.includes('-')) return true; // custom elements can be anything
export function is_tag_valid_with_parent(child_tag, parent_tag, child_loc, parent_loc) {
if (child_tag.includes('-') || parent_tag?.includes('-')) return null; // custom elements can be anything
const disallowed = disallowed_children[parent_tag];
const child = child_loc ? `\`<${child_tag}>\` (${child_loc})` : `\`<${child_tag}>\``;
const parent = parent_loc ? `\`<${parent_tag}>\` (${parent_loc})` : `\`<${parent_tag}>\``;
if (disallowed) {
if ('direct' in disallowed && disallowed.direct.includes(tag)) {
return false;
if ('direct' in disallowed && disallowed.direct.includes(child_tag)) {
return `${child} cannot be a direct child of ${parent}`;
}
if ('descendant' in disallowed && disallowed.descendant.includes(tag)) {
return false;
if ('descendant' in disallowed && disallowed.descendant.includes(child_tag)) {
return `${child} cannot be a child of ${parent}`;
}
if ('only' in disallowed && disallowed.only) {
return disallowed.only.includes(tag);
if (disallowed.only.includes(child_tag)) {
return null;
} else {
return `${child} cannot be a child of ${parent}. \`<${parent_tag}>\` only allows these children: ${disallowed.only.map((d) => `\`<${d}>\``).join(', ')}`;
}
}
}
switch (tag) {
// These tags are only valid with a few parents that have special child
// parsing rules - if we're down here, then none of those matched and
// so we allow it only if we don't know what the parent is, as all other
// cases are invalid (and we only get into this function if we know the parent).
switch (child_tag) {
case 'body':
case 'caption':
case 'col':
@ -196,18 +222,17 @@ export function is_tag_valid_with_parent(tag, parent_tag) {
case 'frame':
case 'head':
case 'html':
return `${child} cannot be a child of ${parent}`;
case 'thead':
case 'tbody':
case 'td':
case 'tfoot':
return `${child} must be the child of a \`<table>\`, not a ${parent}`;
case 'td':
case 'th':
case 'thead':
return `${child} must be the child of a \`<tr>\`, not a ${parent}`;
case 'tr':
// These tags are only valid with a few parents that have special child
// parsing rules - if we're down here, then none of those matched and
// so we allow it only if we don't know what the parent is, as all other
// cases are invalid (and we only get into this function if we know the parent).
return false;
return `\`<tr>\` must be the child of a \`<thead>\`, \`<tbody>\`, or \`<tfoot>\`, not a ${parent}`;
}
return true;
return null;
}

@ -34,12 +34,11 @@ function stringify(element) {
/**
* @param {Payload} payload
* @param {Element} parent
* @param {Element} child
* @param {string} message
*/
function print_error(payload, parent, child) {
var message =
`node_invalid_placement_ssr: ${stringify(parent)} cannot contain ${stringify(child)}\n\n` +
function print_error(payload, message) {
message =
`node_invalid_placement_ssr: ${message}\n\n` +
'This can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.';
if ((seen ??= new Set()).has(message)) return;
@ -72,15 +71,23 @@ export function push_element(payload, tag, line, column) {
var ancestor = parent.parent;
var ancestors = [parent.tag];
if (!is_tag_valid_with_parent(tag, parent.tag)) {
print_error(payload, parent, child);
}
const child_loc = filename ? `${filename}:${line}:${column}` : undefined;
const parent_loc = parent.filename
? `${parent.filename}:${parent.line}:${parent.column}`
: undefined;
const message = is_tag_valid_with_parent(tag, parent.tag, child_loc, parent_loc);
if (message) print_error(payload, message);
while (ancestor != null) {
ancestors.push(ancestor.tag);
if (!is_tag_valid_with_ancestor(tag, ancestors)) {
print_error(payload, ancestor, child);
}
const ancestor_loc = ancestor.filename
? `${ancestor.filename}:${ancestor.line}:${ancestor.column}`
: undefined;
const message = is_tag_valid_with_ancestor(tag, ancestors, child_loc, ancestor_loc);
if (message) print_error(payload, message);
ancestor = ancestor.parent;
}
}

@ -0,0 +1,21 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
html: `<form><div></div></form>`,
recover: true,
mode: ['hydrate'],
errors: [
'node_invalid_placement_ssr: `<form>` (form.svelte:1:0) cannot be a descendant of `<form>` (main.svelte:5:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
],
warnings: [
'Hydration failed because the initial UI does not match what was rendered on the server'
]
});

@ -0,0 +1,9 @@
<script>
import Form from './form.svelte';
</script>
<form>
<div>
<Form />
</div>
</form>

@ -12,8 +12,8 @@ export default test({
mode: ['hydrate'],
errors: [
'node_invalid_placement_ssr: `<p>` (main.svelte:6:0) cannot contain `<h1>` (h1.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.',
'node_invalid_placement_ssr: `<form>` (main.svelte:9:0) cannot contain `<form>` (form.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
'node_invalid_placement_ssr: `<h1>` (h1.svelte:1:0) cannot be a child of `<p>` (main.svelte:6:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.',
'node_invalid_placement_ssr: `<form>` (form.svelte:1:0) cannot be a child of `<form>` (main.svelte:9:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
],
warnings: [

@ -6,6 +6,6 @@ export default test({
},
errors: [
'node_invalid_placement_ssr: `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0) cannot contain `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
'node_invalid_placement_ssr: `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1) cannot be a child of `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
]
});

@ -1 +1 @@
<script>console.error("node_invalid_placement_ssr: `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0) cannot contain `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.")</script>
<script>console.error("node_invalid_placement_ssr: `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1) cannot be a child of `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.")</script>

@ -1,7 +1,7 @@
[
{
"code": "node_invalid_placement",
"message": "`<div>` is invalid inside `<p>`",
"message": "`<div>` cannot be a descendant of `<p>`. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.",
"start": {
"line": 4,
"column": 3

@ -1,7 +1,7 @@
[
{
"code": "node_invalid_placement",
"message": "`<form>` is invalid inside `<form>`",
"message": "`<form>` cannot be a descendant of `<form>`. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.",
"start": {
"line": 4,
"column": 3

@ -1,7 +1,7 @@
[
{
"code": "node_invalid_placement_ssr",
"message": "`<form>` is invalid inside `<form>`. When rendering this component on the server, the resulting HTML will be modified by the browser, likely resulting in a `hydration_mismatch` warning",
"message": "`<form>` cannot be a child of `<form>`. When rendering this component on the server, the resulting HTML will be modified by the browser (by moving, removing, or inserting elements), likely resulting in a `hydration_mismatch` warning",
"start": {
"line": 4,
"column": 3

@ -1,7 +1,7 @@
[
{
"code": "node_invalid_placement",
"message": "`<dt>` is invalid inside `<dd>`",
"message": "`<dt>` cannot be a descendant of `<dd>`. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.",
"start": {
"line": 16,
"column": 3

@ -1,7 +1,7 @@
[
{
"code": "node_invalid_placement",
"message": "`<tbody>` is invalid inside `<div>`",
"message": "`<tbody>` must be the child of a `<table>`, not a `<div>`. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.",
"start": {
"line": 8,
"column": 1

@ -1,7 +1,7 @@
[
{
"code": "node_invalid_placement",
"message": "Text node is invalid inside `<tbody>`",
"message": "`<#text>` cannot be a child of `<tbody>`. `<tbody>` only allows these children: `<tr>`, `<style>`, `<script>`, `<template>`. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.",
"start": {
"line": 3,
"column": 8

@ -1,7 +1,7 @@
[
{
"code": "node_invalid_placement",
"message": "`<a>` is invalid inside `<a>`",
"message": "`<a>` cannot be a descendant of `<a>`. The browser will 'repair' the HTML (by moving, removing, or inserting elements) which breaks Svelte's assumptions about the structure of your components.",
"start": {
"line": 4,
"column": 6

Loading…
Cancel
Save