Include tag and attribute selectors in guarded pseudo-class invalidation
plans. This keeps selectors like a:hover .target from applying work to
unrelated descendants when :hover changes on other elements.
Store both original and lowercase attribute names for invalidation keys.
HTML attribute mutations still hit lowercase selector buckets, while SVG
and MathML selectors such as [viewBox] keep their case-sensitive guards.
Use the same names for :has() metadata and mutation feature filtering so
attribute changes do not get filtered out after metadata lookup.
Match tag invalidation properties against lowercased local names, like
invalidation data and rule cache buckets do. The regression tests cover
SVG hover fanout and case-sensitive :has() attribute mutation.
Scoped rules depend on their `@scope` start and end selectors, not just
the selectors for the declarations inside the rule. Register those
boundary selectors in the style invalidation data and selector insights
so mutations that create or remove scope roots or limits invalidate
descendant styles that may now match differently.
Scope boundary matching also needs to record selector involvement
against the element being styled. That keeps :has(), sibling, and
structural selector invalidation from treating the boundary candidate
as the only affected subject.
Run the stored :has() invalidation plans for affected anchors as well
as marking subject anchors dirty. This lets scope-boundary :has()
changes invalidate scoped descendants instead of leaving stale styles.
Compound selectors with a non-rightmost pseudo-class used to register
their descendant and sibling invalidation plans directly under the
pseudo-class property. A selector like `.item:hover * .target` ran
the descendant plan for every hovered element, even when that element
could not match `.item`.
Store those non-rightmost plans behind a guard made from stable class
and id subject features in the same compound selector. Deliberately
leave pseudo-classes out of guards, since one mutation can change
multiple state pseudo-classes at once. Also leave tag and attribute
selectors out, since their matching depends on document and namespace
case-sensitivity that the guard property does not carry.
The new style-invalidation test covers the GitHub-shaped
`:hover * :not(...)` case, co-invalidated `:link` and `:any-link`,
partial or complex `:is()`/`:where()` alternatives, and case-sensitive
SVG tag and attribute selectors.
Previously, and according to the spec, `a::part(foo)::before` would be a
single CompoundSelector, even though it matches against 3 different
targets. This meant some awkward swapping of targets in the middle of
matching, and in particular it made `::part()` and `::slotted()` quite
hacky, requiring them to track extra data on the MatchContext to then
use later. This was scattered around and difficult to follow.
Partly inspired by Gecko, this commit instead introduces an invisible
PseudoElement combinator. After parsing a selector, we find any
CompoundSelectors that contain a pseudo-element and split them up, so
that each CompoundSelector only has a single target in the end. Where
the pseudo-element was at the start of a CompoundSelector, we insert an
invisible universal selector before it to represent its originating
element.
So now, a CompoundSelector deals with one target, and switching targets
is done at the combinator.
The one inconsistency is that we match the target of ::slotted()
and ::part() in pseudo_element_transition_target(), instead of before
then when processing the SimpleSelector. This is to avoid repeating the
same computations twice.
No outward-facing behaviour changes, though the invalidation metrics
have changed.
Stylesheet add/remove previously fell back to a whole-subtree
invalidation whenever a sheet's rightmost compound carried one of
these pseudo-classes. They each match a small, knowable set of
elements at any moment, so the invalidation walk can target them
directly:
- :host matches one element per shadow root and :root matches the
html element. Mark them targetable.
- :hover, :focus, :focus-visible, :focus-within, :active, and
:target all match at most a handful of elements at a time. Mark
them targetable and add matchers in InvalidationSetMatcher that
consult Document::hovered_node, focused_area, target_element,
and the element's own focused/active state.
When a :has() mutation is known to come from a specific subtree, use
that subtree as the mutation root while walking observed ancestors.
Before dirtying an anchor and its non-subject descendants, check whether
any cached :has() rule for that anchor can observe the changed subtree.
This keeps unrelated descendant mutations from invalidating every rule
that merely contains :has().
Track the simple selector features that appear inside :has() arguments
on each StyleScope, then consult that metadata before scheduling an
ancestor walk for a structural mutation. If the mutated subtree has no
tag, id, class, attribute, or pseudo-class feature that any cached
:has() argument cares about, skip the walk entirely.
Stay conservative for featureless-sensitive arguments such as :has(*),
:has(:not(...)), :has(:empty), and child-index pseudos: an unfeatured
node can still start or stop matching there. Track that case via a new
has_selectors_sensitive_to_featureless_subtree_changes flag on
StyleInvalidationData and fall back to the old conservative walk.
Stay conservative for pseudo-classes the subtree filter cannot probe
(:focus, :hover, validation pseudos). Move :default out of the set of
trackable feature pseudo-classes for the same reason; it now triggers
the conservative walk where it previously recorded metadata.
Tag and attribute names are stored lowercased, so for non-HTML elements
(SVG, MathML) treat lowercased matches as scheduling hints only; the
actual :has() match still goes through case-sensitive selector matching.
Test counter expectations are rebaselined to reflect the skipped walks
and reduced recomputations. Matching behavior is unchanged.
build_invalidation_sets_for_simple_selector ignored ::slotted()
pseudo-element selectors entirely. As a result, the invalidation
plans built for a shadow scope didn't list class, attribute, or
pseudo-class properties referenced inside ::slotted(), so changing
those properties on a slottable couldn't enqueue an invalidation
plan for the rule.
Recurse into ::slotted()'s compound argument and feed each simple
selector through the same invalidation-set builder.
collect_properties_used_in_has only inspected pseudo-class argument
selectors. With ::slotted(.x:has(.descendant)) rules, the property
references inside the :has() argument were therefore never recorded
as :has()-affecting, so attribute and state changes on a slottable's
descendants couldn't enqueue an invalidation plan that covered the
::slotted() rule.
Recurse into the ::slotted() compound argument so the :has() metadata
maps include the properties used inside.
Record per-feature :has() invalidation metadata instead of only tracking
whether some selector somewhere mentions a class, id, attribute, tag,
or pseudo-class. The new buckets preserve the relative selector and a
coarse scope classification for each :has() argument, which gives the
next invalidation step enough information to route mutations more
precisely.
Keep this commit behavior-preserving for mutation handling by only
switching the lookup path over to the new metadata buckets. Expose a
test-only counter for the number of candidate :has() metadata entries a
mutation matched, and add coverage showing that one feature can map to
one or multiple :has() buckets without forcing a document-wide yes/no
answer.
Treat structurally equivalent invalidation plans as equal even when
their descendant or sibling rules were accumulated in a different
order. This lets :has() invalidation merge more of the repeated
descendant-only payloads that still showed up after the earlier
structural dedup.
Add a :has() invalidation counter test that exercises equivalent
selector permutations so this shape stays covered.
Compare invalidation sets, rules, and plans structurally so repeated
descendant and sibling invalidation entries can be merged even when
they were built as separate payload objects.
Also deduplicate pending and active descendant invalidations in the
style invalidator so equivalent rules are not re-applied as the DOM
walk descends. This reduces :has() invalidation fanout while keeping
behavior the same.
Replace the broad whole-subtree fallback for :has() invalidation with
a more targeted approach. The old code unconditionally overwrote
fine-grained :has() invalidation plans with invalidate_whole_subtree
for every non-rightmost compound containing :has(). This prevented
optimization for direct cases like `.a:has(.b) .c`.
The new approach propagates pseudo_class:Has through :is()/:where()
argument processing when :has() appears in non-rightmost compounds of
the inner selector. For complex :is() arguments (multiple compounds),
it falls back to whole-subtree invalidation since the outer plan can't
correctly capture the nested combinator structure.
Replace flat InvalidationSet with recursive InvalidationPlan trees
that preserve selector combinator structure. Previously, selectors
with sibling combinators (+ and ~) fell back to whole-subtree
invalidation. Now the StyleInvalidator walks the DOM following
combinator-specific rules, so ".a + .b" only invalidates the
adjacent sibling matching ".b" rather than the entire subtree.
Plans are compiled at stylesheet parse time by walking selector
compounds right-to-left. For ".a .b + .c":
```
[.c]: plan = { invalidate_self }
register: "c" → plan
[.b]: wrap("+", righthand)
plan = { sibling_rules: [match ".c", adjacent, {self}] }
register: "b" → plan
[.a]: wrap(" ", righthand)
plan = { descendant_rules: [match ".b", <sibling plan>] }
register: "a" → plan
```
Changing class "a" produces a plan that walks descendants for ".b",
checks ".b"'s adjacent sibling for ".c", and invalidates only that
element.
These pseudo-classes were missing from collect_properties_used_in_has,
which meant changes to the `required` attribute did not trigger
:has()-based ancestor invalidation.
Instead of doing a full document style invalidation when a stylesheet is
dynamically added, we now analyze the new sheet's selectors to determine
which elements could potentially be affected, and only invalidate those.
This works by building an InvalidationSet from the rightmost compound
selector (the "subject") of each rule in the new stylesheet, extracting
class, ID, tag name, attribute, and pseudo-class features. We then walk
the DOM tree and only mark elements matching those features as needing a
style update.
If any selector has a rightmost compound that is purely universal (no
identifying features), or uses a pseudo-class not supported by the
invalidation set matching logic, we fall back to full invalidation.
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.
Prior to this change, we invalidated all elements in the document if it
used any selectors with :has(). This change aims to improve that by
applying a combination of techniques:
- Collect metadata for each element if it was matched against a selector
with :has() in the subject position. This is needed to invalidate all
elements that could be affected by selectors like `div:has(.a:empty)`
because they are not covered by the invalidation sets.
- Use invalidation sets to invalidate elements that are affected by
selectors with :has() in a non-subject position.
Selectors like `.a:has(.b) + .c` still cause whole-document invalidation
because invalidation sets cover only descendants, not siblings. As a
result, there is no performance improvement on github.com due to this
limitation. However, youtube.com and discord.com benefit from this
change.
For all invalidation properties nested into nth-child argument list we
need to invalidate whole subtree to make sure style of sibling elements
will be recalculated.
Implements idea described in
https://docs.google.com/document/d/1vEW86DaeVs4uQzNFI5R-_xS9TcS1Cs_EUsHRSgCHGu8
Invalidation sets are used to reduce the number of elements marked for
style recalculation by collecting metadata from style rules about the
dependencies between properties that could affect an element’s style.
Currently, this optimization is only applied to style invalidation
triggered by class list mutations on an element.