Skip to content

Commit e4308da

Browse files
Template migrations: Add variant order codemods (#14524)
One of the breaking changes of v4 is the [inversion of variant order application](#13478). In v3, variants are applied "inside-out". For example a candidate like `*:first:underline` would produce the following CSS in v3: ```css .\*\:first\:underline:first-child > * { text-decoration-line: underline; } ``` To get the same behavior in v4, you would need to invert the candidate order to `first:*:underline`. This would generate the following CSS in v4: ```css :where(.first\:\*\:underline:first-child > *) { text-decoration-line: underline; } ``` ## The Migration The most naive approach would be to invert the variants for every candidate with at least two variants. This, however, runs into one issue and some unexpected inconsistencies. I have identified the following areas: 1. Some pseudo class variants _must appear at the end of the selector_. v3 was patching over this by doing some manual reordering in for these variants. For example, in v3, both of these variants create the same output CSS: `hover:before:underline` and `before:hover:underline`. In v4 we simplified this system though and no longer generate the same output in both cases. Instead, you'd always want to write `hover:before:underline`, ensuring that these variants will appear at the end. For an exact list of which variants these affect, take a look [at this diff](https://github.com/tailwindlabs/tailwindcss/pull/13478/files#diff-7779a0eebf6b980dd3abd63b39729b3023cf9a31c91594f5a25ea020b066e1c0L228-L246). 2. The `dark` variant and other at-rule variants are usually written before other variants. This is more of a recommendation to make it easier to read candidates rather than a difference in behavior as `@media` queries are hoisted by the engine. For this reason, both of these variants are _correct_ yet in real applications we prefer the first one: `lg:hover:underline`, `hover:lg:underline`. To avoid shuffling these rules across all candidates during the migration, we bucket `dark` and other at-rule variants into a special bucket that will not have their order changed (since people wrote stacks like `sm:max-lg:` before and we want to keep them as-is) and appear before all other variants. 3. For some variant stacks, the order does not matter. E.g.: `focus:hover:underline` and `hover:focus:underline` will be the same. We don't want to needlessly shuffle their order if we have to. With these considerations, the migration now works as follows: - If there is less then two variants, we do not need to migrate the candidate - If _every_ variant in the stack is an order-independent variant, we do not need to migrate the candidate - _Note that this is currently hardcoded to only support `&:hover` and `&:focus`._ - Otherwise, we loop over the candidates and put them into three buckets: - `mediaVariants` hold variants that only contribute `@media` rules _and_ the `dark` variant. - `pseudoElementVariants` hold variants that _must appear at the end of the selector_. This is based on the allow list from v3/early v4. - `regularVariants` contains the rest. - We now compute if any of the variants inside `regularVariants` is order dependent. - With this list of variants, we now construct the new order of variants as: ```ts [ ...atRuleVariants, ...(anyRegularVariantOrderDependent ? regularVariants.reverse() : regularVariants), ...pseudoElementVariants, ] ``` --------- Co-authored-by: Adam Wathan <[email protected]>
1 parent bbe08c3 commit e4308da

File tree

4 files changed

+219
-1
lines changed

4 files changed

+219
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Add support for `blocklist` in config files ([#14556](https://github.com/tailwindlabs/tailwindcss/pull/14556))
1515
- Add `color-scheme` utilities ([#14567](https://github.com/tailwindlabs/tailwindcss/pull/14567))
1616
- _Experimental_: Migrate `@import "tailwindcss/tailwind.css"` to `@import "tailwindcss"` ([#14514](https://github.com/tailwindlabs/tailwindcss/pull/14514))
17+
- _Experimental_: Add template codemods for migrating variant order ([#14524](https://github.com/tailwindlabs/tailwindcss/pull/14524]))
1718
- _Experimental_: Add template codemods for migrating `bg-gradient-*` utilities to `bg-linear-*` ([#14537](https://github.com/tailwindlabs/tailwindcss/pull/14537]))
1819
- _Experimental_: Add template codemods for migrating prefixes ([#14557](https://github.com/tailwindlabs/tailwindcss/pull/14557]))
1920
- _Experimental_: Add template codemods for removal of automatic `var(…)` injection ([#14526](https://github.com/tailwindlabs/tailwindcss/pull/14526))
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
2+
import dedent from 'dedent'
3+
import { expect, test } from 'vitest'
4+
import { variantOrder } from './variant-order'
5+
6+
let css = dedent
7+
8+
test.each([
9+
// Does nothing unless there are at least two variants
10+
['flex', 'flex'],
11+
['hover:flex', 'hover:flex'],
12+
['[color:red]', '[color:red]'],
13+
['[&:focus]:[color:red]', '[&:focus]:[color:red]'],
14+
15+
// Reorders simple variants that include combinators
16+
['*:first:flex', 'first:*:flex'],
17+
18+
// Does not reorder variants without combinators
19+
['data-[invalid]:data-[hover]:flex', 'data-[invalid]:data-[hover]:flex'],
20+
21+
// Does not reorder some known combinations where the order does not matter
22+
['hover:focus:flex', 'hover:focus:flex'],
23+
['focus:hover:flex', 'focus:hover:flex'],
24+
['[&:hover]:[&:focus]:flex', '[&:hover]:[&:focus]:flex'],
25+
['[&:focus]:[&:hover]:flex', '[&:focus]:[&:hover]:flex'],
26+
['data-[a]:data-[b]:flex', 'data-[a]:data-[b]:flex'],
27+
28+
// Handles pseudo-elements that cannot have anything after them
29+
// c.f. https://github.com/tailwindlabs/tailwindcss/pull/13478/files#diff-7779a0eebf6b980dd3abd63b39729b3023cf9a31c91594f5a25ea020b066e1c0
30+
['dark:before:flex', 'dark:before:flex'],
31+
['before:dark:flex', 'dark:before:flex'],
32+
33+
// Puts some pseudo-elements that must appear at the end of the selector at
34+
// the end of the candidate
35+
['dark:*:before:after:flex', 'dark:*:before:after:flex'],
36+
['dark:before:after:*:flex', 'dark:*:before:after:flex'],
37+
38+
// Some pseudo-elements are treated as regular variants
39+
['dark:*:hover:file:focus:underline', 'dark:focus:file:hover:*:underline'],
40+
41+
// Keeps at-rule-variants and the dark variant in the beginning and keeps their
42+
// order
43+
['sm:dark:hover:flex', 'sm:dark:hover:flex'],
44+
['[@media(print)]:group-hover:flex', '[@media(print)]:group-hover:flex'],
45+
['sm:max-xl:data-[a]:data-[b]:dark:hover:flex', 'sm:max-xl:dark:data-[a]:data-[b]:hover:flex'],
46+
[
47+
'sm:data-[root]:*:data-[a]:even:*:data-[b]:even:before:underline',
48+
'sm:even:data-[b]:*:even:data-[a]:*:data-[root]:before:underline',
49+
],
50+
['hover:[@supports(display:grid)]:flex', '[@supports(display:grid)]:hover:flex'],
51+
])('%s => %s', async (candidate, result) => {
52+
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
53+
base: __dirname,
54+
})
55+
56+
expect(variantOrder(designSystem, {}, candidate)).toEqual(result)
57+
})
58+
59+
test('it works with custom variants', async () => {
60+
let designSystem = await __unstable__loadDesignSystem(
61+
css`
62+
@import 'tailwindcss';
63+
@variant atrule {
64+
@media (print) {
65+
@slot;
66+
}
67+
}
68+
69+
@variant combinator {
70+
> * {
71+
@slot;
72+
}
73+
}
74+
75+
@variant pseudo {
76+
&::before {
77+
@slot;
78+
}
79+
}
80+
`,
81+
{
82+
base: __dirname,
83+
},
84+
)
85+
86+
expect(variantOrder(designSystem, {}, 'combinator:pseudo:atrule:underline')).toEqual(
87+
'atrule:combinator:pseudo:underline',
88+
)
89+
})
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { Config } from 'tailwindcss'
2+
import { walk, type AstNode } from '../../../../tailwindcss/src/ast'
3+
import type { Variant } from '../../../../tailwindcss/src/candidate'
4+
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
5+
import { printCandidate } from '../candidates'
6+
7+
export function variantOrder(
8+
designSystem: DesignSystem,
9+
_userConfig: Config,
10+
rawCandidate: string,
11+
): string {
12+
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
13+
if (candidate.variants.length <= 1) {
14+
continue
15+
}
16+
17+
let atRuleVariants = []
18+
let regularVariants = []
19+
let pseudoElementVariants = []
20+
21+
let originalOrder = candidate.variants
22+
23+
for (let variant of candidate.variants) {
24+
if (isAtRuleVariant(designSystem, variant)) {
25+
atRuleVariants.push(variant)
26+
} else if (isEndOfSelectorPseudoElement(designSystem, variant)) {
27+
pseudoElementVariants.push(variant)
28+
} else {
29+
regularVariants.push(variant)
30+
}
31+
}
32+
33+
// We only need to reorder regular variants if order is important
34+
let regularVariantsNeedReordering = regularVariants.some((v) =>
35+
isCombinatorVariant(designSystem, v),
36+
)
37+
38+
// The candidate list in the AST need to be in reverse order
39+
let newOrder = [
40+
...pseudoElementVariants,
41+
...(regularVariantsNeedReordering ? regularVariants.reverse() : regularVariants),
42+
...atRuleVariants,
43+
]
44+
45+
if (orderMatches(originalOrder, newOrder)) {
46+
continue
47+
}
48+
49+
return printCandidate(designSystem, { ...candidate, variants: newOrder })
50+
}
51+
return rawCandidate
52+
}
53+
54+
function isAtRuleVariant(designSystem: DesignSystem, variant: Variant) {
55+
// Handle the dark variant as an at-rule variant
56+
if (variant.kind === 'static' && variant.root === 'dark') {
57+
return true
58+
}
59+
let stack = getAppliedNodeStack(designSystem, variant)
60+
return stack.every((node) => node.kind === 'rule' && node.selector[0] === '@')
61+
}
62+
63+
function isCombinatorVariant(designSystem: DesignSystem, variant: Variant) {
64+
let stack = getAppliedNodeStack(designSystem, variant)
65+
return stack.some(
66+
(node) =>
67+
node.kind === 'rule' &&
68+
// Ignore at-rules as they are hoisted
69+
node.selector[0] !== '@' &&
70+
// Combinators include any of the following characters
71+
(node.selector.includes(' ') ||
72+
node.selector.includes('>') ||
73+
node.selector.includes('+') ||
74+
node.selector.includes('~')),
75+
)
76+
}
77+
78+
function isEndOfSelectorPseudoElement(designSystem: DesignSystem, variant: Variant) {
79+
let stack = getAppliedNodeStack(designSystem, variant)
80+
return stack.some(
81+
(node) =>
82+
node.kind === 'rule' &&
83+
(node.selector.includes('::after') ||
84+
node.selector.includes('::backdrop') ||
85+
node.selector.includes('::before') ||
86+
node.selector.includes('::first-letter') ||
87+
node.selector.includes('::first-line') ||
88+
node.selector.includes('::marker') ||
89+
node.selector.includes('::placeholder') ||
90+
node.selector.includes('::selection')),
91+
)
92+
}
93+
94+
function getAppliedNodeStack(designSystem: DesignSystem, variant: Variant): AstNode[] {
95+
let stack: AstNode[] = []
96+
let ast = designSystem
97+
.compileAstNodes({
98+
kind: 'arbitrary',
99+
property: 'color',
100+
value: 'red',
101+
modifier: null,
102+
variants: [variant],
103+
important: false,
104+
raw: 'candidate',
105+
})
106+
.map((c) => c.node)
107+
108+
walk(ast, (node) => {
109+
// Ignore the variant root class
110+
if (node.kind === 'rule' && node.selector === '.candidate') {
111+
return
112+
}
113+
// Ignore the dummy declaration
114+
if (node.kind === 'declaration' && node.property === 'color' && node.value === 'red') {
115+
return
116+
}
117+
stack.push(node)
118+
})
119+
return stack
120+
}
121+
122+
function orderMatches<T>(a: T[], b: T[]): boolean {
123+
if (a.length !== b.length) {
124+
return false
125+
}
126+
return a.every((v, i) => b[i] === v)
127+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { automaticVarInjection } from './codemods/automatic-var-injection'
77
import { bgGradient } from './codemods/bg-gradient'
88
import { important } from './codemods/important'
99
import { prefix } from './codemods/prefix'
10+
import { variantOrder } from './codemods/variant-order'
1011

1112
export type Migration = (
1213
designSystem: DesignSystem,
@@ -18,7 +19,7 @@ export default async function migrateContents(
1819
designSystem: DesignSystem,
1920
userConfig: Config,
2021
contents: string,
21-
migrations: Migration[] = [prefix, important, automaticVarInjection, bgGradient],
22+
migrations: Migration[] = [prefix, important, bgGradient, automaticVarInjection, variantOrder],
2223
): Promise<string> {
2324
let candidates = await extractRawCandidates(contents)
2425

0 commit comments

Comments
 (0)