Skip to content

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

Closed
WeAreResilience wants to merge 1 commit into
tailwindlabs:mainfrom
WeAreResilience:fix/20571-pseudo-element-nested-combinator
Closed

WeAreResilience wants to merge 1 commit into
tailwindlabs:mainfrom
WeAreResilience:fix/20571-pseudo-element-nested-combinator

Conversation

@WeAreResilience

Copy link
Copy Markdown

Fixes #20571

Summary

A functional utility with a nested child rule, like @utility bg-* { & > * { … } }, used under a pseudo-element variant (selection:, marker:, backdrop:) gets flattened in handleNesting into .selection\:bg-red-500::selection > *. A pseudo-element can only be followed by pseudo-classes, so that rule never matches, and Lightning CSS prints the "Pseudo-elements like '::before' or '::after' can't be followed by selectors like 'Delim('>')'" warning on every build.

When a nested rule is flattened, each selector that ends up with a combinator right after a pseudo-element (::x, functional ones like ::part(x), and the legacy :before/:after/:first-line/:first-letter) is dropped. If none of the selectors are left, the rule is dropped. Pseudo-classes after a pseudo-element (.a::part(x):hover) and combinators before one (.a ::selection) are untouched, and user-authored top-level selectors are never rewritten.

One case is not covered here: for before:/after: the parent rule has declarations, so the child stays nested as & > * and Lightning CSS already drops it without a warning.

Test plan

  • New combinators after a pseudo-element cases in ast.test.ts, and a selection:/marker:/hover: case for the issue's utility in utilities.test.ts. 4 of them fail without the change; the two "keeps" cases pass on both and guard against over-matching.
  • npx vitest run in packages/tailwindcss: 5108 passed, 1 skipped.
  • prettier --check on the changed files.

A functional utility with a nested child rule, such as `& > *`, used
under a pseudo-element variant like `selection:` or `marker:` was
flattened into `.x::selection > *`. A pseudo-element can only be
followed by pseudo-classes, so that selector never matches and
Lightning CSS warns about it while optimizing.

When flattening a nested rule, drop each selector that puts a
combinator after a pseudo-element, and drop the rule when none are
left.

Fixes #20571
@WeAreResilience
WeAreResilience requested a review from a team as a code owner October 14, 2026 22:06
@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: d2955bf2-3ae6-4325-9c10-63913af258c6
📥 Commits

Reviewing files that changed from the base of the PR and between 2ce6652 and 08d1c13.

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

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

Walkthrough

Nested selector processing now removes selectors where a combinator follows a pseudo-element. It filters selector lists individually and skips a rule if no selectors remain. Tests cover standard and legacy pseudo-elements, valid selector forms, selector lists, and generated utility output for pseudo-element variants.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 08d1c

The changed selector filtering appears ready to merge after normal checks.

Pre-merge checks: Passed 4
Passed Passed checks: 4 passed
Check name Status Explanation
Description check Passed The description clearly explains the selector bug, the implemented fix, the intended behavior, and the validation results.
Title check Passed The title clearly and concisely states the main change: dropping nested rules with combinators after pseudo-elements.
Linked Issues check Passed Issue #20571 requires dropping nested selectors when flattening places a combinator after a pseudo-element, without emitting the Lightning CSS warning. The change in handleNesting filters invalid se…
Out of Scope Changes check Passed The changes are limited to nesting behavior in packages/tailwindcss/src/ast.ts and regression tests in ast.test.ts and utilities.test.ts. These changes directly support issue #20571. No unrelate…
  • 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 actionable issues were found.

Reviews (1) · Last reviewed commit: "Drop nested rules that put a combinator ..." · Reviewed by Greptile

@RobinMalfait

RobinMalfait commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Thanks a lot for the PR and for tracking this down, @WeAreResilience. Your root cause analysis matches what we fixed: a combinator after a pseudo-element can never match.

We merged the fix in #20575 instead of this PR. The core idea is the same. The implementation differs in these ways:

  • We only treat standard, vendor-prefixed and legacy pseudo-elements as pseudo-elements. This keeps framework specific deep combinators like ::ng-deep and ::v-deep, which another compiler rewrites later.
  • We also drop :is(…) and :where(…) when all of their arguments can never match. Built-in utilities like selection:space-x-4 no longer leave an empty :where() rule after optimization.
  • A rule that can never match drops its own declarations. We still process its nested rules, because they can match, for example :is(&, .fallback).
  • We moved isPseudoElement into selector-parser.ts, so canonicalize-candidates.ts and handleNesting share one helper.

Your report and your test cases helped a lot. We close this PR because the merged change covers it.

Sent by an AI assistant on behalf of @RobinMalfait.

@WeAreResilience

Copy link
Copy Markdown
Author

Thanks for the detailed breakdown. The ::ng-deep/::v-deep exception and the empty :where() cleanup are good catches I hadn't considered. Glad the tests were useful, and appreciate the quick fix.

isiahhill pushed a commit to isiahhill/tailwindcss that referenced this pull request Oct 15, 2026
…indlabs#20575)

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.

```css
@Utility bg-* {
  & > * {
    --nearest-bg: --value(--color-*);
  }
}
```

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

```css
.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: tailwindlabs#20571
Closes tailwindlabs#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.
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

2 participants