Skip to content

Commit 8e826b1

Browse files
authored
Ensure @tailwindcss/upgrade runs on Tailwind CSS v4 projects and is idempotent (#17717)
This PR ensures that the `@tailwindcss/upgrade` tool works on existing Tailwind CSS v4 projects. This PR also ensures that the upgrade tool is idempotent, meaning that it can be run multiple times and it should result in the same output. One awesome feature this unlocks is that you can run the upgrade tool on your codebase at any time and upgrade classes if you still have some legacy syntaxes, such as `bg-[var(--my-color)]`, in your muscle memory. One small note: If something changed in the first run, re-running will not work immediately because your git repository will not be clean and the upgrade tool requires your git repo to be clean. But once you verified and committed your changes, the upgrade tool will be idempotent. Idempotency is guaranteed by ensuring that some migrations are skipped by checking what version of Tailwind CSS you are on _before_ the version is upgraded. For the Tailwind CSS version: We will resolve `tailwindcss` itself to know the _actual_ version that is installed (the one resolved from `node_modules`). Not the one available in your package.json. Your `package.json` could be out of sync if you reverted changes but didn't run `npm install` yet. Back to Idempotency: For example, we have migrations where we change the variant order of stacked variants. If we would run these migrations every time you run the upgrade tool then we would be flip-flopping the order every run. See: https://tailwindcss.com/docs/upgrade-guide#variant-stacking-order Another example is where we rename some utilities. For example, we rename: | Before | After | | ----------- | ----------- | | `shadow` | `shadow-sm` | | `shadow-sm` | `shadow-xs` | Notice how we have `shadow-sm` in both the `before` and `after` column. If we would run the upgrade tool again, then we would eventually migrate your original `shadow` to `shadow-sm` (first run) and then to `shadow-xs` (second run). Which would result in the wrong shadow. See: https://tailwindcss.com/docs/upgrade-guide#renamed-utilities --- The order of upgrade steps changed a bit as well to make the internals are easier to work with and reason about. 1. Find CSS files 2. Link JS config files (if you are in a Tailwind CSS v3 project) 3. Migrate the JS config files (if you are in a Tailwind CSS v3 project) 4. Upgrade Tailwind CSS to v4 (or the latest version at that point) 5. Migrate the stylesheets (we used to migrate the source files first) 6. Migrate the source files This is done so that step 5 and 6 will always operate on a Tailwind CSS v4 project and we don't need to check the version number again. This is also necessary because your CSS file will now very likely contain `@import "tailwindcss";` which doesn't exist in Tailwind CSS v3. This also means that we can rely on the same internals that Tailwind CSS actually uses for locating the source files. We will use `@tailwindcss/oxide`'s scanner to find the source files (and it also keeps your custom `@source` directives into account). This PR also introduces a few actual migrations related to recent features and changes we shipped. 1. We migrate deprecated classes to their new names: | Before | After | | --------------------- | --------------------- | | `bg-left-top` | `bg-top-left` | | `bg-left-bottom` | `bg-bottom-left` | | `bg-right-top` | `bg-top-right` | | `bg-right-bottom` | `bg-bottom-right` | | `object-left-top` | `object-top-left` | | `object-left-bottom` | `object-bottom-left` | | `object-right-top` | `object-top-right` | | `object-right-bottom` | `object-bottom-right` | Introduced in: - #17378 - #17437 2. We migrate simple arbitrary variants to their dedicated variant: | Before | After | | ----------------------- | ------------------- | | `[&:user-valid]:flex` | `user-valid:flex` | | `[&:user-invalid]:flex` | `user-invalid:flex` | Introduced in: - #12370 3. We migrate `@media` variants to their dedicated variant: | Before | After | | ----------------------------------------------------- | ------------------------- | | `[@media_print]:flex` | `print:flex` | | `[@media(prefers-reduced-motion:no-preference)]:flex` | `motion-safe:flex` | | `[@media(prefers-reduced-motion:reduce)]:flex` | `motion-reduce:flex` | | `[@media(prefers-contrast:more)]:flex` | `contrast-more:flex` | | `[@media(prefers-contrast:less)]:flex` | `contrast-less:flex` | | `[@media(orientation:portrait)]:flex` | `portrait:flex` | | `[@media(orientation:landscape)]:flex` | `landscape:flex` | | `[@media(forced-colors:active)]:flex` | `forced-colors:flex` | | `[@media(inverted-colors:inverted)]:flex` | `inverted-colors:flex` | | `[@media(pointer:none)]:flex` | `pointer-none:flex` | | `[@media(pointer:coarse)]:flex` | `pointer-coarse:flex` | | `[@media(pointer:fine)]:flex` | `pointer-fine:flex` | | `[@media(any-pointer:none)]:flex` | `any-pointer-none:flex` | | `[@media(any-pointer:coarse)]:flex` | `any-pointer-coarse:flex` | | `[@media(any-pointer:fine)]:flex` | `any-pointer-fine:flex` | | `[@media(scripting:none)]:flex` | `noscript:flex` | The new variants related to `inverted-colors`, `pointer`, `any-pointer` and `scripting` were introduced in: - #11693 - #16946 - #11929 - #17431 This also applies to the `not` case, e.g.: | Before | After | | --------------------------------------------------------- | ----------------------------- | | `[@media_not_print]:flex` | `not-print:flex` | | `[@media_not(prefers-reduced-motion:no-preference)]:flex` | `not-motion-safe:flex` | | `[@media_not(prefers-reduced-motion:reduce)]:flex` | `not-motion-reduce:flex` | | `[@media_not(prefers-contrast:more)]:flex` | `not-contrast-more:flex` | | `[@media_not(prefers-contrast:less)]:flex` | `not-contrast-less:flex` | | `[@media_not(orientation:portrait)]:flex` | `not-portrait:flex` | | `[@media_not(orientation:landscape)]:flex` | `not-landscape:flex` | | `[@media_not(forced-colors:active)]:flex` | `not-forced-colors:flex` | | `[@media_not(inverted-colors:inverted)]:flex` | `not-inverted-colors:flex` | | `[@media_not(pointer:none)]:flex` | `not-pointer-none:flex` | | `[@media_not(pointer:coarse)]:flex` | `not-pointer-coarse:flex` | | `[@media_not(pointer:fine)]:flex` | `not-pointer-fine:flex` | | `[@media_not(any-pointer:none)]:flex` | `not-any-pointer-none:flex` | | `[@media_not(any-pointer:coarse)]:flex` | `not-any-pointer-coarse:flex` | | `[@media_not(any-pointer:fine)]:flex` | `not-any-pointer-fine:flex` | | `[@media_not(scripting:none)]:flex` | `not-noscript:flex` | For each candidate, we run a set of upgrade migrations. If at the end of the migrations the original candidate is still the same as the new candidate, then we will parse & print the candidate one more time to pretty print into consistent classes. Luckily parsing is cached so there is no real downside overhead. Consistency (especially with arbitrary variants and values) will reduce your CSS file because there will be fewer "versions" of your class. Concretely, the pretty printing will apply changes such as: | Before | After | | ---------------------- | ----------------- | | `bg-[var(--my-color)]` | `bg-(--my-color)` | | `bg-[rgb(0,_0,_0)]` | `bg-[rgb(0,0,0)]` | Another big important reason for this change is that these classes on their own would have been migrated _if_ another migration was relevant for this candidate. This means that there are were some inconsistencies. E.g.: | Before | After | Reason | | ----------------------- | ---------------------- | ------------------------------------ | | `!bg-[var(--my-color)]` | `bg-(--my-color)!` | Because the `!` is in the wrong spot | | `bg-[var(--my-color)]` | `bg-[var(--my-color)]` | Because no migrations rand | As you can see, the way the `--my-color` variable is used, is different. This changes will make sure it will now always be consistent: | Before | After | | ----------------------- | ---------------------- | | `!bg-[var(--my-color)]` | `bg-(--my-color)!` | | `bg-[var(--my-color)]` | `bg-(--my-color)` | Yay! Of course, if you don't want these more cosmetic changes, you can always ignore the upgrade and revert these changes and only commit the changes you want. # Test plan - All existing tests still pass. - But I had to delete 1 test (we tested that Tailwind CSS v3 was required). - And had to mock the `version.isMajor` call to ensure we run the individual migration tests correctly. - Added new tests to test: 1. Migrating Tailwind CSS v4 projects works 1. Idempotency of the upgrade tool [ci-all]
1 parent 25ec6a3 commit 8e826b1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+640
-323
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Ensure `@tailwindcss/upgrade` runs on Tailwind CSS v4 projects ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
13+
1014
### Fixed
1115

1216
- Don't scan `.geojson` files for classes by default ([#17700](https://github.com/tailwindlabs/tailwindcss/pull/17700))

integrations/upgrade/index.test.ts

+186-34
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git'
12
import { candidate, css, html, js, json, test, ts } from '../utils'
23

34
test(
@@ -2595,40 +2596,6 @@ test(
25952596
},
25962597
)
25972598

2598-
test(
2599-
'requires Tailwind v3 before attempting an upgrade',
2600-
{
2601-
fs: {
2602-
'package.json': json`
2603-
{
2604-
"dependencies": {
2605-
"tailwindcss": "workspace:^",
2606-
"@tailwindcss/upgrade": "workspace:^"
2607-
}
2608-
}
2609-
`,
2610-
'tailwind.config.ts': js` export default {} `,
2611-
'src/index.html': html`
2612-
<div class="underline"></div>
2613-
`,
2614-
'src/index.css': css`
2615-
@tailwind base;
2616-
@tailwind components;
2617-
@tailwind utilities;
2618-
`,
2619-
},
2620-
},
2621-
async ({ exec, expect }) => {
2622-
let output = await exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) =>
2623-
e.toString(),
2624-
)
2625-
2626-
expect(output).toMatch(
2627-
/Tailwind CSS v.* found. The migration tool can only be run on v3 projects./,
2628-
)
2629-
},
2630-
)
2631-
26322599
test(
26332600
`upgrades opacity namespace values to percentages`,
26342601
{
@@ -2810,6 +2777,191 @@ test(
28102777
},
28112778
)
28122779
2780+
test(
2781+
'upgrades are idempotent, and can run on v4 projects',
2782+
{
2783+
fs: {
2784+
'package.json': json`
2785+
{
2786+
"dependencies": {
2787+
"tailwindcss": "^3",
2788+
"@tailwindcss/upgrade": "workspace:^"
2789+
},
2790+
"devDependencies": {
2791+
"@tailwindcss/cli": "workspace:^"
2792+
}
2793+
}
2794+
`,
2795+
'tailwind.config.js': js`
2796+
/** @type {import('tailwindcss').Config} */
2797+
module.exports = {
2798+
content: ['./src/**/*.{html,js}'],
2799+
}
2800+
`,
2801+
'src/index.html': html`
2802+
<div class="ring"></div>
2803+
`,
2804+
'src/input.css': css`
2805+
@tailwind base;
2806+
@tailwind components;
2807+
@tailwind utilities;
2808+
2809+
.foo {
2810+
@apply !bg-[var(--my-color)] rounded;
2811+
}
2812+
`,
2813+
},
2814+
},
2815+
async ({ exec, fs, expect }) => {
2816+
await exec('npx @tailwindcss/upgrade')
2817+
2818+
let before = await fs.dumpFiles('./src/**/*.{css,html}')
2819+
expect(before).toMatchInlineSnapshot(`
2820+
"
2821+
--- ./src/index.html ---
2822+
<div class="ring-3"></div>
2823+
2824+
--- ./src/input.css ---
2825+
@import 'tailwindcss';
2826+
2827+
/*
2828+
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
2829+
so we've added these compatibility styles to make sure everything still
2830+
looks the same as it did with Tailwind CSS v3.
2831+
2832+
If we ever want to remove these styles, we need to add an explicit border
2833+
color utility to any element that depends on these defaults.
2834+
*/
2835+
@layer base {
2836+
*,
2837+
::after,
2838+
::before,
2839+
::backdrop,
2840+
::file-selector-button {
2841+
border-color: var(--color-gray-200, currentcolor);
2842+
}
2843+
}
2844+
2845+
.foo {
2846+
@apply bg-(--my-color)! rounded-sm;
2847+
}
2848+
"
2849+
`)
2850+
2851+
// Commit the changes
2852+
if (isRepoDirty()) {
2853+
await exec('git add .')
2854+
await exec('git commit -m "upgrade"')
2855+
}
2856+
2857+
// Run the upgrade again
2858+
let output = await exec('npx @tailwindcss/upgrade')
2859+
expect(output).toContain('No changes were made to your repository')
2860+
2861+
let after = await fs.dumpFiles('./src/**/*.{css,html}')
2862+
expect(after).toMatchInlineSnapshot(`
2863+
"
2864+
--- ./src/index.html ---
2865+
<div class="ring-3"></div>
2866+
2867+
--- ./src/input.css ---
2868+
@import 'tailwindcss';
2869+
2870+
/*
2871+
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
2872+
so we've added these compatibility styles to make sure everything still
2873+
looks the same as it did with Tailwind CSS v3.
2874+
2875+
If we ever want to remove these styles, we need to add an explicit border
2876+
color utility to any element that depends on these defaults.
2877+
*/
2878+
@layer base {
2879+
*,
2880+
::after,
2881+
::before,
2882+
::backdrop,
2883+
::file-selector-button {
2884+
border-color: var(--color-gray-200, currentcolor);
2885+
}
2886+
}
2887+
2888+
.foo {
2889+
@apply bg-(--my-color)! rounded-sm;
2890+
}
2891+
"
2892+
`)
2893+
2894+
// Ensure the file system is in the same state
2895+
expect(before).toEqual(after)
2896+
},
2897+
)
2898+
2899+
test(
2900+
'upgrades run on v4 projects',
2901+
{
2902+
fs: {
2903+
'package.json': json`
2904+
{
2905+
"dependencies": {
2906+
"tailwindcss": "^4",
2907+
"@tailwindcss/upgrade": "workspace:^"
2908+
},
2909+
"devDependencies": {
2910+
"@tailwindcss/cli": "workspace:^"
2911+
}
2912+
}
2913+
`,
2914+
'src/index.html': html`
2915+
<!-- Migrating 'ring', 'rounded' and 'outline-none' are unsafe in v4 -> v4 migrations -->
2916+
<div class="ring rounded outline"></div>
2917+
2918+
<!-- Variant order is also unsafe to change in v4 projects -->
2919+
<div class="file:hover:flex *:hover:flex"></div>
2920+
<div class="hover:file:flex hover:*:flex"></div>
2921+
2922+
<!-- These are safe to migrate: -->
2923+
<div
2924+
class="!flex bg-red-500/[var(--my-opacity)] [@media(pointer:fine)]:flex bg-right-bottom object-left-top"
2925+
></div>
2926+
`,
2927+
'src/input.css': css`
2928+
@import 'tailwindcss';
2929+
2930+
.foo {
2931+
@apply !bg-[var(--my-color)];
2932+
}
2933+
`,
2934+
},
2935+
},
2936+
async ({ exec, fs, expect }) => {
2937+
await exec('npx @tailwindcss/upgrade')
2938+
2939+
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
2940+
"
2941+
--- ./src/index.html ---
2942+
<!-- Migrating 'ring', 'rounded' and 'outline-none' are unsafe in v4 -> v4 migrations -->
2943+
<div class="ring rounded outline"></div>
2944+
2945+
<!-- Variant order is also unsafe to change in v4 projects -->
2946+
<div class="file:hover:flex *:hover:flex"></div>
2947+
<div class="hover:file:flex hover:*:flex"></div>
2948+
2949+
<!-- These are safe to migrate: -->
2950+
<div
2951+
class="flex! bg-red-500/(--my-opacity) pointer-fine:flex bg-bottom-right object-top-left"
2952+
></div>
2953+
2954+
--- ./src/input.css ---
2955+
@import 'tailwindcss';
2956+
2957+
.foo {
2958+
@apply bg-(--my-color)!;
2959+
}
2960+
"
2961+
`)
2962+
},
2963+
)
2964+
28132965
function withBOM(text: string): string {
28142966
return '\uFEFF' + text
28152967
}

packages/@tailwindcss-upgrade/package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,15 @@
4040
"postcss-import": "^16.1.0",
4141
"postcss-selector-parser": "^7.1.0",
4242
"prettier": "catalog:",
43+
"semver": "^7.7.1",
44+
"tailwindcss": "workspace:*",
4345
"tree-sitter": "^0.22.4",
44-
"tree-sitter-typescript": "^0.23.2",
45-
"tailwindcss": "workspace:*"
46+
"tree-sitter-typescript": "^0.23.2"
4647
},
4748
"devDependencies": {
4849
"@types/braces": "^3.0.5",
4950
"@types/node": "catalog:",
50-
"@types/postcss-import": "^14.0.3"
51+
"@types/postcss-import": "^14.0.3",
52+
"@types/semver": "^7.7.0"
5153
}
5254
}

packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
22
import dedent from 'dedent'
33
import postcss from 'postcss'
4-
import { expect, it } from 'vitest'
4+
import { expect, it, vi } from 'vitest'
55
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
6+
import * as versions from '../../utils/version'
67
import { migrateAtApply } from './migrate-at-apply'
8+
vi.spyOn(versions, 'isMajor').mockReturnValue(true)
79

810
const css = dedent
911

packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ export function migrateAtApply({
88
designSystem,
99
userConfig,
1010
}: {
11-
designSystem: DesignSystem
12-
userConfig: Config
11+
designSystem: DesignSystem | null
12+
userConfig: Config | null
1313
}): Plugin {
1414
function migrate(atRule: AtRule) {
1515
let utilities = atRule.params.split(/(\s+)/)
@@ -35,6 +35,8 @@ export function migrateAtApply({
3535
})
3636

3737
return async () => {
38+
if (!designSystem) return
39+
3840
// If we have a valid designSystem and config setup, we can run all
3941
// candidate migrations on each utility
4042
params = await Promise.all(

packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import dedent from 'dedent'
22
import postcss from 'postcss'
3-
import { describe, expect, it } from 'vitest'
3+
import { describe, expect, it, vi } from 'vitest'
44
import { Stylesheet } from '../../stylesheet'
5+
import * as versions from '../../utils/version'
56
import { formatNodes } from './format-nodes'
67
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'
78
import { sortBuckets } from './sort-buckets'
9+
vi.spyOn(versions, 'isMajor').mockReturnValue(true)
810

911
const css = dedent
1012

packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.ts

+6
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
22
import SelectorParser from 'postcss-selector-parser'
33
import { segment } from '../../../../tailwindcss/src/utils/segment'
44
import { Stylesheet } from '../../stylesheet'
5+
import * as version from '../../utils/version'
56
import { walk, WalkAction, walkDepth } from '../../utils/walk'
67

78
export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
89
function migrate(atRule: AtRule) {
10+
// Migrating `@layer utilities` to `@utility` is only supported in Tailwind
11+
// CSS v3 projects. Tailwind CSS v4 projects could also have `@layer
12+
// utilities` but those aren't actual utilities.
13+
if (!version.isMajor(3)) return
14+
915
// Only migrate `@layer utilities` and `@layer components`.
1016
if (atRule.params !== 'utilities' && atRule.params !== 'components') return
1117

packages/@tailwindcss-upgrade/src/codemods/css/migrate-config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ export function migrateConfig(
1111
{
1212
configFilePath,
1313
jsConfigMigration,
14-
}: { configFilePath: string; jsConfigMigration: JSConfigMigration },
14+
}: { configFilePath: string | null; jsConfigMigration: JSConfigMigration | null },
1515
): Plugin {
1616
function migrate() {
1717
if (!sheet.isTailwindRoot) return
18+
if (!configFilePath) return
1819

1920
let alreadyInjected = ALREADY_INJECTED.get(sheet)
2021
if (alreadyInjected && alreadyInjected.includes(configFilePath)) {

packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ export function migrateMediaScreen({
99
designSystem,
1010
userConfig,
1111
}: {
12-
designSystem?: DesignSystem
13-
userConfig?: Config
12+
designSystem?: DesignSystem | null
13+
userConfig?: Config | null
1414
} = {}): Plugin {
1515
function migrate(root: Root) {
1616
if (!designSystem || !userConfig) return

packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
22
import dedent from 'dedent'
33
import postcss from 'postcss'
4-
import { expect, it } from 'vitest'
4+
import { expect, it, vi } from 'vitest'
5+
import * as versions from '../../utils/version'
56
import { formatNodes } from './format-nodes'
67
import { migratePreflight } from './migrate-preflight'
78
import { sortBuckets } from './sort-buckets'
9+
vi.spyOn(versions, 'isMajor').mockReturnValue(true)
810

911
const css = dedent
1012

0 commit comments

Comments
 (0)