diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c85b5c38d9..7648d7fa4597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts index e4f8c3720f0f..e33c67e18404 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts @@ -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(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, + ) +} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts index 2f3ee7e434c7..1e71dbf930c9 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -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>((ds) => { - let signatures = computeUtilitySignature.get(ds) - let lookup = new DefaultMap(() => []) - - 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>( () => new Map(), @@ -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 diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts index d2d78931e8a9..a210b03d48c0 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -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) => { - let signatures = computeVariantSignature.get(designSystem) - let lookup = new DefaultMap(() => []) - - // 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 diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.test.ts new file mode 100644 index 000000000000..2a75b9208a43 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.test.ts @@ -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) + }) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts new file mode 100644 index 000000000000..475720191ae6 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-bare-utilities.ts @@ -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>( + () => new Map(), +) + +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 + + // 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, + ): Generator { + // 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 + } + } + } +} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts index 20b30fe0c779..4fcb18c2fecd 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts @@ -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) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 029a2a97bd98..594342a3f58f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -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, diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts index f15cf35d4711..aacdfd256c00 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts @@ -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>( + (ds) => { + let signatures = computeUtilitySignature.get(ds) + let lookup = new DefaultMap(() => []) + + 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) => { + let signatures = computeVariantSignature.get(designSystem) + let lookup = new DefaultMap(() => []) + + // 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(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