Skip to content

Instantly share code, notes, and snippets.

@hjanuschka
Last active July 28, 2026 12:19
Show Gist options
  • Select an option

  • Save hjanuschka/666099bd2182c85e1663758e290c2414 to your computer and use it in GitHub Desktop.

Select an option

Save hjanuschka/666099bd2182c85e1663758e290c2414 to your computer and use it in GitHub Desktop.
Chrome 150 style invalidation regression with :nth-child(... of S):has(...)::after

Chrome 150 regression: stale ::after style for :nth-child(... of S):has(...)

Summary

Chrome 150 fails to update a generated ::after pseudo-element after an ancestor class starts matching a selector containing this combination:

:nth-child(3n + 2 of .ad-count):has(+ .LIFY_block)::after

Wrapping :nth-child() in :is() makes invalidation work:

:is(:nth-child(3n + 2 of .ad-count)):has(+ .LIFY_block)::after

The two selectors match the same elements and have the same specificity. This appears to be style under-invalidation, not a parsing or selector matching problem.

Known results

  • Chrome for Testing 149.0.7827.155 arm64: PASS
  • Chrome for Testing 150.0.7871.125 arm64: FAIL
  • Google Chrome 150.0.7871.183 arm64: FAIL

Expected result after adding .show-ads:

{"direct":"\"DIRECT\"","wrapped":"\"WRAPPED\""}

Broken result:

{"direct":"\"\"","wrapped":"\"WRAPPED\""}

Element.matches() reports that both selector forms match after the class change. The direct form nevertheless retains its old content: "" computed style.

Reproduction

Open repro.html, or run:

/path/to/chrome \
  --headless \
  --no-sandbox \
  --disable-gpu \
  --dump-dom \
  file:///absolute/path/to/repro.html

Inspect the JSON in <pre id="result">.

Suggested red/green test

Add a Blink web test that:

  1. Creates the DOM without .show-ads.
  2. Forces lifecycle/style update and verifies both pseudo-elements have empty content.
  3. Adds .show-ads to each ancestor.
  4. Forces lifecycle/style update.
  5. Verifies both the direct and :is() forms now generate their expected content.

The direct form should fail before the fix and pass after it. The :is() form is a useful control.

Likely locations:

  • third_party/blink/web_tests/external/wpt/css/selectors/
  • third_party/blink/renderer/core/css/invalidation/
  • third_party/blink/renderer/core/css/invalidation/style_invalidator_test.cc

Confirmed regression and fix

Regression CL:

Fix CL:

The regression CL moved pseudo-element validation into SelectorChecker and made selector sub-contexts responsible for clearing pseudo-element state. The :nth-child(N of S) filter path in NthIndexCache::MatchesFilter() did not clear sub_context.pseudo_element.

While resolving a ::before or ::after style, the stale pseudo-element context leaked into matching the selector list inside :nth-child(... of S). This made originating-element matching fail even though Element.matches() on the originating element returned true.

The :is() workaround succeeded because logical selector-list matching already cleared the pseudo-element context. The fix adds the missing line:

sub_context.pseudo_element = nullptr;

The fix includes a WPT at:

third_party/blink/web_tests/external/wpt/css/selectors/nth-of-selector-before.html

Verified release behavior:

  • Chrome 149: passes
  • Chrome 150: fails
  • Chrome 152.0.7977.1 Canary: passes

Related issue

https://issues.chromium.org/issues/514372707 is related but not identical. The confirmed issue for this regression is https://issues.chromium.org/issues/531138929.

<!doctype html>
<meta charset="utf-8">
<title>nth-child(... of S):has() pseudo-element invalidation</title>
<style>
.LIFY_block::after {
content: "";
}
#direct-post:not(.force-print).show-ads
.LIFY_core .main-content
> :nth-child(3n + 2 of .ad-count):has(+ .LIFY_block)::after {
content: "DIRECT";
}
#wrapped-post:not(.force-print).show-ads
.LIFY_core .main-content
> :is(:nth-child(3n + 2 of .ad-count)):has(+ .LIFY_block)::after {
content: "WRAPPED";
}
</style>
<div id="direct-post">
<div class="LIFY_core"><div class="main-content"></div></div>
</div>
<div id="wrapped-post">
<div class="LIFY_core"><div class="main-content"></div></div>
</div>
<pre id="result"></pre>
<script>
for (const parent of document.querySelectorAll(".main-content")) {
for (let i = 1; i <= 8; ++i) {
const child = document.createElement("div");
child.className = `LIFY_block ad-count child-${i}`;
parent.append(child);
}
}
function pseudoContent(root, index) {
const element = document.querySelector(`#${root} .child-${index}`);
return getComputedStyle(element, "::after").content;
}
const before = {
direct: pseudoContent("direct-post", 2),
wrapped: pseudoContent("wrapped-post", 2),
};
document.querySelectorAll('[id$="-post"]').forEach((element) => {
element.classList.add("show-ads");
});
const directElement = document.querySelector("#direct-post .child-2");
const wrappedElement = document.querySelector("#wrapped-post .child-2");
const after = {
direct: pseudoContent("direct-post", 2),
wrapped: pseudoContent("wrapped-post", 2),
};
const matching = {
direct: directElement.matches(
":nth-child(3n + 2 of .ad-count):has(+ .LIFY_block)"
),
wrapped: wrappedElement.matches(
":is(:nth-child(3n + 2 of .ad-count)):has(+ .LIFY_block)"
),
};
result.textContent = JSON.stringify({before, after, matching});
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment