Clone DOM elements without reparsing
This commit is contained in:
@@ -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
|
||||
|
||||
4
crates/obscura-js/js/bootstrap.js
vendored
4
crates/obscura-js/js/bootstrap.js
vendored
@@ -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
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user