LibWeb: Reduce over-invalidation for :has() selectors

Feature-filter :not() arguments in :has() when the same compound also
has a concrete tag, id, class, or attribute selector. Bare negations
still stay conservative, but anchored negations no longer make unrelated
subtree mutations walk every :has() candidate.

Also avoid installing the whole-subtree :has() fallback for rightmost
complex :is()/:where() arguments that only use descendant or child
combinators. Keep the fallback for non-rightmost and sibling-combinator
cases where the existing plan cannot represent the nested selector
context.

Add counter-based style invalidation coverage for both cases.
This commit is contained in:
Andreas Kling 2026-06-11 16:39:59 +02:00 committed by Andreas Kling
parent 4c87227078
commit baaaea6cf1
6 changed files with 108 additions and 14 deletions

View file

@ -138,6 +138,22 @@ static bool selector_may_match_mutation_features(Selector const& selector, Pendi
};
for (auto const& compound_selector : selector.compound_selectors()) {
bool compound_has_positive_concrete_feature = false;
for (auto const& simple_selector : compound_selector.simple_selectors) {
switch (simple_selector.type) {
case Selector::SimpleSelector::Type::TagName:
case Selector::SimpleSelector::Type::Id:
case Selector::SimpleSelector::Type::Class:
case Selector::SimpleSelector::Type::Attribute:
compound_has_positive_concrete_feature = true;
break;
default:
break;
}
if (compound_has_positive_concrete_feature)
break;
}
if ((compound_selector.combinator == Selector::Combinator::NextSibling
|| compound_selector.combinator == Selector::Combinator::SubsequentSibling)
&& mutation_features.may_affect_sibling_relationships)
@ -228,6 +244,15 @@ static bool selector_may_match_mutation_features(Selector const& selector, Pendi
|| mutation_features.pseudo_classes.contains(pseudo_class.type);
break;
case PseudoClass::Not:
// A bare negation can match because any unrelated node exists, but a negation
// attached to a positive concrete feature only changes when either side changes.
if (!compound_has_positive_concrete_feature) {
must_be_conservative = true;
break;
}
saw_concrete_feature = true;
concrete_feature_found_in_mutation_subtree |= visit_selector_list(pseudo_class.argument_selector_list);
break;
case PseudoClass::Has:
default:
must_be_conservative = true;

View file

@ -12,6 +12,11 @@
namespace Web::CSS {
enum class SimpleSelectorGroupPosition {
Rightmost,
NonRightmost,
};
static void append_or_merge_descendant_rule(Vector<DescendantInvalidationRule>& rules, DescendantInvalidationRule const& rule)
{
for (auto& existing_rule : rules) {
@ -376,6 +381,17 @@ static bool selector_contains_featureless_subtree_sensitive_selector(Selector co
return false;
}
static bool selector_contains_sibling_combinator(Selector const& selector)
{
for (auto const& compound_selector : selector.compound_selectors()) {
if (compound_selector.combinator == Selector::Combinator::NextSibling
|| compound_selector.combinator == Selector::Combinator::SubsequentSibling) {
return true;
}
}
return false;
}
static InvalidationSet build_invalidation_sets_for_selector_impl(StyleInvalidationData& style_invalidation_data, Selector const& selector, InsideNthChildPseudoClass inside_nth_child_pseudo_class, InvalidationPlan const& root_invalidation_plan);
static void add_invalidation_sets_to_cover_scope_leakage_of_relative_selector_in_has_pseudo_class(Selector const& selector, StyleInvalidationData& style_invalidation_data);
@ -386,7 +402,7 @@ static bool should_register_invalidation_property(InvalidationSet::Property cons
}
static void collect_guard_properties_for_simple_selector(Selector::SimpleSelector const&, InvalidationSet&);
static void build_invalidation_sets_for_simple_selector_impl(Selector::SimpleSelector const&, InvalidationSet&, ExcludePropertiesNestedInNotPseudoClass, StyleInvalidationData&, InsideNthChildPseudoClass, InvalidationPlan const&);
static void build_invalidation_sets_for_simple_selector_impl(Selector::SimpleSelector const&, InvalidationSet&, ExcludePropertiesNestedInNotPseudoClass, StyleInvalidationData&, InsideNthChildPseudoClass, SimpleSelectorGroupPosition, InvalidationPlan const&);
static Optional<InvalidationSet> build_invalidation_guard_property_set_for_selector_subject(Selector const& selector)
{
@ -447,11 +463,11 @@ static InvalidationGuard build_invalidation_guard_for_simple_selectors(Vector<Se
return guard;
}
static InvalidationSet build_invalidation_set_for_simple_selectors(Vector<Selector::SimpleSelector const&> const& simple_selectors, ExcludePropertiesNestedInNotPseudoClass exclude_properties_nested_in_not_pseudo_class, StyleInvalidationData& style_invalidation_data, InsideNthChildPseudoClass inside_nth_child_pseudo_class, InvalidationPlan const& root_invalidation_plan)
static InvalidationSet build_invalidation_set_for_simple_selectors(Vector<Selector::SimpleSelector const&> const& simple_selectors, ExcludePropertiesNestedInNotPseudoClass exclude_properties_nested_in_not_pseudo_class, StyleInvalidationData& style_invalidation_data, InsideNthChildPseudoClass inside_nth_child_pseudo_class, SimpleSelectorGroupPosition simple_selector_group_position, InvalidationPlan const& root_invalidation_plan)
{
InvalidationSet invalidation_set;
for (auto const& simple_selector : simple_selectors)
build_invalidation_sets_for_simple_selector_impl(simple_selector, invalidation_set, exclude_properties_nested_in_not_pseudo_class, style_invalidation_data, inside_nth_child_pseudo_class, root_invalidation_plan);
build_invalidation_sets_for_simple_selector_impl(simple_selector, invalidation_set, exclude_properties_nested_in_not_pseudo_class, style_invalidation_data, inside_nth_child_pseudo_class, simple_selector_group_position, root_invalidation_plan);
return invalidation_set;
}
@ -536,7 +552,7 @@ static NonnullRefPtr<InvalidationPlan> build_invalidation_for_combinator(Selecto
return invalidation;
}
static void build_invalidation_sets_for_simple_selector_impl(Selector::SimpleSelector const& selector, InvalidationSet& invalidation_set, ExcludePropertiesNestedInNotPseudoClass exclude_properties_nested_in_not_pseudo_class, StyleInvalidationData& style_invalidation_data, InsideNthChildPseudoClass inside_nth_child_selector, InvalidationPlan const& root_invalidation_plan)
static void build_invalidation_sets_for_simple_selector_impl(Selector::SimpleSelector const& selector, InvalidationSet& invalidation_set, ExcludePropertiesNestedInNotPseudoClass exclude_properties_nested_in_not_pseudo_class, StyleInvalidationData& style_invalidation_data, InsideNthChildPseudoClass inside_nth_child_selector, SimpleSelectorGroupPosition simple_selector_group_position, InvalidationPlan const& root_invalidation_plan)
{
switch (selector.type) {
case Selector::SimpleSelector::Type::Class:
@ -612,13 +628,15 @@ static void build_invalidation_sets_for_simple_selector_impl(Selector::SimpleSel
// non-rightmost positions (e.g., :is(:has(.x) .y)) is not propagated. We need it in the
// outer invalidation set so outer compounds register plans for pseudo_class:Has that
// account for the full selector context.
// Additionally, when :has() is inside a complex :is()/:where() argument (multiple
// compounds), the outer invalidation plan can't correctly capture the nested combinator
// structure — e.g., sibling combinators at the outer level would be applied at the wrong
// DOM level. Fall back to whole-subtree invalidation for :has() in these cases.
// Additionally, when :has() is inside a complex :is()/:where() argument in a
// non-rightmost compound, or in one that contains sibling combinators, the outer
// invalidation plan can't correctly capture the nested combinator structure. Fall back
// to whole-subtree invalidation for :has() in these cases. Descendant and child
// combinators in the rightmost compound are represented by the nested selector's own
// invalidation plan above.
if (nested_selector->contains_pseudo_class(PseudoClass::Has)) {
invalidation_set.set_needs_invalidate_pseudo_class(PseudoClass::Has);
if (nested_selector->compound_selectors().size() > 1) {
if (nested_selector->compound_selectors().size() > 1 && (simple_selector_group_position == SimpleSelectorGroupPosition::NonRightmost || selector_contains_sibling_combinator(*nested_selector))) {
InvalidationSet has_only;
has_only.set_needs_invalidate_pseudo_class(PseudoClass::Has);
add_invalidation_plan_for_properties(style_invalidation_data, has_only, *make_invalidate_whole_subtree_invalidation());
@ -634,7 +652,7 @@ static void build_invalidation_sets_for_simple_selector_impl(Selector::SimpleSel
if (pseudo_element.type() == PseudoElement::Slotted) {
for (auto const& compound_selector : pseudo_element.compound_selector().compound_selectors()) {
for (auto const& nested_simple : compound_selector.simple_selectors)
build_invalidation_sets_for_simple_selector_impl(nested_simple, invalidation_set, exclude_properties_nested_in_not_pseudo_class, style_invalidation_data, inside_nth_child_selector, root_invalidation_plan);
build_invalidation_sets_for_simple_selector_impl(nested_simple, invalidation_set, exclude_properties_nested_in_not_pseudo_class, style_invalidation_data, inside_nth_child_selector, simple_selector_group_position, root_invalidation_plan);
}
}
break;
@ -646,7 +664,7 @@ static void build_invalidation_sets_for_simple_selector_impl(Selector::SimpleSel
void build_invalidation_sets_for_simple_selector(Selector::SimpleSelector const& selector, InvalidationSet& invalidation_set, ExcludePropertiesNestedInNotPseudoClass exclude_properties_nested_in_not_pseudo_class, StyleInvalidationData& style_invalidation_data, InsideNthChildPseudoClass inside_nth_child_selector)
{
build_invalidation_sets_for_simple_selector_impl(selector, invalidation_set, exclude_properties_nested_in_not_pseudo_class, style_invalidation_data, inside_nth_child_selector, *make_invalidate_self_invalidation());
build_invalidation_sets_for_simple_selector_impl(selector, invalidation_set, exclude_properties_nested_in_not_pseudo_class, style_invalidation_data, inside_nth_child_selector, SimpleSelectorGroupPosition::Rightmost, *make_invalidate_self_invalidation());
}
static void add_invalidation_sets_to_cover_scope_leakage_of_relative_selector_in_has_pseudo_class(Selector const& selector, StyleInvalidationData& style_invalidation_data)
@ -662,7 +680,7 @@ static void add_invalidation_sets_to_cover_scope_leakage_of_relative_selector_in
if (rightmost)
return;
auto invalidation_set = build_invalidation_set_for_simple_selectors(simple_selectors, ExcludePropertiesNestedInNotPseudoClass::No, style_invalidation_data, InsideNthChildPseudoClass::No, *make_invalidate_self_invalidation());
auto invalidation_set = build_invalidation_set_for_simple_selectors(simple_selectors, ExcludePropertiesNestedInNotPseudoClass::No, style_invalidation_data, InsideNthChildPseudoClass::No, SimpleSelectorGroupPosition::Rightmost, *make_invalidate_self_invalidation());
add_invalidation_plan_for_properties(style_invalidation_data, invalidation_set, *make_invalidate_whole_subtree_invalidation());
});
};
@ -696,8 +714,9 @@ static InvalidationSet build_invalidation_sets_for_selector_impl(StyleInvalidati
collect_properties_used_in_has(simple_selector, style_invalidation_data, {});
}
auto invalidation_properties = build_invalidation_set_for_simple_selectors(simple_selectors, ExcludePropertiesNestedInNotPseudoClass::No, style_invalidation_data, inside_nth_child_pseudo_class, root_invalidation_plan);
auto subject_match_set = build_invalidation_set_for_simple_selectors(simple_selectors, ExcludePropertiesNestedInNotPseudoClass::Yes, style_invalidation_data, inside_nth_child_pseudo_class, root_invalidation_plan);
auto simple_selector_group_position = is_rightmost ? SimpleSelectorGroupPosition::Rightmost : SimpleSelectorGroupPosition::NonRightmost;
auto invalidation_properties = build_invalidation_set_for_simple_selectors(simple_selectors, ExcludePropertiesNestedInNotPseudoClass::No, style_invalidation_data, inside_nth_child_pseudo_class, simple_selector_group_position, root_invalidation_plan);
auto subject_match_set = build_invalidation_set_for_simple_selectors(simple_selectors, ExcludePropertiesNestedInNotPseudoClass::Yes, style_invalidation_data, inside_nth_child_pseudo_class, simple_selector_group_position, root_invalidation_plan);
auto subject_guard = build_invalidation_guard_for_simple_selectors(simple_selectors);
bool subject_matches_any = subject_match_set.is_empty() && simple_selector_group_matches_any(simple_selectors);

View file

@ -0,0 +1 @@
PASS: descendant-only complex :has() keeps later :has() invalidation targeted | styleInvalidations=2, fullStyleInvalidations=0, elementStyleRecomputations=2, elementStyleNoopRecomputations=1, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=1, hasInvalidationMetadataCandidates=1, hasMatchInvocations=1, hasResultCacheHits=0, hasResultCacheMisses=1

View file

@ -6,6 +6,8 @@ PASS: actual mutation root skips unrelated :has anchor: id argument | styleInval
PASS: actual mutation root keeps related :has anchor: id argument | styleInvalidations=2, fullStyleInvalidations=0, elementStyleRecomputations=5, elementStyleNoopRecomputations=2, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=1, hasInvalidationMetadataCandidates=1, hasMatchInvocations=4, hasResultCacheHits=0, hasResultCacheMisses=4
PASS: actual mutation root skips unrelated :has anchor: tag argument | styleInvalidations=2, fullStyleInvalidations=0, elementStyleRecomputations=2, elementStyleNoopRecomputations=2, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=0, hasInvalidationMetadataCandidates=0, hasMatchInvocations=1, hasResultCacheHits=0, hasResultCacheMisses=1
PASS: actual mutation root keeps related :has anchor: tag argument | styleInvalidations=2, fullStyleInvalidations=0, elementStyleRecomputations=5, elementStyleNoopRecomputations=2, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=1, hasInvalidationMetadataCandidates=0, hasMatchInvocations=4, hasResultCacheHits=0, hasResultCacheMisses=4
PASS: actual mutation root skips unrelated :has anchor: negation argument with positive tag | styleInvalidations=0, fullStyleInvalidations=0, elementStyleRecomputations=0, elementStyleNoopRecomputations=0, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=0, hasInvalidationMetadataCandidates=0, hasMatchInvocations=0, hasResultCacheHits=0, hasResultCacheMisses=0
PASS: actual mutation root keeps related :has anchor: negation argument with positive tag | styleInvalidations=2, fullStyleInvalidations=0, elementStyleRecomputations=4, elementStyleNoopRecomputations=2, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=1, hasInvalidationMetadataCandidates=0, hasMatchInvocations=4, hasResultCacheHits=0, hasResultCacheMisses=4
PASS: actual mutation root skips unrelated :has anchor: child combinator argument | styleInvalidations=2, fullStyleInvalidations=0, elementStyleRecomputations=3, elementStyleNoopRecomputations=2, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=1, hasInvalidationMetadataCandidates=1, hasMatchInvocations=1, hasResultCacheHits=0, hasResultCacheMisses=1
PASS: actual mutation root keeps related :has anchor: child combinator argument | styleInvalidations=2, fullStyleInvalidations=0, elementStyleRecomputations=4, elementStyleNoopRecomputations=2, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=1, hasInvalidationMetadataCandidates=1, hasMatchInvocations=4, hasResultCacheHits=0, hasResultCacheMisses=4
PASS: actual mutation root skips unrelated :has anchor: descendant combinator argument | styleInvalidations=3, fullStyleInvalidations=0, elementStyleRecomputations=4, elementStyleNoopRecomputations=2, elementInheritedStyleRecomputations=0, elementInheritedStyleNoopRecomputations=0, hasAncestorWalkInvocations=1, hasInvalidationMetadataCandidates=2, hasMatchInvocations=1, hasResultCacheHits=0, hasResultCacheMisses=1

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<meta charset="utf-8">
<script src="../../include.js"></script>
<script src="structural-matrix.js"></script>
<style>
:is(:where(.group):has(.flag) *) {
--poison-probe: match;
}
html:has(.toggle) .target {
color: rgb(1, 2, 3);
}
.target {
color: rgb(4, 5, 6);
}
</style>
<section id="fixture">
<div id="toggle"></div>
<div class="target"></div>
</section>
<script>
test(() => {
const fixture = document.getElementById("fixture");
for (let i = 0; i < 80; ++i)
fixture.appendChild(makeElement("span", { className: "filler" }));
const target = document.querySelector(".target");
internals.updateStyle();
assertEqual("target initially has base color", getComputedStyle(target).color, "rgb(4, 5, 6)");
resetStyleCounters();
document.getElementById("toggle").classList.add("toggle");
internals.updateStyle();
assertEqual("target matches after :has() mutation", getComputedStyle(target).color, "rgb(1, 2, 3)");
const counters = styleCounters();
assertEqual("descendant-only complex :has() does not force whole-subtree no-op recomputes", counters.elementStyleNoopRecomputations < 10, true);
printPassWithCounters("descendant-only complex :has() keeps later :has() invalidation targeted");
});
</script>

View file

@ -57,6 +57,12 @@
({ fixture }) => fixture.appendChild(makeElement("strong")),
({ subgrid }) => subgrid.appendChild(makeElement("em"))
);
runMutationRootCase(
"negation argument with positive tag",
`div:not([role="group"])`,
({ subgrid }) => subgrid.classList.add("unrelated"),
({ subgrid }) => subgrid.appendChild(makeElement("div"))
);
runMutationRootCase(
"child combinator argument",
`> .needle`,