From 0206a2019ec55ab62e8dbfd4449e371e9d76eb5c Mon Sep 17 00:00:00 2001 From: dev-miro26 <121471669+dev-miro26@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:11:16 -0600 Subject: [PATCH] fix: clean up externally-added DOM nodes in {@html} on re-render (#17853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes `{@html}` content duplication when used inside a contenteditable element. When `{@html content}` is inside a contenteditable element and the user types, the browser inserts DOM nodes directly into the {@html} managed region. On re-render (e.g. triggered by a blur handler setting `content = e.currentTarget.innerText`, the `{@html} `block only removed nodes it previously created via` effect.nodes`, leaving browser-inserted nodes in place. This caused content to appear twice — once as leftover text nodes and once as the new `{@html}` output. The fix tracks the boundary node (`previousSibling `of the anchor at init) and removes all nodes between the boundary and the anchor on re-render, ensuring externally-added nodes are also cleaned up. Closes: #16993 --------- Co-authored-by: 7nik Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .changeset/html-contenteditable-fix.md | 5 +++ .../3-transform/client/visitors/HtmlTag.js | 13 +++++-- .../client/visitors/shared/fragment.js | 2 ++ .../svelte/src/compiler/types/template.d.ts | 2 ++ .../src/internal/client/dom/blocks/html.js | 34 ++++++++++++++++++- .../html-tag-contenteditable/_config.js | 25 ++++++++++++++ .../html-tag-contenteditable/main.svelte | 9 +++++ 7 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 .changeset/html-contenteditable-fix.md create mode 100644 packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/html-tag-contenteditable/main.svelte 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}