Skip to content

Keep root-level :where()/:is() combined with other pseudos#1467

Open
AdrianBannister wants to merge 1 commit into
FullHuman:mainfrom
AdrianBannister:bannister-fix-where-not-root-purge
Open

Keep root-level :where()/:is() combined with other pseudos#1467
AdrianBannister wants to merge 1 commit into
FullHuman:mainfrom
AdrianBannister:bannister-fix-where-not-root-purge

Conversation

@AdrianBannister

Copy link
Copy Markdown
Contributor

Problem

A CSS rule whose selector is a root-level compound of pseudo-classes was purged even when the element/class it targets is present in the scanned content. Examples that were wrongly dropped:

  • :where(.a):not(_) / :is(.a):not(_):not(_):where(.a) alone and .a:not(_) alone were both kept; only the combination broke.
  • :is(h1, h2, h3), :is(body) — selector lists purged regardless of matching elements.
  • :is(button, input):not(.some-class):is() combined with :not().
  • :where(:not(.alignleft):not(.alignright):not(.alignfull)) — Gutenberg's :where()-wrapped :not() chains.
  • bare :hover, :not(:hover) — pseudo-classes with no preceding selector.

These were reported in #978 and #1282.

Root cause

In shouldKeepSelector, the per-node loop starts isPresent = false and only sets it from matchable nodes (class/id/tag/attribute); an absent matchable node early-returns false, while pseudo-class nodes hit the switch default and continue. So the loop reaches its terminal return isPresent with false in exactly one situation: the compound had no matchable node at all (every node was a pseudo). Nothing was actually missing, yet the rule was dropped.

The previous isPseudoClassAtRootLevel helper was a band-aid that rescued only the single-node form of this (selector.nodes.length === 1), so any compound (:where(.a):not(_), :is(...):not(...), …) still fell through and was purged.

Fix

Fix the terminal value instead of special-casing: reaching the end of the loop means no matchable part was found missing, so return true. This makes the isPseudoClassAtRootLevel special case redundant — it's removed.

The classes inside :where()/:is() are evaluated separately as their own selector nodes, so the real keep/purge decision (and the empty-:where/:is cleanup pass) already happens there — the compound level only needs to not veto it. A rule is still purged when none of the classes/tags it depends on are present (e.g. :where(.unused):not(_) → removed, verified).

Behavioural change

The only change in matching logic is that terminal return, so behaviour differs only for root-level compounds made up entirely of pseudo-classes. Anything containing a real class/id/tag is unchanged.

Selector Content has Before After
:where(.a):not(_) .a purged ✗ kept ✓
:is(.a):not(_):not(_) .a purged ✗ kept ✓
:is(h1, h2, h3) <h2> purged ✗ kept ✓
:is(button, input):not(.x) <button> purged ✗ kept ✓
:where(:not(.l):not(.r)) matching ancestor purged ✗ kept ✓
:hover, :hover:focus purged kept (matches *:hover, already kept)
:not(.unused):not(.other) purged kept (single :not(.unused) already kept; :not inverts)
:where(.unused):not(_) purged purged ✓ (unused class → still dropped)
:where(:is(.unused)) purged purged ✓ (holds through nesting)
.foo:not(.x) .foo absent purged purged ✓ (unchanged)
.foo:hover, *:hover matching kept kept ✓ (unchanged)

Tests

Expanded packages/purgecss/__tests__/pseudo-class.test.ts with the compound :where/:is/:not cases above, including negative cases that must still purge (unused class in :where, absent tag in :is(...):not(...), nested unused). Full purgecss suite: 147 passed, 25 suites.

A CSS rule whose selector is a root-level compound of pseudo-classes
(e.g. ":where(.a):not(_)", ":is(h1, h2, h3)", ":where(:not(.x):not(.y))",
or a bare ":hover") was purged even when the element/class it targets is
present in the scanned content.

Root cause is the terminal value of the matching loop in shouldKeepSelector.
"isPresent" is only set by matchable nodes (class/id/tag/attribute); an absent
one early-returns false, while pseudo-class nodes hit the switch default and
"continue". So the loop reaches "return isPresent" with false in exactly one
case: the compound had no matchable node at all (every node was a pseudo).
Nothing was actually missing, yet the rule was dropped. The classes inside
:where()/:is() are evaluated separately as their own selector nodes, so the
real keep/purge decision (and the empty-:where/:is cleanup) already happens
there -- the compound level only needs to not veto it.

Reaching the end of the loop means no matchable part was found missing, so
return true. This makes the previous isPseudoClassAtRootLevel special case
(which only rescued the single-node form) redundant; remove it.

Fixes the selectors reported in FullHuman#978 and FullHuman#1282 (including the :is()+:not()
and Gutenberg :where(:not(...)) shapes). A rule is still purged when none of
the classes/tags it depends on are present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant