From 1f1b46dd18b7dbf51b5f2824c3fd4178172cd907 Mon Sep 17 00:00:00 2001 From: Adrian Bannister Date: Fri, 12 Jun 2026 08:22:27 +1000 Subject: [PATCH] fix(purgecss): keep root-level pseudo-class selectors that still match 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 #978 and #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 --- .../purgecss/__tests__/pseudo-class.test.ts | 135 ++++++++++++++++++ packages/purgecss/src/index.ts | 44 +----- 2 files changed, 142 insertions(+), 37 deletions(-) diff --git a/packages/purgecss/__tests__/pseudo-class.test.ts b/packages/purgecss/__tests__/pseudo-class.test.ts index 6a55e230..d6c34c44 100644 --- a/packages/purgecss/__tests__/pseudo-class.test.ts +++ b/packages/purgecss/__tests__/pseudo-class.test.ts @@ -131,6 +131,141 @@ describe(":where pseudo class", () => { }); }); +describe(":where/:is combined with other pseudo classes at the root level", () => { + const purge = async (css: string, raw: string): Promise => { + const resultsPurge = await new PurgeCSS().purge({ + content: [{ raw, extension: "html" }], + css: [{ raw: css }], + }); + return resultsPurge[0].css; + }; + + it("keeps :where(.x) when x is used", async () => { + expect(await purge(":where(.aaa){color:red}", "aaa")).toContain("aaa"); + }); + + it("keeps .x:not(_) when x is used", async () => { + expect(await purge(".bbb:not(_){color:red}", "bbb")).toContain("bbb"); + }); + + it("keeps :where(.x):not(_) when x is used", async () => { + expect(await purge(":where(.aaa):not(_){color:red}", "aaa")).toContain( + "aaa", + ); + }); + + it("keeps :where(.x):not(_):not(_) when x is used", async () => { + expect( + await purge(":where(.ccc):not(_):not(_){color:red}", "ccc"), + ).toContain("ccc"); + }); + + it("keeps :is(.x):not(_) when x is used", async () => { + expect(await purge(":is(.ddd):not(_){color:red}", "ddd")).toContain("ddd"); + }); + + it("removes :where(.x):not(_) when x is unused", async () => { + expect(await purge(":where(.unused):not(_){color:red}", "aaa")).toBe(""); + }); + + it("keeps the used class inside :where() and trims the unused one", async () => { + const result = await purge( + ":where(.unused, .aaa):not(_):not(_){color:red}", + "aaa", + ); + expect(result).toContain(".aaa"); + expect(result).not.toContain(".unused"); + }); +}); + +describe("root-level pseudo-class selectors", () => { + const purge = async (css: string, raw: string): Promise => { + const resultsPurge = await new PurgeCSS().purge({ + content: [{ raw, extension: "html" }], + css: [{ raw: css }], + }); + return resultsPurge[0].css; + }; + + it("keeps :is(h1, h2, h3) when an h2 is present", async () => { + expect(await purge(":is(h1, h2, h3){color:red}", "

x

")).toContain( + "h2", + ); + }); + + it("removes :is(h1, h2, h3) when no matching tag is present", async () => { + expect(await purge(":is(h1, h2, h3){color:red}", "

x

")).toBe(""); + }); + + it("keeps :is(body) when body is present", async () => { + expect( + await purge(":is(body){background:white}", ""), + ).toContain("body"); + }); + + it("keeps :is(button, input):not(.x) when a button is present", async () => { + expect( + await purge( + ":is(button, input):not(.some-class){color:red}", + "", + ), + ).toContain("button"); + }); + + it("removes :is(button, input):not(.x) when neither tag is present", async () => { + expect( + await purge( + ":is(button, input):not(.some-class){color:red}", + "
d
", + ), + ).toBe(""); + }); + + it("keeps :where(:not(...):not(...)) combined with a present class", async () => { + const css = + ".is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin:1px}"; + expect( + await purge(css, "

x

"), + ).toContain(".is-layout-constrained"); + }); + + it("removes :where(:not(...)) when its class is absent", async () => { + const css = + ".is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin:1px}"; + expect(await purge(css, "

x

")).toBe(""); + }); + + it("keeps :where(.x) > :first-child when the class is present", async () => { + expect( + await purge( + ":where(.wp-site-blocks) > :first-child{margin:1px}", + "

x

", + ), + ).toContain(".wp-site-blocks"); + }); + + it("removes :where(.x) > :first-child when the class is absent", async () => { + expect( + await purge( + ":where(.wp-site-blocks) > :first-child{margin:1px}", + "

x

", + ), + ).toBe(""); + }); + + it("keeps a bare :hover rule", async () => { + expect(await purge(":hover{color:red}", "
x
")).toContain( + ":hover", + ); + }); + + it("keeps a bare :not(:hover) rule", async () => { + expect(await purge(":not(:hover){color:red}", "
x
")).toContain( + ":not(:hover)", + ); + }); +}); + describe(":is pseudo class", () => { let purgedCSS: string; beforeAll(async () => { diff --git a/packages/purgecss/src/index.ts b/packages/purgecss/src/index.ts index 8ea76009..f0ab68ac 100644 --- a/packages/purgecss/src/index.ts +++ b/packages/purgecss/src/index.ts @@ -310,33 +310,6 @@ function isInPseudoClassWhereOrIs(selector: selectorParser.Node): boolean { ); } -/** - * Returns true if the selector is a pseudo class at the root level - * Pseudo classes checked: :where, :is, :has, :not - * @param selector - selector - */ -function isPseudoClassAtRootLevel(selector: selectorParser.Node): boolean { - let result = false; - if ( - selector.type === "selector" && - selector.parent?.type === "root" && - selector.nodes.length === 1 - ) { - selector.walk((node) => { - if ( - node.type === "pseudo" && - (node.value === ":where" || - node.value === ":is" || - node.value === ":has" || - node.value === ":not") - ) { - result = true; - } - }); - } - return result; -} - function isPostCSSAtRule(node?: postcss.Node): node is postcss.AtRule { return node?.type === "atrule"; } @@ -906,10 +879,6 @@ class PurgeCSS { return true; } - if (isPseudoClassAtRootLevel(selector)) { - return true; - } - // if there is any greedy safelist pattern, run all the selector parts through them // if there is any match, return true if (this.options.safelist.greedy.length > 0) { @@ -924,8 +893,6 @@ class PurgeCSS { } } - let isPresent = false; - for (const selectorNode of selector.nodes) { const selectorValue = this.getSelectorValue(selectorNode); @@ -941,7 +908,6 @@ class PurgeCSS { (CSS_SAFELIST.includes(selectorValue) || this.isSelectorSafelisted(selectorValue)) ) { - isPresent = true; continue; } @@ -950,6 +916,7 @@ class PurgeCSS { return false; } + let isPresent: boolean; switch (selectorNode.type) { case "attribute": // `value` is a dynamic attribute, highly used in input element @@ -975,17 +942,20 @@ class PurgeCSS { isPresent = isTagFound(selectorNode, selectorsFromExtractor); break; default: + // Pseudo-classes (:where, :is, :not, …) carry no matchable name at + // this level; their inner selectors are evaluated separately. Skip + // them without treating the compound as unused. continue; } - // selector is not safelisted - // and it has not been found as an attribute/class/id/tag + // a matchable part (class/id/tag/attribute) was not found: drop it if (!isPresent) { return false; } } - return isPresent; + // nothing was found missing: keep the selector + return true; } /**