diff --git a/crates/obscura-dom/src/tree.rs b/crates/obscura-dom/src/tree.rs index eeef499..0c554bc 100644 --- a/crates/obscura-dom/src/tree.rs +++ b/crates/obscura-dom/src/tree.rs @@ -839,18 +839,22 @@ impl DomTree { self.append_child(parent_id, text_id); } - pub fn find_body_or_root(&self) -> NodeId { + /// The node whose children are a parsed fragment's top-level nodes: the + /// synthetic root `` element html5ever wraps a fragment in, or the + /// document if there is none. Importing this node's children reproduces the + /// fragment as written. + /// + /// This must NOT descend into a synthesized ``. A `` child of the + /// root only appears when the fragment is parsed in the `` context + /// (documentElement.innerHTML), where html5ever's "before head" mode + /// synthesizes both `` and ``; returning the body there dropped + /// the head siblings. Every other element context leaves the parsed content + /// directly under the root, so it is unaffected. + pub fn fragment_root(&self) -> NodeId { let doc = self.document(); for child in self.children(doc) { if let Some(n) = self.get_node(child) { if n.as_element().map(|name| name.local.as_ref() == "html").unwrap_or(false) { - for html_child in self.children(child) { - if let Some(hc) = self.get_node(html_child) { - if hc.as_element().map(|name| name.local.as_ref() == "body").unwrap_or(false) { - return html_child; - } - } - } return child; } } diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index eac338e..440b7c9 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -431,7 +431,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str Some(name) => obscura_dom::parse_fragment_with_context(&arg2, name), None => obscura_dom::parse_fragment(&arg2), }; - let import_root = fragment.find_body_or_root(); + let import_root = fragment.fragment_root(); dom.import_children_from(target, &fragment, import_root); } "true".into() diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 92a900d..d2ded4d 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2142,6 +2142,38 @@ mod tests { ); } + /// Setting innerHTML on the element parses in the "before head" + /// insertion mode, which synthesizes head and body. The importer must keep + /// both; it previously returned the synthesized body and dropped the head + /// (so a /<meta> assigned this way vanished). + #[test] + fn documentelement_inner_html_keeps_head_and_body() { + let mut rt = setup_runtime("<html><head></head><body></body></html>"); + let v = rt + .evaluate( + "(function(){ document.documentElement.innerHTML = '<head><title>T

hi

'; \ + var t = document.querySelector('title'); var p = document.querySelector('p'); \ + return (t ? t.textContent : 'no-title') + '|' + (p ? p.textContent : 'no-p'); })()", + ) + .unwrap(); + assert_eq!(v, serde_json::json!("T|hi")); + } + + /// Regression guard: innerHTML on an ordinary element still imports the + /// parsed nodes directly (no head/body is synthesized for a div context), + /// so the fix above must not change the common case. + #[test] + fn ordinary_element_inner_html_imports_content_directly() { + let mut rt = setup_runtime("
"); + let v = rt + .evaluate( + "(function(){ var d=document.getElementById('d'); d.innerHTML='ab'; \ + return d.children.length + '|' + d.textContent; })()", + ) + .unwrap(); + assert_eq!(v, serde_json::json!("2|ab")); + } + /// Issue #463: the same must hold for a template that arrives via innerHTML /// rather than the initial document parse — that is how most frameworks /// inject templates.