How Blink invalidates styles when :has() in use?

How to avoid unnecessary style invalidation when using :has()

Posted by Byungwoo's Blog on May 31, 2023 · 38 mins read

It’s no longer surprising that you can style parent elements using :has() pseudo-class in Chrome. According to the Chrome Platform Status metrics (as of May 1st, 2023), approximately 4% of page loads in Chrome use :has() pseudo-class in their style rules. 4% of page loads in Chrome use <code>:has()</code> pseudo class

As I mentioned in a previous post, the problems related to :has() pseudo-class can be categorized into two categories:

  • Issues to be addressed in testing :has() on an element
  • Issues to be addressed in handling style invalidation with :has()

In another post, I introduced how Blink, the rendering engine used in Chrome, addresses the challenges related to testing :has() pseudo-class on an element.

In this post, I will explain how Blink handles style invalidation with the :has() pseudo-class.

Performance and variation

Style invalidation is a process of identifying and marking elements that require their style to be recalculated in response to a mutation in the DOM. The style engine traverses the DOM tree and designates the affected elements for recalculation.

Efficient handling of style invalidation is crucial as it directly impacts meeting the 60fps criterion, which is essential for smooth and responsive rendering. Several performance factors can be considered in this context:

  • The quantity of elements requiring traversal
  • The overhead associated with verifying the relationship between a DOM mutation and an element
  • The number of elements incorrectly marked for recalculation

These factors are affected by how selectors are defined within style rules. Selectors can take various forms and can be combined in infinite ways, leading to a wide range of possibilities. This selector variation greatly adds to the complexity of the issue.

The :has() pseudo-class complicates the issue by introducing additional complexities and enables a wider range of variations in selectors:

  • variations in argument relationships
  • variations in the position of the :has() pseudo-class (subject/non-subject)
  • complexity of logical combinations inside :has()
  • various mutation scenarios
    • changes in class or attribute
    • element insertion or removal
    • state change triggered by user action)

To provide a basic understanding of style invalidation with :has() pseudo-class, this post will focus on a simple scenario: .a:has(.b).

Before going through how Chrome handles style invalidation with :has(), it is essential to grasp the two style invalidation approaches in Blink.

To get a brief understanding of Blink’s ‘Invalidation Sets’ approach, we can refer to design documents such as “CSS Style Invalidation in Blink”, “Invalidation Sets Design Doc.” and “Sibling Invalidation Design Doc”. Additionally, relevant information can be found in the comments within the rule_feature_set.h and invalidation_set.h files.

