diff --git a/.isu/issues.json b/.isu/issues.json index d5059ae..7ead1f1 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -4254,7 +4254,7 @@ ], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-06-04T19:05:09Z" } ] diff --git a/crates/dom/src/lib.rs b/crates/dom/src/lib.rs index 762c77d..4b18770 100644 --- a/crates/dom/src/lib.rs +++ b/crates/dom/src/lib.rs @@ -128,6 +128,13 @@ pub struct Document { /// Decoded RGBA8 pixel data for `` elements, keyed by NodeId. /// Populated by the resource loader after image decoding. image_buffers: HashMap, u32, u32)>, + /// Shadow roots keyed by host element. The value is a `DocumentFragment` + /// node that holds the shadow tree; it is *not* a light-DOM child of the + /// host (so `children()` never returns it), but its own children carry a + /// parent pointer back to it for tree walks within the shadow tree. + shadow_roots: HashMap, + /// Reverse map: shadow-root fragment -> host element. + shadow_hosts: HashMap, } impl fmt::Debug for Document { @@ -165,9 +172,38 @@ impl Document { canvas_sizes: HashMap::new(), canvas_contexts: CanvasContextStore::new(), image_buffers: HashMap::new(), + shadow_roots: HashMap::new(), + shadow_hosts: HashMap::new(), } } + /// Attach a shadow root to `host` and return the shadow-root fragment node. + /// + /// The fragment is created detached from the light tree: it is recorded in + /// the host→root and root→host maps but never appended as a light-DOM + /// child, so `children(host)` does not return it. Content parsed into the + /// shadow tree is appended to this fragment. If `host` already has a shadow + /// root, the existing one is returned unchanged. + pub fn attach_shadow_root(&mut self, host: NodeId) -> NodeId { + if let Some(&existing) = self.shadow_roots.get(&host) { + return existing; + } + let root = self.create_document_fragment(); + self.shadow_roots.insert(host, root); + self.shadow_hosts.insert(root, host); + root + } + + /// Returns the shadow-root fragment attached to `host`, if any. + pub fn shadow_root(&self, host: NodeId) -> Option { + self.shadow_roots.get(&host).copied() + } + + /// Returns the host element of a shadow-root fragment, if `node` is one. + pub fn shadow_host(&self, node: NodeId) -> Option { + self.shadow_hosts.get(&node).copied() + } + /// Returns the root document node ID. pub fn root(&self) -> NodeId { self.root @@ -2557,4 +2593,29 @@ mod tests { assert!(entries.contains(&(c1, (100, 50)))); assert!(entries.contains(&(c2, (200, 100)))); } + + #[test] + fn attach_shadow_root_maps_host_and_root() { + let mut doc = Document::new(); + let host = doc.create_element("my-bar"); + doc.append_child(doc.root(), host); + + assert!(doc.shadow_root(host).is_none()); + let root = doc.attach_shadow_root(host); + assert_eq!(doc.shadow_root(host), Some(root)); + assert_eq!(doc.shadow_host(root), Some(host)); + assert!(matches!(doc.node_data(root), NodeData::DocumentFragment)); + + // The shadow root is not a light-DOM child of the host. + assert_eq!(doc.children(host).count(), 0); + + // Content appended to the shadow root is not visible as a host child. + let p = doc.create_element("p"); + doc.append_child(root, p); + assert_eq!(doc.children(host).count(), 0); + assert_eq!(doc.children(root).next(), Some(p)); + + // Re-attaching returns the same root. + assert_eq!(doc.attach_shadow_root(host), root); + } } diff --git a/crates/e2e/scenarios/real-web/blocket.se.we b/crates/e2e/scenarios/real-web/blocket.se.we index 1fba656..50f362e 100644 --- a/crates/e2e/scenarios/real-web/blocket.se.we +++ b/crates/e2e/scenarios/real-web/blocket.se.we @@ -60,6 +60,11 @@ assert_link_at a[href="/23744090"] /23744090 # flex-shrink crushed the category-nav wrapper and the listing-card grid # overlapped it, stealing this click). assert_link_at [id=first-market-link] /recommerce/forsale/search +# A native click on the fixed-header "Logga in" link resolves to the login URL +# (regression guard for isu issue 348 — the finn-topbar header lives in a +# declarative shadow root; before declarative shadow DOM was rendered the header +# collapsed to a 0x0 box at the document origin and this link was unclickable). +assert_link_at [data-automation-id=profile-link] /auth/login assert_screenshot_matches real-web/blocket.se/desktop.png blocket.se.desktop.chromium.expected.png viewport 390 844 diff --git a/crates/e2e/src/dom_dump.rs b/crates/e2e/src/dom_dump.rs index eee5ea6..524ef5c 100644 --- a/crates/e2e/src/dom_dump.rs +++ b/crates/e2e/src/dom_dump.rs @@ -36,6 +36,17 @@ fn walk(doc: &Document, node: NodeId, depth: usize, out: &mut String) { } } out.push_str(">\n"); + // Descend into a declarative shadow root, if present, so shadow + // content appears in the dump (it is not a light-DOM child). + if let Some(root) = doc.shadow_root(node) { + for _ in 0..depth + 1 { + out.push_str(" "); + } + out.push_str("#shadow-root\n"); + for child in doc.children(root) { + walk(doc, child, depth + 2, out); + } + } } NodeData::Text { data } => { let trimmed = data.trim(); diff --git a/crates/html/src/tree_builder.rs b/crates/html/src/tree_builder.rs index 5d1e8a2..b7a0dd5 100644 --- a/crates/html/src/tree_builder.rs +++ b/crates/html/src/tree_builder.rs @@ -549,6 +549,9 @@ impl TreeBuilder { self.insert_node(elem); // Don't push void elements onto the stack. } + Token::StartTag { ref name, .. } if name == "template" => { + self.handle_template_start(&token); + } Token::StartTag { .. } => { // Generic start tag: create element and push onto stack. let elem = self.create_element_from_token(&token); @@ -636,6 +639,20 @@ impl TreeBuilder { } } } + Token::EndTag { ref name } if name == "template" => { + // If a declarative-shadow-root fragment is open, pop down to and + // including it. Otherwise fall back to generic end-tag handling + // for an ordinary `