LibWeb: Narrow :has() style invalidation to ancestor nodes

The current implementation of `:has()` style invalidation is divided
into two cases:
- When used in subject position (e.g., `.a:has(.b)`).
- When in a non-subject position (e.g., `.a > .b:has(.c)`).

This change focuses on improving the first case. For non-subject usage,
we still perform a full tree traversal and invalidate all elements
affected by the `:has()` pseudo-class invalidation set.

We already optimize subject `:has()` invalidations by limiting
invalidated elements to ones that were tested against `has()` selectors
during selector matching. However, selectors like `div:has(.a)`
currently cause every div element in the document to be invalidated.
By modifying the invalidation traversal to consider only ancestor nodes
(and, optionally, their siblings), we can drastically reduce the number
of invalidated elements for broad selectors like the example above.

On Discord, when scrolling through message history, this change allows
to reduce number of invalidated elements from ~1k to ~5.
This commit is contained in:
Aliaksandr Kalenik 2025-02-08 19:09:08 +01:00 committed by Andreas Kling
parent 7441aa34e4
commit e677ab1699
Notes: github-actions[bot] 2025-02-10 00:15:09 +00:00
7 changed files with 104 additions and 22 deletions

View file

@ -121,6 +121,9 @@ static inline bool matches_relative_selector(CSS::Selector const& selector, size
return has;
}
case CSS::Selector::Combinator::NextSibling: {
if (context.collect_per_element_selector_involvement_metadata) {
const_cast<DOM::Element&>(*anchor).set_affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator(true);
}
auto* sibling = element.next_element_sibling();
if (!sibling)
return false;
@ -129,6 +132,9 @@ static inline bool matches_relative_selector(CSS::Selector const& selector, size
return matches_relative_selector(selector, compound_index + 1, *sibling, shadow_host, context, anchor);
}
case CSS::Selector::Combinator::SubsequentSibling: {
if (context.collect_per_element_selector_involvement_metadata) {
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 (!matches(selector, compound_index, *sibling, shadow_host, context, {}, SelectorKind::Relative, anchor))
continue;

View file

@ -107,6 +107,8 @@ enum class InsideNthChildPseudoClass {
static InvalidationSet build_invalidation_sets_for_selector_impl(StyleInvalidationData& style_invalidation_data, Selector const& selector, InsideNthChildPseudoClass inside_nth_child_pseudo_class);
static void add_invalidation_sets_to_cover_scope_leakage_of_relative_selector_in_has_pseudo_class(Selector const& selector, StyleInvalidationData& style_invalidation_data);
static 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)
{
switch (selector.type) {
@ -130,7 +132,12 @@ static void build_invalidation_sets_for_simple_selector(Selector::SimpleSelector
case PseudoClass::Disabled:
case PseudoClass::PlaceholderShown:
case PseudoClass::Checked:
case PseudoClass::Has:
case PseudoClass::Has: {
for (auto const& nested_selector : pseudo_class.argument_selector_list) {
add_invalidation_sets_to_cover_scope_leakage_of_relative_selector_in_has_pseudo_class(*nested_selector, style_invalidation_data);
}
[[fallthrough]];
}
case PseudoClass::Link:
case PseudoClass::AnyLink:
case PseudoClass::LocalLink:
@ -158,6 +165,47 @@ static void build_invalidation_sets_for_simple_selector(Selector::SimpleSelector
}
}
static void add_invalidation_sets_to_cover_scope_leakage_of_relative_selector_in_has_pseudo_class(Selector const& selector, StyleInvalidationData& style_invalidation_data)
{
// Normally, :has() invalidation scope is limited to ancestors and ancestor siblings, however it could require
// descendants invalidation when :is() with complex selector is used inside :has() relative selector.
// For example ".a:has(:is(.b .c))" requires invalidation whenever "b" class is added or removed.
// To cover this case, we add descendant invalidation set that requires whole subtree invalidation for each
// property used in non-subject part of complex selector.
auto invalidate_whole_subtree_for_invalidation_properties_in_non_subject_part_of_complex_selector = [&](Selector const& selector) {
for_each_consecutive_simple_selector_group(selector, [&](Vector<Selector::SimpleSelector const&> const& simple_selectors, Selector::Combinator, bool rightmost) {
if (rightmost) {
return;
}
InvalidationSet invalidation_set;
for (auto const& simple_selector : simple_selectors) {
build_invalidation_sets_for_simple_selector(simple_selector, invalidation_set, ExcludePropertiesNestedInNotPseudoClass::No, style_invalidation_data, InsideNthChildPseudoClass::No);
}
invalidation_set.for_each_property([&](auto const& invalidation_property) {
auto& descendant_invalidation_set = style_invalidation_data.descendant_invalidation_sets.ensure(invalidation_property, [] { return InvalidationSet {}; });
descendant_invalidation_set.set_needs_invalidate_whole_subtree();
return IterationDecision::Continue;
});
});
};
for_each_consecutive_simple_selector_group(selector, [&](Vector<Selector::SimpleSelector const&> const& simple_selectors, Selector::Combinator, bool) {
for (auto const& simple_selector : simple_selectors) {
if (simple_selector.type != Selector::SimpleSelector::Type::PseudoClass)
continue;
auto const& pseudo_class = simple_selector.pseudo_class();
if (pseudo_class.type == PseudoClass::Is || pseudo_class.type == PseudoClass::Where || pseudo_class.type == PseudoClass::Not) {
for (auto const& selector : pseudo_class.argument_selector_list) {
invalidate_whole_subtree_for_invalidation_properties_in_non_subject_part_of_complex_selector(*selector);
}
}
}
});
}
static InvalidationSet build_invalidation_sets_for_selector_impl(StyleInvalidationData& style_invalidation_data, Selector const& selector, InsideNthChildPseudoClass inside_nth_child_pseudo_class)
{
auto const& compound_selectors = selector.compound_selectors();

View file

@ -1438,7 +1438,7 @@ void Document::update_style()
// style change event. [CSS-Transitions-2]
m_transition_generation++;
invalidate_elements_affected_by_has();
invalidate_elements_affected_by_has_in_non_subject_position();
if (!needs_full_style_update() && !needs_style_update() && !child_needs_style_update())
return;
@ -1659,30 +1659,24 @@ static Node* find_common_ancestor(Node* a, Node* b)
return nullptr;
}
void Document::invalidate_elements_affected_by_has()
void Document::invalidate_elements_affected_by_has_in_non_subject_position()
{
if (!m_needs_invalidate_elements_affected_by_has)
if (!m_needs_invalidate_elements_affected_by_has_in_non_subject_position)
return;
m_needs_invalidate_elements_affected_by_has = false;
m_needs_invalidate_elements_affected_by_has_in_non_subject_position = false;
// Take care of elements that affected by :has() in non-subject position, i.e., ".a:has(.b) > .c".
// Elements affected by :has() in subject position, i.e., ".a:has(.b)", are handled by
// Node::invalidate_style() and should already be marked for style update by now.
Vector<CSS::InvalidationSet::Property, 1> changed_properties;
changed_properties.append({ .type = CSS::InvalidationSet::Property::Type::PseudoClass, .value = CSS::PseudoClass::Has });
auto invalidation_set = document().style_computer().invalidation_set_for_properties(changed_properties);
auto invalidation_set = style_computer().invalidation_set_for_properties(changed_properties);
for_each_shadow_including_inclusive_descendant([&](Node& node) {
if (!node.is_element())
return TraversalDecision::Continue;
auto& element = static_cast<Element&>(node);
bool needs_style_recalculation = false;
// There are two cases in which an element must be invalidated, depending on the position of :has() in a selector:
// 1) In the subject position, i.e., ".a:has(.b)". In that case, invalidation sets are not helpful
// for narrowing down the set of elements that need to be invalidated. Instead, we invalidate
// all elements that were tested against selectors with :has() in the subject position during
// selector matching.
// 2) In the non-subject position, i.e., ".a:has(.b) > .c". Here, invalidation sets can be used to
// determine that only elements with the "c" class have to be invalidated.
if (element.affected_by_has_pseudo_class_in_subject_position()) {
needs_style_recalculation = true;
} else if (invalidation_set.needs_invalidate_whole_subtree()) {
if (invalidation_set.needs_invalidate_whole_subtree()) {
needs_style_recalculation = true;
} else if (element.includes_properties_from_invalidation_set(invalidation_set)) {
needs_style_recalculation = true;

View file

@ -510,8 +510,8 @@ public:
[[nodiscard]] bool needs_full_layout_tree_update() const { return m_needs_full_layout_tree_update; }
void set_needs_full_layout_tree_update(bool b) { m_needs_full_layout_tree_update = b; }
bool needs_invalidate_elements_affected_by_has() const { return m_needs_invalidate_elements_affected_by_has; }
void set_needs_invalidate_elements_affected_by_has(bool b) { m_needs_invalidate_elements_affected_by_has = b; }
bool needs_invalidate_elements_affected_by_has_in_non_subject_position() const { return m_needs_invalidate_elements_affected_by_has_in_non_subject_position; }
void set_needs_invalidate_elements_affected_by_has_in_non_subject_position(bool b) { m_needs_invalidate_elements_affected_by_has_in_non_subject_position = b; }
void set_needs_to_refresh_scroll_state(bool b);
@ -817,7 +817,7 @@ private:
// ^HTML::GlobalEventHandlers
virtual GC::Ptr<EventTarget> global_event_handlers_to_event_target(FlyString const&) final { return *this; }
void invalidate_elements_affected_by_has();
void invalidate_elements_affected_by_has_in_non_subject_position();
void tear_down_layout_tree();
@ -961,7 +961,7 @@ private:
bool m_needs_full_style_update { false };
bool m_needs_full_layout_tree_update { false };
bool m_needs_invalidate_elements_affected_by_has { false };
bool m_needs_invalidate_elements_affected_by_has_in_non_subject_position { false };
bool m_needs_animated_style_update { false };

View file

@ -423,6 +423,9 @@ public:
bool affected_by_has_pseudo_class_in_subject_position() const { return m_affected_by_has_pseudo_class_in_subject_position; }
void set_affected_by_has_pseudo_class_in_subject_position(bool value) { m_affected_by_has_pseudo_class_in_subject_position = value; }
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; }
bool affected_by_sibling_combinator() const { return m_affected_by_sibling_combinator; }
void set_affected_by_sibling_combinator(bool value) { m_affected_by_sibling_combinator = value; }
@ -531,6 +534,7 @@ private:
bool m_affected_by_sibling_combinator : 1 { false };
bool m_affected_by_first_or_last_child_pseudo_class : 1 { false };
bool m_affected_by_nth_child_pseudo_class : 1 { false };
bool m_affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator : 1 { false };
OwnPtr<CSS::CountersSet> m_counters_set;

View file

@ -397,13 +397,41 @@ GC::Ptr<HTML::Navigable> Node::navigable() const
}
}
void Node::invalidate_ancestors_affected_by_has_in_subject_position()
{
// This call only takes care of invalidating `:has()` in subject position (e.g., ".a:has(.b)").
// Non-subject position (e.g., ".a:has(.b) + .c") is still handled by another call that uses
// invalidation sets and requires whole document traversal.
// Here we assume that :has() invalidation scope is limited to ancestors and sibling ancestors.
for (auto* ancestor = this; ancestor; ancestor = ancestor->parent_or_shadow_host()) {
if (!ancestor->is_element())
continue;
auto& element = static_cast<Element&>(*ancestor);
if (element.affected_by_has_pseudo_class_in_subject_position()) {
element.set_needs_style_update(true);
}
// If any previous sibling ancestor was tested against selectors like ".a:has(+ .b)" or ".a:has(~ .b)"
// its style might be affected by the change in descendant node.
for (auto* previous_sibling = element.previous_sibling(); previous_sibling; previous_sibling = previous_sibling->previous_sibling()) {
if (!previous_sibling->is_element())
continue;
auto& element = static_cast<Element&>(*previous_sibling);
if (element.affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator()) {
element.set_needs_style_update(true);
}
}
}
}
void Node::invalidate_style(StyleInvalidationReason reason)
{
if (is_character_data())
return;
if (document().style_computer().may_have_has_selectors()) {
document().set_needs_invalidate_elements_affected_by_has(true);
invalidate_ancestors_affected_by_has_in_subject_position();
document().set_needs_invalidate_elements_affected_by_has_in_non_subject_position(true);
}
if (!needs_style_update() && !document().needs_full_style_update()) {
@ -465,7 +493,8 @@ void Node::invalidate_style(StyleInvalidationReason, Vector<CSS::InvalidationSet
properties_used_in_has_selectors |= document().style_computer().invalidation_property_used_in_has_selector(property);
}
if (properties_used_in_has_selectors) {
document().set_needs_invalidate_elements_affected_by_has(true);
invalidate_ancestors_affected_by_has_in_subject_position();
document().set_needs_invalidate_elements_affected_by_has_in_non_subject_position(true);
}
auto invalidation_set = document().style_computer().invalidation_set_for_properties(properties);

View file

@ -311,6 +311,7 @@ public:
[[nodiscard]] bool entire_subtree_needs_style_update() const { return m_entire_subtree_needs_style_update; }
void set_entire_subtree_needs_style_update(bool b) { m_entire_subtree_needs_style_update = b; }
void invalidate_ancestors_affected_by_has_in_subject_position();
void invalidate_style(StyleInvalidationReason);
struct StyleInvalidationOptions {
bool invalidate_self { false };