LibWeb: Target :active and :open invalidation

Changing :active or :open used the broad style invalidation path, so
large subtrees were recomputed even when only the target element and
selector-matched relatives could be affected.

Reuse the :has() feature collector to keep conservative fallback for
observable :has() cases, then use pseudo-class property invalidation
for the common targeted path.
This commit is contained in:
Jelle Raaijmakers 2026-06-17 00:27:27 +02:00 committed by Andreas Kling
parent 4bbc5e0950
commit d8a55dad1c
6 changed files with 154 additions and 54 deletions

View file

@ -5,14 +5,36 @@
*/
#include <LibWeb/CSS/Invalidation/ElementStateInvalidator.h>
#include <LibWeb/CSS/Invalidation/HasMutationFeatureCollector.h>
#include <LibWeb/CSS/InvalidationSet.h>
#include <LibWeb/DOM/Element.h>
#include <LibWeb/DOM/StyleInvalidationReason.h>
namespace Web::CSS::Invalidation {
static void invalidate_style_after_pseudo_class_state_change(DOM::Element& element, DOM::StyleInvalidationReason reason,
PseudoClass pseudo_class)
{
bool may_affect_has_selectors = false;
element.for_each_style_scope_which_may_observe_the_node([&](StyleScope& scope) {
if (element_has_feature_used_in_has_selector(element, pseudo_class, scope))
may_affect_has_selectors = true;
});
if (may_affect_has_selectors) {
element.invalidate_style(reason);
return;
}
Vector<InvalidationSet::Property, 1> pseudo_class_properties {
{ .type = InvalidationSet::Property::Type::PseudoClass, .value = pseudo_class },
};
element.invalidate_style(reason, pseudo_class_properties, { .invalidate_self = true });
}
void invalidate_style_after_active_state_change(DOM::Element& element)
{
element.invalidate_style(DOM::StyleInvalidationReason::ElementSetActive);
invalidate_style_after_pseudo_class_state_change(element, DOM::StyleInvalidationReason::ElementSetActive,
PseudoClass::Active);
}
void invalidate_style_after_modal_state_change(DOM::Element& element)
@ -22,9 +44,8 @@ void invalidate_style_after_modal_state_change(DOM::Element& element)
void invalidate_style_after_open_state_change(DOM::Element& element)
{
// The :open pseudo-class can affect sibling selectors (e.g. dialog:open + sibling), so keep the existing broad
// subtree and sibling invalidation.
element.invalidate_style(DOM::StyleInvalidationReason::HTMLDetailsOrDialogOpenAttributeChange);
invalidate_style_after_pseudo_class_state_change(element,
DOM::StyleInvalidationReason::HTMLDetailsOrDialogOpenAttributeChange, PseudoClass::Open);
}
void invalidate_style_after_option_selected_state_change(DOM::Element& element)

View file

@ -87,6 +87,27 @@ bool HasMutationFeatureCollector::subtree_has_feature_used_in_has_selector(DOM::
return found;
}
bool element_has_feature_used_in_has_selector(DOM::Element const& element, PseudoClass changed_pseudo_class,
StyleScope const& style_scope)
{
if (!style_scope.may_have_has_selectors())
return false;
auto const* data = style_scope.m_rule_cache ? &style_scope.m_rule_cache->style_invalidation_data : nullptr;
if (!data)
return true;
HasMutationFeatureCollector collector { *data };
if (!collector.has_any_metadata())
return true;
if (data->has_selectors_sensitive_to_featureless_subtree_changes)
return true;
if (data->pseudo_classes_used_in_has_selectors.contains(changed_pseudo_class))
return true;
return collector.element_has_feature_used_in_has_selector(element);
}
bool subtree_has_feature_used_in_has_selector(DOM::Node& node, StyleScope const& style_scope)
{
auto const* data = style_scope.m_rule_cache ? &style_scope.m_rule_cache->style_invalidation_data : nullptr;

View file

@ -15,6 +15,7 @@ class Node;
namespace Web::CSS {
enum class PseudoClass;
struct StyleInvalidationData;
class StyleScope;
@ -25,14 +26,14 @@ public:
explicit HasMutationFeatureCollector(StyleInvalidationData const&);
[[nodiscard]] bool has_any_metadata() const;
[[nodiscard]] bool element_has_feature_used_in_has_selector(DOM::Element const&) const;
[[nodiscard]] bool subtree_has_feature_used_in_has_selector(DOM::Node&) const;
private:
[[nodiscard]] bool element_has_feature_used_in_has_selector(DOM::Element const&) const;
StyleInvalidationData const& m_data;
};
[[nodiscard]] bool element_has_feature_used_in_has_selector(DOM::Element const&, PseudoClass, StyleScope const&);
[[nodiscard]] bool subtree_has_feature_used_in_has_selector(DOM::Node&, StyleScope const&);
}

View file

@ -1,3 +1,4 @@
plain text hover: styleInvalidations=0, elementStyleRecomputations=0, elementStyleNoopRecomputations=0
link hover: styleInvalidations=1, elementStyleRecomputations=2, elementStyleNoopRecomputations=1
link color: rgb(0, 128, 0)
active state: fullStyleInvalidations=0, elementStyleRecomputations=8

View file

@ -1,48 +0,0 @@
<!DOCTYPE html>
<meta charset="utf-8">
<script src="../../include.js"></script>
<style>
body {
margin: 0;
}
#plain,
#link {
display: block;
width: 100px;
height: 20px;
}
a:hover {
color: rgb(0, 128, 0);
}
</style>
<div id="plain">plain text</div>
<a id="link" href="#">link text</a>
<script>
function moveTo(element) {
const rect = element.getBoundingClientRect();
internals.mouseMove(rect.left + 5, rect.top + 5);
internals.updateStyle();
}
function dumpCounters(label) {
const c = internals.getStyleInvalidationCounters();
println(`${label}: styleInvalidations=${c.styleInvalidations}, elementStyleRecomputations=${c.elementStyleRecomputations}, elementStyleNoopRecomputations=${c.elementStyleNoopRecomputations}`);
}
test(() => {
const plain = document.getElementById("plain");
const link = document.getElementById("link");
getComputedStyle(plain).color;
internals.resetStyleInvalidationCounters();
moveTo(plain);
dumpCounters("plain text hover");
internals.resetStyleInvalidationCounters();
moveTo(link);
dumpCounters("link hover");
println(`link color: ${getComputedStyle(link).color}`);
});
</script>

