Merge pull request #494 from mnaza/fix/clonenode-structural-490

fix(js): clone nodes structurally instead of via div.innerHTML
This commit is contained in:
SGavrl authored and GitHub committed 2026-08-03 15:32:18 +00:00
commit 62ada5ebb9
2 files changed
+130 -23

No files matched your search

+56 -23
View File
@@ -703,6 +703,33 @@ const _styleProxy = (decl) => new Proxy(decl, {
}, },
}); });
// Clone a single node (no children), used by Node.cloneNode. Elements are built
// with createElement/createElementNS and their content attributes copied, so no
// HTML parsing context is involved and every attribute (including style) is
// preserved. Text/Comment/DocumentFragment map to their factory; anything else
// yields null.
function _shallowCloneNode(node) {
const nt = node.nodeType;
if (nt === 3) return document.createTextNode(node.data != null ? node.data : (node.textContent || ""));
if (nt === 8) return document.createComment(node.data != null ? node.data : (node.nodeValue || ""));
if (nt === 11) return document.createDocumentFragment();
if (nt !== 1) return null;
const ns = node.namespaceURI;
const el = (ns && ns !== "http://www.w3.org/1999/xhtml")
? document.createElementNS(ns, node.nodeName)
: document.createElement(node.localName || node.nodeName.toLowerCase());
const names = node.getAttributeNames ? node.getAttributeNames() : [];
for (const name of names) {
const v = node.getAttribute(name);
if (v !== null) el.setAttribute(name, v);
}
// CSS declarations currently live on the JS wrapper independently of the
// DOM attribute. Copy that state as well so styles assigned through
// `node.style` survive cloning even before attribute reflection runs.
if (node.style && node.style.cssText) el.style.cssText = node.style.cssText;
return el;
}
class Node { class Node {
static ELEMENT_NODE = 1; static ELEMENT_NODE = 1;
static ATTRIBUTE_NODE = 2; static ATTRIBUTE_NODE = 2;
@@ -893,32 +920,38 @@ class Node {
contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; } contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; }
hasChildNodes() { return _dom("has_child_nodes", this._nid) === "true"; } hasChildNodes() { return _dom("has_child_nodes", this._nid) === "true"; }
cloneNode(deep) { cloneNode(deep) {
const t = this.nodeType; // Clone structurally via real DOM nodes rather than round-tripping through a
if (t === 1) { // throwaway <div>.innerHTML: the fragment parser discards elements that are
if (deep) { // not valid children of <div> (<tr>, <td>, <option>, …), so the old path
const wrapper = document.createElement('div'); // returned null for them and lost JS-set inline styles. Building each node
wrapper.innerHTML = _domParse("outer_html", this._nid) || ""; // directly with createElement(NS) + attribute copy avoids any parsing
const clone = wrapper.firstChild; // context, and an explicit stack keeps a deep subtree from overflowing the
return clone; // JS stack (issue #490).
} const root = _shallowCloneNode(this);
const el = document.createElement(this.nodeName.toLowerCase()); if (!deep || !root) return root;
const html = _domParse("outer_html", this._nid) || ""; const stack = [[this, root]];
const attrMatch = html.match(/^<[a-zA-Z][^\s>]*([\s\S]*?)>/); while (stack.length) {
if (attrMatch && attrMatch[1]) { const [src, dst] = stack.pop();
const attrStr = attrMatch[1].trim(); // A <template>'s children hang off its content fragment, not childNodes,
const re = /([a-zA-Z_:][a-zA-Z0-9_.:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g; // so clone them into the clone's fragment. Gated on the tag name because
let m; // .content means something else on other elements (e.g. <meta>).
while ((m = re.exec(attrStr)) !== null) { if (src.localName === 'template' && dst.localName === 'template') {
const name = m[1]; const sc = src.content, dc = dst.content;
const val = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] || ""; if (sc && dc && sc.childNodes) {
if (name !== this.nodeName.toLowerCase()) el.setAttribute(name, val); const tk = sc.childNodes;
for (let i = 0; i < tk.length; i++) {
const c = _shallowCloneNode(tk[i]);
if (c) { dc.appendChild(c); stack.push([tk[i], c]); }
} }
} }
return el;
} }
if (t === 3) return document.createTextNode(this.textContent); const kids = src.childNodes;
if (t === 8) return document.createComment(this.nodeValue || ""); for (let i = 0; i < kids.length; i++) {
return null; const c = _shallowCloneNode(kids[i]);
if (c) { dst.appendChild(c); stack.push([kids[i], c]); }
}
}
return root;
} }
compareDocumentPosition(other) { compareDocumentPosition(other) {
if (!other) return 0; if (!other) return 0;
+74
View File
@@ -1475,6 +1475,80 @@ mod tests {
assert_eq!(color, serde_json::json!("green")); assert_eq!(color, serde_json::json!("green"));
} }
#[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 clone_node_copies_js_assigned_inline_styles() {
let mut rt = setup_runtime("<html><body><div id='d'></div></body></html>");
let out = rt
.evaluate(
"(function(){var d=document.getElementById('d');d.style.color='red';d.style.fontSize='12px';var c=d.cloneNode(false);return c.style.color+'|'+c.style.fontSize+'|'+c.style.cssText;})()",
)
.unwrap();
assert_eq!(out, serde_json::json!("red|12px|color: red; font-size: 12px;"));
}
#[test]
fn clone_node_deep_copies_template_content() {
let mut rt = setup_runtime("<html><body></body></html>");
let out = rt
.evaluate(
"(function(){var t=document.createElement('template');t.content.appendChild(document.createElement('option')).textContent='choice';var c=t.cloneNode(true);return c.content.childNodes.length+'|'+c.content.firstChild.tagName+'|'+c.content.firstChild.textContent;})()",
)
.unwrap();
assert_eq!(out, serde_json::json!("1|OPTION|choice"));
}
#[test] #[test]
fn test_document_title() { fn test_document_title() {
let mut rt = setup_runtime("<html><head><title>Test</title></head><body></body></html>"); let mut rt = setup_runtime("<html><head><title>Test</title></head><body></body></html>");