Skip to content

Canonicalization: keep & when moving a pseudo-element into * or ** - #20550

Open
lazerg wants to merge 3 commits into
tailwindlabs:mainfrom
lazerg:fix/issue-20549-canonicalize-pseudo-element
Open

lazerg wants to merge 3 commits into
tailwindlabs:mainfrom
lazerg:fix/issue-20549-canonicalize-pseudo-element

Conversation

@lazerg

@lazerg lazerg commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #20549.

[&_::before]:flex was canonicalized to **:[::before]:flex. An arbitrary variant without & compiles to &:is(…), and pseudo-elements can't go inside :is(), so the canonical class generated an invalid selector.

When the */** fallback moves a pseudo-element (anything starting with ::, plus the legacy :before, :after, :first-line and :first-letter) into its own arbitrary variant, it now keeps the &, so [&_::before]:flex becomes **:[&::before]:flex and [&>::before]:flex becomes *:[&::before]:flex. Pseudo-classes still canonicalize to **:[:hover] as before.

Test plan

Added [&_::before]:flex, [&_>_::before]:flex, [&_:before]:flex and [&_>_:before]:flex cases next to the existing :--custom ones in canonicalize-candidates.test.ts. They fail on main (**:[::before]:flex) and pass with this change.

cd packages/tailwindcss
pnpm vitest run src/canonicalize-candidates.test.ts

@lazerg
lazerg requested a review from a team as a code owner October 7, 2026 01:58
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Walkthrough

When the canonicalizer rebuilds an arbitrary variant with a * or ** prefix, it now adds & to selectors that start with :: or match the listed legacy pseudo-elements. Tests cover descendant and child forms using ::before and :before. The Unreleased changelog records the fix.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🔵 Low · up to f4657

Mixed-case legacy pseudo-element selectors can still canonicalize incorrectly. This is a narrow edge case, so the PR is mergeable with bounded follow-up awareness.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #20549 requires preserving & when canonicalization moves a pseudo-element into a fallback arbitrary variant. The implementation adds & for selectors starting with :: and for legacy pseudo-…
Out of Scope Changes check ✅ Passed The source change and regression tests address the pseudo-element canonicalization defect in issue #20549. The changes since the previous review extend that same fix to legacy :before syntax. No unr…
Title check ✅ Passed The title clearly describes the main change: preserving & when canonicalization moves pseudo-elements into * or ** variants.
Description check ✅ Passed The description explains the pseudo-element canonicalization issue, the fix, and the added test cases. It is directly related to the changeset.
  • Autopilot · 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 7, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Fixes CSS selector canonicalization for pseudo-elements.

The PR appears safe to merge; the previous pseudo-element issue is fixed.

What we checked:

  • Extra pseudo-element declarations: No. The output stays an arbitrary variant. Parsing keeps its explicit &, and compilation uses its selector directly.

Reviews (2) · Last reviewed commit: "Handle single-colon pseudo-elements like..." · Reviewed by Greptile

replaceObject(variant, {
kind: 'arbitrary',
selector: target.value,
selector: target.value.startsWith('::') ? `&${target.value}` : target.value,

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.

P1 Legacy pseudo-element styles still disappear

The startsWith('::') check misses valid single-colon spellings such as :before, :after, :first-letter, and :first-line. For example, [&_:before]:flex still becomes **:[:before]:flex. Parsing that class wraps :before in &:is(:before). Pseudo-elements cannot appear inside :is(), so the browser ignores the rule and those styles disappear.

Preserve & for these legacy spellings too, and add child and descendant regression cases.

Knowledge Base Used: Utility and variant compilation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added :before, :after, :first-line and :first-letter to the same check in f465703, with tests for [&_:before] and [&_>_:before].

@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: 0c394f07-befc-4ce4-aad1-82f3c1ec9537
📥 Commits

Reviewing files that changed from the base of the PR and between ddbd733 and f465703.

📒 Files selected for processing (2)
  • packages/tailwindcss/src/canonicalize-candidates.test.ts
  • packages/tailwindcss/src/canonicalize-candidates.ts

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

selector: target.value,
selector:
target.value.startsWith('::') ||
[':before', ':after', ':first-line', ':first-letter'].includes(target.value)

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

git diff --unified=30 fa81d697fe572a10ac150d18964a093a7a874081 f465703fa74c1dba1025fd3bbad5cd059c8f2f6e -- packages/tailwindcss/src/canonicalize-candidates.ts
printf '\\n--- relevant source ---\\n'
nl -ba packages/tailwindcss/src/canonicalize-candidates.ts | sed -n '2020,2140p'
printf '\\n--- parser bindings and definitions ---\\n'
rg -n -F -- 'SelectorParser' packages/tailwindcss/src

Repository: tailwindlabs/tailwindcss

Length of output: 12300


🏁 Script executed:

printf '%s\\n' '--- selector parser declarations ---'
rg -n 'export function parse|function parse|case .:|kind: .selector.|value:' packages/tailwindcss/src/selector-parser.ts
printf '%s\\n' '--- parser implementation ---'
nl -ba packages/tailwindcss/src/selector-parser.ts | sed -n '1,240p'
printf '%s\\n' '--- canonicalization setup and target binding ---'
nl -ba packages/tailwindcss/src/canonicalize-candidates.ts | sed -n '1780,1835p'
nl -ba packages/tailwindcss/src/canonicalize-candidates.ts | sed -n '1980,2035p'

Repository: tailwindlabs/tailwindcss

Length of output: 12088


🏁 Script executed:

nl -ba packages/tailwindcss/src/selector-parser.ts | sed -n '232,520p'
printf '%s\\n' '--- variant selector initialization and canonicalization callers ---'
rg -n -F -- 'modernizeArbitraryValuesVariant' packages/tailwindcss/src/canonicalize-candidates.ts
rg -n -F -- 'selector: ' packages/tailwindcss/src/candidate.ts packages/tailwindcss/src/variants.ts

Repository: tailwindlabs/tailwindcss

Length of output: 9888


🏁 Script executed:

printf '%s\\n' '--- selector parser completion ---'
nl -ba packages/tailwindcss/src/selector-parser.ts | sed -n '490,680p'
printf '%s\\n' '--- arbitrary variant parsing ---'
rg -n 'kind: .arbitrary.|relative:|arbitrary variant|decode.*underscore|replaceAll.*_' packages/tailwindcss/src/candidate.ts packages/tailwindcss/src
nl -ba packages/tailwindcss/src/candidate.ts | sed -n '240,300p'
printf '%s\\n' '--- selector filtering before target selection ---'
nl -ba packages/tailwindcss/src/canonicalize-candidates.ts | sed -n '1920,2005p'

Repository: tailwindlabs/tailwindcss

Length of output: 19985


🏁 Script executed:

nl -ba packages/tailwindcss/src/candidate.ts | sed -n '985,1065p'
nl -ba packages/tailwindcss/src/utils/decode-arbitrary-value.ts | sed -n '1,100p'
nl -ba packages/tailwindcss/src/candidate.ts | sed -n '1170,1210p'

Repository: tailwindlabs/tailwindcss

Length of output: 7692


🏁 Script executed:

rg -n 'decodeArbitraryValue\\(|variant\\.selector|parseVariant\\(' packages/tailwindcss/src/candidate.ts packages/tailwindcss/src/variants.ts
nl -ba packages/tailwindcss/src/candidate.ts | sed -n '400,460p'
nl -ba packages/tailwindcss/src/candidate.ts | sed -n '560,720p'
nl -ba packages/tailwindcss/src/candidate.ts | sed -n '760,825p'

Repository: tailwindlabs/tailwindcss

Length of output: 11660


🏁 Script executed:

rg -n 'kind === .arbitrary.|case .arbitrary.|selector.*relative|variant\\.selector|selector: variant' packages/tailwindcss/src/variants.ts packages/tailwindcss/src/compile.ts packages/tailwindcss/src/canonicalize-candidates.ts
nl -ba packages/tailwindcss/src/variants.ts | sed -n '480,570p'
nl -ba packages/tailwindcss/src/compile.ts | sed -n '150,215p'

Repository: tailwindlabs/tailwindcss

Length of output: 9321


🌐 Web query:

site:w3.org/TR/selectors pseudo-elements ASCII case-insensitive names CSS

💡 Result:

Yes. **Selectors Level 4** says pseudo-element names are ASCII case-insensitive, so `::before` and `::BEFORE` are equivalent. ([w3.org](https://www.w3.org/TR/selectors/?utm_source=openai))

Citations:

- 1: https://www.w3.org/TR/selectors/?utm_source=openai

🏁 Script executed:

nl -ba packages/tailwindcss/src/canonicalize-candidates.ts | sed -n '1755,1780p'

Repository: tailwindlabs/tailwindcss

Length of output: 1116


Match legacy pseudo-element names without case sensitivity.

CSS treats legacy pseudo-element names as ASCII case-insensitive. For [&_:BEFORE]:flex, the parser preserves :BEFORE, so the exact lowercase match misses it and the fallback stores the selector without &. Normalize the name before matching and add a mixed-case regression test.

🐛 Suggested fix
-                [':before', ':after', ':first-line', ':first-letter'].includes(target.value)
+                [':before', ':after', ':first-line', ':first-letter'].includes(target.value.toLowerCase())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[':before', ':after', ':first-line', ':first-letter'].includes(target.value)
[':before', ':after', ':first-line', ':first-letter'].includes(target.value.toLowerCase())

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.

Canonicalization rewrites [&_::pseudo-element]:* to **:[::pseudo-element]:*, which is a syntax error

1 participant