While it is impractical to cover every intricate detail of Blink’s style invalidation in this post, I will provide some highlights that aid in comprehending the approach.

  • To identify elements that need to be invalidated, Blink uses what we call ‘Invalidation sets’ to store meta-data of selectors in style sheets.
    <style>.a .b .c { ... }</style>
    <!--
    .a[>] { .c }  // For an element's class 'a' mutation,
                  // find descendants having class 'c'
                  // and follow .c[>] invalidation set.
                  // - mark the element to be reclaculated.
    .b[>] { .c }  // For an element's class 'b' mutation,
                  // find descendants having class 'c'
                  // and follow .c[>] invalidation set.
                  // - mark the element to be reclaculated.
    .c[>] { $ }   // For an element's class 'c' mutation,
                  // mark the element to be reclaculated.
    -->
    
    <div id="mutation_target">
      <div class="b">
        <div class="c"></div>
        <div></div>
      </div>
      <div>
        <div class="c"></div>
        <div></div>
      </div>
    </div>
    
    <script>
      mutation_target.classList.toggle("a");
    </script>
    
  • Invalidation sets serve as a mutation filter, allowing the style engine to disregard irrelevant DOM mutations.
    <style>.a .b .c { ... }</style>
    
    <div id="mutation_target">
      <div class="b">
        <div class="c"></div>
        <div></div>
      </div>
      <div>
        <div class="c"></div>
        <div></div>
      </div>
    </div>
    
    <script>
      mutation_target.classList.toggle("d"); // Do not trigger
                                             // any traversal
    </script>
    
  • Invalidation sets provide a means to identify a smaller set of elements that are potentially affected by a DOM mutation.
    <style>.a .b .c { ... }</style>
    
    <div id="mutation_target">
      <div class="b">
        <div class="c"></div> <!-- marked -->
        <div></div>
      </div>
      <div>
        <div class="c"></div> <!-- marked -->
        <div></div>
      </div>
    </div>
    
    <script>
      mutation_target.classList.toggle("a");
    </script>
    
  • Although the invalidation process is not perfect and may mark some elements unnecessarily for recalculation, it is still significantly more efficient than recalculating everything.
    <style>.a .b .c { ... }</style>
    
    <div id="mutation_target">
      <div class="b">
        <div class="c"></div> <!-- necessary marking -->
        <div></div>
      </div>
      <div>
        <div class="c"></div> <!-- unnecessary marking -->
        <div></div>
      </div>
    </div>
    
    <script>
      mutation_target.classList.toggle("a");
    </script>
    
  • The invalidation sets approach aims to identify elements that require invalidation by examining the changed element itself, its descendants, next siblings. This determination is based on the relationship defined by CSS combinators within selectors.
    <style> .a { ... } </style>
    <!--
    .a[>] { $ }   // For an element's class 'a' mutation,
                  // mark the element to be reclaculated.
    -->
    
    <style> .b .c { ... } </style>
    <!--
    .b[>] { .c }  // For an element's class 'b' mutation,
                  // find descendants having class 'c'
                  // and follow .c[>] invalidation set.
                  // - mark the element to be reclaculated.
    .c[>] { $ }   // For an element's class 'c' mutation,
                  // mark the element to be reclaculated.
    -->
    
    <style> .d ~ .e { ... } </style>
    <!--
    .d[+] { .e }  // For an element's class 'd' mutation,
                  // find next siblings having class 'e'
                  // and follow .e[>] invalidation set.
                  // - mark the element to be reclaculated.
    .e[>] { $ }   // For an element's class 'e' mutation,
                  // mark the element to be reclaculated.
    -->
    
    <style> .f ~ .g .h { ... } </style>
    <!--
    .f[+] { .g }  // For an element's class 'f' mutation,
                  // find next siblings having class 'g'
                  // and follow .g[>] invalidation set.
                  // - find descendants having class 'h'
                  // - and follow .h[>] invalidation set.
                  //   - mark the element to be reclaculated.
    .g[>] { .h }  // For an element's class 'g' mutation,
                  // find descendants having class 'h'
                  // and mark those to be recalculated.
    .h[>] { $ }   // For an element's class 'h' mutation,
                  // mark the element to be reclaculated.
    -->
    

In summary, the ‘Invalidation Sets’ approach involves extracting meta-data from selectors in style rules and creating invalidation sets. These sets enable the skipping of irrelevant mutations, resulting in a smaller set of elements that require style recalculation.

While the use of invalidation sets allows for skipping irrelevant mutations, it may not provide significant benefits in certain cases involving pseudo-state mutations. These cases include pseudo-states that are frequently changed in many elements (e.g., :hover) or pseudo-states that require relatively intensive operations to check (e.g., :nth-child()).

To address such scenarios, the Blink style engine implements the ‘Dynamic Restyle Flags’ approach. During style recalculation, the style engine sets these flags on DOM elements while testing selectors. This enables the style engine to determine whether an element is potentially affected by the pseudo-state change.

:hover dynamic restyle flag setting
<style> .a:hover { ... } </style>
<!--
.a[>] { $ }     // For an element's class 'a' mutation,
                // mark the element to be reclaculated.
:hover[>] { $ } // For an element's ':hover' state mutation,
                // mark the element to be reclaculated.
-->

<div> ... </div>
<div id="mutation_target"> ... </div>
<div> ... </div>

During the initial loading, the style engine attempts to match the style rule .a:hover { ... } against all elements. Every matching on each element fails at the first part (.a) of the selector. As a result, the style engine does not set the :hover dynamic restyle flag on any of the <div> elements.

