From d0d8617ca6f91826058339d6309d79592a094050 Mon Sep 17 00:00:00 2001 From: Andrey Mnatsakanov Date: Tue, 4 Aug 2026 17:41:57 +0200 Subject: [PATCH] fix(dom): keep content when setting documentElement.innerHTML Setting innerHTML on the element parses the fragment in its own context (added in a2fd4d1), which runs html5ever's "before head" insertion mode and synthesizes both and under the fragment root. The importer's helper walked into the root and, if it found a child, returned that body -- so import_children_from imported only the body's children and any assigned content (, <meta>, <link>) was silently dropped. A <body> child of the fragment root only appears in this <html>-context case; every other element context leaves parsed content directly under the root and already fell through to returning it. Rename find_body_or_root -> fragment_root and return the root <html> element so both head and body children import. Behavior for all non-<html> contexts is unchanged. Fixes #556 --- crates/obscura-dom/src/tree.rs | 20 ++++++++++++-------- crates/obscura-js/src/ops.rs | 2 +- crates/obscura-js/src/runtime.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) 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 `<html>` 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 `<body>`. A `<body>` child of the + /// root only appears when the fragment is parsed in the `<html>` context + /// (documentElement.innerHTML), where html5ever's "before head" mode + /// synthesizes both `<head>` and `<body>`; 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 03a12fc..b5dcb26 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2122,6 +2122,38 @@ mod tests { ); } + /// Setting innerHTML on the <html> 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 <title>/<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.