Skip to content

Commit 498f9ff

Browse files
authored
Migrate bare values to named values (#18000)
This PR improves the upgrade tool by also migrating bare values to named values defined in the `@theme`. Recently we shipped some updates dat allowed us to migrate arbitrary values (with square brackets), but we didn't migrate bare values yet. That means that in this example: ```html <div class="aspect-[16/9]"></div> <div class="aspect-16/9"></div> ``` We migrated this to: ```html <div class="aspect-video"></div> <div class="aspect-16/9"></div> ``` With this change, we will also try and migrate the bare value to a named value. So this example: ```html <div class="aspect-[16/9]"></div> <div class="aspect-16/9"></div> ``` Now becomes: ```html <div class="aspect-video"></div> <div class="aspect-video"></div> ``` ## Test plan 1. Added unit tests for the new functionality. 2. Ran this on a local project Before: <img width="432" alt="image" src="https://github.com/user-attachments/assets/ce1adfbd-7be1-4062-bea5-66368f748e44" /> After: <img width="382" alt="image" src="https://github.com/user-attachments/assets/a385c94c-4e4c-4e1c-ac73-680c56ac4081" />
1 parent ef2e6c7 commit 498f9ff

9 files changed

+283
-72
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- `lightningcss` now statically links Visual Studio redistributables ([#17979](https://github.com/tailwindlabs/tailwindcss/pull/17979))
1717
- Ensure that running the Standalone build does not leave temporary files behind ([#17981](https://github.com/tailwindlabs/tailwindcss/pull/17981))
1818

19+
### Added
20+
21+
- Upgrade: Migrate bare values to named values ([#18000](https://github.com/tailwindlabs/tailwindcss/pull/18000))
22+
1923
## [4.1.6] - 2025-05-09
2024

2125
### Added

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

+20
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Scanner } from '@tailwindcss/oxide'
2+
import type { Candidate } from '../../../../tailwindcss/src/candidate'
3+
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
24

35
export async function extractRawCandidates(
46
content: string,
@@ -13,3 +15,21 @@ export async function extractRawCandidates(
1315
}
1416
return candidates
1517
}
18+
19+
// Create a basic stripped candidate without variants or important flag
20+
export function baseCandidate<T extends Candidate>(candidate: T) {
21+
let base = structuredClone(candidate)
22+
23+
base.important = false
24+
base.variants = []
25+
26+
return base
27+
}
28+
29+
export function parseCandidate(designSystem: DesignSystem, input: string) {
30+
return designSystem.parseCandidate(
31+
designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`)
32+
? `${designSystem.theme.prefix}:${input}`
33+
: input,
34+
)
35+
}

packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts

+3-47
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,11 @@ import { printModifier, type Candidate } from '../../../../tailwindcss/src/candi
22
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
33
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
44
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
5-
import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type'
65
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
76
import { dimensions } from '../../utils/dimension'
87
import type { Writable } from '../../utils/types'
9-
import { computeUtilitySignature } from './signatures'
10-
11-
// For all static utilities in the system, compute a lookup table that maps the
12-
// utility signature to the utility name. This is used to find the utility name
13-
// for a given utility signature.
14-
//
15-
// For all functional utilities, we can compute static-like utilities by
16-
// essentially pre-computing the values and modifiers. This is a bit slow, but
17-
// also only has to happen once per design system.
18-
const preComputedUtilities = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>((ds) => {
19-
let signatures = computeUtilitySignature.get(ds)
20-
let lookup = new DefaultMap<string, string[]>(() => [])
21-
22-
for (let [className, meta] of ds.getClassList()) {
23-
let signature = signatures.get(className)
24-
if (typeof signature !== 'string') continue
25-
lookup.get(signature).push(className)
26-
27-
for (let modifier of meta.modifiers) {
28-
// Modifiers representing numbers can be computed and don't need to be
29-
// pre-computed. Doing the math and at the time of writing this, this
30-
// would save you 250k additionally pre-computed utilities...
31-
if (isValidSpacingMultiplier(modifier)) {
32-
continue
33-
}
34-
35-
let classNameWithModifier = `${className}/${modifier}`
36-
let signature = signatures.get(classNameWithModifier)
37-
if (typeof signature !== 'string') continue
38-
lookup.get(signature).push(classNameWithModifier)
39-
}
40-
}
41-
42-
return lookup
43-
})
8+
import { baseCandidate, parseCandidate } from './candidates'
9+
import { computeUtilitySignature, preComputedUtilities } from './signatures'
4410

4511
const baseReplacementsCache = new DefaultMap<DesignSystem, Map<string, Candidate>>(
4612
() => new Map<string, Candidate>(),
@@ -114,9 +80,7 @@ export function migrateArbitraryUtilities(
11480
// will re-add those later but they are irrelevant for what we are trying to
11581
// do here (and will increase cache hits because we only have to deal with
11682
// the base utility, nothing more).
117-
let targetCandidate = structuredClone(candidate)
118-
targetCandidate.important = false
119-
targetCandidate.variants = []
83+
let targetCandidate = baseCandidate(candidate)
12084

12185
let targetCandidateString = designSystem.printCandidate(targetCandidate)
12286
if (baseReplacementsCache.get(designSystem).has(targetCandidateString)) {
@@ -275,14 +239,6 @@ export function migrateArbitraryUtilities(
275239
}
276240
}
277241

278-
function parseCandidate(designSystem: DesignSystem, input: string) {
279-
return designSystem.parseCandidate(
280-
designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`)
281-
? `${designSystem.theme.prefix}:${input}`
282-
: input,
283-
)
284-
}
285-
286242
// Let's make sure that all variables used in the value are also all used in the
287243
// found replacement. If not, then we are dealing with a different namespace or
288244
// we could lose functionality in case the variable was changed higher up in the

packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts

+2-21
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,17 @@
11
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
22
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
3-
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
43
import { replaceObject } from '../../utils/replace-object'
54
import type { Writable } from '../../utils/types'
65
import { walkVariants } from '../../utils/walk-variants'
7-
import { computeVariantSignature } from './signatures'
8-
9-
const variantsLookup = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>(
10-
(designSystem) => {
11-
let signatures = computeVariantSignature.get(designSystem)
12-
let lookup = new DefaultMap<string, string[]>(() => [])
13-
14-
// Actual static variants
15-
for (let [root, variant] of designSystem.variants.entries()) {
16-
if (variant.kind === 'static') {
17-
let signature = signatures.get(root)
18-
if (typeof signature !== 'string') continue
19-
lookup.get(signature).push(root)
20-
}
21-
}
22-
23-
return lookup
24-
},
25-
)
6+
import { computeVariantSignature, preComputedVariants } from './signatures'
267

278
export function migrateArbitraryVariants(
289
designSystem: DesignSystem,
2910
_userConfig: Config | null,
3011
rawCandidate: string,
3112
): string {
3213
let signatures = computeVariantSignature.get(designSystem)
33-
let variants = variantsLookup.get(designSystem)
14+
let variants = preComputedVariants.get(designSystem)
3415

3516
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
3617
// We are only interested in the variants
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
2+
import { describe, expect, test } from 'vitest'
3+
import type { UserConfig } from '../../../../tailwindcss/src/compat/config/types'
4+
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
5+
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
6+
import { migrateBareValueUtilities } from './migrate-bare-utilities'
7+
8+
const css = String.raw
9+
10+
const designSystems = new DefaultMap((base: string) => {
11+
return new DefaultMap((input: string) => {
12+
return __unstable__loadDesignSystem(input, { base })
13+
})
14+
})
15+
16+
function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) {
17+
for (let migration of [migrateBareValueUtilities]) {
18+
rawCandidate = migration(designSystem, userConfig, rawCandidate)
19+
}
20+
return rawCandidate
21+
}
22+
23+
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
24+
let testName = '%s => %s (%#)'
25+
if (strategy === 'with-variant') {
26+
testName = testName.replaceAll('%s', 'focus:%s')
27+
} else if (strategy === 'important') {
28+
testName = testName.replaceAll('%s', '%s!')
29+
} else if (strategy === 'prefix') {
30+
testName = testName.replaceAll('%s', 'tw:%s')
31+
}
32+
33+
// Basic input with minimal design system to keep the tests fast
34+
let input = css`
35+
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
36+
@theme {
37+
--*: initial;
38+
--spacing: 0.25rem;
39+
--aspect-video: 16 / 9;
40+
--tab-size-github: 8;
41+
}
42+
43+
@utility tab-* {
44+
tab-size: --value(--tab-size, integer);
45+
}
46+
`
47+
48+
test.each([
49+
// Built-in utility with bare value fraction
50+
['aspect-16/9', 'aspect-video'],
51+
52+
// Custom utility with bare value integer
53+
['tab-8', 'tab-github'],
54+
])(testName, async (candidate, result) => {
55+
if (strategy === 'with-variant') {
56+
candidate = `focus:${candidate}`
57+
result = `focus:${result}`
58+
} else if (strategy === 'important') {
59+
candidate = `${candidate}!`
60+
result = `${result}!`
61+
} else if (strategy === 'prefix') {
62+
// Not only do we need to prefix the candidate, we also have to make
63+
// sure that we prefix all CSS variables.
64+
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
65+
result = `tw:${result.replaceAll('var(--', 'var(--tw-')}`
66+
}
67+
68+
let designSystem = await designSystems.get(__dirname).get(input)
69+
let migrated = migrate(designSystem, {}, candidate)
70+
expect(migrated).toEqual(result)
71+
})
72+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { type Candidate } from '../../../../tailwindcss/src/candidate'
2+
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
3+
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
4+
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
5+
import type { Writable } from '../../utils/types'
6+
import { baseCandidate, parseCandidate } from './candidates'
7+
import { computeUtilitySignature, preComputedUtilities } from './signatures'
8+
9+
const baseReplacementsCache = new DefaultMap<DesignSystem, Map<string, Candidate>>(
10+
() => new Map<string, Candidate>(),
11+
)
12+
13+
export function migrateBareValueUtilities(
14+
designSystem: DesignSystem,
15+
_userConfig: Config | null,
16+
rawCandidate: string,
17+
): string {
18+
let utilities = preComputedUtilities.get(designSystem)
19+
let signatures = computeUtilitySignature.get(designSystem)
20+
21+
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
22+
// We are only interested in bare value utilities
23+
if (readonlyCandidate.kind !== 'functional' || readonlyCandidate.value?.kind !== 'named') {
24+
continue
25+
}
26+
27+
// The below logic makes use of mutation. Since candidates in the
28+
// DesignSystem are cached, we can't mutate them directly.
29+
let candidate = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>
30+
31+
// Create a basic stripped candidate without variants or important flag. We
32+
// will re-add those later but they are irrelevant for what we are trying to
33+
// do here (and will increase cache hits because we only have to deal with
34+
// the base utility, nothing more).
35+
let targetCandidate = baseCandidate(candidate)
36+
37+
let targetCandidateString = designSystem.printCandidate(targetCandidate)
38+
if (baseReplacementsCache.get(designSystem).has(targetCandidateString)) {
39+
let target = structuredClone(
40+
baseReplacementsCache.get(designSystem).get(targetCandidateString)!,
41+
)
42+
// Re-add the variants and important flag from the original candidate
43+
target.variants = candidate.variants
44+
target.important = candidate.important
45+
46+
return designSystem.printCandidate(target)
47+
}
48+
49+
// Compute the signature for the target candidate
50+
let targetSignature = signatures.get(targetCandidateString)
51+
if (typeof targetSignature !== 'string') continue
52+
53+
// Try a few options to find a suitable replacement utility
54+
for (let replacementCandidate of tryReplacements(targetSignature, targetCandidate)) {
55+
let replacementString = designSystem.printCandidate(replacementCandidate)
56+
let replacementSignature = signatures.get(replacementString)
57+
if (replacementSignature !== targetSignature) {
58+
continue
59+
}
60+
61+
replacementCandidate = structuredClone(replacementCandidate)
62+
63+
// Cache the result so we can re-use this work later
64+
baseReplacementsCache.get(designSystem).set(targetCandidateString, replacementCandidate)
65+
66+
// Re-add the variants and important flag from the original candidate
67+
replacementCandidate.variants = candidate.variants
68+
replacementCandidate.important = candidate.important
69+
70+
// Update the candidate with the new value
71+
Object.assign(candidate, replacementCandidate)
72+
73+
// We will re-print the candidate to get the migrated candidate out
74+
return designSystem.printCandidate(candidate)
75+
}
76+
}
77+
78+
return rawCandidate
79+
80+
function* tryReplacements(
81+
targetSignature: string,
82+
candidate: Extract<Candidate, { kind: 'functional' }>,
83+
): Generator<Candidate> {
84+
// Find a corresponding utility for the same signature
85+
let replacements = utilities.get(targetSignature)
86+
87+
// Multiple utilities can map to the same signature. Not sure how to migrate
88+
// this one so let's just skip it for now.
89+
//
90+
// TODO: Do we just migrate to the first one?
91+
if (replacements.length > 1) return
92+
93+
// If we didn't find any replacement utilities, let's try to strip the
94+
// modifier and find a replacement then. If we do, we can try to re-add the
95+
// modifier later and verify if we have a valid migration.
96+
//
97+
// This is necessary because `text-red-500/50` will not be pre-computed,
98+
// only `text-red-500` will.
99+
if (replacements.length === 0 && candidate.modifier) {
100+
let candidateWithoutModifier = { ...candidate, modifier: null }
101+
let targetSignatureWithoutModifier = signatures.get(
102+
designSystem.printCandidate(candidateWithoutModifier),
103+
)
104+
if (typeof targetSignatureWithoutModifier === 'string') {
105+
for (let replacementCandidate of tryReplacements(
106+
targetSignatureWithoutModifier,
107+
candidateWithoutModifier,
108+
)) {
109+
yield Object.assign({}, replacementCandidate, { modifier: candidate.modifier })
110+
}
111+
}
112+
}
113+
114+
// If only a single utility maps to the signature, we can use that as the
115+
// replacement.
116+
if (replacements.length === 1) {
117+
for (let replacementCandidate of parseCandidate(designSystem, replacements[0])) {
118+
yield replacementCandidate
119+
}
120+
}
121+
}
122+
}

packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
66
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
77
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
88
import * as version from '../../utils/version'
9+
import { baseCandidate } from './candidates'
910
import { isSafeMigration } from './is-safe-migration'
1011

1112
const __filename = url.fileURLToPath(import.meta.url)
@@ -92,10 +93,8 @@ export async function migrateLegacyClasses(
9293
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
9394
// Create a base candidate string from the candidate.
9495
// E.g.: `hover:blur!` -> `blur`
95-
let baseCandidate = structuredClone(candidate) as Candidate
96-
baseCandidate.variants = []
97-
baseCandidate.important = false
98-
let baseCandidateString = designSystem.printCandidate(baseCandidate)
96+
let base = baseCandidate(candidate)
97+
let baseCandidateString = designSystem.printCandidate(base)
9998

10099
// Find the new base candidate string. `blur` -> `blur-sm`
101100
let newBaseCandidateString = LEGACY_CLASS_MAP.get(baseCandidateString)

packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities'
99
import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value'
1010
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
1111
import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection'
12+
import { migrateBareValueUtilities } from './migrate-bare-utilities'
1213
import { migrateBgGradient } from './migrate-bg-gradient'
1314
import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types'
1415
import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values'
@@ -47,6 +48,7 @@ export const DEFAULT_MIGRATIONS: Migration[] = [
4748
migrateAutomaticVarInjection,
4849
migrateLegacyArbitraryValues,
4950
migrateArbitraryUtilities,
51+
migrateBareValueUtilities,
5052
migrateModernizeArbitraryValues,
5153
migrateArbitraryVariants,
5254
migrateDropUnnecessaryDataTypes,

0 commit comments

Comments
 (0)