Skip to content

Commit 4e42756

Browse files
RobinMalfaitthecrypticacephilipp-spiess
authored
Design system driven upgrade migrations (#17831)
This PR introduces a vastly improved upgrade migrations system, to migrate your codebase and modernize your utilities to make use of the latest variants and utilities. It all started when I saw this PR the other day: #17790 I was about to comment "Don't forget to add a migration". But I've been thinking about a system where we can automate this process away. This PR introduces this system. This PR introduces upgrade migrations based on the internal Design System, and it mainly updates arbitrary variants, arbitrary properties and arbitrary values. ## The problem Whenever we ship new utilities, or you make changes to your CSS file by introducing new `@theme` values, or adding new `@utility` rules. It could be that the rest of your codebase isn't aware of that, but you could be using these values. For example, it could be that you have a lot of arbitrary properties in your codebase, they look something like this: ```html <div class="[color-scheme:dark] [text-wrap:balance]"></div> ``` Whenever we introduce new features in Tailwind CSS, you probably don't keep an eye on the release notes and update all of these arbitrary properties to the newly introduced utilities. But with this PR, we can run the upgrade tool: ```console npx -y @tailwindcss/upgrade@latest ``` ...and it will upgrade your project to use the new utilities: ```html <div class="scheme-dark text-balance"></div> ``` It also works for arbitrary values, for example imagine you have classes like this: ```html <!-- Arbitrary property --> <div class="[max-height:1lh]"></div> <!-- Arbitrary value --> <div class="max-h-[1lh]"></div> ``` Running the upgrade tool again: ```console npx -y @tailwindcss/upgrade@latest ``` ... gives you the following output: ```html <!-- Arbitrary property --> <div class="max-h-lh"></div> <!-- Arbitrary value --> <div class="max-h-lh"></div> ``` This is because of the original PR I mentioned, which introduced the `max-h-lh` utilities. A nice benefit is that this output only has 1 unique class instead of 2, which also potentially reduces the size of your CSS file. It could also be that you are using arbitrary values where you (or a team member) didn't even know an alternative solution existed. E.g.: ```html <div class="w-[48rem]"></div> ``` After running the upgrade tool you will get this: ```html <div class="w-3xl"></div> ``` We can go further though. Since the release of Tailwind CSS v4, we introduced the concept of "bare values". Essentially allowing you to type a number on utilities where it makes sense, and we produce a value based on that number. So an input like this: ```html <div class="border-[123px]"></div> ``` Will be optimized to just: ```html <div class="border-123"></div> ``` This can be very useful for complex utilities, for example, how many times have you written something like this: ```html <div class="grid-cols-[repeat(16,minmax(0,1fr))]"></div> ``` Because up until Tailwind CSS v4, we only generated 12 columns by default. But since v4, we can generate any number of columns automatically. Running the migration tool will give you this: ```html <div class="grid-cols-16"></div> ``` ### User CSS But, what if I told you that we can keep going... In [Catalyst](https://tailwindcss.com/plus/ui-kit) we often use classes that look like this for accessibility reasons: ```html <div class="text-[CanvasText] bg-[Highlight]"></div> ``` What if you want to move the `CanvasText` and `Highlight` colors to your CSS: ```css @import "tailwincdss"; @theme { --color-canvas: CanvasText; --color-highlight: Highlight; } ``` If you now run the upgrade tool again, this will be the result: ```html <div class="text-canvas bg-highlight"></div> ``` We never shipped a `text-canvas` or `bg-highlight` utility, but the upgrade tool uses your own CSS configuration to migrate your codebase. This will keep your codebase clean, consistent and modern and you are in control. Let's look at one more example, what if you have this in a lot of places: ```html <div class="[scrollbar-gutter:stable]"></div> ``` And you don't want to wait for the Tailwind CSS team to ship a `scrollbar-stable` (or similar) feature. You can add your own utility: ```css @import "tailwincdss"; @Utility scrollbar-stable { scrollbar-gutter: stable; } ``` ```html <div class="scrollbar-stable"></div> ``` ## The solution — how it works There are 2 big things happening here: 1. Instead of us (the Tailwind CSS team) hardcoding certain migrations, we will make use of the internal `DesignSystem` which is the source of truth for all this information. This is also what Tailwind CSS itself uses to generate the CSS file. The internal `DesignSystem` is essentially a list of all: 1. The internal utilities 2. The internal variants 3. The default theme we ship 4. The user CSS 1. With custom `@theme` values 2. With custom `@custom-variant` implementations 3. With custom `@utility` implementations 2. The upgrade tool now has a concept of `signatures` The signatures part is the most interesting one, and it allows us to be 100% sure that we can migrate your codebase without breaking anything. A signature is some unique identifier that represents a utility. But 2 utilities that do the exact same thing will have the same signature. To make this work, we have to make sure that we normalize values. One such value is the selector. I think a little visualization will help here: | UTILITY | GENERATED SIGNATURE | | ---------------- | ----------------------- | | `[display:flex]` | `.x { display: flex; }` | | `flex` | `.x { display: flex; }` | They have the exact same signature and therefore the upgrade tool can safely migrate them to the same utility. For this we will prefer the following order: 1. Static utilities — essentially no brackets. E.g.: `flex`, `grid-cols-2` 2. Arbitrary values — e.g.: `max-h-[1lh]`, `border-[2px]` 3. Arbitrary properties — e.g.: `[color-scheme:dark]`, `[display:flex]` We also have to canonicalize utilities to there minimal form. Essentially making sure we increase the chance of finding a match. ``` [display:_flex_] → [display:flex] → flex [display:_flex] → [display:flex] → flex [display:flex_] → [display:flex] → flex [display:flex] → [display:flex] → flex ``` If we don't do this, then the signatures will be slightly different, due to the whitespace: | UTILITY | GENERATED SIGNATURE | | ------------------ | ------------------------- | | `[display:_flex_]` | `.x { display: flex ; }` | | `[display:_flex]` | `.x { display: flex; }` | | `[display:flex_]` | `.x { display: flex ; }` | | `[display:flex]` | `.x { display: flex; }` | ### Other small improvements A few other improvements are for optimizing existing utilities: 1. Remove unnecessary data types. E.g.: - `bg-[color:red]` -> `bg-[red]` - `shadow-[shadow:inset_0_1px_--theme(--color-white/15%)]` -> `shadow-[inset_0_1px_--theme(--color-white/15%)]` This also makes use of these signatures and if dropping the data type results in the same signature then we can safely drop it. Additionally, if a more specific utility exists, we will prefer that one. This reduced ambiguity and the need for data types. - `bg-[position:123px]` → `bg-position-[123px]` - `bg-[123px]` → `bg-position-[123px]` - `bg-[size:123px]` → `bg-size-[123px]` 2. Optimizing modifiers. E.g.: - `bg-red-500/[25%]` → `bg-red-500/25` - `bg-red-500/[100%]` → `bg-red-500` - `bg-red-500/100` → `bg-red-500` 3. Hoist `not` in arbitrary variants - `[@media_not_(prefers-color-scheme:dark)]:flex` → `not-[@media_(prefers-color-scheme:dark)]:flex` → `not-dark:flex` (in case you are using the default `dark` mode implementation 4. Optimize raw values that could be converted to bare values. This uses the `--spacing` variable to ensure it is safe. - `w-[64rem]` → `w-256` --------- Co-authored-by: Jordan Pittman <[email protected]> Co-authored-by: Philipp Spiess <[email protected]>
1 parent 45cd32e commit 4e42756

38 files changed

+2169
-628
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Added
11+
12+
- Upgrade: Automatically convert candidates with arbitrary values to their utilities ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))
13+
14+
### Fixed
15+
16+
- Ensure negative arbitrary `scale` values generate negative values ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))
1117