When the mouse pointer hovers over the #mutation_target element after the initial loading, the :hover state of the element changes. Despite the presence of an invalidation set :hover[>] { $ }, the style engine does not trigger the style invalidation step because the element does not have the :hover dynamic restyle flag set.

If we add the a class to the #mutation_target element, it becomes invalidated due to the invalidation set .a[>] { $ }.

During the subsequent style recalculation, the style engine tests :hover selector against #mutation_target. At this point, regardless of the selector testing result, style engine sets the :hover dynamic restyle flag to indicate that the :hover state change of #mutation_target can affect an element’s style.

After the dynamic restyle flag set, the :hover state change on the #mutation_target can trigger the style invalidation.

nth-child() dynamic restyle flag setting

Let’s check how Blink handles style invalidation with :nth-child(). In terms of :nth-child() pseudo-state change, we need to focus on the element insertion and removal operations, as these can impact the nth-child() state of the inserted/removed element’s siblings.

In theory, every element possesses every possible :nth-child() pseudo-state, as they all belong to the DOM tree. Once a node becomes part of the DOM, all :nth-child() states are fixed.

However, maintaining all possible :nth-child() pseudo-states for every DOM node would be inefficient due to these reasons:

  • The calculation of :nth-child() is a heavy operation.
  • An insertion or removal operation can trigger :nth-child() state change for every next sibling element of the inserted or removed element, even if the affected :nth-child() pseudo-states are not needed.

To address this inefficiency, the style engine only calculates the :nth-child() pseudo-state when testing selectors during the style recalculation process.

The problem arises from the need to optimize restyle performance by minimizing unnecessary element invalidation. To achieve this, the style engine must determine whether the :nth-child() state of next siblings has changed in response to the mutation. However, since the state is not stored within the element nodes themselves, checking for state changes becomes complex and slows down the invalidation process.

To circumvent the need for :nth-child() state checking during the style invalidation step, the style engine introduces the :nth dynamic restyle flags. These flags are set on the parent element of the element being tested for :nth-child() pseudo-class. Their purpose is to indicate that the children of the parent element could be affected by :nth-child state change.

<style> .a:nth-child(2n) { ... } </style>
<!--
.a[>] { $ }         // For an element's class 'a' mutation,
                    // mark the element to be reclaculated.
:nth-child[>] { $ } // For an element's :nth-child state change,
                    // mark the element to be recalculated.
-->

<div id="nth_parent">
  <div></div>
  <div id="insertion_reference"></div>
  <div></div>
</div>

During the initial loading, the style engine attempts to match the style rule .a:nth-child(2n) { ... } to all elements. However, since none of the elements satisfy the first part of the selector(.a), the style engine does not set the :nth dynamic restyle flag on any elements in the DOM tree.

If an element is inserted before #insertion_reference, causing a change in the :nth-child(2n) state for certain elements, the style engine does not perform any invalidation steps because #nth_parent does not have the :nth dynamic restyle flag set.

However, when we add the class value a to the #insertion_reference, the style engine invalidates and recalculates the style of the element. While matching style rules, the style engine tests the :nth-child(2n) pseudo-class on the inserted element and sets the :nth dynamic restyle flag of its parent (#nth_parent).

Once the :nth dynamic restyle flag is set on #nth_parent, subsequent insertions can trigger sibling invalidation. When an element is inserted before #insertion_reference, the style engine marks the inserted element to be recalculated and checks if the parent element has the flag set. If the flag set, the style engine invalidates all next siblings of the inserted element to recalculate their styles.

The interesting aspect of this scenario is that multiple elements are involved in this process:

  • The inserted element
  • The element that has the flags set
  • The elements to be invalidated due to the :nth-child(2n) state change

In summary, the ‘Dynamic Restyle Flags’ approach utilizes flags within element nodes to indicate their relationship to a mutation. By checking these flags, the style engine can skip irrelevant mutations on elements, even if there are corresponding invalidation sets for those mutations.

The style invalidation for .a:has(.b)

As described above, the invalidation sets approach was designed to identify the subject element from descendants, next siblings and next sibling descendants.

