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:
parent
fdfe806e68
commit
6eba8860f7
8 changed files with 293 additions and 5 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
Quirks mode: BackCompat
|
||||
:has() matches case-insensitive class selector after cached miss: true
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
initial match: false
|
||||
match after adding sibling: true
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue