Template migrations: Add variant order codemods (#14524)

One of the breaking changes of v4 is the [inversion of variant order
application](https://github.com/tailwindlabs/tailwindcss/pull/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 <adam.wathan@gmail.com>
This commit is contained in:
Philipp Spiess 2024-10-03 15:15:19 +02:00 committed by GitHub
parent bbe08c3b84
commit e4308da604
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 219 additions and 1 deletions

View File

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `blocklist` in config files ([#14556](https://github.com/tailwindlabs/tailwindcss/pull/14556))
- Add `color-scheme` utilities ([#14567](https://github.com/tailwindlabs/tailwindcss/pull/14567))
- _Experimental_: Migrate `@import "tailwindcss/tailwind.css"` to `@import "tailwindcss"` ([#14514](https://github.com/tailwindlabs/tailwindcss/pull/14514))
- _Experimental_: Add template codemods for migrating variant order ([#14524](https://github.com/tailwindlabs/tailwindcss/pull/14524]))
- _Experimental_: Add template codemods for migrating `bg-gradient-*` utilities to `bg-linear-*` ([#14537](https://github.com/tailwindlabs/tailwindcss/pull/14537]))
- _Experimental_: Add template codemods for migrating prefixes ([#14557](https://github.com/tailwindlabs/tailwindcss/pull/14557]))
- _Experimental_: Add template codemods for removal of automatic `var(…)` injection ([#14526](https://github.com/tailwindlabs/tailwindcss/pull/14526))

View File

@ -0,0 +1,89 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import dedent from 'dedent'
import { expect, test } from 'vitest'
import { variantOrder } from './variant-order'
let css = dedent
test.each([
// Does nothing unless there are at least two variants
['flex', 'flex'],
['hover:flex', 'hover:flex'],
['[color:red]', '[color:red]'],
['[&:focus]:[color:red]', '[&:focus]:[color:red]'],
// Reorders simple variants that include combinators
['*:first:flex', 'first:*:flex'],
// Does not reorder variants without combinators
['data-[invalid]:data-[hover]:flex', 'data-[invalid]:data-[hover]:flex'],
// Does not reorder some known combinations where the order does not matter
['hover:focus:flex', 'hover:focus:flex'],
['focus:hover:flex', 'focus:hover:flex'],
['[&:hover]:[&:focus]:flex', '[&:hover]:[&:focus]:flex'],
['[&:focus]:[&:hover]:flex', '[&:focus]:[&:hover]:flex'],
['data-[a]:data-[b]:flex', 'data-[a]:data-[b]:flex'],
// Handles pseudo-elements that cannot have anything after them
// c.f. https://github.com/tailwindlabs/tailwindcss/pull/13478/files#diff-7779a0eebf6b980dd3abd63b39729b3023cf9a31c91594f5a25ea020b066e1c0
['dark:before:flex', 'dark:before:flex'],
['before:dark:flex', 'dark:before:flex'],
// Puts some pseudo-elements that must appear at the end of the selector at
// the end of the candidate
['dark:*:before:after:flex', 'dark:*:before:after:flex'],
['dark:before:after:*:flex', 'dark:*:before:after:flex'],
// Some pseudo-elements are treated as regular variants
['dark:*:hover:file:focus:underline', 'dark:focus:file:hover:*:underline'],
// Keeps at-rule-variants and the dark variant in the beginning and keeps their
// order
['sm:dark:hover:flex', 'sm:dark:hover:flex'],
['[@media(print)]:group-hover:flex', '[@media(print)]:group-hover:flex'],
['sm:max-xl:data-[a]:data-[b]:dark:hover:flex', 'sm:max-xl:dark:data-[a]:data-[b]:hover:flex'],
[
'sm:data-[root]:*:data-[a]:even:*:data-[b]:even:before:underline',
'sm:even:data-[b]:*:even:data-[a]:*:data-[root]:before:underline',
],
['hover:[@supports(display:grid)]:flex', '[@supports(display:grid)]:hover:flex'],
])('%s => %s', async (candidate, result) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(variantOrder(designSystem, {}, candidate)).toEqual(result)
})
test('it works with custom variants', async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@import 'tailwindcss';
@variant atrule {
@media (print) {
@slot;
}
}
@variant combinator {
> * {
@slot;
}
}
@variant pseudo {
&::before {
@slot;
}
}
`,
{
base: __dirname,
},
)
expect(variantOrder(designSystem, {}, 'combinator:pseudo:atrule:underline')).toEqual(
'atrule:combinator:pseudo:underline',
)
})

View File

@ -0,0 +1,127 @@
import type { Config } from 'tailwindcss'
import { walk, type AstNode } from '../../../../tailwindcss/src/ast'
import type { Variant } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from '../candidates'
export function variantOrder(
designSystem: DesignSystem,
_userConfig: Config,
rawCandidate: string,
): string {
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
if (candidate.variants.length <= 1) {
continue
}
let atRuleVariants = []
let regularVariants = []
let pseudoElementVariants = []
let originalOrder = candidate.variants
for (let variant of candidate.variants) {
if (isAtRuleVariant(designSystem, variant)) {
atRuleVariants.push(variant)
} else if (isEndOfSelectorPseudoElement(designSystem, variant)) {
pseudoElementVariants.push(variant)
} else {
regularVariants.push(variant)
}
}
// We only need to reorder regular variants if order is important
let regularVariantsNeedReordering = regularVariants.some((v) =>
isCombinatorVariant(designSystem, v),
)
// The candidate list in the AST need to be in reverse order
let newOrder = [
...pseudoElementVariants,
...(regularVariantsNeedReordering ? regularVariants.reverse() : regularVariants),
...atRuleVariants,
]
if (orderMatches(originalOrder, newOrder)) {
continue
}
return printCandidate(designSystem, { ...candidate, variants: newOrder })
}
return rawCandidate
}
function isAtRuleVariant(designSystem: DesignSystem, variant: Variant) {
// Handle the dark variant as an at-rule variant
if (variant.kind === 'static' && variant.root === 'dark') {
return true
}
let stack = getAppliedNodeStack(designSystem, variant)
return stack.every((node) => node.kind === 'rule' && node.selector[0] === '@')
}
function isCombinatorVariant(designSystem: DesignSystem, variant: Variant) {
let stack = getAppliedNodeStack(designSystem, variant)
return stack.some(
(node) =>
node.kind === 'rule' &&
// Ignore at-rules as they are hoisted
node.selector[0] !== '@' &&
// Combinators include any of the following characters
(node.selector.includes(' ') ||
node.selector.includes('>') ||
node.selector.includes('+') ||
node.selector.includes('~')),
)
}
function isEndOfSelectorPseudoElement(designSystem: DesignSystem, variant: Variant) {
let stack = getAppliedNodeStack(designSystem, variant)
return stack.some(
(node) =>
node.kind === 'rule' &&
(node.selector.includes('::after') ||
node.selector.includes('::backdrop') ||
node.selector.includes('::before') ||
node.selector.includes('::first-letter') ||
node.selector.includes('::first-line') ||
node.selector.includes('::marker') ||
node.selector.includes('::placeholder') ||
node.selector.includes('::selection')),
)
}
function getAppliedNodeStack(designSystem: DesignSystem, variant: Variant): AstNode[] {
let stack: AstNode[] = []
let ast = designSystem
.compileAstNodes({
kind: 'arbitrary',
property: 'color',
value: 'red',
modifier: null,
variants: [variant],
important: false,
raw: 'candidate',
})
.map((c) => c.node)
walk(ast, (node) => {
// Ignore the variant root class
if (node.kind === 'rule' && node.selector === '.candidate') {
return
}
// Ignore the dummy declaration
if (node.kind === 'declaration' && node.property === 'color' && node.value === 'red') {
return
}
stack.push(node)
})
return stack
}
function orderMatches<T>(a: T[], b: T[]): boolean {
if (a.length !== b.length) {
return false
}
return a.every((v, i) => b[i] === v)
}

View File

@ -7,6 +7,7 @@ import { automaticVarInjection } from './codemods/automatic-var-injection'
import { bgGradient } from './codemods/bg-gradient'
import { important } from './codemods/important'
import { prefix } from './codemods/prefix'
import { variantOrder } from './codemods/variant-order'
export type Migration = (
designSystem: DesignSystem,
@ -18,7 +19,7 @@ export default async function migrateContents(
designSystem: DesignSystem,
userConfig: Config,
contents: string,
migrations: Migration[] = [prefix, important, automaticVarInjection, bgGradient],
migrations: Migration[] = [prefix, important, bgGradient, automaticVarInjection, variantOrder],
): Promise<string> {
let candidates = await extractRawCandidates(contents)