Keep root-level :where()/:is() combined with other pseudos#1467
Open
AdrianBannister wants to merge 1 commit into
Open
Keep root-level :where()/:is() combined with other pseudos#1467AdrianBannister wants to merge 1 commit into
AdrianBannister wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.:hover,:not(:hover)— pseudo-classes with no preceding selector.These were reported in #978 and #1282.
Root cause
In
shouldKeepSelector, the per-node loop startsisPresent = falseand only sets it from matchable nodes (class/id/tag/attribute); an absent matchable node early-returnsfalse, while pseudo-class nodes hit theswitchdefaultandcontinue. So the loop reaches its terminalreturn isPresentwithfalsein 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
isPseudoClassAtRootLevelhelper 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 theisPseudoClassAtRootLevelspecial 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/:iscleanup 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.:where(.a):not(_).a:is(.a):not(_):not(_).a:is(h1, h2, h3)<h2>:is(button, input):not(.x)<button>:where(:not(.l):not(.r)):hover,:hover:focus*:hover, already kept):not(.unused):not(.other):not(.unused)already kept;:notinverts):where(.unused):not(_):where(:is(.unused)).foo:not(.x).fooabsent.foo:hover,*:hoverTests
Expanded
packages/purgecss/__tests__/pseudo-class.test.tswith the compound:where/:is/:notcases above, including negative cases that must still purge (unused class in:where, absent tag in:is(...):not(...), nested unused). Fullpurgecsssuite: 147 passed, 25 suites.