diff --git a/packages/svelte/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md index 9b43e99820ec..4e24714647cd 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -190,7 +190,13 @@ ## node_invalid_placement -> %thing% is invalid inside <%parent%> +> %thing% is invalid inside `<%parent%>` + +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: + +- `

hello

world

` will result in `

hello

world

` for example (the `
` autoclosed the `

` because `

` cannot contain block-level elements) +- `

option a
` will result in `` (the `
` is removed) +- `
cell
` will result in `
cell
` (a `` is auto-inserted) ## render_tag_invalid_call_expression diff --git a/packages/svelte/messages/compile-warnings/template.md b/packages/svelte/messages/compile-warnings/template.md index 3460e1098ee0..58a251c729f9 100644 --- a/packages/svelte/messages/compile-warnings/template.md +++ b/packages/svelte/messages/compile-warnings/template.md @@ -38,6 +38,18 @@ > Using `on:%name%` to listen to the %name% event is deprecated. Use the event attribute `on%name%` instead +## 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 + +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: + +- `

hello

world

` will result in `

hello

world

` for example (the `
` autoclosed the `

` because `

` cannot contain block-level elements) +- `

option a
` will result in `` (the `
` is removed) +- `
cell
` will result in `
cell
` (a `` is auto-inserted) + +This code will work when the component is rendered on the client (which is why this is a warning rather than an error), but if you use server rendering it will cause hydration to fail. + ## slot_element_deprecated > Using `` to render parent content is deprecated. Use `{@render ...}` tags instead diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 040356df2592..e3e3eb76cd00 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -978,14 +978,14 @@ export function mixed_event_handler_syntaxes(node, name) { } /** - * %thing% is invalid inside <%parent%> + * %thing% is invalid inside `<%parent%>` * @param {null | number | NodeLike} node * @param {string} thing * @param {string} parent * @returns {never} */ export function node_invalid_placement(node, thing, parent) { - e(node, "node_invalid_placement", `${thing} is invalid inside <${parent}>`); + e(node, "node_invalid_placement", `${thing} is invalid inside \`<${parent}>\``); } /** diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index 88dc5eae561a..11a9de09511c 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -5,12 +5,13 @@ import { is_void } from '../../../../constants.js'; import read_expression from '../read/expression.js'; import { read_script } from '../read/script.js'; import read_style from '../read/style.js'; -import { closing_tag_omitted, decode_character_references } from '../utils/html.js'; +import { decode_character_references } from '../utils/html.js'; import * as e from '../../../errors.js'; import * as w from '../../../warnings.js'; import { create_fragment } from '../utils/create.js'; import { create_attribute } from '../../nodes.js'; import { get_attribute_expression, is_expression_attribute } from '../../../utils/ast.js'; +import { closing_tag_omitted } from '../../../../html-tree-validation.js'; // eslint-disable-next-line no-useless-escape const valid_tag_name = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/; diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/html.js b/packages/svelte/src/compiler/phases/1-parse/utils/html.js index 3cc9a5a20724..a68acb996faf 100644 --- a/packages/svelte/src/compiler/phases/1-parse/utils/html.js +++ b/packages/svelte/src/compiler/phases/1-parse/utils/html.js @@ -1,4 +1,3 @@ -import { interactive_elements } from '../../../../constants.js'; import entities from './entities.js'; const windows_1252 = [ @@ -119,48 +118,3 @@ function validate_code(code) { return NUL; } - -// based on http://developers.whatwg.org/syntax.html#syntax-tag-omission - -/** @type {Record>} */ -const disallowed_contents = { - li: new Set(['li']), - dt: new Set(['dt', 'dd']), - dd: new Set(['dt', 'dd']), - p: new Set( - 'address article aside blockquote div dl fieldset footer form h1 h2 h3 h4 h5 h6 header hgroup hr main menu nav ol p pre section table ul'.split( - ' ' - ) - ), - rt: new Set(['rt', 'rp']), - rp: new Set(['rt', 'rp']), - optgroup: new Set(['optgroup']), - option: new Set(['option', 'optgroup']), - thead: new Set(['tbody', 'tfoot']), - tbody: new Set(['tbody', 'tfoot']), - tfoot: new Set(['tbody']), - tr: new Set(['tr', 'tbody']), - td: new Set(['td', 'th', 'tr']), - th: new Set(['td', 'th', 'tr']) -}; - -for (const interactive_element of interactive_elements) { - disallowed_contents[interactive_element] = interactive_elements; -} - -// can this be a child of the parent element, or does it implicitly -// close it, like `
  • one
  • two`? - -/** - * @param {string} current - * @param {string} [next] - */ -export function closing_tag_omitted(current, next) { - if (disallowed_contents[current]) { - if (!next || disallowed_contents[current].has(next)) { - return true; - } - } - - return false; -} diff --git a/packages/svelte/src/compiler/phases/2-analyze/validation.js b/packages/svelte/src/compiler/phases/2-analyze/validation.js index 129b7e280cf7..eda5ed42b060 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/validation.js +++ b/packages/svelte/src/compiler/phases/2-analyze/validation.js @@ -3,11 +3,6 @@ /** @import { NodeLike } from '../../errors.js' */ /** @import { AnalysisState, Context, Visitors } from './types.js' */ import is_reference from 'is-reference'; -import { - disallowed_paragraph_contents, - interactive_elements, - is_tag_valid_with_parent -} from '../../../constants.js'; import * as e from '../../errors.js'; import { extract_identifiers, @@ -37,6 +32,10 @@ import { import { Scope, get_rune } from '../scope.js'; import { merge } from '../visitors.js'; import { a11y_validators } from './a11y.js'; +import { + is_tag_valid_with_ancestor, + is_tag_valid_with_parent +} from '../../../html-tree-validation.js'; /** * @param {Attribute} attribute @@ -602,40 +601,57 @@ const validation = { validate_element(node, context); if (context.state.parent_element) { - if (!is_tag_valid_with_parent(node.name, context.state.parent_element)) { - e.node_invalid_placement(node, `<${node.name}>`, context.state.parent_element); - } - } + let past_parent = false; + let only_warn = false; - // can't add form to interactive elements because those are also used by the parser - // to check for the last auto-closing parent. - if (node.name === 'form') { - const path = context.path; - for (let parent of path) { - if (parent.type === 'RegularElement' && parent.name === 'form') { - e.node_invalid_placement(node, `<${node.name}>`, parent.name); - } - } - } + for (let i = context.path.length - 1; i >= 0; i--) { + const ancestor = context.path[i]; - if (interactive_elements.has(node.name)) { - const path = context.path; - for (let parent of path) { if ( - parent.type === 'RegularElement' && - parent.name === node.name && - interactive_elements.has(parent.name) + ancestor.type === 'IfBlock' || + ancestor.type === 'EachBlock' || + ancestor.type === 'AwaitBlock' || + ancestor.type === 'KeyBlock' ) { - e.node_invalid_placement(node, `<${node.name}>`, parent.name); + // We're creating a separate template string inside blocks, which means client-side this would work + only_warn = true; } - } - } - if (disallowed_paragraph_contents.includes(node.name)) { - const path = context.path; - for (let parent of path) { - if (parent.type === 'RegularElement' && parent.name === 'p') { - e.node_invalid_placement(node, `<${node.name}>`, parent.name); + 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)) { + if (only_warn) { + w.node_invalid_placement_ssr( + node, + `\`<${node.name}>\``, + context.state.parent_element + ); + } else { + e.node_invalid_placement(node, `\`<${node.name}>\``, context.state.parent_element); + } + } + + past_parent = true; + } + } else if (ancestor.type === 'RegularElement') { + if (!is_tag_valid_with_ancestor(node.name, ancestor.name)) { + if (only_warn) { + w.node_invalid_placement_ssr(node, `\`<${node.name}>\``, ancestor.name); + } else { + e.node_invalid_placement(node, `\`<${node.name}>\``, ancestor.name); + } + } + } else if ( + ancestor.type === 'Component' || + ancestor.type === 'SvelteComponent' || + ancestor.type === 'SvelteElement' || + ancestor.type === 'SvelteSelf' || + ancestor.type === 'SnippetBlock' + ) { + break; } } } @@ -818,7 +834,7 @@ const validation = { if (!node.parent) return; if (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); + e.node_invalid_placement(node, '`{expression}`', context.state.parent_element); } } } diff --git a/packages/svelte/src/compiler/warnings.js b/packages/svelte/src/compiler/warnings.js index d9c197cf7522..ca3eaf13f613 100644 --- a/packages/svelte/src/compiler/warnings.js +++ b/packages/svelte/src/compiler/warnings.js @@ -114,6 +114,7 @@ export const codes = [ "component_name_lowercase", "element_invalid_self_closing_tag", "event_directive_deprecated", + "node_invalid_placement_ssr", "slot_element_deprecated", "svelte_element_invalid_this" ]; @@ -739,6 +740,16 @@ export function event_directive_deprecated(node, name) { w(node, "event_directive_deprecated", `Using \`on:${name}\` to listen to the ${name} event is deprecated. Use the event attribute \`on${name}\` instead`); } +/** + * %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 + * @param {null | NodeLike} node + * @param {string} thing + * @param {string} parent + */ +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`); +} + /** * Using `` to render parent content is deprecated. Use `{@render ...}` tags instead * @param {null | NodeLike} node diff --git a/packages/svelte/src/constants.js b/packages/svelte/src/constants.js index 061e6e2eb8c8..0cc2102925e2 100644 --- a/packages/svelte/src/constants.js +++ b/packages/svelte/src/constants.js @@ -116,175 +116,6 @@ export const DOMBooleanAttributes = [ export const namespace_svg = 'http://www.w3.org/2000/svg'; export const namespace_mathml = 'http://www.w3.org/1998/Math/MathML'; -// while `input` is also an interactive element, it is never moved by the browser, so we don't need to check for it -export const interactive_elements = new Set([ - 'a', - 'button', - 'iframe', - 'embed', - 'select', - 'textarea' -]); - -export const disallowed_paragraph_contents = [ - 'address', - 'article', - 'aside', - 'blockquote', - 'details', - 'div', - 'dl', - 'fieldset', - 'figcapture', - 'figure', - 'footer', - 'form', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'header', - 'hr', - 'menu', - 'nav', - 'ol', - 'pre', - 'section', - 'table', - 'ul', - 'p' -]; - -// https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags -const implied_end_tags = ['dd', 'dt', 'li', 'option', 'optgroup', 'p', 'rp', 'rt']; - -/** - * @param {string} tag - * @param {string} parent_tag - * @returns {boolean} - */ -export function is_tag_valid_with_parent(tag, parent_tag) { - // First, let's check if we're in an unusual parsing mode... - switch (parent_tag) { - // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect - case 'select': - return ( - tag === 'option' || - tag === 'optgroup' || - tag === '#text' || - tag === 'hr' || - tag === 'script' || - tag === 'template' - ); - case 'optgroup': - return tag === 'option' || tag === '#text'; - // Strictly speaking, seeing an