<style> .a .b { ... } </style>

<div id="target">
  <div>
    <div class="b"></div>
  </div>
</div>

<script>
target.classList.toggle('a');
</script>

When the class value a is added to the #target, the style engine will search for the .b element among the descendant elements of #target. Once the .b is found, it will be invalidated.

We can change the subject of the relationship between .a and .b by using the :has() pseudo-class:

  • In the selector .a .b, the subject is .b.
  • In the selector .a:has(.b), the subject is .a.
<style> .a:has(.b) { ... } </style>

<div id="subject" class="a">
  <div id="not_subject">
    <div id="relevant_target"></div>
  </div>
</div>
<div>
  <div id="irrelevant_target"></div>
</div>

<script>
relevant_target.classList.toggle('b');
</script>

In the case of the style rule .a:has(.b) { ... }, the invalidation direction is different from .a .b { ... }. When the class value b is added to the #relevant_target, the style engine needs to traverse the ancestors of the #relevant_target to find the subject element .a.

By addressing the following questions, we can get insights into how Blink ensures efficient performance in style invalidation for the style rule .a:has(.b) { ... }:

  1. How does Blink avoid unnecessary ancestor traversal for irrelevant mutations?
  2. How does Blink prevent the invalidation of ancestors that are not the subject element?
  3. How does Blink avoid unnecessary ancestor traversal for relevant mutations on irrelevant elements?

Extracting meta-data from :has() argument selectors

When extracting meta-data from a selector, it is important to note that only the meta-data from :has() argument selectors has an impact on the :has() state change.

For example:

<style> .a:has(.b) { ... } </style>

<div id="subject" class="a">
  <div>
    <div id="relevant_target"></div>
  </div>
</div>

<script>
relevant_target.classList.toggle('c');
relevant_target.classList.toggle('a');
</script>

In the above scenario, the mutations involving the toggling of class value c and a should not trigger ancestor traversal. This is because the class value a does not affect the :has(.b) state and the class value c is not used in the style rule.

The only class value that influences the :has(.b) state is b, which is a part of the argument selector.

By extracting the meta-data from the argument selector, we can make meta-data sets that serve as mutation filters. These sets enable the style engine to avoid unnecessary ancestor traversal for mutations that do not involve the meta-data contained in the set.

<style>
  .a:has(.b) { ... }
  .c:has(.d ~ .e .f) { ... }
</style>
<!--
 The meta-data set { .b .d .e .f } is extracted from the
 argument selectors. This allows the style engine to
 specifically target mutations that involve changing the
 class values within the set.
-->

In blink, RuleFeatureSet class provides the functionality to StyleEngine class:

class CORE_EXPORT RuleFeatureSet {
  ...
  ValuesInHasArgument classes_in_has_argument_;
  ...
};
...
bool RuleFeatureSet::AddValueOfSimpleSelectorInHasArgument(
    const CSSSelector& selector) {
  if (selector.Match() == CSSSelector::kClass) {
    classes_in_has_argument_.insert(selector.Value());
    return true;
  }
  ...
}
...
bool RuleFeatureSet::NeedsHasInvalidationForClass(
    const AtomicString& class_name) const {
  return classes_in_has_argument_.Contains(class_name);
}
...

Set ‘Affected by :has()’ flag

In order to determine whether an ancestor element is a subject element and needs to be invalidated during ancestor traversal, the style engine sets an ‘Affected by :has()’ dynamic restyle flag.

This flag indicates that the element, when flagged, can be affected by a :has() state change.

In the example scenario

<style> .a:has(.b) { ... } </style>
<!--
The meta-data set { .b } is extracted.
-->

<div id="subject" class="a">
  <div id="not_subject">
    <div id="relevant_target"></div>
  </div>
</div>

<script>
relevant_target.classList.toggle('b');
</script>

When the class value b is added to #relevant_target, the style engine initiates ancestor traversal since b is included in the meta-data set from the argument selector.

During the ancestor traversal, the style engine encounters four ancestors: #not_subject, #subject, <body> and <html>. Among these ancestors, only the #subject is the subject element of the selector .a:has(.b) and needs to have its style recalculated after the mutation.

