Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/two-doors-exercise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

fix: add check for `is` attribute to correctly detect custom elements
4 changes: 2 additions & 2 deletions packages/svelte/src/compiler/phases/2-analyze/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ export interface AnalysisState {
options: ValidatedCompileOptions;
ast_type: 'instance' | 'template' | 'module';
/**
* Tag name of the parent element. `null` if the parent is `svelte:element`, `#snippet`, a component or the root.
* The parent element. `null` if the parent is `svelte:element`, `#snippet`, a component or the root.
* Parent doesn't necessarily mean direct path predecessor because there could be `#each`, `#if` etc in-between.
*/
parent_element: string | null;
parent_element: AST.RegularElement | null;
has_props_rune: boolean;
/** Which slots the current parent component has */
component_slots: Set<string>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/** @import { Context } from '../types' */
import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js';
import * as e from '../../../errors.js';
import { is_custom_element_node } from '../../nodes.js';
import { mark_subtree_dynamic } from './shared/fragment.js';

/**
Expand All @@ -12,7 +13,16 @@ export function ExpressionTag(node, context) {
const in_template = context.path.at(-1)?.type === 'Fragment';

if (in_template && context.state.parent_element) {
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
const message = is_tag_valid_with_parent(
{
tag: '#text',
custom_element: false
},
{
tag: context.state.parent_element.name,
custom_element: is_custom_element_node(context.state.parent_element)
}
);
if (message) {
e.node_invalid_placement(node, message);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,12 @@ export function RegularElement(node, context) {
if (context.state.parent_element) {
let past_parent = false;
let only_warn = false;
const ancestors = [context.state.parent_element];
const ancestors = [
{
tag: context.state.parent_element.name,
custom_element: is_custom_element_node(context.state.parent_element)
}
];

for (let i = context.path.length - 1; i >= 0; i--) {
const ancestor = context.path[i];
Expand All @@ -132,8 +137,20 @@ export function RegularElement(node, context) {
}

if (!past_parent) {
if (ancestor.type === 'RegularElement' && ancestor.name === context.state.parent_element) {
const message = is_tag_valid_with_parent(node.name, context.state.parent_element);
if (
ancestor.type === 'RegularElement' &&
ancestor.name === context.state.parent_element.name
) {
const message = is_tag_valid_with_parent(
{
tag: node.name,
custom_element: is_custom_element_node(node)
},
{
tag: context.state.parent_element.name,
custom_element: is_custom_element_node(context.state.parent_element)
}
);
if (message) {
if (only_warn) {
w.node_invalid_placement_ssr(node, message);
Expand All @@ -145,9 +162,18 @@ export function RegularElement(node, context) {
past_parent = true;
}
} else if (ancestor.type === 'RegularElement') {
ancestors.push(ancestor.name);

const message = is_tag_valid_with_ancestor(node.name, ancestors);
ancestors.push({
tag: ancestor.name,
custom_element: is_custom_element_node(ancestor)
});

const message = is_tag_valid_with_ancestor(
{
tag: node.name,
custom_element: is_custom_element_node(node)
},
ancestors
);
if (message) {
if (only_warn) {
w.node_invalid_placement_ssr(node, message);
Expand Down Expand Up @@ -178,7 +204,7 @@ export function RegularElement(node, context) {
w.element_invalid_self_closing_tag(node, node.name);
}

context.next({ ...context.state, parent_element: node.name });
context.next({ ...context.state, parent_element: node });

// Special case: <a> tags are valid in both the SVG and HTML namespace.
// If there's no parent, look downwards to see if it's the parent of a SVG or HTML element.
Expand Down
12 changes: 11 additions & 1 deletion packages/svelte/src/compiler/phases/2-analyze/visitors/Text.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js';
import { regex_not_whitespace } from '../../patterns.js';
import * as e from '../../../errors.js';
import { is_custom_element_node } from '../../nodes.js';

/**
* @param {AST.Text} node
Expand All @@ -12,7 +13,16 @@ 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)) {
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
const message = is_tag_valid_with_parent(
{
tag: '#text',
custom_element: false
},
{
tag: context.state.parent_element.name,
custom_element: is_custom_element_node(context.state.parent_element)
}
);
if (message) {
e.node_invalid_placement(node, message);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export function RegularElement(node, context) {
node_id,
attributes_id,
(node.metadata.svg || node.metadata.mathml || is_custom_element_node(node)) && b.true,
node.name.includes('-') && b.true,
is_custom_element_node(node) && b.true,
context.state
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { cannot_be_set_statically } from '../../../../../../utils.js';
import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js';
import * as b from '../../../../../utils/builders.js';
import { is_custom_element_node } from '../../../../nodes.js';
import { build_template_chunk } from './utils.js';

/**
Expand Down Expand Up @@ -128,7 +129,7 @@ export function process_children(nodes, initial, is_element, { visit, state }) {
function is_static_element(node, state) {
if (node.type !== 'RegularElement') return false;
if (node.fragment.metadata.dynamic) return false;
if (node.name.includes('-')) return false; // we're setting all attributes on custom elements through properties
if (is_custom_element_node(node)) return false; // we're setting all attributes on custom elements through properties

for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { is_void } from '../../../../../utils.js';
import { dev, locator } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
import { is_custom_element_node } from '../../../nodes.js';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_element_attributes } from './shared/element.js';
import { process_children, build_template } from './shared/utils.js';
Expand Down Expand Up @@ -62,6 +63,7 @@ export function RegularElement(node, context) {
'$.push_element',
b.id('$$payload'),
b.literal(node.name),
b.literal(is_custom_element_node(node)),
b.literal(location.line),
b.literal(location.column)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function SvelteElement(node, context) {
'$.push_element',
b.id('$$payload'),
tag,
b.literal(false),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't this also be a custom element?

<svelte:element tag={tag} is="my-element" />

i wonder if we need to add extra checks for the SvelteElement case (before it was using the name so it was probably fine)?

b.literal(location.line),
b.literal(location.column)
)
Expand Down
8 changes: 6 additions & 2 deletions packages/svelte/src/compiler/phases/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ export function is_element_node(node) {

/**
* @param {AST.RegularElement | AST.SvelteElement} node
* @returns {node is AST.RegularElement}
* @returns {boolean}
*/
export function is_custom_element_node(node) {
return node.type === 'RegularElement' && node.name.includes('-');
return (
node.type === 'RegularElement' &&
(node.name.includes('-') ||
node.attributes.some((attr) => attr.type === 'Attribute' && attr.name === 'is'))
);
}

/**
Expand Down
53 changes: 31 additions & 22 deletions packages/svelte/src/html-tree-validation.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* @typedef {{
* tag: string;
* custom_element: boolean;
* }} Element
*/

/**
* Map of elements that have certain elements that are not allowed inside them, in the sense that they will auto-close the parent/ancestor element.
* Theoretically one could take advantage of it but most of the time it will just result in confusing behavior and break when SSR'd.
Expand Down Expand Up @@ -137,33 +144,33 @@ const disallowed_children = {
/**
* 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} child_tag
* @param {string[]} ancestors All nodes starting with the parent, up until the ancestor, which means two entries minimum
* @param {Element} child_node
* @param {Element[]} ancestors All nodes starting with the parent, up until the ancestor, which means two entries minimum
* @param {string} [child_loc]
* @param {string} [ancestor_loc]
* @returns {string | null}
*/
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
export function is_tag_valid_with_ancestor(child_node, ancestors, child_loc, ancestor_loc) {
if (child_node.custom_element) return null; // custom elements can be anything

const ancestor_tag = ancestors[ancestors.length - 1];
const ancestor_tag = ancestors[ancestors.length - 1].tag;
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 null; // custom elements can be anything
if (ancestor.custom_element) return null; // custom elements can be anything

// A reset means that forbidden descendants are allowed again
if (disallowed.reset_by.includes(ancestors[i])) {
if (disallowed.reset_by.includes(ancestors[i].tag)) {
return null;
}
}
}

if ('descendant' in disallowed && disallowed.descendant.includes(child_tag)) {
const child = child_loc ? `\`<${child_tag}>\` (${child_loc})` : `\`<${child_tag}>\``;
if ('descendant' in disallowed && disallowed.descendant.includes(child_node.tag)) {
const child = child_loc ? `\`<${child_node.tag}>\` (${child_loc})` : `\`<${child_node.tag}>\``;
const ancestor = ancestor_loc
? `\`<${ancestor_tag}>\` (${ancestor_loc})`
: `\`<${ancestor_tag}>\``;
Expand All @@ -177,36 +184,38 @@ export function is_tag_valid_with_ancestor(child_tag, ancestors, child_loc, ance
/**
* 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} child_tag
* @param {string} parent_tag
* @param {Element} child_node
* @param {Element} parent_node
* @param {string} [child_loc]
* @param {string} [parent_loc]
* @returns {string | null}
*/
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
export function is_tag_valid_with_parent(child_node, parent_node, child_loc, parent_loc) {
if (child_node.custom_element || parent_node?.custom_element) return null; // custom elements can be anything

if (parent_tag === 'template') return null; // no errors or warning should be thrown in immediate children of template tags
if (parent_node.tag === 'template') return null; // no errors or warning should be thrown in immediate children of template tags

const disallowed = disallowed_children[parent_tag];
const disallowed = disallowed_children[parent_node.tag];

const child = child_loc ? `\`<${child_tag}>\` (${child_loc})` : `\`<${child_tag}>\``;
const parent = parent_loc ? `\`<${parent_tag}>\` (${parent_loc})` : `\`<${parent_tag}>\``;
const child = child_loc ? `\`<${child_node.tag}>\` (${child_loc})` : `\`<${child_node.tag}>\``;
const parent = parent_loc
? `\`<${parent_node.tag}>\` (${parent_loc})`
: `\`<${parent_node.tag}>\``;

if (disallowed) {
if ('direct' in disallowed && disallowed.direct.includes(child_tag)) {
if ('direct' in disallowed && disallowed.direct.includes(child_node.tag)) {
return `${child} cannot be a direct child of ${parent}`;
}

if ('descendant' in disallowed && disallowed.descendant.includes(child_tag)) {
if ('descendant' in disallowed && disallowed.descendant.includes(child_node.tag)) {
return `${child} cannot be a child of ${parent}`;
}

if ('only' in disallowed && disallowed.only) {
if (disallowed.only.includes(child_tag)) {
if (disallowed.only.includes(child_node.tag)) {
return null;
} else {
return `${child} cannot be a child of ${parent}. \`<${parent_tag}>\` only allows these children: ${disallowed.only.map((d) => `\`<${d}>\``).join(', ')}`;
return `${child} cannot be a child of ${parent}. \`<${parent_node.tag}>\` only allows these children: ${disallowed.only.map((d) => `\`<${d}>\``).join(', ')}`;
}
}
}
Expand All @@ -215,7 +224,7 @@ export function is_tag_valid_with_parent(child_tag, parent_tag, child_loc, paren
// 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) {
switch (child_node.tag) {
case 'body':
case 'caption':
case 'col':
Expand Down
30 changes: 24 additions & 6 deletions packages/svelte/src/internal/server/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { current_component } from './context.js';
/**
* @typedef {{
* tag: string;
* custom_element: boolean;
* parent: null | Element;
* filename: null | string;
* line: number;
Expand Down Expand Up @@ -60,32 +61,49 @@ export function reset_elements() {
/**
* @param {Payload} payload
* @param {string} tag
* @param {boolean} custom_element
* @param {number} line
* @param {number} column
*/
export function push_element(payload, tag, line, column) {
export function push_element(payload, tag, custom_element, line, column) {
var filename = /** @type {Component} */ (current_component).function[FILENAME];
var child = { tag, parent, filename, line, column };
var child = { tag, custom_element, parent, filename, line, column };

if (parent !== null) {
var ancestor = parent.parent;
var ancestors = [parent.tag];
var ancestors = [parent];

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);
const message = is_tag_valid_with_parent(
{
tag,
custom_element
},
parent,
child_loc,
parent_loc
);
if (message) print_error(payload, message);

while (ancestor != null) {
ancestors.push(ancestor.tag);
ancestors.push(ancestor);
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);
const message = is_tag_valid_with_ancestor(
{
tag,
custom_element
},
ancestors,
child_loc,
ancestor_loc
);
if (message) print_error(payload, message);

ancestor = ancestor.parent;
Expand Down
Loading
Loading