diff --git a/.changeset/html-contenteditable-fix.md b/.changeset/html-contenteditable-fix.md new file mode 100644 index 0000000000..5cae7f6234 --- /dev/null +++ b/.changeset/html-contenteditable-fix.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: `{@html}` no longer duplicates content inside `contenteditable` elements diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js index 2706cf7f0a..6c8b7c0354 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js @@ -9,7 +9,11 @@ import { build_expression } from './shared/utils.js'; * @param {ComponentContext} context */ export function HtmlTag(node, context) { - context.state.template.push_comment(); + const is_controlled = node.metadata.is_controlled; + + if (!is_controlled) { + context.state.template.push_comment(); + } const has_await = node.metadata.expression.has_await; const has_blockers = node.metadata.expression.has_blockers(); @@ -17,14 +21,17 @@ export function HtmlTag(node, context) { const expression = build_expression(context, node.expression, node.metadata.expression); const html = has_await ? b.call('$.get', b.id('$$html')) : expression; - const is_svg = context.state.metadata.namespace === 'svg'; - const is_mathml = context.state.metadata.namespace === 'mathml'; + // When is_controlled, the parent node already provides the correct namespace, + // so is_svg/is_mathml are only needed for the non-controlled path's wrapper element + const is_svg = !is_controlled && context.state.metadata.namespace === 'svg'; + const is_mathml = !is_controlled && context.state.metadata.namespace === 'mathml'; const statement = b.stmt( b.call( '$.html', context.state.node, b.thunk(html), + is_controlled && b.true, is_svg && b.true, is_mathml && b.true, is_ignored(node, 'hydration_html_changed') && b.true diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js index 59b93f24ef..bd3e708662 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js @@ -109,6 +109,8 @@ export function process_children(nodes, initial, is_element, context) { !node.metadata.expression.is_async() ) { node.metadata.is_controlled = true; + } else if (node.type === 'HtmlTag' && nodes.length === 1 && is_element) { + node.metadata.is_controlled = true; } else { const id = flush_node( false, diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index d44a31349a..3c1e3e772c 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -133,6 +133,8 @@ export namespace AST { /** @internal */ metadata: { expression: ExpressionMetadata; + /** If `true`, the `{@html}` block is the only child of its parent element and can use `parent.innerHTML` directly */ + is_controlled?: boolean; }; } diff --git a/packages/svelte/src/internal/client/dom/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js index af66a04534..ffe947eb16 100644 --- a/packages/svelte/src/internal/client/dom/blocks/html.js +++ b/packages/svelte/src/internal/client/dom/blocks/html.js @@ -42,17 +42,33 @@ function check_hash(element, server_hash, value) { /** * @param {Element | Text | Comment} node * @param {() => string | TrustedHTML} get_value + * @param {boolean} [is_controlled] * @param {boolean} [svg] * @param {boolean} [mathml] * @param {boolean} [skip_warning] * @returns {void} */ -export function html(node, get_value, svg = false, mathml = false, skip_warning = false) { +export function html( + node, + get_value, + is_controlled = false, + svg = false, + mathml = false, + skip_warning = false +) { var anchor = node; /** @type {string | TrustedHTML} */ var value = ''; + if (is_controlled) { + var parent_node = /** @type {Element} */ (node); + + if (hydrating) { + anchor = set_hydrate_node(get_first_child(parent_node)); + } + } + template_effect(() => { var effect = /** @type {Effect} */ (active_effect); @@ -61,6 +77,22 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning return; } + if (is_controlled && !hydrating) { + // When @html is the only child, use innerHTML directly. + // This also handles contenteditable, where the user may delete the anchor comment. + effect.nodes = null; + parent_node.innerHTML = /** @type {string} */ (value); + + if (value !== '') { + assign_nodes( + /** @type {TemplateNode} */ (get_first_child(parent_node)), + /** @type {TemplateNode} */ (parent_node.lastChild) + ); + } + + return; + } + if (effect.nodes !== null) { remove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end)); effect.nodes = null; diff --git a/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js new file mode 100644 index 0000000000..9e188b1119 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from '../../../../src/index-client'; +import { test } from '../../test'; + +export default test({ + html: `

`, + + test({ assert, target }) { + const div = /** @type {HTMLDivElement} */ (target.querySelector('#editable')); + const output = /** @type {HTMLParagraphElement} */ (target.querySelector('#output')); + + // Simulate user typing by directly modifying the DOM + div.textContent = 'hello'; + + // Simulate blur which triggers `content = e.currentTarget.innerText` + const event = new Event('blur'); + div.dispatchEvent(event); + flushSync(); + + // The output should show "hello" (innerText was set correctly) + assert.equal(output.textContent, 'hello'); + + // The contenteditable div should contain "hello" once, not duplicated + assert.htmlEqual(div.innerHTML, 'hello'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte new file mode 100644 index 0000000000..3f887f2a9a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte @@ -0,0 +1,9 @@ + + +
{ content = e.currentTarget.textContent; }} contenteditable="true"> + {@html content} +
+ +

{content}