One possible approach is to invalidate all ancestors, regardless of whether they are subject or not. This approach is simple but inefficient.

Another approach is to extract meta-data, such as the class value a, from the selector and use it as a filter to identify the subject element. This approach can potentially reduce the number of invalidations, but implementing it requires careful consideration of selector variation, such as .a:is(:has(.b)).

There is a simpler approach: adding a dynamic restyle flag that indicates an element is an anchor element of a :has() argument selector (“Anchor element” refers to the element that can be affected by the state change of :has()).

In the case of .a:has(.b) { ... }, the anchor element for :has(.b) is the subject element of .a:has(.b), as :has(.b) is in the subject position. During ancestor traversal, the style engine can invalidate an ancestor element only if the ancestor element has the dynamic restyle flag set.

<style> .a:has(.b) { ... } </style>

<div id="subject" class="a"> <!-- AffectedBySubjectHas flag set -->
  <div id="not_subject">
    <div id="relevant_target"></div>
  </div>
</div>

Similar to the process of setting other dynamic restyle flags, the style engine sets the AffectedBySubjectHas flag when testing selectors. During the initial loading, when the style engine attempts to match the style rule .a:has(.b) { ... } to all elements, it tests the selector :has(.b) on #subject. Since the style engine needs to test a :has() on #subject, it sets the AffectedBySubjectHas flag on the element to indicate that it is the subject element affected by a :has() state change.

The dynamic restyle flags about :has() is in HasInvalidationFlags class and Element class provides the setter/getter functionality to SelectorChecker class and StyleEngine class

