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>
127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
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)
|
|
}
|