Skip to content

Drop nested rules that put a combinator after a pseudo-element - #20575

Merged
RobinMalfait merged 6 commits into
mainfrom
robin/research-issue-20571
Oct 15, 2026
Merged

RobinMalfait merged 6 commits into
mainfrom
robin/research-issue-20571

Conversation

@RobinMalfait

@RobinMalfait RobinMalfait commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

This PR fixes an issue where a utility with a nested rule, used with a pseudo-element variant, results in a selector that can never match anything.

@utility bg-* {
  & > * {
    --nearest-bg: --value(--color-*);
  }
}

With selection:bg-red-500, the nested rule was flattened to:

.selection\:bg-red-500::selection > * {
  --nearest-bg: var(--color-red-500);
}

A pseudo-element can only be followed by pseudo-classes, so this never matches. Lightning CSS drops these rules, but warns about each of them on every build:

Pseudo-elements like '::before' or '::after' can't be followed by selectors like 'Delim('>')'

The same happens for backdrop: and marker:. For marker: the ::-webkit-details-marker > * rule was even kept in the output.

When we flatten nested rules in handleNesting, we now drop each selector that puts a combinator after a pseudo-element (e.g. ::selection, ::part(…), vendor-prefixed ones like ::-webkit-details-marker, and the legacy :before, :after, :first-line and :first-letter). This also covers :is(…) and :where(…) when all of their arguments are dead, which means that built-in utilities like selection:space-x-4 no longer emit an empty :where() rule after optimization.

If none of the selectors are left, then the declarations of the rule are dropped. Rules nested inside of it are still processed, because they can still match (e.g. :is(&, .fallback)). This also applies to rules inside hoistable at-rules such as @media and @scope. An @scope is only dropped when its <scope-start> selector can never match. Empty @layer rules are kept (without the dead selector), because they can establish the layer order.

Things that are intentionally left alone:

  • Pseudo-classes after a pseudo-element (e.g. .a::before:hover).
  • Combinators before a pseudo-element (e.g. .a > ::before).
  • Top-level selectors written by the user (e.g. .a::selection > * { … }).
  • Unknown pseudo-elements, such as framework specific deep combinators like ::ng-deep (Angular) and ::v-deep (Vue 2). Another compiler rewrites them later on.
  • before: and after:. The parent rule has declarations (content: var(--tw-content)), so the nested rule stays nested as & > *. Browsers and Lightning CSS already drop it without a warning.

I also moved the isPseudoElement helper from canonicalize-candidates.ts to selector-parser.ts so both places can use it.

Fixes: #20571
Closes #20573

Test plan

  1. Added tests to ast.test.ts for dropped selectors, partially dropped selector lists, selectors that must be kept, unknown pseudo-elements, and nested rules, @scope and @layer rules inside a dropped rule.
  2. Added a test to utilities.test.ts for the issue's utility with selection:, backdrop: and hover:.
  3. Existing tests still pass.
  4. Built the issue's example and ran it through Lightning CSS: no warnings anymore.

@RobinMalfait
RobinMalfait requested a review from a team as a code owner October 14, 2026 22:25
@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d07f01ce-7751-46cf-ba36-6482bfdf28fb
📥 Commits

Reviewing files that changed from the base of the PR and between b3723bb and 84bf030.

📒 Files selected for processing: 2
  • packages/tailwindcss/src/ast.test.ts
  • packages/tailwindcss/src/ast.ts
🚧 Files skipped from review as they are similar to previous changes: 2
  • packages/tailwindcss/src/ast.test.ts
  • packages/tailwindcss/src/ast.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Walkthrough

The selector parser now provides shared pseudo-element detection. Nesting handling uses selector analysis to remove selectors that cannot match after nesting substitution and skips declarations under dead selector entries. It continues to process matchable nested rules. Tests cover invalid and valid selector combinations, including pseudo-element variants applied to utilities with nested rules.

Priority: ⬇️ Low

Severity of issue fixed: Low

Merge Risk: ⚪ Minimal · up to 84bf0

The change can proceed through normal checks; no concrete merge blocker remains.

Pre-merge checks: Passed 4
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed The changes meet the coding requirements in [#20571] and [#20573]. handleNesting removes selectors with a combinator after a recognized pseudo-element, including standard, functional, vendor-prefixe…
Out of Scope Changes check Passed The changes stay within [#20571] and [#20573]. The isPseudoElement move supports shared selector analysis. The @layer, at-rule, and @scope handling prevents dead nested content from producing in…
Description check Passed The description clearly explains the pseudo-element nesting issue, the implementation change, preserved cases, and test coverage. It is directly related to the changeset.
Title check Passed The title clearly and concisely summarizes the primary change: dropping nested rules that place a combinator after a pseudo-element.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new actionable issues were found.

Reviews (4) · Last reviewed commit: "Keep empty `@layer` rules inside a rule ..." · Reviewed by Greptile

Comment thread packages/tailwindcss/src/ast.ts Outdated
So that it can be used outside of canonicalization as well.
A utility with a nested rule like `& > *`, used with a pseudo-element
variant like `selection:`, was flattened to `.x::selection > *`. A
pseudo-element can only be followed by pseudo-classes, so this selector
never matches anything, and Lightning CSS warns about it on every build.

When flattening nested rules, we now drop the selectors that put a
combinator after a pseudo-element. This also covers selectors wrapped in
`:is(…)` or `:where(…)` when all of their arguments are dead (e.g.
`selection:space-x-4`). If no selectors are left, the rule is dropped.

Fixes: #20571
The initial fix dropped every flattened selector that puts a combinator
after a pseudo-element, together with all of its nested rules. That is
too aggressive in two cases:

- Unknown pseudo-elements. Some of them are not real pseudo-elements,
  such as the framework specific `::ng-deep` (Angular) and `::v-deep`
  (Vue 2), which are rewritten by another compiler later on. Now only
  standard pseudo-elements are dead before a combinator, including the
  legacy single-colon ones and vendor-prefixed ones (e.g.
  `::-webkit-details-marker`). This way we don't have to track third
  party frameworks.
- Nested rules of a rule that can never match. Its declarations are
  dropped, but nested rules can still match something, e.g.
  `:is(&, .fallback)` still matches `.fallback`, so keep walking them.
@RobinMalfait
RobinMalfait force-pushed the robin/research-issue-20571 branch from cb828e7 to 20ba773 Compare October 14, 2026 23:37
…e can still match

A user-authored `<scope-start>` like `:is(&, .fallback)` and a variant `@scope` with a live selector inside can still match, even when the parent rule can't. Only drop the `@scope` when its resolved `<scope-start>` can never match.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 45c7ae22-4a6d-441a-ab08-1c34b0f524ea
📥 Commits

Reviewing files that changed from the base of the PR and between 20ba773 and b3723bb.

📒 Files selected for processing: 2
  • packages/tailwindcss/src/ast.test.ts
  • packages/tailwindcss/src/ast.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Comment thread packages/tailwindcss/src/ast.ts Outdated
An empty `@layer` can establish the layer order, so dropping it can change the cascade of other rules. Emit it without the selector of the dead rule.

The `@layer foo;` line moves out of the "drop at-rules" test on purpose, because that behavior changes: it now has its own test.
@RobinMalfait
RobinMalfait merged commit 9c92dfd into main Oct 15, 2026
10 checks passed
@RobinMalfait
RobinMalfait deleted the robin/research-issue-20571 branch October 15, 2026 00:19
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.

A utility's nested rule under a pseudo-element variant is written as ::selection > * and dropped with a warning

1 participant