From c4f545e68b6ba505e14c1f848b59682f15b4924b Mon Sep 17 00:00:00 2001 From: lisa0314 Date: Tue, 21 Jul 2026 18:07:04 +0800 Subject: [PATCH] fix(js): flatten DocumentFragment during node insertion Move fragment children into the target for appendChild, insertBefore, and replaceChild while preserving their order and emptying the fragment. Add runtime regression coverage for the DOM attachment portion of #452. --- crates/obscura-js/js/bootstrap.js | 16 +++++ crates/obscura-js/src/runtime.rs | 106 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index db39028..fb4b90c 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -663,6 +663,11 @@ class Node { get previousSibling() { return _wrap(+_dom("prev_sibling", this._nid)); } appendChild(c) { if (!c) return c; + if (c instanceof DocumentFragment) { + const children = Array.from(c.childNodes); + for (const child of children) this.appendChild(child); + return c; + } _dom("append_child", this._nid, c._nid); if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []); if (c instanceof Element && c.tagName === 'SCRIPT') { @@ -745,6 +750,12 @@ class Node { } replaceChild(newChild, oldChild) { if (!oldChild || !newChild) return oldChild; + if (newChild instanceof DocumentFragment) { + const children = Array.from(newChild.childNodes); + for (const child of children) this.insertBefore(child, oldChild); + this.removeChild(oldChild); + return oldChild; + } _dom("insert_before", newChild._nid, oldChild._nid); _dom("remove_child", oldChild._nid); return oldChild; @@ -752,6 +763,11 @@ class Node { insertBefore(n, ref) { if (!n) return n; if (!ref) { this.appendChild(n); return n; } + if (n instanceof DocumentFragment) { + const children = Array.from(n.childNodes); + for (const child of children) this.insertBefore(child, ref); + return n; + } _dom("insert_before", n._nid, ref._nid); return n; } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index b1ee845..2916f1a 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1371,6 +1371,112 @@ mod tests { ); } + #[test] + fn append_child_flattens_document_fragment() { + let mut rt = setup_runtime(r#"
"#); + rt.run_page_init(); + let result = rt + .evaluate( + r#" + const host = document.getElementById('host'); + const fragment = document.createDocumentFragment(); + const first = document.createElement('article'); + const second = document.createElement('article'); + first.id = 'first'; + second.id = 'second'; + first.className = second.className = 'quote'; + fragment.appendChild(first); + fragment.appendChild(second); + + const returned = host.appendChild(fragment); + return [ + returned === fragment, + Array.from(host.children).map(node => node.id), + host.querySelectorAll('.quote').length, + fragment.childNodes.length, + first.parentNode === host, + first.parentElement === host, + ]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([true, ["first", "second"], 2, 0, true, true]) + ); + } + + #[test] + fn insert_before_flattens_document_fragment_in_order() { + let mut rt = setup_runtime(r#"
"#); + rt.run_page_init(); + let result = rt + .evaluate( + r#" + const host = document.getElementById('host'); + const last = document.getElementById('last'); + const fragment = document.createDocumentFragment(); + const first = document.createElement('article'); + const second = document.createElement('article'); + first.id = 'first'; + second.id = 'second'; + fragment.appendChild(first); + fragment.appendChild(second); + + const returned = host.insertBefore(fragment, last); + return [ + returned === fragment, + Array.from(host.children).map(node => node.id), + fragment.childNodes.length, + first.parentElement === host, + second.parentElement === host, + ]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([true, ["first", "second", "last"], 0, true, true]) + ); + } + + #[test] + fn replace_child_flattens_document_fragment_and_removes_old_child() { + let mut rt = setup_runtime( + r#"
"#, + ); + rt.run_page_init(); + let result = rt + .evaluate( + r#" + const host = document.getElementById('host'); + const old = document.getElementById('old'); + const fragment = document.createDocumentFragment(); + const first = document.createElement('article'); + const second = document.createElement('article'); + first.id = 'first'; + second.id = 'second'; + fragment.appendChild(first); + fragment.appendChild(second); + + const returned = host.replaceChild(fragment, old); + return [ + returned === old, + Array.from(host.children).map(node => node.id), + fragment.childNodes.length, + old.parentNode === null, + first.parentElement === host, + second.parentElement === host, + ]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([true, ["first", "second", "tail"], 0, true, true, true]) + ); + } + #[test] fn test_inner_html() { let mut rt = setup_runtime(r#"

Hello

"#);