fix(js): clone nodes structurally instead of via div.innerHTML

cloneNode(true) round-tripped through a throwaway <div>'s innerHTML, so the
HTML fragment parser discarded elements that are not valid children of <div>
(<tr>, <td>, <option>, <tbody>, …) — createElement('tr').cloneNode(true)
returned null — and JS-set inline styles were lost. The shallow path also
re-derived attributes by regex-scraping serialized HTML.

Clone structurally with real DOM APIs: shallow-clone each node via
createElement/createElementNS plus a getAttributeNames() attribute copy (no
parsing context, every attribute preserved), then descend into children with an
explicit stack so a deep subtree cannot overflow the JS stack. Text, Comment,
DocumentFragment and <template> contents are handled.

Fixes #490
This commit is contained in:
Andrey Mnatsakanov committed 2026-07-26 11:09:42 +02:00
1 parent e314b9b0f3
commit 491ecfb0f1
2 files changed
+104 -23

No files matched your search

+52
View File
@@ -1317,6 +1317,58 @@ mod tests {
rt
}
#[test]
fn clone_node_deep_preserves_context_sensitive_elements() {
// A <tr> is not a valid child of <div>, so cloning through a throwaway
// <div>.innerHTML dropped it and returned null. A structural clone keeps it.
let mut rt = setup_runtime("<html><body></body></html>");
let tag = rt
.evaluate("(document.createElement('tr').cloneNode(true) || {}).tagName || 'NULL'")
.unwrap();
assert_eq!(tag, serde_json::json!("TR"));
let td = rt
.evaluate("(document.createElement('td').cloneNode(true) || {}).tagName || 'NULL'")
.unwrap();
assert_eq!(td, serde_json::json!("TD"));
}
#[test]
fn clone_node_deep_copies_children_and_attributes() {
let mut rt = setup_runtime(r#"<html><body><ul id="l"><li class="a">one</li><li class="b">two</li></ul></body></html>"#);
let out = rt
.evaluate(
"(function(){var c=document.getElementById('l').cloneNode(true); return c.children.length + '|' + c.children[0].className + '|' + c.children[1].textContent;})()",
)
.unwrap();
assert_eq!(out, serde_json::json!("2|a|two"));
}
#[test]
fn clone_node_deep_preserves_table_rows() {
let mut rt = setup_runtime(
r#"<html><body><table id="t"><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>"#,
);
// Navigate the detached clone directly (querySelector does not traverse
// detached subtrees). tbody > tr > (td, td).
let out = rt
.evaluate(
"(function(){var tb=document.querySelector('#t tbody').cloneNode(true); var tr=tb.children[0]; return tr.tagName + '|' + tr.children.length + '|' + tr.children[1].textContent;})()",
)
.unwrap();
assert_eq!(out, serde_json::json!("TR|2|2"));
}
#[test]
fn clone_node_shallow_copies_attributes_without_children() {
let mut rt = setup_runtime(r#"<html><body><div id="d" data-x="7"><span>kid</span></div></body></html>"#);
let out = rt
.evaluate(
"(function(){var c=document.getElementById('d').cloneNode(false); return c.getAttribute('data-x') + '|' + c.childNodes.length;})()",
)
.unwrap();
assert_eq!(out, serde_json::json!("7|0"));
}
#[test]
fn test_document_title() {
let mut rt = setup_runtime("<html><head><title>Test</title></head><body></body></html>");