fix: clean up externally-added DOM nodes in {@html} on re-render (#17853)

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 <kfiiranet@gmail.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
pull/17877/head
dev-miro26 5 months ago committed by GitHub
parent 3df2645451
commit 0206a2019e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: `{@html}` no longer duplicates content inside `contenteditable` elements

@ -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

@ -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,

@ -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;
};
}

@ -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;

@ -0,0 +1,25 @@
import { flushSync } from '../../../../src/index-client';
import { test } from '../../test';
export default test({
html: `<div id="editable" contenteditable="true"></div><p id="output"></p>`,
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');
}
});

@ -0,0 +1,9 @@
<script>
let content = $state("");
</script>
<div id="editable" onblur={(e) => { content = e.currentTarget.textContent; }} contenteditable="true">
{@html content}
</div>
<p id="output">{content}</p>
Loading…
Cancel
Save