LibWeb: Add a lazy fast reject filter for :has()

Build a per-anchor Bloom-style filter for :has() argument matching
after an anchor sees a second check for the same traversal scope. The
filter stores salted tag, id, class, and attribute-name hashes from the
child or descendant scope and rejects arguments whose required
identifiers are absent.

This avoids repeatedly walking the same subtree for unrelated :has()
arguments while preserving the single-check case. More complex
direct-child arguments use the descendant scope so hashes from later
descendant compounds cannot cause false rejections.

Keep the filter conservative for quirks-mode class selectors and for
sibling-combinator relative selectors during invalidation metadata
collection. Text tests cover cache-primed misses for both cases.
This commit is contained in:
Andreas Kling 2026-06-19 10:45:46 +02:00 committed by Andreas Kling
parent fdfe806e68
commit 6eba8860f7
8 changed files with 293 additions and 5 deletions

View file

@ -6,6 +6,7 @@
*/
#include <AK/ScopeGuard.h>
#include <LibWeb/CSS/AncestorFilter.h>
#include <LibWeb/CSS/CSSStyleSheet.h>
#include <LibWeb/CSS/ComputedProperties.h>
#include <LibWeb/CSS/Keyword.h>
@ -39,9 +40,206 @@
namespace Web::SelectorEngine {
static u32 salted_tag_name_hash(FlyString const& tag_name)
{
return CSS::ancestor_filter_hash_for_tag_name(tag_name.ascii_case_insensitive_hash());
}
static u32 salted_id_hash(FlyString const& id)
{
return CSS::ancestor_filter_hash_for_id(id.hash());
}
static u32 salted_class_hash(FlyString const& class_name)
{
return CSS::ancestor_filter_hash_for_class(class_name.hash());
}
static u32 salted_attribute_hash(FlyString const& attribute_name)
{
return CSS::ancestor_filter_hash_for_attribute(attribute_name.ascii_case_insensitive_hash());
}
void HasFastRejectFilter::add(u32 hash)
{
auto const first_bit = hash & 4095;
auto const second_bit = (hash >> 16) & 4095;
buckets[first_bit / 64] |= 1ull << (first_bit % 64);
buckets[second_bit / 64] |= 1ull << (second_bit % 64);
}
bool HasFastRejectFilter::may_contain(u32 hash) const
{
auto const first_bit = hash & 4095;
auto const second_bit = (hash >> 16) & 4095;
return (buckets[first_bit / 64] & (1ull << (first_bit % 64)))
&& (buckets[second_bit / 64] & (1ull << (second_bit % 64)));
}
static bool fast_matches_simple_selector(CSS::Selector::SimpleSelector const& simple_selector, DOM::Element const& element, GC::Ptr<DOM::Element const> shadow_host, MatchContext& context);
static bool fast_matches_compound_selector(CSS::Selector::CompoundSelector const& compound_selector, DOM::Element const& element, GC::Ptr<DOM::Element const> shadow_host, MatchContext& context);
static bool is_excluded_attribute_for_has_filter(FlyString const& name)
{
return name == HTML::AttributeNames::class_
|| name == HTML::AttributeNames::id
|| name == HTML::AttributeNames::style;
}
static void add_element_identifier_hashes(HasFastRejectFilter& filter, DOM::Element const& element, MatchContext const& context)
{
filter.add(salted_tag_name_hash(element.local_name()));
if (element.id().has_value())
filter.add(salted_id_hash(element.id().value()));
for (auto const& class_name : element.class_names())
filter.add(salted_class_hash(class_name));
element.for_each_attribute([&](auto const& attribute) {
auto const& name = attribute.name();
if (is_excluded_attribute_for_has_filter(name))
return;
filter.add(salted_attribute_hash(name));
});
if (context.inside_has_argument_match && context.collect_per_element_selector_involvement_metadata)
const_cast<DOM::Element&>(element).set_in_has_scope(true);
}
static void populate_has_fast_reject_filter(HasFastRejectFilter& filter, DOM::Element const& anchor, HasFastRejectFilterTraversalType traversal_type, MatchContext const& context)
{
// This intentionally mirrors the traversal scope that a matching :has()
// argument would inspect. The filter is only populated on the second
// :has() check for the same anchor/scope, so one subtree walk can reject
// several later arguments without penalizing the single-check case.
switch (traversal_type) {
case HasFastRejectFilterTraversalType::Children:
anchor.for_each_child([&](DOM::Node const& child) {
if (child.is_element())
add_element_identifier_hashes(filter, static_cast<DOM::Element const&>(child), context);
return IterationDecision::Continue;
});
break;
case HasFastRejectFilterTraversalType::Descendants:
anchor.for_each_in_subtree([&](DOM::Node const& descendant) {
if (descendant.is_element())
add_element_identifier_hashes(filter, static_cast<DOM::Element const&>(descendant), context);
return TraversalDecision::Continue;
});
break;
}
filter.populated = true;
}
static void collect_has_fast_reject_hashes(CSS::Selector::SimpleSelector const& simple_selector, Vector<u32>& hashes, bool in_quirks_mode)
{
switch (simple_selector.type) {
case CSS::Selector::SimpleSelector::Type::TagName:
hashes.append(salted_tag_name_hash(simple_selector.qualified_name().name.lowercase_name));
break;
case CSS::Selector::SimpleSelector::Type::Id:
hashes.append(salted_id_hash(simple_selector.name()));
break;
case CSS::Selector::SimpleSelector::Type::Class:
if (in_quirks_mode)
break;
hashes.append(salted_class_hash(simple_selector.name()));
break;
case CSS::Selector::SimpleSelector::Type::Attribute: {
auto const& name = simple_selector.attribute().qualified_name.name.lowercase_name;
if (!is_excluded_attribute_for_has_filter(name))
hashes.append(salted_attribute_hash(name));
break;
}
case CSS::Selector::SimpleSelector::Type::Universal:
case CSS::Selector::SimpleSelector::Type::PseudoClass:
case CSS::Selector::SimpleSelector::Type::PseudoElement:
case CSS::Selector::SimpleSelector::Type::Nesting:
case CSS::Selector::SimpleSelector::Type::Invalid:
break;
}
}
static Vector<u32> collect_has_fast_reject_hashes(CSS::Selector const& selector, bool in_quirks_mode)
{
Vector<u32> hashes;
for (auto const& compound_selector : selector.compound_selectors()) {
for (auto const& simple_selector : compound_selector.simple_selectors)
collect_has_fast_reject_hashes(simple_selector, hashes, in_quirks_mode);
}
return hashes;
}
static Optional<HasFastRejectFilterTraversalType> has_fast_reject_filter_traversal_type(CSS::Selector const& selector)
{
if (selector.compound_selectors().is_empty())
return {};
switch (selector.compound_selectors().first().combinator) {
case CSS::Selector::Combinator::ImmediateChild:
if (selector.compound_selectors().size() == 1)
return HasFastRejectFilterTraversalType::Children;
// The argument can still contain later descendant combinators, e.g.
// `:has(> .wrapper .hit)`. Since we collect hashes from the whole
// relative selector, use the descendant scope so nested requirements
// do not cause false rejections.
return HasFastRejectFilterTraversalType::Descendants;
case CSS::Selector::Combinator::Descendant:
return HasFastRejectFilterTraversalType::Descendants;
case CSS::Selector::Combinator::None:
case CSS::Selector::Combinator::NextSibling:
case CSS::Selector::Combinator::SubsequentSibling:
case CSS::Selector::Combinator::Column:
case CSS::Selector::Combinator::PseudoElement:
return {};
}
VERIFY_NOT_REACHED();
}
static bool selector_contains_sibling_combinator(CSS::Selector const& selector)
{
for (auto const& compound_selector : selector.compound_selectors()) {
if (compound_selector.combinator == CSS::Selector::Combinator::NextSibling
|| compound_selector.combinator == CSS::Selector::Combinator::SubsequentSibling) {
return true;
}
}
return false;
}
static bool should_reject_with_has_fast_reject_filter(CSS::Selector const& selector, DOM::Element const& anchor, MatchContext& context)
{
if (!context.has_fast_reject_filter_cache)
return false;
if (context.collect_per_element_selector_involvement_metadata && selector_contains_sibling_combinator(selector))
return false;
auto traversal_type = has_fast_reject_filter_traversal_type(selector);
if (!traversal_type.has_value())
return false;
auto hashes = collect_has_fast_reject_hashes(selector, anchor.document().in_quirks_mode());
if (hashes.is_empty())
return false;
HasFastRejectFilterKey key {
.element = &anchor,
.traversal_type = *traversal_type,
};
auto& filter = context.has_fast_reject_filter_cache->ensure(key);
if (!filter.seen_once) {
filter.seen_once = true;
return false;
}
if (!filter.populated)
populate_has_fast_reject_filter(filter, anchor, *traversal_type, context);
for (auto hash : hashes) {
if (!filter.may_contain(hash))
return true;
}
return false;
}
static CSS::Selector::SimpleSelector const* simple_has_child_tag_selector(CSS::Selector const& selector)
{
if (selector.compound_selectors().size() != 1)
@ -349,7 +547,9 @@ static inline bool matches_has_pseudo_class(CSS::Selector const& selector, DOM::
ScopeGuard restore_inside_has = [&] { context.inside_has_argument_match = saved_inside_has; };
bool result;
if (context.collect_per_element_selector_involvement_metadata) {
if (should_reject_with_has_fast_reject_filter(selector, anchor, context)) {
result = false;
} else if (context.collect_per_element_selector_involvement_metadata) {
result = matches_relative_selector(selector, 0, anchor, shadow_host, context, anchor, scope);
} else if (auto const* simple_selector = simple_has_child_tag_selector(selector)) {
result = matches_has_child_tag_fast_path(*simple_selector, anchor, shadow_host, context);

View file

@ -6,6 +6,7 @@
#pragma once
#include <AK/Array.h>
#include <AK/HashMap.h>
#include <LibWeb/CSS/Selector.h>
#include <LibWeb/DOM/Element.h>
@ -43,6 +44,43 @@ struct HasResultCacheKeyTraits : Traits<HasResultCacheKey> {
using HasResultCache = HashMap<HasResultCacheKey, HasMatchResult, HasResultCacheKeyTraits>;
enum class HasFastRejectFilterTraversalType : u8 {
Children,
Descendants,
};
struct HasFastRejectFilterKey {
GC::Ptr<DOM::Element const> element;
HasFastRejectFilterTraversalType traversal_type;
void visit_edges(GC::Cell::Visitor& visitor)
{
visitor.visit(element);
}
bool operator==(HasFastRejectFilterKey const&) const = default;
};
struct HasFastRejectFilterKeyTraits : Traits<HasFastRejectFilterKey> {
static unsigned hash(HasFastRejectFilterKey const& key)
{
return pair_int_hash(ptr_hash(key.element.ptr()), to_underlying(key.traversal_type));
}
};
struct HasFastRejectFilter {
static constexpr size_t bucket_count = 64;
bool seen_once { false };
bool populated { false };
Array<u64, bucket_count> buckets {};
void add(u32 hash);
[[nodiscard]] bool may_contain(u32 hash) const;
};
using HasFastRejectFilterCache = HashMap<HasFastRejectFilterKey, HasFastRejectFilter, HasFastRejectFilterKeyTraits>;
struct MatchContext {
GC::Ptr<CSS::CSSStyleSheet const> style_sheet_for_rule {};
GC::Ptr<DOM::Element const> subject {};
@ -55,6 +93,7 @@ struct MatchContext {
// by matches_has_pseudo_class with a ScopeGuard.
bool inside_has_argument_match { false };
HasResultCache* has_result_cache { nullptr };
HasFastRejectFilterCache* has_fast_reject_filter_cache { nullptr };
};
bool matches(CSS::Selector const&, DOM::AbstractElement const&, GC::Ptr<DOM::Element const> shadow_host, MatchContext& context, GC::Ptr<DOM::ParentNode const> scope = {}, SelectorKind selector_kind = SelectorKind::Normal, GC::Ptr<DOM::Element const> anchor = nullptr);

View file

@ -164,6 +164,8 @@ void StyleComputer::visit_edges(Visitor& visitor)
visitor.visit(m_document);
if (m_has_result_cache)
visitor.visit(*m_has_result_cache);
if (m_has_fast_reject_filter_cache)
visitor.visit(*m_has_fast_reject_filter_cache);
if (m_cached_font_computation_context.has_value())
m_cached_font_computation_context->visit_edges(visitor);
@ -705,8 +707,9 @@ Vector<StyleComputer::ScopedMatchingRule> StyleComputer::collect_matching_rules_
// (for :host::part() within the shadow DOM's own stylesheet).
if (shadow_root && (abstract_element.pseudo_element().has_value() || !abstract_element.element().part_names().is_empty())) {
if (context_shadow_root == shadow_root) {
if (auto const* rule_cache = rule_cache_for_cascade_origin(cascade_origin, qualified_layer_name, shadow_root))
if (auto const* rule_cache = rule_cache_for_cascade_origin(cascade_origin, qualified_layer_name, shadow_root)) {
add_rules_to_run(rule_cache->part_rules, shadow_root);
}
}
for (auto* part_shadow_root = abstract_element.element().first_flat_tree_ancestor_of_type<DOM::ShadowRoot>();
part_shadow_root;
@ -714,12 +717,14 @@ Vector<StyleComputer::ScopedMatchingRule> StyleComputer::collect_matching_rules_
if (context_shadow_root != part_shadow_root)
continue;
if (auto const* rule_cache = rule_cache_for_cascade_origin(cascade_origin, qualified_layer_name, part_shadow_root))
if (auto const* rule_cache = rule_cache_for_cascade_origin(cascade_origin, qualified_layer_name, part_shadow_root)) {
add_rules_to_run(rule_cache->part_rules, part_shadow_root);
}
}
if (!context_shadow_root) {
if (auto const* rule_cache = rule_cache_for_cascade_origin(cascade_origin, qualified_layer_name, nullptr))
if (auto const* rule_cache = rule_cache_for_cascade_origin(cascade_origin, qualified_layer_name, nullptr)) {
add_rules_to_run(rule_cache->part_rules, nullptr);
}
}
}
@ -750,6 +755,7 @@ Vector<StyleComputer::ScopedMatchingRule> StyleComputer::collect_matching_rules_
.rule_shadow_root = rule_root,
.collect_per_element_selector_involvement_metadata = true,
.has_result_cache = m_has_result_cache.ptr(),
.has_fast_reject_filter_cache = m_has_fast_reject_filter_cache.ptr(),
};
if (!abstract_element.pseudo_element().has_value() && matching_pseudo_element_styles) {
if (auto pseudo_element = selector.target_pseudo_element(); pseudo_element.has_value()) {
@ -4189,6 +4195,11 @@ void StyleComputer::reset_has_result_cache()
m_has_result_cache = make<SelectorEngine::HasResultCache>();
else
m_has_result_cache->clear();
if (!m_has_fast_reject_filter_cache)
m_has_fast_reject_filter_cache = make<SelectorEngine::HasFastRejectFilterCache>();
else
m_has_fast_reject_filter_cache->clear();
}
void StyleComputer::push_ancestor(DOM::Element const& element)
@ -4382,8 +4393,9 @@ static IterationDecision for_each_matching_rule_bucket(DOM::AbstractElement abst
IterationDecision decision = IterationDecision::Continue;
abstract_element.element().for_each_attribute([&](auto& name, auto&) {
if (auto it = rule_buckets.rules_by_attribute_name.find(name); it != rule_buckets.rules_by_attribute_name.end())
if (auto it = rule_buckets.rules_by_attribute_name.find(name); it != rule_buckets.rules_by_attribute_name.end()) {
decision = callback(it->value);
}
});
if (decision == IterationDecision::Break)
return IterationDecision::Break;

View file

@ -248,6 +248,7 @@ private:
OwnPtr<CountingBloomFilter<u8, 14>> m_ancestor_filter;
OwnPtr<SelectorEngine::HasResultCache> m_has_result_cache;
OwnPtr<SelectorEngine::HasFastRejectFilterCache> m_has_fast_reject_filter_cache;
};
inline bool StyleComputer::should_reject_with_ancestor_filter(Selector const& selector) const

View file

@ -0,0 +1,2 @@
Quirks mode: BackCompat
:has() matches case-insensitive class selector after cached miss: true

View file

@ -0,0 +1,2 @@
initial match: false
match after adding sibling: true

View file

@ -0,0 +1,13 @@
<!DOCTYPE quirks>
<style>
body:has(.missing) { --missing: 1; }
body:has(.FOO) { --match: 1; }
</style>
<script src="../include.js"></script>
<div class="foo"></div>
<script>
test(() => {
println(`Quirks mode: ${document.compatMode}`);
println(`:has() matches case-insensitive class selector after cached miss: ${getComputedStyle(document.body).getPropertyValue("--match").trim() === "1"}`);
});
</script>

View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<style>
#anchor:has(.missing) { --unused: 1; }
#anchor:has(.a + .b) { --match: 1; }
</style>
<script src="../include.js"></script>
<div id="anchor"><span class="a"></span></div>
<script>
test(() => {
const anchor = document.getElementById("anchor");
println(`initial match: ${getComputedStyle(anchor).getPropertyValue("--match").trim() === "1"}`);
const b = document.createElement("span");
b.className = "b";
anchor.appendChild(b);
println(`match after adding sibling: ${getComputedStyle(anchor).getPropertyValue("--match").trim() === "1"}`);
});
</script>