Files
ladybird/Libraries/LibWeb/CSS/Invalidation/HasMutationInvalidator.cpp
Andreas Kling 0b4cea5b29 LibWeb: Coalesce safe :has() child-list invalidations
Avoid repeated :has() child-list scheduling when pending data already
covers every concrete feature bucket used by :has() selectors in a style
scope. Featureless-sensitive scopes record child-list mutations
conservatively instead.

That conservative path avoids scanning mutation subtrees for concrete
features when invalidation cannot rely on them anyway. When loading
the Intel ISA PDF in pdf.js, instrumented subtree feature-collection
visits at about 40k style invalidations dropped from around 71k to 1.6k,
saving ~650ms of main thread time on my Linux machine. :^)
2026-04-30 14:31:28 +02:00

484 lines
22 KiB
C++

/*
* Copyright (c) 2026-present, the Ladybird developers
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/CSS/Invalidation/HasMutationFeatureCollector.h>
#include <LibWeb/CSS/Invalidation/HasMutationInvalidator.h>
#include <LibWeb/CSS/StyleScope.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/Element.h>
#include <LibWeb/DOM/Node.h>
namespace Web::CSS::Invalidation {
static bool reason_may_affect_has_selectors(DOM::StyleInvalidationReason reason)
{
// :has() selectors match based on DOM state only (structure, attributes, pseudo-classes). Reasons that don't change
// any DOM state can't affect :has() matching, so we can skip scheduling :has() ancestor invalidation.
switch (reason) {
case DOM::StyleInvalidationReason::BaseURLChanged:
case DOM::StyleInvalidationReason::CSSFontLoaded:
case DOM::StyleInvalidationReason::HTMLIFrameElementGeometryChange:
case DOM::StyleInvalidationReason::HTMLObjectElementUpdateLayoutAndChildObjects:
case DOM::StyleInvalidationReason::NavigableSetViewportSize:
case DOM::StyleInvalidationReason::SettingsChange:
return false;
default:
return true;
}
}
static void invalidate_children_affected_by_has_sibling_combinators(DOM::Node& parent)
{
parent.for_each_child_of_type<DOM::Element>([&](auto& element) {
if (element.affected_by_has_pseudo_class_with_relative_selector_that_has_sibling_combinator())
invalidate_element_if_affected_by_has(element);
return IterationDecision::Continue;
});
}
static bool pending_has_invalidation_covers_all_child_list_mutation_features(StyleScope& scope, DOM::Node& parent)
{
auto pending_invalidation = scope.m_pending_has_invalidations.find(parent);
if (pending_invalidation == scope.m_pending_has_invalidations.end())
return false;
auto const& mutation_features = pending_invalidation->value;
if (mutation_features.is_conservative)
return true;
auto const* data = scope.m_rule_cache ? &scope.m_rule_cache->style_invalidation_data : nullptr;
if (!data)
return false;
if (!mutation_features.may_affect_sibling_relationships)
return false;
auto contains_all_keys = [](auto const& existing_features, auto const& used_features) {
for (auto const& entry : used_features) {
if (!existing_features.contains(entry.key))
return false;
}
return true;
};
if (!contains_all_keys(mutation_features.tag_names, data->tag_names_used_in_has_selectors))
return false;
if (!contains_all_keys(mutation_features.ids, data->ids_used_in_has_selectors))
return false;
if (!contains_all_keys(mutation_features.class_names, data->class_names_used_in_has_selectors))
return false;
if (!contains_all_keys(mutation_features.attribute_names, data->attribute_names_used_in_has_selectors))
return false;
if (!data->pseudo_classes_used_in_has_selectors.is_empty() && !mutation_features.may_affect_pseudo_classes)
return false;
return true;
}
static bool scope_has_featureless_sensitive_has_selectors(StyleScope const& scope)
{
auto const* data = scope.m_rule_cache ? &scope.m_rule_cache->style_invalidation_data : nullptr;
return data && data->has_selectors_sensitive_to_featureless_subtree_changes;
}
void invalidate_element_if_affected_by_has(DOM::Element& element)
{
if (element.affected_by_has_pseudo_class_in_subject_position())
element.set_needs_style_update(true);
if (element.affected_by_has_pseudo_class_in_non_subject_position())
element.invalidate_style(DOM::StyleInvalidationReason::Other, { { InvalidationSet::Property::Type::PseudoClass, PseudoClass::Has } }, {});
}
static bool is_in_has_scope(DOM::Element const& element)
{
return element.in_has_scope()
|| element.affected_by_has_pseudo_class_in_subject_position()
|| element.affected_by_has_pseudo_class_in_non_subject_position();
}
static bool 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();
}
static bool selector_may_match_mutation_features(Selector const& selector, PendingHasInvalidationMutationFeatures const& mutation_features)
{
if (mutation_features.is_conservative)
return true;
Function<bool(Selector const&)> visit_selector = [&](Selector const& selector) {
bool saw_concrete_feature = false;
bool concrete_feature_found_in_mutation_subtree = false;
bool must_be_conservative = false;
auto visit_selector_list = [&](SelectorList const& selector_list) {
for (auto const& argument_selector : selector_list) {
if (visit_selector(*argument_selector))
return true;
}
return false;
};
for (auto const& compound_selector : selector.compound_selectors()) {
if ((compound_selector.combinator == Selector::Combinator::NextSibling
|| compound_selector.combinator == Selector::Combinator::SubsequentSibling)
&& mutation_features.may_affect_sibling_relationships)
must_be_conservative = true;
if (compound_selector.simple_selectors.is_empty()) {
must_be_conservative = true;
continue;
}
for (auto const& simple_selector : compound_selector.simple_selectors) {
switch (simple_selector.type) {
case Selector::SimpleSelector::Type::Universal:
case Selector::SimpleSelector::Type::Nesting:
case Selector::SimpleSelector::Type::Invalid:
case Selector::SimpleSelector::Type::PseudoElement:
must_be_conservative = true;
break;
case Selector::SimpleSelector::Type::TagName:
saw_concrete_feature = true;
concrete_feature_found_in_mutation_subtree |= mutation_features.tag_names.contains(simple_selector.qualified_name().name.lowercase_name);
break;
case Selector::SimpleSelector::Type::Id:
saw_concrete_feature = true;
concrete_feature_found_in_mutation_subtree |= mutation_features.ids.contains(simple_selector.name());
break;
case Selector::SimpleSelector::Type::Class:
saw_concrete_feature = true;
concrete_feature_found_in_mutation_subtree |= mutation_features.class_names.contains(simple_selector.name());
break;
case Selector::SimpleSelector::Type::Attribute:
saw_concrete_feature = true;
concrete_feature_found_in_mutation_subtree |= mutation_features.attribute_names.contains(simple_selector.attribute().qualified_name.name.lowercase_name);
break;
case Selector::SimpleSelector::Type::PseudoClass: {
auto const& pseudo_class = simple_selector.pseudo_class();
switch (pseudo_class.type) {
case PseudoClass::Is:
case PseudoClass::Where:
saw_concrete_feature = true;
concrete_feature_found_in_mutation_subtree |= visit_selector_list(pseudo_class.argument_selector_list);
break;
case PseudoClass::Enabled:
case PseudoClass::Defined:
case PseudoClass::Disabled:
case PseudoClass::Empty:
case PseudoClass::PlaceholderShown:
case PseudoClass::Checked:
case PseudoClass::Dir:
case PseudoClass::Lang:
case PseudoClass::Link:
case PseudoClass::AnyLink:
case PseudoClass::LocalLink:
case PseudoClass::Required:
case PseudoClass::Optional:
saw_concrete_feature = true;
concrete_feature_found_in_mutation_subtree |= mutation_features.may_affect_pseudo_classes
|| mutation_features.pseudo_classes.contains(pseudo_class.type);
break;
case PseudoClass::Hover:
case PseudoClass::Focus:
case PseudoClass::FocusVisible:
case PseudoClass::FocusWithin:
case PseudoClass::Active:
case PseudoClass::Not:
case PseudoClass::Has:
default:
must_be_conservative = true;
break;
}
break;
}
}
}
}
if (must_be_conservative)
return true;
return !saw_concrete_feature || concrete_feature_found_in_mutation_subtree;
};
return visit_selector(selector);
}
static bool compound_may_match_anchor_ignoring_has(Selector::CompoundSelector const& compound_selector, DOM::Element const& anchor)
{
for (auto const& simple_selector : compound_selector.simple_selectors) {
switch (simple_selector.type) {
case Selector::SimpleSelector::Type::Universal:
break;
case Selector::SimpleSelector::Type::Nesting:
case Selector::SimpleSelector::Type::Invalid:
case Selector::SimpleSelector::Type::PseudoElement:
return true;
case Selector::SimpleSelector::Type::TagName:
if (anchor.local_name() != simple_selector.qualified_name().name.lowercase_name)
return false;
break;
case Selector::SimpleSelector::Type::Id: {
auto id = anchor.id();
if (!id.has_value() || *id != simple_selector.name())
return false;
break;
}
case Selector::SimpleSelector::Type::Class:
if (!anchor.class_names().contains_slow(simple_selector.name()))
return false;
break;
case Selector::SimpleSelector::Type::Attribute: {
bool has_attribute = false;
anchor.for_each_attribute([&](FlyString const& name, String const&) {
if (name == simple_selector.attribute().qualified_name.name.lowercase_name)
has_attribute = true;
});
if (!has_attribute)
return false;
break;
}
case Selector::SimpleSelector::Type::PseudoClass:
switch (simple_selector.pseudo_class().type) {
case PseudoClass::Has:
break;
case PseudoClass::Root:
if (&anchor != anchor.document().document_element())
return false;
break;
default:
return true;
}
break;
}
}
return true;
}
static bool has_rule_that_may_be_affected_by_mutation(StyleScope& style_scope, DOM::Element const& anchor, PendingHasInvalidationMutationFeatures const& mutation_features)
{
bool found_has_rule = false;
bool may_be_affected = false;
auto check_selector = [&](Selector const& selector) {
Function<void(Selector const&)> visit_selector = [&](Selector const& selector) {
if (may_be_affected)
return;
for (auto const& compound_selector : selector.compound_selectors()) {
for (auto const& simple_selector : compound_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::Has) {
if (!compound_may_match_anchor_ignoring_has(compound_selector, anchor))
continue;
found_has_rule = true;
for (auto const& argument_selector : pseudo_class.argument_selector_list) {
if (selector_may_match_mutation_features(*argument_selector, mutation_features)) {
may_be_affected = true;
return;
}
}
}
for (auto const& argument_selector : pseudo_class.argument_selector_list)
visit_selector(*argument_selector);
}
}
};
visit_selector(selector);
};
auto check_rule_vector = [&](Vector<MatchingRule> const& rules) {
for (auto const& rule : rules) {
check_selector(rule.selector);
if (may_be_affected)
return;
}
};
auto const& has_rule_cache = style_scope.get_pseudo_class_rule_cache(PseudoClass::Has);
for (auto const& entry : has_rule_cache.rules_by_id)
check_rule_vector(entry.value);
for (auto const& entry : has_rule_cache.rules_by_class)
check_rule_vector(entry.value);
for (auto const& entry : has_rule_cache.rules_by_tag_name)
check_rule_vector(entry.value);
for (auto const& entry : has_rule_cache.rules_by_attribute_name)
check_rule_vector(entry.value);
for (auto const& rules : has_rule_cache.rules_by_pseudo_element)
check_rule_vector(rules);
check_rule_vector(has_rule_cache.root_rules);
check_rule_vector(has_rule_cache.slotted_rules);
check_rule_vector(has_rule_cache.part_rules);
check_rule_vector(has_rule_cache.other_rules);
return !found_has_rule || may_be_affected;
}
static void invalidate_style_of_elements_affected_by_pending_has_mutations(StyleScope& style_scope)
{
if (style_scope.m_pending_has_invalidations.is_empty())
return;
ScopeGuard clear_pending_nodes_guard = [&] {
style_scope.m_pending_has_invalidations.clear();
};
// It's ok to call have_has_selectors() instead of may_have_has_selectors() here and force
// rule cache build, because it's going to be built soon anyway, since we could get here
// only from update_style().
if (!style_scope.have_has_selectors())
return;
auto& counters = style_scope.document().style_invalidation_counters();
++counters.has_ancestor_walk_invocations;
HashTable<GC::Ref<DOM::Element>> elements_already_invalidated_for_has;
auto pending_has_invalidations = style_scope.m_pending_has_invalidations;
bool should_scan_ancestor_siblings = style_scope.have_has_selectors_with_relative_selector_that_has_sibling_combinator();
for (auto& [node, mutation_features] : pending_has_invalidations) {
HashTable<GC::Ref<DOM::Element>> elements_skipped_by_has_feature_filter;
Vector<GC::Ref<DOM::Element>, 16> has_scope_ancestors;
bool should_delay_ancestor_sibling_scans = false;
for (GC::Ptr<DOM::Node> ancestor = node; ancestor; ancestor = ancestor->parent_or_shadow_host()) {
if (!ancestor->is_element())
continue;
GC::Ref<DOM::Element> element = static_cast<DOM::Element&>(*ancestor);
// Terminate the upward walk once we reach an element that no :has()
// anchor has ever observed. Its style cannot be affected by a mutation
// further down the tree, so neither can anything above it.
if (!is_in_has_scope(*element))
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) {
if (elements_already_invalidated_for_has.contains(element))
continue;
++counters.has_ancestor_walk_visits;
bool can_skip_unchanged_has_fanout = !element->root().is_shadow_root() && !element->assigned_slot_internal() && !element->is_shadow_host();
bool should_invalidate_element = true;
if (element->affected_by_has_pseudo_class_in_non_subject_position() && can_skip_unchanged_has_fanout)
should_invalidate_element = has_rule_that_may_be_affected_by_mutation(style_scope, element, mutation_features);
if (should_invalidate_element) {
elements_already_invalidated_for_has.set(element);
invalidate_element_if_affected_by_has(*element);
} else {
elements_skipped_by_has_feature_filter.set(element);
}
GC::Ptr<DOM::Node> parent = element->parent_or_shadow_host();
if (!parent)
return;
// If any ancestor's sibling was tested against selectors like ".a:has(+ .b)" or ".a:has(~ .b)"
// 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()) {
GC::Ref<DOM::Element> ancestor_sibling = ancestor_sibling_element;
if (elements_skipped_by_has_feature_filter.contains(ancestor_sibling))
return IterationDecision::Continue;
if (elements_already_invalidated_for_has.set(ancestor_sibling) != AK::HashSetResult::InsertedNewEntry)
return IterationDecision::Continue;
++counters.has_ancestor_walk_visits;
invalidate_element_if_affected_by_has(*ancestor_sibling);
}
return IterationDecision::Continue;
});
}
}
}
void invalidate_style_for_pending_has_mutations(DOM::Document& document)
{
invalidate_style_of_elements_affected_by_pending_has_mutations(document.style_scope());
document.for_each_shadow_root([&](auto& shadow_root) {
invalidate_style_of_elements_affected_by_pending_has_mutations(shadow_root.style_scope());
});
}
static void schedule_has_invalidation_for_child_list_mutation(DOM::Node& parent, DOM::Node& mutation_root, StyleScope& scope)
{
if (!scope.may_have_has_selectors())
return;
auto has_sibling_combinator_has_selectors = scope.may_have_has_selectors_with_relative_selector_that_has_sibling_combinator();
if (pending_has_invalidation_covers_all_child_list_mutation_features(scope, parent))
return;
if (scope_has_featureless_sensitive_has_selectors(scope)) {
scope.record_conservative_pending_has_invalidation(parent, true);
if (has_sibling_combinator_has_selectors)
invalidate_children_affected_by_has_sibling_combinators(parent);
return;
}
// Sibling-combinator :has() selectors are sensitive to featureless insertions/removals because a plain node can
// still change adjacency and following-sibling relationships.
auto may_affect_has_match = mutation_root.is_character_data()
|| subtree_has_feature_used_in_has_selector(mutation_root, scope)
|| has_sibling_combinator_has_selectors;
if (!may_affect_has_match)
return;
scope.record_pending_has_invalidation_mutation_features(parent, mutation_root, true);
scope.schedule_ancestors_style_invalidation_due_to_presence_of_has(parent);
if (has_sibling_combinator_has_selectors)
invalidate_children_affected_by_has_sibling_combinators(parent);
}
void schedule_has_invalidation_for_node(DOM::Node& node, DOM::StyleInvalidationReason reason)
{
auto is_child_list_mutation = reason == DOM::StyleInvalidationReason::NodeRemove
|| reason == DOM::StyleInvalidationReason::NodeInsertBefore;
// On insertion and removal the mutated node itself is uninteresting to the
// :has() walker (a freshly inserted node has no :has() scope flags yet, and
// a removed node is about to leave the tree). Start the walk at the parent,
// which was in scope before and reliably carries the correct flags.
if (is_child_list_mutation) {
auto* parent = node.parent_or_shadow_host();
if (!parent)
return;
// Walk every scope that can observe the parent, including enclosing and hosted shadow roots, so :has() in
// :host(), ::slotted(), and ::part() selectors can react to the mutation.
parent->for_each_style_scope_which_may_observe_the_node([&](StyleScope& scope) {
schedule_has_invalidation_for_child_list_mutation(*parent, node, scope);
});
return;
}
auto& style_scope = node.style_scope();
if (!style_scope.may_have_has_selectors() || !reason_may_affect_has_selectors(reason))
return;
style_scope.record_pending_has_invalidation_mutation_features(node, node, false);
style_scope.schedule_ancestors_style_invalidation_due_to_presence_of_has(node);
}
void schedule_has_invalidation_for_same_parent_move(DOM::Node& node)
{
auto* parent = node.parent_or_shadow_host();
if (!parent)
return;
parent->for_each_style_scope_which_may_observe_the_node([&](StyleScope& scope) {
schedule_has_invalidation_for_child_list_mutation(*parent, node, scope);
});
}
}