Skip to content

Migrate bare values to named values #18000

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>(),
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,17 @@
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,
_userConfig: Config | null,
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -47,6 +48,7 @@ export const DEFAULT_MIGRATIONS: Migration[] = [
migrateAutomaticVarInjection,
migrateLegacyArbitraryValues,
migrateArbitraryUtilities,
migrateBareValueUtilities,
migrateModernizeArbitraryValues,
migrateArbitraryVariants,
migrateDropUnnecessaryDataTypes,
Expand Down
Loading