From 2840b08b5690f83cb8a12e915fb3bb0a2a55baf8 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:48:20 +0000 Subject: [PATCH] dom: support :has() selectors (#331) querySelectorAll / --selector silently returned nothing for :has(). Two fixes were needed: - Enable :has() parsing: the selectors crate gates it on the parser's parse_has() (default false), so a:has(p) failed to parse and the error was swallowed into an empty match set. - Fix OpaqueElement stability: opaque() was keyed off the transient DomElement (which is Copy and gets a fresh stack address each traversal step), so the same node produced a different id every call. :has anchor matching compares the anchor's opaque against the element reached by walking up the tree, so it never matched. Key opaque() off the node's stable slot in the tree's Vec instead. --- crates/obscura-dom/src/selector.rs | 37 +++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index 8ede02f..4c5acf4 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -167,6 +167,15 @@ impl<'i> parser::Parser<'i> for ObscuraSelectorParser { type Impl = ObscuraSelector; type Error = SelectorParseErrorKind<'i>; + // Allow `:has()`. The selectors crate gates relative-selector parsing on + // this (default false), so without it `a:has(p)` failed to parse and the + // error was swallowed into an empty match set. Matching already passes a + // SelectorCaches (which holds the relative-selector cache), so enabling + // parsing is sufficient. + fn parse_has(&self) -> bool { + true + } + fn parse_non_ts_pseudo_class( &self, _location: cssparser::SourceLocation, @@ -219,7 +228,18 @@ impl<'a> Element for DomElement<'a> { type Impl = ObscuraSelector; fn opaque(&self) -> OpaqueElement { - OpaqueElement::new(self) + // Must be stable per node. DomElement is Copy and gets a fresh stack + // address on every traversal step, so OpaqueElement::new(self) returns a + // different id for the same node each call. That breaks `:has` anchor + // matching, which compares the anchor's opaque against the element + // reached by walking up the tree. Key off the node's stable slot in the + // tree's Vec instead (OpaqueElement only compares the address, never + // dereferences it, and the Vec is not mutated during a query). + let inner = self.tree.borrow_inner(); + match inner.nodes.get(self.node_id.index()) { + Some(slot) => OpaqueElement::new(slot), + None => OpaqueElement::new(self), + } } fn parent_element(&self) -> Option { @@ -666,6 +686,21 @@ mod tests { assert_eq!(results.len(), 3); } + #[test] + fn test_has_parses() { + // Isolate parse from match: :has must parse, not error. + assert!(super::parse_selector("a:has(p.bt)").is_ok(), ":has failed to parse"); + } + + #[test] + fn test_query_selector_has() { + let tree = parse_html(r#"

x

"#); + let all = tree.query_selector_all("a:has(p.bt)").unwrap(); + assert_eq!(all.len(), 1, "a:has(p.bt) should match the "); + let none = tree.query_selector_all("a:has(span.bt)").unwrap(); + assert_eq!(none.len(), 0, "a:has(span.bt) should match nothing"); + } + #[test] fn test_query_selector_descendant() { let tree =