diff --git a/.changeset/loud-mayflies-melt.md b/.changeset/loud-mayflies-melt.md new file mode 100644 index 000000000000..af64eb8b29b8 --- /dev/null +++ b/.changeset/loud-mayflies-melt.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow dom elements as `svelte:element` `this` attribute diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 2c2e0707ea12..687c42709485 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -133,3 +133,9 @@ Reading state that was created inside the same derived is forbidden. Consider us ``` Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state` ``` + +### svelte_element_already_connected + +``` +You can't use an HTML element as the `this` attribute of `svelte:element` if it's already connected to a document +``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index ce1f222c63ea..051c4fabae62 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -87,3 +87,7 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long ## state_unsafe_mutation > Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state` + +## svelte_element_already_connected + +> You can't use an HTML element as the `this` attribute of `svelte:element` if it's already connected to a document diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js index 18641300e537..81dfe324a5c8 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js @@ -23,10 +23,11 @@ import { DEV } from 'esm-env'; import { EFFECT_TRANSPARENT } from '../../constants.js'; import { assign_nodes } from '../template.js'; import { is_raw_text_element } from '../../../../utils.js'; +import * as e from '../../errors.js'; /** * @param {Comment | Element} node - * @param {() => string} get_tag + * @param {() => string | HTMLElement | SVGElement} get_tag * @param {boolean} is_svg * @param {undefined | ((element: Element, anchor: Node | null) => void)} render_fn, * @param {undefined | (() => string)} get_namespace @@ -42,10 +43,10 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio var filename = DEV && location && component_context?.function[FILENAME]; - /** @type {string | null} */ + /** @type {string | HTMLElement | SVGElement | null} */ var tag; - /** @type {string | null} */ + /** @type {string | HTMLElement | SVGElement | null} */ var current_tag; /** @type {null | Element} */ @@ -70,11 +71,19 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio block(() => { const next_tag = get_tag() || null; - var ns = get_namespace ? get_namespace() : is_svg || next_tag === 'svg' ? NAMESPACE_SVG : null; + var ns = get_namespace + ? get_namespace() + : is_svg || (typeof next_tag === 'string' ? next_tag === 'svg' : next_tag?.tagName === 'svg') + ? NAMESPACE_SVG + : null; // Assumption: Noone changes the namespace but not the tag (what would that even mean?) if (next_tag === tag) return; + if (typeof next_tag !== 'string' && next_tag?.isConnected) { + e.svelte_element_already_connected(); + } + // See explanation of `each_item_block` above var previous_each_item = current_each_item; set_current_each_item(each_item_block); @@ -100,9 +109,11 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio effect = branch(() => { element = hydrating ? /** @type {Element} */ (element) - : ns - ? document.createElementNS(ns, next_tag) - : document.createElement(next_tag); + : typeof next_tag === 'string' + ? ns + ? document.createElementNS(ns, next_tag) + : document.createElement(next_tag) + : next_tag; if (DEV && location) { // @ts-expect-error @@ -118,7 +129,10 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio assign_nodes(element, element); if (render_fn) { - if (hydrating && is_raw_text_element(next_tag)) { + if ( + hydrating && + is_raw_text_element(typeof next_tag === 'string' ? next_tag : next_tag.nodeName) + ) { // prevent hydration glitches element.append(document.createComment('')); } diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 682816e1d64b..6e4098f1a2f3 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -335,4 +335,19 @@ export function state_unsafe_mutation() { } else { throw new Error(`https://svelte.dev/e/state_unsafe_mutation`); } +} + +/** + * You can't use an HTML element as the `this` attribute of `svelte:element` if it's already connected to a document + * @returns {never} + */ +export function svelte_element_already_connected() { + if (DEV) { + const error = new Error(`svelte_element_already_connected\nYou can't use an HTML element as the \`this\` attribute of \`svelte:element\` if it's already connected to a document\nhttps://svelte.dev/e/svelte_element_already_connected`); + + error.name = 'Svelte error'; + throw error; + } else { + throw new Error(`https://svelte.dev/e/svelte_element_already_connected`); + } } \ No newline at end of file diff --git a/packages/svelte/src/internal/shared/validate.js b/packages/svelte/src/internal/shared/validate.js index 852c0e83bfb1..07bff851341b 100644 --- a/packages/svelte/src/internal/shared/validate.js +++ b/packages/svelte/src/internal/shared/validate.js @@ -7,13 +7,14 @@ import * as e from './errors.js'; export { invalid_default_snippet } from './errors.js'; /** - * @param {() => string} tag_fn + * @param {() => string | HTMLElement | SVGElement} tag_fn * @returns {void} */ export function validate_void_dynamic_element(tag_fn) { const tag = tag_fn(); - if (tag && is_void(tag)) { - w.dynamic_void_element_content(tag); + const tag_name = typeof tag === 'string' ? tag : tag?.tagName; + if (tag_name && is_void(tag_name)) { + w.dynamic_void_element_content(tag_name); } } @@ -21,7 +22,8 @@ export function validate_void_dynamic_element(tag_fn) { export function validate_dynamic_element_tag(tag_fn) { const tag = tag_fn(); const is_string = typeof tag === 'string'; - if (tag && !is_string) { + const is_element = tag instanceof HTMLElement || tag instanceof SVGElement; + if (tag && !(is_string || is_element)) { e.svelte_element_invalid_this_value(); } } diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-element-element-connected/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-element-element-connected/_config.js new file mode 100644 index 000000000000..8a94e240769c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-element-element-connected/_config.js @@ -0,0 +1,13 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const btn = target.querySelector('button'); + assert.throws(() => { + flushSync(() => { + btn?.click(); + }); + }, 'svelte_element_already_connected'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-element-element-connected/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-element-element-connected/main.svelte new file mode 100644 index 000000000000..558a65a71131 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-element-element-connected/main.svelte @@ -0,0 +1,9 @@ + + +
+ + + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-element-element-null/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-element-element-null/_config.js new file mode 100644 index 000000000000..29c900d592e7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-element-element-null/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + ssrHtml: ``, + test({ assert, target }) { + assert.htmlEqual(target.innerHTML, `
children

children

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-element-element-null/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-element-element-null/main.svelte new file mode 100644 index 000000000000..afcc6adecb82 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-element-element-null/main.svelte @@ -0,0 +1,13 @@ + + + +

children

+
\ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-element-element/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-element-element/_config.js new file mode 100644 index 000000000000..7b3fbaaddae0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-element-element/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + ssrHtml: `

children

`, + test({ assert, target }) { + assert.htmlEqual(target.innerHTML, `
children

children

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-element-element/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-element-element/main.svelte new file mode 100644 index 000000000000..efdcb822ec65 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-element-element/main.svelte @@ -0,0 +1,13 @@ + + + +

children

+
\ No newline at end of file