struct HasInvalidationFlags {
  ...
  unsigned affected_by_subject_has : 1;
  ...
};
...
bool SelectorChecker::CheckPseudoClass(
    const SelectorCheckingContext& context,
    MatchResult& result) const {
  ...
    case CSSSelector::kPseudoHas:
      if (mode_ == kResolvingStyle) {
        if (context.in_rightmost_compound) {
          element.SetAffectedBySubjectHas();
        } else {
        ...
  ...
}
...
void StyleEngine::InvalidateElementAffectedByHas(
    Element& element,
    bool for_element_affected_by_pseudo_in_has) {
  ...
  if (element.AffectedBySubjectHas()) {
    element.SetNeedsStyleRecalc(
        StyleChangeType::kLocalStyleChange,
        StyleChangeReasonForTracing::Create(
            blink::style_change_reason::kStyleInvalidator));
  }
  ...
}

Set ‘Ancestors affected by :has()’ Flag

The problem with the ‘Affected by :has() flag’ approach is that, the style engine needs to traverse ancestors for a relevant mutation even if there is no ancestor that has the flag set.

<style> .a:has(.b) { ... } </style>
<!--
Extract class value meta-data set { .b }
-->

<div id="subject" class="a"> <!-- AffectedBySubjectHas -->
  <div>
    <div id="relevant_target"></div>
  </div>
</div>
<div id="not_subject">
  <div>
    <div id="irrelevant_target"></div>
  </div>
</div>

When the style engine detects the mutation of adding the class value b, it will traverse ancestors because the class value is in the meta-data set. In the case of the mutation on #relevant_target, the style engine will find #subject during the ancestor traversal and invalidate it. However, in the case of the mutation on #irrelevant_target, the style engine does not need to traverse ancestors since there is no subject element in the ancestors of #irrelevant_target.

To skip the unnecessary ancestor traversal on #irrelevant_target, we can add an additional dynamic restyle flag indicating that the style engine can find a :has() argument’s anchor element in its ancestors.

<style> .a:has(.b) { ... } </style>
<!--
Extract class value meta-data set { .b }
-->

<div id="subject" class="a"> <!-- AffectedBySubjectHas -->
  <div>                               <!-- AncestorsAffectedByHas -->
    <div id="relevant_target"></div>  <!-- AncestorsAffectedByHas -->
  </div>
</div>
<div id="not_subject">
  <div>
    <div id="irrelevant_target"></div>
  </div>
</div>

While matching the style rule .a:has(.b) { ... }, the style engine tests :has(.b) on #subject. To test the pseudo-class, the style engine tries to test its argument :relative-anchor .b on the descendants of #subject. While testing the argument selector, the style engine sets the AncestorsAffectedBySubjectHas flag for each descendant. This flag indicates that the style engine can find a :has() anchor element among the ancestors of the element that have the flag set.

After the matching process, when the style engine detects a mutation on #irrelevant_target, it can skip the ancestor traversal because the element doesn’t have the AncestorsAffectedBySubjectHas flag set.

Similar to the AffectedBySubjectHas flag, this flag is in HasInvalidationFlags class and Element class provides the setter and getter functionality to SelectorChecker class and StyleEngine class.

struct HasInvalidationFlags {
  ...
  unsigned ancestors_or_ancestor_siblings_affected_by_has : 1;
  ...
};
...
void SetAffectedByHasFlagsForElementAtDepth(
    CheckPseudoHasArgumentContext& argument_context,
    Element* element,
    int depth) {
  if (depth > 0) {
    element->SetAncestorsOrAncestorSiblingsAffectedByHas();
  } else {
  ...
}
...
bool SelectorChecker::CheckPseudoHas(
    const SelectorCheckingContext& context,
    MatchResult& result) const {
  ...
    for (CheckPseudoHasArgumentTraversalIterator iterator(
             *has_anchor_element, argument_context);
         !iterator.AtEnd(); ++iterator) {
      if (update_affected_by_has_flags) {
        SetAffectedByHasFlagsForElementAtDepth(
            argument_context,
            iterator.CurrentElement(),
            iterator.CurrentDepth());
      }
      ...
    }
}
...
void StyleEngine::InvalidateAncestorsOrSiblingsAffectedByHas(
    const PseudoHasInvalidationTraversalContext& traversal_context) {
  ...
  Element* element = traversal_context.FirstElement();
  ...
  while (element) {
    traverse_to_parent |=
        element->AncestorsOrAncestorSiblingsAffectedByHas();
    ...
    if (!traverse_to_parent)
      return;

    element = element->parentElement();
    traverse_to_parent = false;
  }
}

Summary

In summary, Blink utilizes several approaches to handle style invalidation for .a:has(.b) { ... }:

  1. Mutation filters:
    Meta-data is extracted from the :has() argument selectors to create mutation filters. These filters determine which mutations are relevant for :has() state change.
  2. :has() anchor element flag:
    A dynamic restyle flag is set on elements to indicate that the elements can be affected by a :has() state change.
  3. :has() anchor element position flag:
    Other dynamic restyle flags are set on elements to indicate that the style engine can find a :has() anchor element by traversing tree from the elements for :has() style invalidation.

To support the variations of :has() style invalidation mentioned above, we can add some modifications to the above basic approaches. Here is the overview:

  • Dynamic restyle flags for finding a :has() anchor element from the previous siblings or the previous siblings of ancestors.
  • Dynamic restyle flag for non-subject :has() anchor element.
  • Extraction of invalidation sets from compound selectors containing :has() and :has() arguments: to handle logical combinations inside :has()
  • Adoption of dynamic restyle flags from parent and previous siblings of the inserted element so that the style engine can find a :has() anchor element by traversing the tree from the inserted element.
  • Dynamic restyle flag for :hover inside :has(). (The flag indicates that a :hover state change on the element can affect a :has() state change)

It would be better to cover more details in a separated post later.

Similar to the other Blink style invalidation approaches, the :has() style invalidation approach is not perfect, but better than recalculating everything.

I’ll conclude by sharing the test result from a simple performance test.
(Reference: has-restyle-performance.html)

The test compares the restyle performance with and without :has(), and it shows reasonable results:

  • Subject :has() invalidation performance in a tree (size: 19531)
    subject :has() invalidation performance
  • Non-subject :has() invalidation performance in a tree (size: 19531)
    non-subject :has() invalidation performance





This blog post is a part of the Chrome :has() pseudo-class support project, implemented by Igalia and funded by eye/o.