From e4308da60413e6b7d0bfacb57c9ccb2800ed7537 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Thu, 3 Oct 2024 15:15:19 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../template/codemods/variant-order.test.ts | 89 ++++++++++++ .../src/template/codemods/variant-order.ts | 127 ++++++++++++++++++ .../src/template/migrate.ts | 3 +- 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f02aa1fc4..04b99390a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts new file mode 100644 index 000000000..e42595645 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.test.ts @@ -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', + ) +}) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts new file mode 100644 index 000000000..12bdfbbb5 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts @@ -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(a: T[], b: T[]): boolean { + if (a.length !== b.length) { + return false + } + return a.every((v, i) => b[i] === v) +} diff --git a/packages/@tailwindcss-upgrade/src/template/migrate.ts b/packages/@tailwindcss-upgrade/src/template/migrate.ts index 738fd5ac4..b6076d80d 100644 --- a/packages/@tailwindcss-upgrade/src/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/template/migrate.ts @@ -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 { let candidates = await extractRawCandidates(contents)