Skip to content

Commit 0d3e13e

Browse files
Migrate arbitrary values to built-in values (#14841)
This PR adds a migration where we detect arbitrary variants and try to upgrade them to built-in variants. For example, if you are using `[[data-visible]]:flex`, we can convert it to `data-visible:flex`. We can also upgrade more advanced examples such as `has-[[data-visible]]:flex` to a compound variant which becomes `has-data-visible:flex`. A table of example migrations: | Before | After | | ------------------------------------------ | ---------------------------------- | | `[[data-visible]]:flex` | `data-visible:flex` | | `[&[data-visible]]:flex` | `data-visible:flex` | | `[[data-visible]&]:flex` | `data-visible:flex` | | `[[data-url*="example"]]:flex` | `data-[url*="example"]:flex` | | `[[data-url$=".com"_i]]:flex` | `data-[url$=".com"_i]:flex` | | `[[data-url$=.com_i]]:flex` | `data-[url$=.com_i]:flex` | | `[&:is([data-visible])]:flex` | `data-visible:flex` | | `has-[[data-visible]]:flex` | `has-data-visible:flex` | | `has-[&:is([data-visible])]:flex` | `has-data-visible:flex` | | `has-[[data-slot=description]]:flex` | `has-data-[slot=description]:flex` | | `has-[&:is([data-slot=description])]:flex` | `has-data-[slot=description]:flex` | | `has-[[aria-visible="true"]]:flex` | `has-aria-visible:flex` | | `has-[[aria-visible]]:flex` | `has-aria-[visible]:flex` | We can also convert combinators from `[&>[data-visible]]:flex` to just `*:data-visible:flex` and `[&_[data-visible]]:flex` to `**:data-visible:flex`. | Before | After | | --- | --- | | `[&>[data-visible]]:flex` | `*:data-visible:flex` | | `[&_>_[data-visible]]:flex` | `*:data-visible:flex` | | `[&_[data-visible]]:flex` | `**:data-visible:flex` | Additionally, if you have complex selectors with `:not()`, we can convert this to a compound `not-*` variant in some cases as well: | Before | After | | --- | --- | | `[&:nth-child(2)]:flex` | `nth-2:flex` | | `[&:not(:nth-child(2))]:flex` | `not-nth-2:flex` | If some of the values in `nth-child(…)` are a bit too complex, then we still try to convert them but to arbitrary values instead. | Before | After | | --- | --- | | `[&:nth-child(-n+3)]:flex` | `nth-[-n+3]:flex` | | `[&:not(:nth-child(-n+3))]:flex` | `not-nth-[-n+3]:flex` | This also implements some optimizations around `even` and `odd`: | Before | After | | --- | --- | | `[&:nth-child(odd)]:flex` | `odd:flex` | | `[&:not(:nth-child(odd))]:flex` | `even:flex` | | `[&:nth-child(even)]:flex` | `even:flex` | | `[&:not(:nth-child(even))]:flex` | `odd:flex` | Some examples that stay as-is: - `has-[&>[data-visible]]:flex` we can't upgrade this one because `has-*` is not a valid variant. - `has-[[data-visible][data-dark]]:flex` we can't upgrade this one because `[data-visible][data-dark]` has to be on the same element. If we convert this to `has-data-visible:has-data-dark:flex` then this condition will be true if an element exists with `data-visible` and another element exists with `data-dark` but we don't guarantee that they are the same element. --- Running this on the Catalyst codebase results in some updates that look like this: <img width="676" alt="image" src="https://github.com/user-attachments/assets/6f0ff21d-5037-440b-9b80-0997ab0c11dd"> <img width="397" alt="image" src="https://github.com/user-attachments/assets/8f0856fa-1709-404a-ac34-7d8c661fa799"> --------- Co-authored-by: Adam Wathan <[email protected]>
1 parent 4f76980 commit 0d3e13e

File tree

4 files changed

+361
-3
lines changed

4 files changed

+361
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- _Upgrade (experimental)_: Rename `rounded` to `rounded-sm` and `rounded-sm` to `rounded-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
2323
- _Upgrade (experimental)_: Rename `blur` to `blur-sm` and `blur-sm` to `blur-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
2424
- _Upgrade (experimental)_: Migrate `theme()` usage and JS config files to use the new `--spacing` multiplier where possible ([#14905](https://github.com/tailwindlabs/tailwindcss/pull/14905))
25+
- _Upgrade (experimental)_: Migrate arbitrary values in variants to built-in values where possible ([#14841](https://github.com/tailwindlabs/tailwindcss/pull/14841))
2526

2627
### Fixed
2728

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
2+
import { expect, test } from 'vitest'
3+
import { modernizeArbitraryValues } from './modernize-arbitrary-values'
4+
5+
test.each([
6+
// Arbitrary variants
7+
['[[data-visible]]:flex', 'data-visible:flex'],
8+
['[&[data-visible]]:flex', 'data-visible:flex'],
9+
['[[data-visible]&]:flex', 'data-visible:flex'],
10+
['[&>[data-visible]]:flex', '*:data-visible:flex'],
11+
['[&_>_[data-visible]]:flex', '*:data-visible:flex'],
12+
13+
['[&_[data-visible]]:flex', '**:data-visible:flex'],
14+
15+
['[&:first-child]:flex', 'first:flex'],
16+
['[&:not(:first-child)]:flex', 'not-first:flex'],
17+
18+
// nth-child
19+
['[&:nth-child(2)]:flex', 'nth-2:flex'],
20+
['[&:not(:nth-child(2))]:flex', 'not-nth-2:flex'],
21+
22+
['[&:nth-child(-n+3)]:flex', 'nth-[-n+3]:flex'],
23+
['[&:not(:nth-child(-n+3))]:flex', 'not-nth-[-n+3]:flex'],
24+
['[&:nth-child(-n_+_3)]:flex', 'nth-[-n+3]:flex'],
25+
['[&:not(:nth-child(-n_+_3))]:flex', 'not-nth-[-n+3]:flex'],
26+
27+
// nth-last-child
28+
['[&:nth-last-child(2)]:flex', 'nth-last-2:flex'],
29+
['[&:not(:nth-last-child(2))]:flex', 'not-nth-last-2:flex'],
30+
31+
['[&:nth-last-child(-n+3)]:flex', 'nth-last-[-n+3]:flex'],
32+
['[&:not(:nth-last-child(-n+3))]:flex', 'not-nth-last-[-n+3]:flex'],
33+
['[&:nth-last-child(-n_+_3)]:flex', 'nth-last-[-n+3]:flex'],
34+
['[&:not(:nth-last-child(-n_+_3))]:flex', 'not-nth-last-[-n+3]:flex'],
35+
36+
// nth-child odd/even
37+
['[&:nth-child(odd)]:flex', 'odd:flex'],
38+
['[&:not(:nth-child(odd))]:flex', 'even:flex'],
39+
['[&:nth-child(even)]:flex', 'even:flex'],
40+
['[&:not(:nth-child(even))]:flex', 'odd:flex'],
41+
42+
// Keep multiple attribute selectors as-is
43+
['[[data-visible][data-dark]]:flex', '[[data-visible][data-dark]]:flex'],
44+
45+
// Complex attribute selectors with operators, quotes and insensitivity flags
46+
['[[data-url*="example"]]:flex', 'data-[url*="example"]:flex'],
47+
['[[data-url$=".com"_i]]:flex', 'data-[url$=".com"_i]:flex'],
48+
['[[data-url$=.com_i]]:flex', 'data-[url$=.com_i]:flex'],
49+
50+
// Attribute selector wrapped in `&:is(…)`
51+
['[&:is([data-visible])]:flex', 'data-visible:flex'],
52+
53+
// Compound arbitrary variants
54+
['has-[[data-visible]]:flex', 'has-data-visible:flex'],
55+
['has-[&:is([data-visible])]:flex', 'has-data-visible:flex'],
56+
['has-[&>[data-visible]]:flex', 'has-[&>[data-visible]]:flex'],
57+
58+
['has-[[data-slot=description]]:flex', 'has-data-[slot=description]:flex'],
59+
['has-[&:is([data-slot=description])]:flex', 'has-data-[slot=description]:flex'],
60+
61+
['has-[[aria-visible="true"]]:flex', 'has-aria-visible:flex'],
62+
['has-[[aria-visible]]:flex', 'has-aria-[visible]:flex'],
63+
64+
['has-[&:not(:nth-child(even))]:flex', 'has-odd:flex'],
65+
])('%s => %s', async (candidate, result) => {
66+
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
67+
base: __dirname,
68+
})
69+
70+
expect(modernizeArbitraryValues(designSystem, {}, candidate)).toEqual(result)
71+
})
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import SelectorParser from 'postcss-selector-parser'
2+
import type { Config } from 'tailwindcss'
3+
import { parseCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
4+
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
5+
import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type'
6+
import { printCandidate } from '../candidates'
7+
8+
export function modernizeArbitraryValues(
9+
designSystem: DesignSystem,
10+
_userConfig: Config,
11+
rawCandidate: string,
12+
): string {
13+
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
14+
let clone = structuredClone(candidate)
15+
let changed = false
16+
17+
for (let [variant, parent] of variants(clone)) {
18+
// Expecting an arbitrary variant
19+
if (variant.kind !== 'arbitrary') continue
20+
21+
// Expecting a non-relative arbitrary variant
22+
if (variant.relative) continue
23+
24+
let ast = SelectorParser().astSync(variant.selector)
25+
26+
// Expecting a single selector node
27+
if (ast.nodes.length !== 1) continue
28+
29+
let prefixedVariant: Variant | null = null
30+
31+
// Track whether we need to add a `**:` variant
32+
let addStarStarVariant = false
33+
34+
// Handling a child combinator. E.g.: `[&>[data-visible]]` => `*:data-visible`
35+
if (
36+
// Only top-level, so `has-[&>[data-visible]]` is not supported
37+
parent === null &&
38+
// [&_>_[data-visible]]:flex
39+
// ^ ^ ^^^^^^^^^^^^^^
40+
ast.nodes[0].length === 3 &&
41+
ast.nodes[0].nodes[0].type === 'nesting' &&
42+
ast.nodes[0].nodes[0].value === '&' &&
43+
ast.nodes[0].nodes[1].type === 'combinator' &&
44+
ast.nodes[0].nodes[1].value === '>' &&
45+
ast.nodes[0].nodes[2].type === 'attribute'
46+
) {
47+
ast.nodes[0].nodes = [ast.nodes[0].nodes[2]]
48+
prefixedVariant = designSystem.parseVariant('*')
49+
}
50+
51+
// Handling a grand child combinator. E.g.: `[&_[data-visible]]` => `**:data-visible`
52+
if (
53+
// Only top-level, so `has-[&_[data-visible]]` is not supported
54+
parent === null &&
55+
// [&_[data-visible]]:flex
56+
// ^ ^^^^^^^^^^^^^^
57+
ast.nodes[0].length === 3 &&
58+
ast.nodes[0].nodes[0].type === 'nesting' &&
59+
ast.nodes[0].nodes[0].value === '&' &&
60+
ast.nodes[0].nodes[1].type === 'combinator' &&
61+
ast.nodes[0].nodes[1].value === ' ' &&
62+
ast.nodes[0].nodes[2].type === 'attribute'
63+
) {
64+
ast.nodes[0].nodes = [ast.nodes[0].nodes[2]]
65+
prefixedVariant = designSystem.parseVariant('**')
66+
}
67+
68+
// Filter out `&`. E.g.: `&[data-foo]` => `[data-foo]`
69+
let selectorNodes = ast.nodes[0].filter((node) => node.type !== 'nesting')
70+
71+
// Expecting a single selector (normal selector or attribute selector)
72+
if (selectorNodes.length !== 1) continue
73+
74+
let target = selectorNodes[0]
75+
if (target.type === 'pseudo' && target.value === ':is') {
76+
// Expecting a single selector node
77+
if (target.nodes.length !== 1) continue
78+
79+
// Expecting a single attribute selector
80+
if (target.nodes[0].nodes.length !== 1) continue
81+
82+
// Unwrap the selector from inside `&:is(…)`
83+
target = target.nodes[0].nodes[0]
84+
}
85+
86+
// Expecting a pseudo selector
87+
if (target.type === 'pseudo') {
88+
let targetNode = target
89+
let compoundNot = false
90+
if (target.value === ':not') {
91+
compoundNot = true
92+
if (target.nodes.length !== 1) continue
93+
if (target.nodes[0].type !== 'selector') continue
94+
if (target.nodes[0].nodes.length !== 1) continue
95+
if (target.nodes[0].nodes[0].type !== 'pseudo') continue
96+
97+
targetNode = target.nodes[0].nodes[0]
98+
}
99+
100+
let newVariant = ((value) => {
101+
//
102+
if (value === ':first-letter') return 'first-letter'
103+
else if (value === ':first-line') return 'first-line'
104+
//
105+
else if (value === ':file-selector-button') return 'file'
106+
else if (value === ':placeholder') return 'placeholder'
107+
else if (value === ':backdrop') return 'backdrop'
108+
// Positional
109+
else if (value === ':first-child') return 'first'
110+
else if (value === ':last-child') return 'last'
111+
else if (value === ':only-child') return 'only'
112+
else if (value === ':first-of-type') return 'first-of-type'
113+
else if (value === ':last-of-type') return 'last-of-type'
114+
else if (value === ':only-of-type') return 'only-of-type'
115+
// State
116+
else if (value === ':visited') return 'visited'
117+
else if (value === ':target') return 'target'
118+
// Forms
119+
else if (value === ':default') return 'default'
120+
else if (value === ':checked') return 'checked'
121+
else if (value === ':indeterminate') return 'indeterminate'
122+
else if (value === ':placeholder-shown') return 'placeholder-shown'
123+
else if (value === ':autofill') return 'autofill'
124+
else if (value === ':optional') return 'optional'
125+
else if (value === ':required') return 'required'
126+
else if (value === ':valid') return 'valid'
127+
else if (value === ':invalid') return 'invalid'
128+
else if (value === ':in-range') return 'in-range'
129+
else if (value === ':out-of-range') return 'out-of-range'
130+
else if (value === ':read-only') return 'read-only'
131+
// Content
132+
else if (value === ':empty') return 'empty'
133+
// Interactive
134+
else if (value === ':focus-within') return 'focus-within'
135+
else if (value === ':focus') return 'focus'
136+
else if (value === ':focus-visible') return 'focus-visible'
137+
else if (value === ':active') return 'active'
138+
else if (value === ':enabled') return 'enabled'
139+
else if (value === ':disabled') return 'disabled'
140+
//
141+
if (
142+
value === ':nth-child' &&
143+
targetNode.nodes.length === 1 &&
144+
targetNode.nodes[0].nodes.length === 1 &&
145+
targetNode.nodes[0].nodes[0].type === 'tag' &&
146+
targetNode.nodes[0].nodes[0].value === 'odd'
147+
) {
148+
if (compoundNot) {
149+
compoundNot = false
150+
return 'even'
151+
}
152+
return 'odd'
153+
}
154+
if (
155+
value === ':nth-child' &&
156+
targetNode.nodes.length === 1 &&
157+
targetNode.nodes[0].nodes.length === 1 &&
158+
targetNode.nodes[0].nodes[0].type === 'tag' &&
159+
targetNode.nodes[0].nodes[0].value === 'even'
160+
) {
161+
if (compoundNot) {
162+
compoundNot = false
163+
return 'odd'
164+
}
165+
return 'even'
166+
}
167+
168+
for (let [selector, variantName] of [
169+
[':nth-child', 'nth'],
170+
[':nth-last-child', 'nth-last'],
171+
[':nth-of-type', 'nth-of-type'],
172+
[':nth-last-of-type', 'nth-of-last-type'],
173+
]) {
174+
if (value === selector && targetNode.nodes.length === 1) {
175+
if (
176+
targetNode.nodes[0].nodes.length === 1 &&
177+
targetNode.nodes[0].nodes[0].type === 'tag' &&
178+
isPositiveInteger(targetNode.nodes[0].nodes[0].value)
179+
) {
180+
return `${variantName}-${targetNode.nodes[0].nodes[0].value}`
181+
}
182+
183+
return `${variantName}-[${targetNode.nodes[0].toString()}]`
184+
}
185+
}
186+
187+
return null
188+
})(targetNode.value)
189+
190+
if (newVariant === null) continue
191+
192+
// Add `not-` prefix
193+
if (compoundNot) newVariant = `not-${newVariant}`
194+
195+
let parsed = designSystem.parseVariant(newVariant)
196+
if (parsed === null) continue
197+
198+
// Update original variant
199+
changed = true
200+
Object.assign(variant, parsed)
201+
}
202+
203+
// Expecting an attribute selector
204+
else if (target.type === 'attribute') {
205+
// Attribute selectors
206+
let attributeKey = target.attribute
207+
let attributeValue = target.value
208+
? target.quoted
209+
? `${target.quoteMark}${target.value}${target.quoteMark}`
210+
: target.value
211+
: null
212+
213+
// Insensitive attribute selectors. E.g.: `[data-foo="value" i]`
214+
// ^
215+
if (target.insensitive && attributeValue) {
216+
attributeValue += ' i'
217+
}
218+
219+
let operator = target.operator ?? '='
220+
221+
// Migrate `data-*`
222+
if (attributeKey.startsWith('data-')) {
223+
changed = true
224+
attributeKey = attributeKey.slice(5) // Remove `data-`
225+
Object.assign(variant, {
226+
kind: 'functional',
227+
root: 'data',
228+
modifier: null,
229+
value:
230+
attributeValue === null
231+
? { kind: 'named', value: attributeKey }
232+
: { kind: 'arbitrary', value: `${attributeKey}${operator}${attributeValue}` },
233+
} satisfies Variant)
234+
}
235+
236+
// Migrate `aria-*`
237+
else if (attributeKey.startsWith('aria-')) {
238+
changed = true
239+
attributeKey = attributeKey.slice(5) // Remove `aria-`
240+
Object.assign(variant, {
241+
kind: 'functional',
242+
root: 'aria',
243+
modifier: null,
244+
value:
245+
attributeValue === null
246+
? { kind: 'arbitrary', value: attributeKey } // aria-[foo]
247+
: operator === '=' && target.value === 'true' && !target.insensitive
248+
? { kind: 'named', value: attributeKey } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true]
249+
: { kind: 'arbitrary', value: `${attributeKey}${operator}${attributeValue}` }, // aria-[foo~="true"], aria-[foo|="true"], …
250+
} satisfies Variant)
251+
}
252+
}
253+
254+
if (prefixedVariant) {
255+
let idx = clone.variants.indexOf(variant)
256+
if (idx === -1) continue
257+
258+
// Ensure we inject the prefixed variant
259+
clone.variants.splice(idx, 1, variant, prefixedVariant)
260+
}
261+
}
262+
263+
return changed ? printCandidate(designSystem, clone) : rawCandidate
264+
}
265+
266+
return rawCandidate
267+
}
268+
269+
function* variants(candidate: Candidate) {
270+
function* inner(
271+
variant: Variant,
272+
parent: Extract<Variant, { kind: 'compound' }> | null = null,
273+
): Iterable<[Variant, Extract<Variant, { kind: 'compound' }> | null]> {
274+
yield [variant, parent]
275+
276+
if (variant.kind === 'compound') {
277+
yield* inner(variant.variant, variant)
278+
}
279+
}
280+
281+
for (let variant of candidate.variants) {
282+
yield* inner(variant, null)
283+
}
284+
}

packages/@tailwindcss-upgrade/src/template/migrate.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { automaticVarInjection } from './codemods/automatic-var-injection'
88
import { bgGradient } from './codemods/bg-gradient'
99
import { important } from './codemods/important'
1010
import { maxWidthScreen } from './codemods/max-width-screen'
11+
import { modernizeArbitraryValues } from './codemods/modernize-arbitrary-values'
1112
import { prefix } from './codemods/prefix'
1213
import { simpleLegacyClasses } from './codemods/simple-legacy-classes'
1314
import { themeToVar } from './codemods/theme-to-var'
@@ -28,13 +29,14 @@ export type Migration = (
2829
export const DEFAULT_MIGRATIONS: Migration[] = [
2930
prefix,
3031
important,
31-
automaticVarInjection,
3232
bgGradient,
3333
simpleLegacyClasses,
34-
arbitraryValueToBareValue,
3534
maxWidthScreen,
3635
themeToVar,
37-
variantOrder,
36+
variantOrder, // Has to happen before migrations that modify variants
37+
automaticVarInjection,
38+
arbitraryValueToBareValue,
39+
modernizeArbitraryValues,
3840
]
3941

4042
export function migrateCandidate(

0 commit comments

Comments
 (0)