View file

@ -0,0 +1,104 @@
<!DOCTYPE html>
<meta charset="utf-8">
<script src="../../include.js"></script>
<style>
body {
margin: 0;
}
#plain,
#link {
display: block;
width: 100px;
height: 20px;
}
a:hover {
color: rgb(0, 128, 0);
}
#active-target {
display: block;
width: 80px;
height: 40px;
color: black;
}
#active-target:active {
color: rgb(1, 2, 3);
}
#active-target:active + #active-sibling {
color: rgb(4, 5, 6);
}
#active-container:active #active-ancestor-descendant {
color: rgb(7, 8, 9);
}
</style>
<div id="plain">plain text</div>
<a id="link" href="#">link text</a>
<div id="active-container">
<button id="active-target">target</button>
<div id="active-sibling">sibling</div>
<div id="active-ancestor-descendant">ancestor descendant</div>
</div>
<script>
function moveTo(element) {
const rect = element.getBoundingClientRect();
internals.mouseMove(rect.left + 5, rect.top + 5);
internals.updateStyle();
}
function dumpCounters(label) {
const c = internals.getStyleInvalidationCounters();
println(
`${label}: styleInvalidations=${c.styleInvalidations}, ` +
`elementStyleRecomputations=${c.elementStyleRecomputations}, ` +
`elementStyleNoopRecomputations=${c.elementStyleNoopRecomputations}`
);
}
function appendUnaffectedSubtree(parent, count) {
for (let i = 0; i < count; ++i) {
let leaf = document.createElement("span");
leaf.textContent = `leaf ${i}`;
parent.appendChild(leaf);
}
}
function clickAndHold(element) {
const rect = element.getBoundingClientRect();
internals.clickAndHold(Math.floor(rect.left + rect.width / 2), Math.floor(rect.top + rect.height / 2));
internals.updateStyle();
}
test(() => {
const plain = document.getElementById("plain");
const link = document.getElementById("link");
const activeContainer = document.getElementById("active-container");
const activeTarget = document.getElementById("active-target");
getComputedStyle(plain).color;
internals.resetStyleInvalidationCounters();
moveTo(plain);
dumpCounters("plain text hover");
internals.resetStyleInvalidationCounters();
moveTo(link);
dumpCounters("link hover");
println(`link color: ${getComputedStyle(link).color}`);
appendUnaffectedSubtree(activeContainer, 100);
getComputedStyle(activeContainer).color;
internals.resetStyleInvalidationCounters();
clickAndHold(activeTarget);
const counters = internals.getStyleInvalidationCounters();
println(
`active state: fullStyleInvalidations=${counters.fullStyleInvalidations}, ` +
`elementStyleRecomputations=${counters.elementStyleRecomputations}`
);
internals.mouseUp(0, 0);
});
</script>