LibWeb: Delay generic :has() sibling scans until sibling roots

Mark elements reached by stepping through sibling combinators inside
:has() and use that breadcrumb during generic invalidation walks.

Keep the existing conservative sibling scans for mutations outside
those marked subtrees so nested :is(), :not(), and nesting cases
continue to invalidate correctly.

Also keep :has() eager within compounds that contain ::part(). Those
selectors retarget the remaining simple selectors to the part host, so
deferring :has() there changes which element the pseudo-class runs
against and can make ::part(foo):has(.match) spuriously match.

Add a counter-based sibling-scan test and a regression test covering
the ::part()/ :has() selector orderings.
This commit is contained in:
Andreas Kling 2026-04-18 23:38:57 +02:00 committed by Alexander Kalenik
parent 85ff13870f
commit 7a5b1d9de1
7 changed files with 118 additions and 12 deletions

View file

@ -297,8 +297,10 @@ static inline bool matches_relative_selector(CSS::Selector const& selector, size
auto* sibling = element.next_element_sibling();
if (!sibling)
return false;
if (context.inside_has_argument_match && context.collect_per_element_selector_involvement_metadata)
if (context.inside_has_argument_match && context.collect_per_element_selector_involvement_metadata) {
const_cast<DOM::Element&>(*sibling).set_in_has_scope(true);
const_cast<DOM::Element&>(*sibling).set_in_subtree_of_has_pseudo_class_relative_selector_with_sibling_combinator(true);
}
if (!matches_compound_selector(selector, compound_index, *sibling, shadow_host, context, scope, SelectorKind::Relative, anchor))
return false;
return matches_relative_selector(selector, compound_index + 1, *sibling, shadow_host, context, anchor, scope);
@ -308,8 +310,10 @@ static inline bool matches_relative_selector(CSS::Selector const& selector, size
const_cast<DOM::Element&>(*anchor).set_affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator(true);
}
for (auto const* sibling = element.next_element_sibling(); sibling; sibling = sibling->next_element_sibling()) {
if (context.inside_has_argument_match && context.collect_per_element_selector_involvement_metadata)
if (context.inside_has_argument_match && context.collect_per_element_selector_involvement_metadata) {
const_cast<DOM::Element&>(*sibling).set_in_has_scope(true);
const_cast<DOM::Element&>(*sibling).set_in_subtree_of_has_pseudo_class_relative_selector_with_sibling_combinator(true);
}
if (!matches_compound_selector(selector, compound_index, *sibling, shadow_host, context, scope, SelectorKind::Relative, anchor))
continue;
if (matches_relative_selector(selector, compound_index + 1, *sibling, shadow_host, context, anchor, scope))
@ -1513,10 +1517,19 @@ bool matches_compound_selector(CSS::Selector const& selector, int component_list
return s.type == CSS::Selector::SimpleSelector::Type::PseudoClass
&& s.pseudo_class().type == CSS::PseudoClass::Has;
};
bool has_part_pseudo_element = false;
for (auto const& simple_selector : compound_selector.simple_selectors) {
if (simple_selector.type == CSS::Selector::SimpleSelector::Type::PseudoElement
&& simple_selector.pseudo_element().type() == CSS::PseudoElement::Part) {
has_part_pseudo_element = true;
break;
}
}
auto defer_has_pseudo_class = !has_part_pseudo_element;
auto element_for_compound_matching { target };
for (auto& simple_selector : compound_selector.simple_selectors.in_reverse()) {
if (is_has_pseudo_class(simple_selector))
if (defer_has_pseudo_class && is_has_pseudo_class(simple_selector))
continue;
if (!matches_simple_selector(simple_selector, element_for_compound_matching, shadow_host, context, scope, selector_kind, anchor)) {
return false;
@ -1540,11 +1553,13 @@ bool matches_compound_selector(CSS::Selector const& selector, int component_list
}
}
}
for (auto& simple_selector : compound_selector.simple_selectors.in_reverse()) {
if (!is_has_pseudo_class(simple_selector))
continue;
if (!matches_simple_selector(simple_selector, element_for_compound_matching, shadow_host, context, scope, selector_kind, anchor)) {
return false;
if (defer_has_pseudo_class) {
for (auto& simple_selector : compound_selector.simple_selectors.in_reverse()) {
if (!is_has_pseudo_class(simple_selector))
continue;
if (!matches_simple_selector(simple_selector, element_for_compound_matching, shadow_host, context, scope, selector_kind, anchor)) {
return false;
}
}
}
auto const& element = element_for_compound_matching;

View file

@ -849,10 +849,17 @@ void StyleScope::invalidate_style_of_elements_affected_by_has()
|| element.affected_by_has_pseudo_class_in_non_subject_position();
};
auto is_in_subtree_of_has_relative_selector_with_sibling_combinator = [](DOM::Element const& element) {
return element.in_subtree_of_has_pseudo_class_relative_selector_with_sibling_combinator()
|| element.affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator();
};
HashTable<DOM::Element*> elements_already_invalidated_for_has;
auto nodes = move(m_pending_nodes_for_style_invalidation_due_to_presence_of_has);
bool should_scan_ancestor_siblings = have_has_selectors_with_relative_selector_that_has_sibling_combinator();
for (auto& node : nodes) {
Vector<DOM::Element*, 16> has_scope_ancestors;
bool should_delay_ancestor_sibling_scans = false;
for (auto* ancestor = &node; ancestor; ancestor = ancestor->parent_or_shadow_host()) {
if (!ancestor->is_element())
continue;
@ -864,13 +871,20 @@ void StyleScope::invalidate_style_of_elements_affected_by_has()
if (!is_in_has_scope(element))
break;
if (elements_already_invalidated_for_has.set(&element) != AK::HashSetResult::InsertedNewEntry)
break;
has_scope_ancestors.append(&element);
should_delay_ancestor_sibling_scans |= is_in_subtree_of_has_relative_selector_with_sibling_combinator(element);
}
for (auto* element : has_scope_ancestors) {
VERIFY(element);
if (elements_already_invalidated_for_has.set(element) != AK::HashSetResult::InsertedNewEntry)
continue;
++counters.has_ancestor_walk_visits;
element.invalidate_style_if_affected_by_has();
element->invalidate_style_if_affected_by_has();
auto* parent = ancestor->parent_or_shadow_host();
auto* parent = element->parent_or_shadow_host();
if (!parent)
return;
@ -878,6 +892,8 @@ void StyleScope::invalidate_style_of_elements_affected_by_has()
// its style might be affected by the change in descendant node.
if (!should_scan_ancestor_siblings)
continue;
if (should_delay_ancestor_sibling_scans && !is_in_subtree_of_has_relative_selector_with_sibling_combinator(*element))
continue;
parent->for_each_child_of_type<DOM::Element>([&](auto& ancestor_sibling_element) {
++counters.has_ancestor_sibling_element_checks;
if (ancestor_sibling_element.affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator()) {

View file

@ -509,6 +509,12 @@ public:
bool affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator() const { return m_affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator; }
void set_affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator(bool value) { m_affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator = value; }
// Set on any element reached by stepping through a + or ~ combinator while
// matching a :has() argument. Lets generic invalidation defer ancestor
// sibling scans until it reaches the sibling subtree root. Write-once,
// intentionally never cleared.
bool in_subtree_of_has_pseudo_class_relative_selector_with_sibling_combinator() const { return m_in_subtree_of_has_pseudo_class_relative_selector_with_sibling_combinator; }
void set_in_subtree_of_has_pseudo_class_relative_selector_with_sibling_combinator(bool value) { m_in_subtree_of_has_pseudo_class_relative_selector_with_sibling_combinator = value; }
// Set on any element that was traversed during matching of a :has() argument
// selector (i.e. the descendant/child/sibling walk inside :has()). Lets the
@ -714,6 +720,7 @@ private:
bool m_affected_by_forward_positional_pseudo_class : 1 { false };
bool m_affected_by_backward_positional_pseudo_class : 1 { false };
bool m_affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator : 1 { false };
bool m_in_subtree_of_has_pseudo_class_relative_selector_with_sibling_combinator : 1 { false };
bool m_in_has_scope : 1 { false };
bool m_fullscreen_flag : 1 { false };

View file

@ -0,0 +1,7 @@
hasAncestorWalkInvocations: 1
hasAncestorWalkVisits: 5
hasAncestorSiblingElementChecks: 3
hasMatchInvocations: 1
hasResultCacheHits: 0
hasResultCacheMisses: 1
styleInvalidations: 1

View file

@ -0,0 +1,2 @@
rgba(0, 0, 0, 0)
rgb(0, 128, 0)

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
.anchor:has(+ .wrapper .match) { color: red; }
</style>
<div id="outer">
<div id="anchor" class="anchor">anchor</div>
<div class="wrapper">
<div>
<div>
<span id="target">target</span>
</div>
</div>
</div>
<div>unrelated sibling</div>
</div>
<script>
function printCounters() {
let counters = internals.getStyleInvalidationCounters();
println(`hasAncestorWalkInvocations: ${counters.hasAncestorWalkInvocations}`);
println(`hasAncestorWalkVisits: ${counters.hasAncestorWalkVisits}`);
println(`hasAncestorSiblingElementChecks: ${counters.hasAncestorSiblingElementChecks}`);
println(`hasMatchInvocations: ${counters.hasMatchInvocations}`);
println(`hasResultCacheHits: ${counters.hasResultCacheHits}`);
println(`hasResultCacheMisses: ${counters.hasResultCacheMisses}`);
println(`styleInvalidations: ${counters.styleInvalidations}`);
}
test(() => {
let anchor = document.getElementById("anchor");
let target = document.getElementById("target");
getComputedStyle(anchor).color;
internals.resetStyleInvalidationCounters();
target.classList.add("match");
getComputedStyle(anchor).color;
printCounters();
});
</script>

View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<style>
#host::part(p):has(.match) { background-color: red; }
#host:has(.match)::part(p) { color: green; }
</style>
<div id="host">
<span class="match"></span>
</div>
<script src="../include.js"></script>
<script>
test(() => {
const host = document.getElementById("host");
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = `<span id="inner" part="p">text</span>`;
const inner = shadow.getElementById("inner");
println(getComputedStyle(inner).backgroundColor);
println(getComputedStyle(inner).color);
});
</script>