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 `` element.
+ if let Some(pos) = self
+ .open_elements
+ .iter()
+ .rposition(|&id| self.document.shadow_host(id).is_some())
+ {
+ self.open_elements.truncate(pos);
+ } else {
+ self.handle_any_other_end_tag(name);
+ }
+ }
Token::EndTag { ref name } => {
// Generic end tag: walk back through open elements.
self.handle_any_other_end_tag(name);
@@ -646,6 +663,45 @@ impl TreeBuilder {
}
}
+ /// Handle a `` start tag.
+ ///
+ /// A `` carrying `shadowrootmode="open|closed"` (or the legacy
+ /// `shadowroot="open|closed"`) is a *declarative shadow root*: instead of
+ /// inserting a `` element into the light tree, attach a shadow
+ /// root to the current (host) element and direct subsequent insertions into
+ /// the shadow fragment. Any other `` is inserted as an ordinary
+ /// element.
+ fn handle_template_start(&mut self, token: &Token) {
+ let shadow_mode = if let Token::StartTag { attributes, .. } = token {
+ attributes
+ .iter()
+ .find(|(n, _)| n == "shadowrootmode" || n == "shadowroot")
+ .map(|(_, v)| v.trim().to_ascii_lowercase())
+ .filter(|v| v == "open" || v == "closed")
+ } else {
+ None
+ };
+
+ if shadow_mode.is_some() {
+ if let Some(&host) = self.open_elements.last() {
+ if self.document.tag_name(host).is_some()
+ && self.document.shadow_root(host).is_none()
+ {
+ let root = self.document.attach_shadow_root(host);
+ // Subsequent inserts target the shadow fragment; the
+ // matching pops it back off the stack.
+ self.open_elements.push(root);
+ return;
+ }
+ }
+ }
+
+ // Ordinary : behave like a generic element.
+ let elem = self.create_element_from_token(token);
+ self.insert_node(elem);
+ self.open_elements.push(elem);
+ }
+
/// Handle tokens inside a `