fix: create `<svelte:element>` instances with the correct namespace (#10006)

Infer namespace from parents where possible, and do a runtime-best-effort where it's not statically known
fixes #9645

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
pull/10333/head
Karol 12 months ago committed by GitHub
parent 5ebd9e0b45
commit 77b4c4be6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -55,6 +55,8 @@ jobs:
- name: type check
run: pnpm check
- name: lint
if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail (avoids multiple runs uncovering different issues at different steps)
run: pnpm lint
- name: build and check generated types
if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail
run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally and commit the changes after you have reviewed them"; git diff; exit 1); }

@ -1,3 +1,4 @@
import { namespace_svg } from '../../../../constants.js';
import { error } from '../../../errors.js';
const regex_valid_tag_name = /^[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]+$/;
@ -156,7 +157,7 @@ export default function read_options(node) {
error(attribute, 'invalid-svelte-option-namespace');
}
if (value === 'http://www.w3.org/2000/svg') {
if (value === namespace_svg) {
component_options.namespace = 'svg';
} else if (value === 'html' || value === 'svg' || value === 'foreign') {
component_options.namespace = value;

@ -139,7 +139,10 @@ export default function tag(parser) {
name,
attributes: [],
fragment: create_fragment(true),
parent: null
parent: null,
metadata: {
svg: false
}
};
parser.allow_whitespace();

@ -20,7 +20,7 @@ import { warn } from '../../warnings.js';
import check_graph_for_cycles from './utils/check_graph_for_cycles.js';
import { regex_starts_with_newline } from '../patterns.js';
import { create_attribute, is_element_node } from '../nodes.js';
import { DelegatedEvents } from '../../../constants.js';
import { DelegatedEvents, namespace_svg } from '../../../constants.js';
import { should_proxy_or_freeze } from '../3-transform/client/utils.js';
/**
@ -1104,8 +1104,47 @@ const common_visitors = {
context.state.analysis.elements.push(node);
},
SvelteElement(node, { state }) {
state.analysis.elements.push(node);
SvelteElement(node, context) {
context.state.analysis.elements.push(node);
if (
context.state.options.namespace !== 'foreign' &&
node.tag.type === 'Literal' &&
typeof node.tag.value === 'string' &&
SVGElements.includes(node.tag.value)
) {
node.metadata.svg = true;
return;
}
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute') {
if (attribute.name === 'xmlns' && is_text_attribute(attribute)) {
node.metadata.svg = attribute.value[0].data === namespace_svg;
return;
}
}
}
for (let i = context.path.length - 1; i >= 0; i--) {
const ancestor = context.path[i];
if (
ancestor.type === 'Component' ||
ancestor.type === 'SvelteComponent' ||
ancestor.type === 'SvelteFragment' ||
ancestor.type === 'SnippetBlock'
) {
// Inside a slot or a snippet -> this resets the namespace, so we can't determine it
return;
}
if (ancestor.type === 'SvelteElement' || ancestor.type === 'RegularElement') {
node.metadata.svg =
ancestor.type === 'RegularElement' && ancestor.name === 'foreignObject'
? false
: ancestor.metadata.svg;
return;
}
}
}
};

@ -9,7 +9,7 @@ import {
import { binding_properties } from '../../../bindings.js';
import {
clean_nodes,
determine_element_namespace,
determine_namespace_for_children,
escape_html,
infer_namespace
} from '../../utils.js';
@ -43,11 +43,7 @@ import { sanitize_template_string } from '../../../../utils/sanitize_template_st
*/
function get_attribute_name(element, attribute, context) {
let name = attribute.name;
if (
element.type === 'RegularElement' &&
!element.metadata.svg &&
context.state.metadata.namespace !== 'foreign'
) {
if (!element.metadata.svg && context.state.metadata.namespace !== 'foreign') {
name = name.toLowerCase();
if (name in AttributeAliases) {
name = AttributeAliases[name];
@ -1854,7 +1850,7 @@ export const template_visitors = {
const metadata = context.state.metadata;
const child_metadata = {
...context.state.metadata,
namespace: determine_element_namespace(node, context.state.metadata.namespace, context.path)
namespace: determine_namespace_for_children(node, context.state.metadata.namespace)
};
context.state.template.push(`<${node.name}`);
@ -2079,9 +2075,6 @@ export const template_visitors = {
/** @type {import('estree').ExpressionStatement[]} */
const lets = [];
/** @type {string | null} */
let namespace = null;
// Create a temporary context which picks up the init/update statements.
// They'll then be added to the function parameter of $.element
const element_id = b.id(context.state.scope.generate('$$element'));
@ -2102,9 +2095,6 @@ export const template_visitors = {
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute') {
attributes.push(attribute);
if (attribute.name === 'xmlns' && is_text_attribute(attribute)) {
namespace = attribute.value[0].data;
}
} else if (attribute.type === 'SpreadAttribute') {
attributes.push(attribute);
} else if (attribute.type === 'ClassDirective') {
@ -2153,19 +2143,32 @@ export const template_visitors = {
}
}
inner.push(...inner_context.state.after_update);
inner.push(...create_block(node, 'dynamic_element', node.fragment.nodes, context));
inner.push(
...create_block(node, 'dynamic_element', node.fragment.nodes, {
...context,
state: {
...context.state,
metadata: {
...context.state.metadata,
namespace: determine_namespace_for_children(node, context.state.metadata.namespace)
}
}
})
);
context.state.after_update.push(
b.stmt(
b.call(
'$.element',
context.state.node,
get_tag,
node.metadata.svg === true
? b.true
: node.metadata.svg === false
? b.false
: b.literal(null),
inner.length === 0
? /** @type {any} */ (undefined)
: b.arrow([element_id, b.id('$$anchor')], b.block(inner)),
namespace === 'http://www.w3.org/2000/svg'
? b.literal(true)
: /** @type {any} */ (undefined)
: b.arrow([element_id, b.id('$$anchor')], b.block(inner))
)
)
);

@ -15,7 +15,7 @@ import {
} from '../../constants.js';
import {
clean_nodes,
determine_element_namespace,
determine_namespace_for_children,
escape_html,
infer_namespace,
transform_inspect_rune
@ -482,11 +482,7 @@ function serialize_set_binding(node, context, fallback) {
*/
function get_attribute_name(element, attribute, context) {
let name = attribute.name;
if (
element.type === 'RegularElement' &&
!element.metadata.svg &&
context.state.metadata.namespace !== 'foreign'
) {
if (!element.metadata.svg && context.state.metadata.namespace !== 'foreign') {
name = name.toLowerCase();
// don't lookup boolean aliases here, the server runtime function does only
// check for the lowercase variants of boolean attributes
@ -761,10 +757,10 @@ function serialize_element_spread_attributes(
}
const lowercase_attributes =
element.type !== 'RegularElement' || element.metadata.svg || is_custom_element_node(element)
element.metadata.svg || (element.type === 'RegularElement' && is_custom_element_node(element))
? b.false
: b.true;
const is_svg = element.type === 'RegularElement' && element.metadata.svg ? b.true : b.false;
const is_svg = element.metadata.svg ? b.true : b.false;
/** @type {import('estree').Expression[]} */
const args = [
b.array(values),
@ -1165,7 +1161,7 @@ const template_visitors = {
RegularElement(node, context) {
const metadata = {
...context.state.metadata,
namespace: determine_element_namespace(node, context.state.metadata.namespace, context.path)
namespace: determine_namespace_for_children(node, context.state.metadata.namespace)
};
context.state.template.push(t_string(`<${node.name}`));
@ -1255,11 +1251,16 @@ const template_visitors = {
context.state.init.push(b.stmt(b.call('$.validate_dynamic_element_tag', b.thunk(tag))));
}
const metadata = {
...context.state.metadata,
namespace: determine_namespace_for_children(node, context.state.metadata.namespace)
};
/** @type {import('./types').ComponentContext} */
const inner_context = {
...context,
state: {
...context.state,
metadata,
template: [],
init: []
}
@ -1276,7 +1277,10 @@ const template_visitors = {
inner_context.state.template.push(t_string('>'));
const before = serialize_template(inner_context.state.template);
const main = create_block(node, node.fragment.nodes, context);
const main = create_block(node, node.fragment.nodes, {
...context,
state: { ...context.state, metadata }
});
const after = serialize_template([
t_expression(inner_id),
t_string('</'),

@ -188,7 +188,7 @@ export function clean_nodes(
}
/**
* Infers the new namespace for the children of a node.
* Infers the namespace for the children of a node that should be used when creating the `$.template(...)`.
* @param {import('#compiler').Namespace} namespace
* @param {import('#compiler').SvelteNode} parent
* @param {import('#compiler').SvelteNode[]} nodes
@ -201,19 +201,28 @@ export function infer_namespace(namespace, parent, nodes, path) {
path.at(-1)
: parent;
if (
namespace !== 'foreign' &&
if (namespace !== 'foreign') {
if (parent_node?.type === 'RegularElement' && parent_node.name === 'foreignObject') {
return 'html';
}
if (parent_node?.type === 'RegularElement' || parent_node?.type === 'SvelteElement') {
return parent_node.metadata.svg ? 'svg' : 'html';
}
// Re-evaluate the namespace inside slot nodes that reset the namespace
(parent_node === undefined ||
if (
parent_node === undefined ||
parent_node.type === 'Root' ||
parent_node.type === 'Component' ||
parent_node.type === 'SvelteComponent' ||
parent_node.type === 'SvelteFragment' ||
parent_node.type === 'SnippetBlock')
) {
const new_namespace = check_nodes_for_namespace(nodes, 'keep');
if (new_namespace !== 'keep' && new_namespace !== 'maybe_html') {
namespace = new_namespace;
parent_node.type === 'SnippetBlock'
) {
const new_namespace = check_nodes_for_namespace(nodes, 'keep');
if (new_namespace !== 'keep' && new_namespace !== 'maybe_html') {
return new_namespace;
}
}
}
@ -229,7 +238,7 @@ export function infer_namespace(namespace, parent, nodes, path) {
*/
function check_nodes_for_namespace(nodes, namespace) {
for (const node of nodes) {
if (node.type === 'RegularElement') {
if (node.type === 'RegularElement' || node.type === 'SvelteElement') {
if (!node.metadata.svg) {
namespace = 'html';
break;
@ -279,36 +288,21 @@ function check_nodes_for_namespace(nodes, namespace) {
}
/**
* @param {import('#compiler').RegularElement} node
* Determines the namespace the children of this node are in.
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node
* @param {import('#compiler').Namespace} namespace
* @param {import('#compiler').SvelteNode[]} path
* @returns {import('#compiler').Namespace}
*/
export function determine_element_namespace(node, namespace, path) {
if (namespace !== 'foreign') {
let parent = path.at(-1);
if (parent?.type === 'Fragment') {
parent = path.at(-2);
}
export function determine_namespace_for_children(node, namespace) {
if (namespace === 'foreign') {
return namespace;
}
if (node.name === 'foreignObject') {
return 'html';
} else if (
namespace !== 'svg' ||
parent?.type === 'Component' ||
parent?.type === 'SvelteComponent' ||
parent?.type === 'SvelteFragment' ||
parent?.type === 'SnippetBlock'
) {
if (node.metadata.svg) {
return 'svg';
} else {
return 'html';
}
}
if (node.name === 'foreignObject') {
return 'html';
}
return namespace;
return node.metadata.svg ? 'svg' : 'html';
}
/**

@ -308,6 +308,13 @@ export interface SvelteElement extends BaseElement {
type: 'SvelteElement';
name: 'svelte:element';
tag: Expression;
metadata: {
/**
* `true`/`false` if this is definitely (not) an svg element.
* `null` means we can't know statically.
*/
svg: boolean | null;
};
}
export interface SvelteFragment extends BaseElement {

@ -85,3 +85,6 @@ export const DOMBooleanAttributes = [
'seamless',
'selected'
];
export const namespace_svg = 'http://www.w3.org/2000/svg';
export const namespace_html = 'http://www.w3.org/1999/xhtml';

@ -19,7 +19,13 @@ import {
create_dynamic_component_block,
create_snippet_block
} from './block.js';
import { PassiveDelegatedEvents, DelegatedEvents, AttributeAliases } from '../../constants.js';
import {
PassiveDelegatedEvents,
DelegatedEvents,
AttributeAliases,
namespace_svg,
namespace_html
} from '../../constants.js';
import { create_fragment_from_html, insert, reconcile_html, remove } from './reconciler.js';
import {
render_effect,
@ -1595,11 +1601,11 @@ function swap_block_dom(block, from, to) {
/**
* @param {Comment} anchor_node
* @param {() => string} tag_fn
* @param {boolean | null} is_svg `null` == not statically known
* @param {undefined | ((element: Element, anchor: Node) => void)} render_fn
* @param {any} is_svg
* @returns {void}
*/
export function element(anchor_node, tag_fn, render_fn, is_svg = false) {
export function element(anchor_node, tag_fn, is_svg, render_fn) {
const block = create_dynamic_element_block();
hydrate_block_anchor(anchor_node);
let has_mounted = false;
@ -1607,7 +1613,7 @@ export function element(anchor_node, tag_fn, render_fn, is_svg = false) {
/** @type {string} */
let tag;
/** @type {null | HTMLElement | SVGElement} */
/** @type {null | Element} */
let element = null;
const element_effect = render_effect(
() => {
@ -1623,11 +1629,20 @@ export function element(anchor_node, tag_fn, render_fn, is_svg = false) {
// Managed effect
const render_effect_signal = render_effect(
() => {
// We try our best infering the namespace in case it's not possible to determine statically,
// but on the first render on the client (without hydration) the parent will be undefined,
// since the anchor is not attached to its parent / the dom yet.
const ns =
is_svg || tag === 'svg'
? namespace_svg
: is_svg === false || anchor_node.parentElement?.tagName === 'foreignObject'
? null
: anchor_node.parentElement?.namespaceURI ?? null;
const next_element = tag
? current_hydration_fragment !== null
? /** @type {HTMLElement | SVGElement} */ (current_hydration_fragment[0])
: is_svg
? document.createElementNS('http://www.w3.org/2000/svg', tag)
? /** @type {Element} */ (current_hydration_fragment[0])
: ns
? document.createElementNS(ns, tag)
: document.createElement(tag)
: null;
const prev_element = element;
@ -2098,7 +2113,7 @@ export function cssProps(anchor, is_html, props, component) {
tag = document.createElement('div');
tag.style.display = 'contents';
} else {
tag = document.createElementNS('http://www.w3.org/2000/svg', 'g');
tag = document.createElementNS(namespace_svg, 'g');
}
insert(tag, null, anchor);
component_anchor = empty();
@ -2629,7 +2644,7 @@ export function spread_dynamic_element_attributes(node, prev, attrs, css_hash) {
/** @type {Element & ElementCSSInlineStyle} */ (node),
prev,
attrs,
node.namespaceURI !== 'http://www.w3.org/2000/svg',
node.namespaceURI !== namespace_svg,
css_hash
);
}

@ -40,15 +40,12 @@ export {
unwrap,
freeze
} from './client/runtime.js';
export * from './client/each.js';
export * from './client/render.js';
export * from './client/validate.js';
export { raf } from './client/timing.js';
export { proxy, readonly, unstate } from './client/proxy.js';
export { create_custom_element } from './client/custom-element.js';
export {
child,
child_frag,

@ -0,0 +1,16 @@
import { test } from '../../test';
export default test({
// This is skipped for now, because it's not clear how to make this work on client-side initial run:
// The anchor isn't connected to its parent at the time we can do a runtime check for the namespace, and we
// need the parent for this check. (this didn't work in Svelte 4 either)
skip: true,
html: '<svg><path></path></svg>',
test({ assert, target }) {
const svg = target.querySelector('svg');
const rect = target.querySelector('path');
assert.equal(svg?.namespaceURI, 'http://www.w3.org/2000/svg');
assert.equal(rect?.namespaceURI, 'http://www.w3.org/2000/svg');
}
});

@ -0,0 +1,10 @@
<script>
// ensure these are treated as dynamic, despite whatever
// optimisations we might apply
export let svg = 'svg';
export let path = 'path';
</script>
<svelte:element this={svg}>
<svelte:element this={path}></svelte:element>
</svelte:element>

@ -0,0 +1,20 @@
import { test } from '../../test';
export default test({
test({ assert, target }) {
const [svg1, svg2] = target.querySelectorAll('svg');
const [path1, path2] = target.querySelectorAll('path');
const [fO1, fO2] = target.querySelectorAll('foreignObject');
const [span1, span2] = target.querySelectorAll('span');
assert.equal(svg1.namespaceURI, 'http://www.w3.org/2000/svg');
assert.equal(path1.namespaceURI, 'http://www.w3.org/2000/svg');
assert.equal(svg2.namespaceURI, 'http://www.w3.org/2000/svg');
assert.equal(path2.namespaceURI, 'http://www.w3.org/2000/svg');
assert.equal(fO1.namespaceURI, 'http://www.w3.org/2000/svg');
assert.equal(span1.namespaceURI, 'http://www.w3.org/1999/xhtml');
assert.equal(fO2.namespaceURI, 'http://www.w3.org/2000/svg');
assert.equal(span2.namespaceURI, 'http://www.w3.org/1999/xhtml');
}
});

@ -0,0 +1,22 @@
<script>
const iconNode = [["path", { "d": "M21 12a9 9 0 1 1-6.219-8.56" }]];
</script>
<svg>
{#each iconNode as [tag, attrs]}
<svelte:element this={tag} {...attrs}/>
{/each}
</svg>
<svg>
<svelte:element this="path">
<foreignObject>
<svelte:element this="span">ok</svelte:element>
</foreignObject>
<foreignObject>
{#if true}
<svelte:element this="span">ok</svelte:element>
{/if}
</foreignObject>
</svelte:element>
</svg>

@ -11,7 +11,7 @@ export default function Svelte_element($$anchor, $$props) {
var fragment = $.comment($$anchor);
var node = $.child_frag(fragment);
$.element(node, tag);
$.element(node, tag, false);
$.close_frag($$anchor, fragment);
$.pop();
}

@ -1342,6 +1342,13 @@ declare module 'svelte/compiler' {
type: 'SvelteElement';
name: 'svelte:element';
tag: Expression;
metadata: {
/**
* `true`/`false` if this is definitely (not) an svg element.
* `null` means we can't know statically.
*/
svg: boolean | null;
};
}
interface SvelteFragment extends BaseElement {

Loading…
Cancel
Save