Clone DOM elements without reparsing

This commit is contained in:
SGavrl
2026-07-31 00:54:22 +00:00
parent 393bac9895
commit 90ed9afcad
4 changed files with 160 additions and 9 deletions

View File

@@ -873,6 +873,67 @@ impl DomTree {
}
}
/// Clone one node within this tree without attaching the clone.
///
/// This operates on node data directly instead of serializing and parsing
/// HTML. Besides avoiding context-sensitive fragment parsing (`<html>`,
/// table children, and foreign content), it preserves the cloned root's
/// element type and namespace. Template contents are stored in a separate
/// document node and therefore need their own remapped clone.
pub fn clone_node(&self, source_node_id: NodeId, deep: bool) -> Option<NodeId> {
let source_data = self.get_node(source_node_id)?.data;
let cloned_root = self.new_node(source_data);
let mut stack = Vec::new();
self.prepare_cloned_children(source_node_id, cloned_root, deep, &mut stack);
while let Some((dest_parent, source_node)) = stack.pop() {
let source_data = match self.get_node(source_node) {
Some(node) => node.data,
None => continue,
};
let cloned_node = self.new_node(source_data);
self.append_child(dest_parent, cloned_node);
self.prepare_cloned_children(source_node, cloned_node, true, &mut stack);
}
Some(cloned_root)
}
fn prepare_cloned_children(
&self,
source_node: NodeId,
cloned_node: NodeId,
deep: bool,
stack: &mut Vec<(NodeId, NodeId)>,
) {
let source_contents = self
.with_node(source_node, |node| match &node.data {
NodeData::Element { template_contents, .. } => *template_contents,
_ => None,
})
.flatten();
if let Some(source_contents) = source_contents {
let cloned_contents = self.new_node(NodeData::Document);
self.with_node_mut(cloned_node, |node| {
if let NodeData::Element { template_contents, .. } = &mut node.data {
*template_contents = Some(cloned_contents);
}
});
if deep {
for child in self.children(source_contents).into_iter().rev() {
stack.push((cloned_contents, child));
}
}
}
if deep {
for child in self.children(source_node).into_iter().rev() {
stack.push((cloned_node, child));
}
}
}
fn import_node_from(&self, parent_id: NodeId, source: &DomTree, source_node_id: NodeId) {
// Iterative DFS with an explicit (dest_parent, source_node) stack so a
// deeply nested source tree cannot overflow the thread stack and abort

View File

@@ -1233,6 +1233,10 @@ class Node {
contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; }
hasChildNodes() { return _dom("has_child_nodes", this._nid) === "true"; }
cloneNode(deep) {
const t = this.nodeType;
if (t === 1) {
return _wrap(+_dom("clone_node", this._nid, deep ? "true" : "false"));
}
// Clone structurally via real DOM nodes rather than round-tripping through a
// throwaway <div>.innerHTML: the fragment parser discards elements that are
// not valid children of <div> (<tr>, <td>, <option>, …), so the old path

View File

@@ -525,6 +525,15 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
"create_document_fragment" => {
dom.new_node(NodeData::Document).index().to_string()
}
"clone_node" => {
let nid = match arg1.parse::<u32>() {
Ok(n) => n,
Err(_) => return "-1".into(),
};
dom.clone_node(NodeId::new(nid), arg2 == "true")
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"create_element" => {
dom.new_node(NodeData::Element {
name: html5ever::QualName::new(None, html5ever::ns!(html), html5ever::LocalName::from(arg1.as_str())),

View File

@@ -4623,15 +4623,92 @@ mod tests {
}
#[test]
fn test_clone_node_deep() {
let mut rt = setup_runtime(r#"<div id="src"><p>A</p><p>B</p></div>"#);
rt.execute_script("test", r#"
var src = document.getElementById('src');
var clone = src.cloneNode(true);
document.body.appendChild(clone);
"#).unwrap();
let count = rt.evaluate("document.querySelectorAll('p').length").unwrap();
assert!(count.as_f64().unwrap() as i64 >= 4, "Deep clone should duplicate <p> children, got: {}", count);
fn shallow_element_clone_preserves_interface_attributes_and_isolation() {
let mut rt = setup_runtime(
r#"<section id="src" class="source" data-token="original"><span>child</span></section>"#,
);
let result = rt
.evaluate(
r#"
const source = document.getElementById('src');
const clone = source.cloneNode(false);
clone.className = 'clone';
source.setAttribute('data-token', 'changed');
return [
clone instanceof Node,
clone instanceof Element,
clone instanceof HTMLElement,
typeof clone.outerHTML,
typeof clone.querySelectorAll,
clone.tagName,
clone.id,
clone.className,
clone.getAttribute('data-token'),
clone.childNodes.length,
clone.ownerDocument === document,
clone.parentNode === null,
clone !== source,
source.className,
source.getAttribute('data-token'),
source.childNodes.length,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
true, true, true, "string", "function", "SECTION", "src", "clone",
"original", 0, true, true, true, "source", "changed", 1
])
);
}
#[test]
fn deep_document_element_clone_stays_an_independent_html_element() {
let mut rt = setup_runtime(
r#"<html lang="en" data-root="original"><head><title>Clone</title></head><body><main id="app" data-state="source"><p class="item">original text</p></main></body></html>"#,
);
let result = rt
.evaluate(
r#"
const source = document.documentElement;
const clone = source.cloneNode(true);
const cloneItem = clone.querySelector('.item');
const sourceItem = source.querySelector('.item');
cloneItem.textContent = 'clone text';
source.querySelector('#app').setAttribute('data-state', 'changed');
clone.setAttribute('lang', 'fr');
return [
clone instanceof Element,
clone instanceof HTMLElement,
clone.tagName,
typeof clone.outerHTML,
typeof clone.querySelectorAll,
clone.querySelectorAll('head, body, main, p').length,
clone.ownerDocument === document,
clone.parentNode === null,
clone !== source,
clone.querySelector('body') !== document.body,
clone.getAttribute('data-root'),
clone.getAttribute('lang'),
source.getAttribute('lang'),
cloneItem.textContent,
sourceItem.textContent,
clone.querySelector('#app').getAttribute('data-state'),
source.querySelector('#app').getAttribute('data-state'),
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
true, true, "HTML", "string", "function", 4, true, true, true,
true, "original", "fr", "en", "clone text", "original text",
"source", "changed"
])
);
}
#[test]