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"
/>
This commit is contained in:
Robin Malfait 2025-05-13 17:35:11 +02:00 committed by GitHub
parent ef2e6c71fe
commit 498f9ff003
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 283 additions and 72 deletions

View File

@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `lightningcss` now statically links Visual Studio redistributables ([#17979](https://github.com/tailwindlabs/tailwindcss/pull/17979))
- Ensure that running the Standalone build does not leave temporary files behind ([#17981](https://github.com/tailwindlabs/tailwindcss/pull/17981))
### Added
- Upgrade: Migrate bare values to named values ([#18000](https://github.com/tailwindlabs/tailwindcss/pull/18000))
## [4.1.6] - 2025-05-09
### Added

View File

@ -1,4 +1,6 @@
import { Scanner } from '@tailwindcss/oxide'
import type { Candidate } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
export async function extractRawCandidates(
content: string,
@ -13,3 +15,21 @@ export async function extractRawCandidates(
}
return candidates
}
// Create a basic stripped candidate without variants or important flag
export function baseCandidate<T extends Candidate>(candidate: T) {
let base = structuredClone(candidate)
base.important = false
base.variants = []
return base
}
export function parseCandidate(designSystem: DesignSystem, input: string) {
return designSystem.parseCandidate(
designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`)
? `${designSystem.theme.prefix}:${input}`
: input,
)
}

View File

@ -2,45 +2,11 @@ import { printModifier, type Candidate } from '../../../../tailwindcss/src/candi
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { dimensions } from '../../utils/dimension'
import type { Writable } from '../../utils/types'
import { computeUtilitySignature } from './signatures'
// For all static utilities in the system, compute a lookup table that maps the
// utility signature to the utility name. This is used to find the utility name
// for a given utility signature.
//
// For all functional utilities, we can compute static-like utilities by
// essentially pre-computing the values and modifiers. This is a bit slow, but
// also only has to happen once per design system.
const preComputedUtilities = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>((ds) => {
let signatures = computeUtilitySignature.get(ds)
let lookup = new DefaultMap<string, string[]>(() => [])
for (let [className, meta] of ds.getClassList()) {
let signature = signatures.get(className)
if (typeof signature !== 'string') continue
lookup.get(signature).push(className)
for (let modifier of meta.modifiers) {
// Modifiers representing numbers can be computed and don't need to be
// pre-computed. Doing the math and at the time of writing this, this
// would save you 250k additionally pre-computed utilities...
if (isValidSpacingMultiplier(modifier)) {
continue
}
let classNameWithModifier = `${className}/${modifier}`
let signature = signatures.get(classNameWithModifier)
if (typeof signature !== 'string') continue
lookup.get(signature).push(classNameWithModifier)
}
}
return lookup
})
import { baseCandidate, parseCandidate } from './candidates'
import { computeUtilitySignature, preComputedUtilities } from './signatures'
const baseReplacementsCache = new DefaultMap<DesignSystem, Map<string, Candidate>>(
() => new Map<string, Candidate>(),
@ -114,9 +80,7 @@ export function migrateArbitraryUtilities(
// will re-add those later but they are irrelevant for what we are trying to
// do here (and will increase cache hits because we only have to deal with
// the base utility, nothing more).
let targetCandidate = structuredClone(candidate)
targetCandidate.important = false
targetCandidate.variants = []
let targetCandidate = baseCandidate(candidate)
let targetCandidateString = designSystem.printCandidate(targetCandidate)
if (baseReplacementsCache.get(designSystem).has(targetCandidateString)) {
@ -275,14 +239,6 @@ export function migrateArbitraryUtilities(
}
}
function parseCandidate(designSystem: DesignSystem, input: string) {
return designSystem.parseCandidate(
designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`)
? `${designSystem.theme.prefix}:${input}`
: input,
)
}
// Let's make sure that all variables used in the value are also all used in the
// found replacement. If not, then we are dealing with a different namespace or
// we could lose functionality in case the variable was changed higher up in the

View File

@ -1,28 +1,9 @@
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { replaceObject } from '../../utils/replace-object'
import type { Writable } from '../../utils/types'
import { walkVariants } from '../../utils/walk-variants'
import { computeVariantSignature } from './signatures'
const variantsLookup = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>(
(designSystem) => {
let signatures = computeVariantSignature.get(designSystem)
let lookup = new DefaultMap<string, string[]>(() => [])
// Actual static variants
for (let [root, variant] of designSystem.variants.entries()) {
if (variant.kind === 'static') {
let signature = signatures.get(root)
if (typeof signature !== 'string') continue
lookup.get(signature).push(root)
}
}
return lookup
},
)
import { computeVariantSignature, preComputedVariants } from './signatures'
export function migrateArbitraryVariants(
designSystem: DesignSystem,
@ -30,7 +11,7 @@ export function migrateArbitraryVariants(
rawCandidate: string,
): string {
let signatures = computeVariantSignature.get(designSystem)
let variants = variantsLookup.get(designSystem)
let variants = preComputedVariants.get(designSystem)
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
// We are only interested in the variants

View File

@ -0,0 +1,72 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import type { UserConfig } from '../../../../tailwindcss/src/compat/config/types'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { migrateBareValueUtilities } from './migrate-bare-utilities'
const css = String.raw
const designSystems = new DefaultMap((base: string) => {
return new DefaultMap((input: string) => {
return __unstable__loadDesignSystem(input, { base })
})
})
function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) {
for (let migration of [migrateBareValueUtilities]) {
rawCandidate = migration(designSystem, userConfig, rawCandidate)
}
return rawCandidate
}
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
let testName = '%s => %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
testName = testName.replaceAll('%s', '%s!')
} else if (strategy === 'prefix') {
testName = testName.replaceAll('%s', 'tw:%s')
}
// Basic input with minimal design system to keep the tests fast
let input = css`
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
@theme {
--*: initial;
--spacing: 0.25rem;
--aspect-video: 16 / 9;
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size, integer);
}
`
test.each([
// Built-in utility with bare value fraction
['aspect-16/9', 'aspect-video'],
// Custom utility with bare value integer
['tab-8', 'tab-github'],
])(testName, async (candidate, result) => {
if (strategy === 'with-variant') {
candidate = `focus:${candidate}`
result = `focus:${result}`
} else if (strategy === 'important') {
candidate = `${candidate}!`
result = `${result}!`
} else if (strategy === 'prefix') {
// Not only do we need to prefix the candidate, we also have to make
// sure that we prefix all CSS variables.
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
result = `tw:${result.replaceAll('var(--', 'var(--tw-')}`
}
let designSystem = await designSystems.get(__dirname).get(input)
let migrated = migrate(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})
})

View File

@ -0,0 +1,122 @@
import { type Candidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import type { Writable } from '../../utils/types'
import { baseCandidate, parseCandidate } from './candidates'
import { computeUtilitySignature, preComputedUtilities } from './signatures'
const baseReplacementsCache = new DefaultMap<DesignSystem, Map<string, Candidate>>(
() => new Map<string, Candidate>(),
)
export function migrateBareValueUtilities(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): string {
let utilities = preComputedUtilities.get(designSystem)
let signatures = computeUtilitySignature.get(designSystem)
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
// We are only interested in bare value utilities
if (readonlyCandidate.kind !== 'functional' || readonlyCandidate.value?.kind !== 'named') {
continue
}
// The below logic makes use of mutation. Since candidates in the
// DesignSystem are cached, we can't mutate them directly.
let candidate = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>
// Create a basic stripped candidate without variants or important flag. We
// will re-add those later but they are irrelevant for what we are trying to
// do here (and will increase cache hits because we only have to deal with
// the base utility, nothing more).
let targetCandidate = baseCandidate(candidate)
let targetCandidateString = designSystem.printCandidate(targetCandidate)
if (baseReplacementsCache.get(designSystem).has(targetCandidateString)) {
let target = structuredClone(
baseReplacementsCache.get(designSystem).get(targetCandidateString)!,
)
// Re-add the variants and important flag from the original candidate
target.variants = candidate.variants
target.important = candidate.important
return designSystem.printCandidate(target)
}
// Compute the signature for the target candidate
let targetSignature = signatures.get(targetCandidateString)
if (typeof targetSignature !== 'string') continue
// Try a few options to find a suitable replacement utility
for (let replacementCandidate of tryReplacements(targetSignature, targetCandidate)) {
let replacementString = designSystem.printCandidate(replacementCandidate)
let replacementSignature = signatures.get(replacementString)
if (replacementSignature !== targetSignature) {
continue
}
replacementCandidate = structuredClone(replacementCandidate)
// Cache the result so we can re-use this work later
baseReplacementsCache.get(designSystem).set(targetCandidateString, replacementCandidate)
// Re-add the variants and important flag from the original candidate
replacementCandidate.variants = candidate.variants
replacementCandidate.important = candidate.important
// Update the candidate with the new value
Object.assign(candidate, replacementCandidate)
// We will re-print the candidate to get the migrated candidate out
return designSystem.printCandidate(candidate)
}
}
return rawCandidate
function* tryReplacements(
targetSignature: string,
candidate: Extract<Candidate, { kind: 'functional' }>,
): Generator<Candidate> {
// Find a corresponding utility for the same signature
let replacements = utilities.get(targetSignature)
// Multiple utilities can map to the same signature. Not sure how to migrate
// this one so let's just skip it for now.
//
// TODO: Do we just migrate to the first one?
if (replacements.length > 1) return
// If we didn't find any replacement utilities, let's try to strip the
// modifier and find a replacement then. If we do, we can try to re-add the
// modifier later and verify if we have a valid migration.
//
// This is necessary because `text-red-500/50` will not be pre-computed,
// only `text-red-500` will.
if (replacements.length === 0 && candidate.modifier) {
let candidateWithoutModifier = { ...candidate, modifier: null }
let targetSignatureWithoutModifier = signatures.get(
designSystem.printCandidate(candidateWithoutModifier),
)
if (typeof targetSignatureWithoutModifier === 'string') {
for (let replacementCandidate of tryReplacements(
targetSignatureWithoutModifier,
candidateWithoutModifier,
)) {
yield Object.assign({}, replacementCandidate, { modifier: candidate.modifier })
}
}
}
// If only a single utility maps to the signature, we can use that as the
// replacement.
if (replacements.length === 1) {
for (let replacementCandidate of parseCandidate(designSystem, replacements[0])) {
yield replacementCandidate
}
}
}
}

View File

@ -6,6 +6,7 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import * as version from '../../utils/version'
import { baseCandidate } from './candidates'
import { isSafeMigration } from './is-safe-migration'
const __filename = url.fileURLToPath(import.meta.url)
@ -92,10 +93,8 @@ export async function migrateLegacyClasses(
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
// Create a base candidate string from the candidate.
// E.g.: `hover:blur!` -> `blur`
let baseCandidate = structuredClone(candidate) as Candidate
baseCandidate.variants = []
baseCandidate.important = false
let baseCandidateString = designSystem.printCandidate(baseCandidate)
let base = baseCandidate(candidate)
let baseCandidateString = designSystem.printCandidate(base)
// Find the new base candidate string. `blur` -> `blur-sm`
let newBaseCandidateString = LEGACY_CLASS_MAP.get(baseCandidateString)

View File

@ -9,6 +9,7 @@ import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities'
import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value'
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection'
import { migrateBareValueUtilities } from './migrate-bare-utilities'
import { migrateBgGradient } from './migrate-bg-gradient'
import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types'
import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values'
@ -47,6 +48,7 @@ export const DEFAULT_MIGRATIONS: Migration[] = [
migrateAutomaticVarInjection,
migrateLegacyArbitraryValues,
migrateArbitraryUtilities,
migrateBareValueUtilities,
migrateModernizeArbitraryValues,
migrateArbitraryVariants,
migrateDropUnnecessaryDataTypes,

View File

@ -5,6 +5,7 @@ import * as SelectorParser from '../../../../tailwindcss/src/compat/selector-par
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { ThemeOptions } from '../../../../tailwindcss/src/theme'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { dimensions } from '../../utils/dimension'
@ -236,6 +237,42 @@ export const computeUtilitySignature = new DefaultMap<
})
})
// For all static utilities in the system, compute a lookup table that maps the
// utility signature to the utility name. This is used to find the utility name
// for a given utility signature.
//
// For all functional utilities, we can compute static-like utilities by
// essentially pre-computing the values and modifiers. This is a bit slow, but
// also only has to happen once per design system.
export const preComputedUtilities = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>(
(ds) => {
let signatures = computeUtilitySignature.get(ds)
let lookup = new DefaultMap<string, string[]>(() => [])
for (let [className, meta] of ds.getClassList()) {
let signature = signatures.get(className)
if (typeof signature !== 'string') continue
lookup.get(signature).push(className)
for (let modifier of meta.modifiers) {
// Modifiers representing numbers can be computed and don't need to be
// pre-computed. Doing the math and at the time of writing this, this
// would save you 250k additionally pre-computed utilities...
if (isValidSpacingMultiplier(modifier)) {
continue
}
let classNameWithModifier = `${className}/${modifier}`
let signature = signatures.get(classNameWithModifier)
if (typeof signature !== 'string') continue
lookup.get(signature).push(classNameWithModifier)
}
}
return lookup
},
)
// Given a variant, compute a signature that represents the variant. The
// signature will be a normalised form of the generated CSS for the variant, or
// a unique symbol if the variant is not valid. The class in the selector will
@ -342,6 +379,24 @@ export const computeVariantSignature = new DefaultMap<
})
})
export const preComputedVariants = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>(
(designSystem) => {
let signatures = computeVariantSignature.get(designSystem)
let lookup = new DefaultMap<string, string[]>(() => [])
// Actual static variants
for (let [root, variant] of designSystem.variants.entries()) {
if (variant.kind === 'static') {
let signature = signatures.get(root)
if (typeof signature !== 'string') continue
lookup.get(signature).push(root)
}
}
return lookup
},
)
function temporarilyDisableThemeInline<T>(designSystem: DesignSystem, cb: () => T): T {
// Turn off `@theme inline` feature such that `@theme` and `@theme inline` are
// considered the same. The biggest motivation for this is referencing