1218
## [4.1.5] - 2025-04-30
1319

1420
### Added
1521

16-
- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
22+
- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.\* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
1723
- Add `h-lh` / `min-h-lh` / `max-h-lh` utilities ([#17790](https://github.com/tailwindlabs/tailwindcss/pull/17790))
1824
- Transition `display`, `visibility`, `content-visibility`, `overlay`, and `pointer-events` when using `transition` to simplify `@starting-style` usage ([#17812](https://github.com/tailwindlabs/tailwindcss/pull/17812))
1925

integrations/upgrade/index.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ test(
209209
"
210210
--- ./src/index.html ---
211211
<div
212-
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:[color:red] tw:in-[.tw\\:group]:flex"
212+
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:text-[red] tw:in-[.tw\\:group]:flex"
213213
></div>
214214
<div
215215
class="tw:group tw:group/foo tw:peer tw:peer/foo tw:group-hover:flex tw:group-hover/foo:flex tw:peer-hover:flex tw:peer-hover/foo:flex"

integrations/upgrade/js-config.test.ts

+12-12
Original file line numberDiff line numberDiff line change
@@ -1329,12 +1329,12 @@ describe('border compatibility', () => {
13291329
"
13301330
--- src/index.html ---
13311331
<div
1332-
class="[width:--spacing(2)]
1333-
[width:--spacing(4.5)]
1334-
[width:var(--spacing-5_5)]
1335-
[width:--spacing(13)]
1336-
[width:var(--spacing-100)]
1337-
[width:var(--spacing-miami)]"
1332+
class="w-2
1333+
w-4.5
1334+
w-5.5
1335+
w-13
1336+
w-100
1337+
w-miami"
13381338
></div>
13391339
13401340
--- src/input.css ---
@@ -1439,12 +1439,12 @@ describe('border compatibility', () => {
14391439
"
14401440
--- src/index.html ---
14411441
<div
1442-
class="[width:var(--spacing-2)]
1443-
[width:var(--spacing-4_5)]
1444-
[width:var(--spacing-5_5)]
1445-
[width:var(--spacing-13)]
1446-
[width:var(--spacing-100)]
1447-
[width:var(--spacing-miami)]"
1442+
class="w-2
1443+
w-4.5
1444+
w-5.5
1445+
w-13
1446+
w-100
1447+
w-miami"
14481448
></div>
14491449
14501450
--- src/input.css ---

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ it('should apply all candidate migration when migrating with a config', async ()
113113
`),
114114
).toMatchInlineSnapshot(`
115115
".foo {
116-
@apply tw:flex! tw:[color:var(--my-color)] tw:bg-linear-to-t;
116+
@apply tw:flex! tw:text-(--my-color) tw:bg-linear-to-t;
117117
}"
118118
`)
119119
})

packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
22
import { describe, expect, test } from 'vitest'
33
import { spliceChangesIntoString } from '../../utils/splice-changes-into-string'
4-
import { extractRawCandidates, printCandidate } from './candidates'
4+
import { extractRawCandidates } from './candidates'
55

66
let html = String.raw
77

@@ -190,7 +190,7 @@ describe('printCandidate()', () => {
190190

191191
// Sometimes we will have a functional and a static candidate for the same
192192
// raw input string (e.g. `-inset-full`). Dedupe in this case.
193-
let cleaned = new Set([...candidates].map((c) => printCandidate(designSystem, c)))
193+
let cleaned = new Set([...candidates].map((c) => designSystem.printCandidate(c)))
194194

195195
expect([...cleaned]).toEqual([result])
196196
})
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
import { Scanner } from '@tailwindcss/oxide'
2-
import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate'
3-
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
4-
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
52

63
export async function extractRawCandidates(
74
content: string,
@@ -16,273 +13,3 @@ export async function extractRawCandidates(
1613
}
1714
return candidates
1815
}
19-
20-
export function printCandidate(designSystem: DesignSystem, candidate: Candidate) {
21-
let parts: string[] = []
22-
23-
for (let variant of candidate.variants) {
24-
parts.unshift(printVariant(variant))
25-
}
26-
27-
// Handle prefix
28-
if (designSystem.theme.prefix) {
29-
parts.unshift(designSystem.theme.prefix)
30-
}
31-
32-
let base: string = ''
33-
34-
// Handle static
35-
if (candidate.kind === 'static') {
36-
base += candidate.root
37-
}
38-
39-
// Handle functional
40-
if (candidate.kind === 'functional') {
41-
base += candidate.root
42-
43-
if (candidate.value) {
44-
if (candidate.value.kind === 'arbitrary') {
45-
if (candidate.value !== null) {
46-
let isVarValue = isVar(candidate.value.value)
47-
let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value
48-
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
49-
50-
if (candidate.value.dataType) {
51-
base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}`
52-
} else {
53-
base += `-${open}${printArbitraryValue(value)}${close}`
54-
}
55-
}
56-
} else if (candidate.value.kind === 'named') {
57-
base += `-${candidate.value.value}`
58-
}
59-
}
60-
}
61-
62-
// Handle arbitrary
63-
if (candidate.kind === 'arbitrary') {
64-
base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]`
65-
}
66-
67-
// Handle modifier
68-
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
69-
if (candidate.modifier) {
70-
let isVarValue = isVar(candidate.modifier.value)
71-
let value = isVarValue ? candidate.modifier.value.slice(4, -1) : candidate.modifier.value
72-
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
73-
74-
if (candidate.modifier.kind === 'arbitrary') {
75-
base += `/${open}${printArbitraryValue(value)}${close}`
76-
} else if (candidate.modifier.kind === 'named') {
77-
base += `/${candidate.modifier.value}`
78-
}
79-
}
80-
}
81-
82-
// Handle important
83-
if (candidate.important) {
84-
base += '!'
85-
}
86-
87-
parts.push(base)
88-
89-
return parts.join(':')
90-
}
91-
92-
function printVariant(variant: Variant) {
93-
// Handle static variants
94-
if (variant.kind === 'static') {
95-
return variant.root
96-
}
97-
98-
// Handle arbitrary variants
99-
if (variant.kind === 'arbitrary') {
100-
return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]`
101-
}
102-
103-
let base: string = ''
104-
105-
// Handle functional variants
106-
if (variant.kind === 'functional') {
107-
base += variant.root
108-
// `@` is a special case for functional variants. We want to print: `@lg`
109-
// instead of `@-lg`
110-
let hasDash = variant.root !== '@'
111-
if (variant.value) {
112-
if (variant.value.kind === 'arbitrary') {
113-
let isVarValue = isVar(variant.value.value)
114-
let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value
115-
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
116-
117-
base += `${hasDash ? '-' : ''}${open}${printArbitraryValue(value)}${close}`
118-
} else if (variant.value.kind === 'named') {
119-
base += `${hasDash ? '-' : ''}${variant.value.value}`
120-
}
121-
}
122-
}
123-
124-
// Handle compound variants
125-
if (variant.kind === 'compound') {
126-
base += variant.root
127-
base += '-'
128-
base += printVariant(variant.variant)
129-
}
130-
131-
// Handle modifiers
132-
if (variant.kind === 'functional' || variant.kind === 'compound') {
133-
if (variant.modifier) {
134-
if (variant.modifier.kind === 'arbitrary') {
135-
base += `/[${printArbitraryValue(variant.modifier.value)}]`
136-
} else if (variant.modifier.kind === 'named') {
137-
base += `/${variant.modifier.value}`
138-
}
139-
}
140-
}
141-
142-
return base
143-
}
144-
145-
function printArbitraryValue(input: string) {
146-
let ast = ValueParser.parse(input)
147-
148-
let drop = new Set<ValueParser.ValueAstNode>()
149-
150-
ValueParser.walk(ast, (node, { parent }) => {
151-
let parentArray = parent === null ? ast : (parent.nodes ?? [])
152-
153-
// Handle operators (e.g.: inside of `calc(…)`)
154-
if (
155-
node.kind === 'word' &&
156-
// Operators
157-
(node.value === '+' || node.value === '-' || node.value === '*' || node.value === '/')
158-
) {
159-
let idx = parentArray.indexOf(node) ?? -1
160-
161-
// This should not be possible
162-
if (idx === -1) return
163-
164-
let previous = parentArray[idx - 1]
165-
if (previous?.kind !== 'separator' || previous.value !== ' ') return
166-
167-
let next = parentArray[idx + 1]
168-
if (next?.kind !== 'separator' || next.value !== ' ') return
169-
170-
drop.add(previous)
171-
drop.add(next)
172-
}
173-
174-
// The value parser handles `/` as a separator in some scenarios. E.g.:
175-
// `theme(colors.red/50%)`. Because of this, we have to handle this case
176-
// separately.
177-
else if (node.kind === 'separator' && node.value.trim() === '/') {
178-
node.value = '/'
179-
}
180-
181-
// Leading and trailing whitespace
182-
else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') {
183-
if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) {
184-
drop.add(node)
185-
}
186-
}
187-
188-
// Whitespace around `,` separators can be removed.
189-
// E.g.: `min(1px , 2px)` -> `min(1px,2px)`
190-
else if (node.kind === 'separator' && node.value.trim() === ',') {
191-
node.value = ','
192-
}
193-
})
194-
195-
if (drop.size > 0) {
196-
ValueParser.walk(ast, (node, { replaceWith }) => {
197-
if (drop.has(node)) {
198-
drop.delete(node)
199-
replaceWith([])
200-
}
201-
})
202-
}
203-
204-
recursivelyEscapeUnderscores(ast)
205-
206-
return ValueParser.toCss(ast)
207-
}
208-
209-
function simplifyArbitraryVariant(input: string) {
210-
let ast = ValueParser.parse(input)
211-
212-
// &:is(…)
213-
if (
214-
ast.length === 3 &&
215-
// &
216-
ast[0].kind === 'word' &&
217-
ast[0].value === '&' &&
218-
// :
219-
ast[1].kind === 'separator' &&
220-
ast[1].value === ':' &&
221-
// is(…)
222-
ast[2].kind === 'function' &&
223-
ast[2].value === 'is'
224-
) {
225-
return ValueParser.toCss(ast[2].nodes)
226-
}
227-
228-
return input
229-
}
230-
231-
function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
232-
for (let node of ast) {
233-
switch (node.kind) {
234-
case 'function': {
235-
if (node.value === 'url' || node.value.endsWith('_url')) {
236-
// Don't decode underscores in url() but do decode the function name
237-
node.value = escapeUnderscore(node.value)
238-
break
239-
}
240-
241-
if (
242-
node.value === 'var' ||
243-
node.value.endsWith('_var') ||
244-
node.value === 'theme' ||
245-
node.value.endsWith('_theme')
246-
) {
247-
node.value = escapeUnderscore(node.value)
248-
for (let i = 0; i < node.nodes.length; i++) {
249-
recursivelyEscapeUnderscores([node.nodes[i]])
250-
}
251-
break
252-
}
253-
254-
node.value = escapeUnderscore(node.value)
255-
recursivelyEscapeUnderscores(node.nodes)
256-
break
257-
}
258-
case 'separator':
259-
node.value = escapeUnderscore(node.value)
260-
break
261-
case 'word': {
262-
// Dashed idents and variables `var(--my-var)` and `--my-var` should not
263-
// have underscores escaped
264-
if (node.value[0] !== '-' && node.value[1] !== '-') {
265-
node.value = escapeUnderscore(node.value)
266-
}
267-
break
268-
}
269-
default:
270-
never(node)
271-
}
272-
}
273-
}
274-
275-
function isVar(value: string) {
276-
let ast = ValueParser.parse(value)
277-
return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var'
278-
}
279-
280-
function never(value: never): never {
281-
throw new Error(`Unexpected value: ${value}`)
282-
}
283-
284-
function escapeUnderscore(value: string): string {
285-
return value
286-
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
287-
.replaceAll(' ', '_') // Replace spaces with underscores
288-
}

0 commit comments

Comments
 (0)