From d1b11439ce818bc8e3629f477a821d190b4330bd Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 12:36:49 +0200 Subject: [PATCH 01/17] cache candidate migrations This is a first step in improving the performance of the upgrade tool. Essentially let's cache the incoming candidate to the outgoing migration based on the given inputs. Notes: this is technically going to be slower now, because the `location` will be different for every candidate right now. In the next commits we will make sure to drop the `location` from the cache (increasing the cache hits) and checking whether the migration is safe ahead of time. --- .../src/codemods/template/migrate.ts | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index fff859c0ad1f..300b2e83d4d6 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -3,6 +3,7 @@ import path, { extname } from 'node:path' import { parseCandidate } 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 { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string' import { extractRawCandidates } from './candidates' import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities' @@ -58,6 +59,41 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateOptimizeModifier, ] +interface Location { + contents: string + start: number + end: number +} + +let migrateCached = new DefaultMap< + DesignSystem, + DefaultMap>>> +>((designSystem) => { + return new DefaultMap((userConfig) => { + return new DefaultMap((location) => { + return new DefaultMap(async (rawCandidate) => { + let original = rawCandidate + for (let migration of DEFAULT_MIGRATIONS) { + rawCandidate = await migration(designSystem, userConfig, rawCandidate, location) + } + + // If nothing changed, let's parse it again and re-print it. This will migrate + // pretty print candidates to the new format. If it did change, we already had + // to re-print it. + // + // E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)` + if (rawCandidate === original) { + for (let candidate of parseCandidate(rawCandidate, designSystem)) { + return designSystem.printCandidate(candidate) + } + } + + return rawCandidate + }) + }) + }) +}) + export async function migrateCandidate( designSystem: DesignSystem, userConfig: Config | null, @@ -69,23 +105,7 @@ export async function migrateCandidate( end: number }, ): Promise { - let original = rawCandidate - for (let migration of DEFAULT_MIGRATIONS) { - rawCandidate = await migration(designSystem, userConfig, rawCandidate, location) - } - - // If nothing changed, let's parse it again and re-print it. This will migrate - // pretty print candidates to the new format. If it did change, we already had - // to re-print it. - // - // E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)` - if (rawCandidate === original) { - for (let candidate of parseCandidate(rawCandidate, designSystem)) { - return designSystem.printCandidate(candidate) - } - } - - return rawCandidate + return migrateCached.get(designSystem).get(userConfig).get(location).get(rawCandidate) } export default async function migrateContents( From 62424b30e456217f27514bfd9566ae77a53bfe36 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 15:59:55 +0200 Subject: [PATCH 02/17] check safe migrations first Instead of checking whether a candidate migration is safe during each migration step, we can do it all up front. To make things worse, we did check the safety in some migrations, but not in others which means that we could still be performing unsafe migrations. The big issue with "unsafe" migrations is where we use very small classes such as `blur` or `visible` and they can be used in non-Tailwind CSS contexts. Migrating these will result in unsafe migrations. The moment we use variants, modifiers, arbitrary values the chances that these are actual unsafe migrations are so low. We can use that as a heuristic to check whether a migration is safe or not. When we are dealing with `blur`, `visible` or `flex` we can apply the other, existing, safety checks. Once we know that a migration is unsafe, we will skip the migration. Bonus points: this also takes the `location` information out of the cache, which means that the cache will be smaller and increase cache hits tremendously. --- .../template/migrate-legacy-classes.test.ts | 20 +++++--- .../template/migrate-legacy-classes.ts | 36 +------------- .../src/codemods/template/migrate.ts | 48 +++++++++---------- 3 files changed, 37 insertions(+), 67 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts index a48834180a85..07a152605b25 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts @@ -1,6 +1,7 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { expect, test, vi } from 'vitest' import * as versions from '../../utils/version' +import { isSafeMigration } from './is-safe-migration' import { migrateLegacyClasses } from './migrate-legacy-classes' vi.spyOn(versions, 'isMajor').mockReturnValue(true) @@ -50,13 +51,18 @@ test('does not replace classes in invalid positions', async () => { }) async function shouldNotReplace(example: string, candidate = 'shadow') { - expect( - await migrateLegacyClasses(designSystem, {}, candidate, { - contents: example, - start: example.indexOf(candidate), - end: example.indexOf(candidate) + candidate.length, - }), - ).toEqual(candidate) + let location = { + contents: example, + start: example.indexOf(candidate), + end: example.indexOf(candidate) + candidate.length, + } + + // Skip this migration if we think that the migration is unsafe + if (location && !isSafeMigration(location)) { + return candidate + } + + expect(await migrateLegacyClasses(designSystem, {}, candidate)).toEqual(candidate) } await shouldNotReplace(`let notShadow = shadow \n`) 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 4fcb18c2fecd..fa425574c882 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts @@ -7,7 +7,6 @@ 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) const __dirname = path.dirname(__filename) @@ -72,11 +71,6 @@ export async function migrateLegacyClasses( designSystem: DesignSystem, _userConfig: Config | null, rawCandidate: string, - location?: { - contents: string - start: number - end: number - }, ): Promise { // These migrations are only safe when migrating from v3 to v4. // @@ -111,7 +105,6 @@ export async function migrateLegacyClasses( newCandidate.important = candidate.important yield [ - candidate, newCandidate, THEME_KEYS.get(baseCandidateString), THEME_KEYS.get(newBaseCandidateString), @@ -119,34 +112,7 @@ export async function migrateLegacyClasses( } } - for (let [fromCandidate, toCandidate, fromThemeKey, toThemeKey] of migrate(rawCandidate)) { - // Every utility that has a simple representation (e.g.: `blur`, `radius`, - // etc.`) without variants or special characters _could_ be a potential - // problem during the migration. - let isPotentialProblematicClass = (() => { - if (fromCandidate.variants.length > 0) { - return false - } - - if (fromCandidate.kind === 'arbitrary') { - return false - } - - if (fromCandidate.kind === 'static') { - return !fromCandidate.root.includes('-') - } - - if (fromCandidate.kind === 'functional') { - return fromCandidate.value === null || !fromCandidate.root.includes('-') - } - - return false - })() - - if (location && isPotentialProblematicClass && !isSafeMigration(location)) { - continue - } - + for (let [toCandidate, fromThemeKey, toThemeKey] of migrate(rawCandidate)) { if (fromThemeKey && toThemeKey) { // Migrating something that resolves to a value in the theme. let customFrom = designSystem.resolveThemeValue(fromThemeKey, true) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 300b2e83d4d6..6b254ed29cfa 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -6,6 +6,7 @@ import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string' import { extractRawCandidates } from './candidates' +import { isSafeMigration } from './is-safe-migration' import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities' import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value' import { migrateArbitraryVariants } from './migrate-arbitrary-variants' @@ -59,37 +60,29 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateOptimizeModifier, ] -interface Location { - contents: string - start: number - end: number -} - let migrateCached = new DefaultMap< DesignSystem, - DefaultMap>>> + DefaultMap>> >((designSystem) => { return new DefaultMap((userConfig) => { - return new DefaultMap((location) => { - return new DefaultMap(async (rawCandidate) => { - let original = rawCandidate - for (let migration of DEFAULT_MIGRATIONS) { - rawCandidate = await migration(designSystem, userConfig, rawCandidate, location) - } + return new DefaultMap(async (rawCandidate) => { + let original = rawCandidate + for (let migration of DEFAULT_MIGRATIONS) { + rawCandidate = await migration(designSystem, userConfig, rawCandidate, undefined) + } - // If nothing changed, let's parse it again and re-print it. This will migrate - // pretty print candidates to the new format. If it did change, we already had - // to re-print it. - // - // E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)` - if (rawCandidate === original) { - for (let candidate of parseCandidate(rawCandidate, designSystem)) { - return designSystem.printCandidate(candidate) - } + // If nothing changed, let's parse it again and re-print it. This will migrate + // pretty print candidates to the new format. If it did change, we already had + // to re-print it. + // + // E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)` + if (rawCandidate === original) { + for (let candidate of parseCandidate(rawCandidate, designSystem)) { + return designSystem.printCandidate(candidate) } + } - return rawCandidate - }) + return rawCandidate }) }) }) @@ -105,7 +98,12 @@ export async function migrateCandidate( end: number }, ): Promise { - return migrateCached.get(designSystem).get(userConfig).get(location).get(rawCandidate) + // Skip this migration if we think that the migration is unsafe + if (location && !isSafeMigration(location)) { + return rawCandidate + } + + return migrateCached.get(designSystem).get(userConfig).get(rawCandidate) } export default async function migrateContents( From 5884994f2f230ec5793b9a5821541395207138c0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 16:05:43 +0200 Subject: [PATCH 03/17] add more safety checks Before even gathering context of the candidate's surroundings, we can already look at the candidate itself to know whether a migration will be safe. If a candidate doesn't even parse at all, we can stop early. If a candidate uses arbitrary property syntax such as `[color:red]`, there is like a 0% chance that this will cause an actual issue.. and so on. --- .../codemods/template/is-safe-migration.ts | 58 ++++++++++++++++++- .../codemods/template/migrate-important.ts | 2 +- .../template/migrate-legacy-classes.test.ts | 2 +- .../src/codemods/template/migrate.ts | 2 +- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts index dc84d302290c..3a49a63906bd 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts @@ -1,3 +1,6 @@ +import { parseCandidate } from '../../../../tailwindcss/src/candidate' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' + const QUOTES = ['"', "'", '`'] const LOGICAL_OPERATORS = ['&&', '||', '?', '===', '==', '!=', '!==', '>', '>=', '<', '<='] const CONDITIONAL_TEMPLATE_SYNTAX = [ @@ -12,7 +15,60 @@ const CONDITIONAL_TEMPLATE_SYNTAX = [ ] const NEXT_PLACEHOLDER_PROP = /placeholder=\{?['"]$/ -export function isSafeMigration(location: { contents: string; start: number; end: number }) { +export function isSafeMigration( + rawCandidate: string, + location: { contents: string; start: number; end: number }, + designSystem: DesignSystem, +): boolean { + let [candidate] = Array.from(parseCandidate(rawCandidate, designSystem)) + + // If we can't parse the candidate, then it's not a candidate at all. + if (!candidate) { + return false + } + + // When we have variants, we can assume that the candidate is safe to migrate + // because that requires colons. + // + // E.g.: `hover:focus:flex` + if (candidate.variants.length > 0) { + return true + } + + // When we have an arbitrary property, the candidate has such a particular + // structure it's very likely to be safe. + // + // E.g.: `[color:red]` + if (candidate.kind === 'arbitrary') { + return true + } + + // A static candidate is very likely safe if it contains a dash. + // + // E.g.: `items-center` + if (candidate.kind === 'static' && candidate.root.includes('-')) { + return true + } + + // A functional candidate is very likely safe if it contains a value (which + // implies a `-`). Or if the root contains a dash. + // + // E.g.: `bg-red-500`, `bg-position-20` + if ( + (candidate.kind === 'functional' && candidate.value !== null) || + (candidate.kind === 'functional' && candidate.root.includes('-')) + ) { + return true + } + + // If the candidate contains a modifier, it's very likely to be safe because + // it implies that it contains a `/`. + // + // E.g.: `text-sm/7` + if (candidate.kind === 'functional' && candidate.modifier) { + return true + } + let currentLineBeforeCandidate = '' for (let i = location.start - 1; i >= 0; i--) { let char = location.contents.at(i)! diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts index 27a663f47acf..4b40449236a7 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts @@ -33,7 +33,7 @@ export function migrateImportant( // with v3 in that it can read `!` in the front of the utility too, we err // on the side of caution and only migrate candidates that we are certain // are inside of a string. - if (location && !isSafeMigration(location)) { + if (location && !isSafeMigration(rawCandidate, location, designSystem)) { continue nextCandidate } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts index 07a152605b25..5637c5cef5fc 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts @@ -58,7 +58,7 @@ test('does not replace classes in invalid positions', async () => { } // Skip this migration if we think that the migration is unsafe - if (location && !isSafeMigration(location)) { + if (location && !isSafeMigration(candidate, location, designSystem)) { return candidate } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 6b254ed29cfa..3a463fcfc307 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -99,7 +99,7 @@ export async function migrateCandidate( }, ): Promise { // Skip this migration if we think that the migration is unsafe - if (location && !isSafeMigration(location)) { + if (location && !isSafeMigration(rawCandidate, location, designSystem)) { return rawCandidate } From 283392f7e9a36d1a96d3c6e28b8ce43734515a38 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 15:48:31 +0200 Subject: [PATCH 04/17] migrate the candidate to its minimal form When we do this upfront, then other migrations don't have to worry about whether a candidate is potentially _not_ in its minimal canonicalized form. Additionally, this allows us to cleanup the main migrate function itself, because we don't have to re-parse and re-print if nothing changed in the meantime. --- .../template/migrate-arbitrary-utilities.ts | 21 -------------- .../migrate-canonicalize-candidate.test.ts | 27 +++++++++++++++++ .../migrate-canonicalize-candidate.ts | 29 +++++++++++++++++++ .../src/codemods/template/migrate.ts | 16 ++-------- 4 files changed, 58 insertions(+), 35 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-canonicalize-candidate.test.ts create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-canonicalize-candidate.ts 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 1e71dbf930c9..c03100cdd8c8 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-utilities.ts @@ -51,27 +51,6 @@ export function migrateArbitraryUtilities( continue } - // 1. Canonicalize the value. This might be a bit wasteful because it might - // have been done by other migrations before, but essentially we want to - // canonicalize the arbitrary value to its simplest canonical form. We - // won't be constant folding `calc(…)` expressions (yet?), but we can - // remove unnecessary whitespace (which the `printCandidate` already - // handles for us). - // - // E.g.: - // - // ``` - // [display:_flex_] => [display:flex] - // [display:_flex] => [display:flex] - // [display:flex_] => [display:flex] - // [display:flex] => [display:flex] - // ``` - // - let canonicalizedCandidate = designSystem.printCandidate(readonlyCandidate) - if (canonicalizedCandidate !== rawCandidate) { - return migrateArbitraryUtilities(designSystem, _userConfig, canonicalizedCandidate) - } - // 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 diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-canonicalize-candidate.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-canonicalize-candidate.test.ts new file mode 100644 index 000000000000..57fe80c22858 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-canonicalize-candidate.test.ts @@ -0,0 +1,27 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { expect, test, vi } from 'vitest' +import * as versions from '../../utils/version' +import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate' +vi.spyOn(versions, 'isMajor').mockReturnValue(true) + +test.each([ + // Normalize whitespace in arbitrary properties + ['[display:flex]', '[display:flex]'], + ['[display:_flex]', '[display:flex]'], + ['[display:flex_]', '[display:flex]'], + ['[display:_flex_]', '[display:flex]'], + + // Normalize whitespace in `calc` expressions + ['w-[calc(100%-2rem)]', 'w-[calc(100%-2rem)]'], + ['w-[calc(100%_-_2rem)]', 'w-[calc(100%-2rem)]'], + + // Normalize the important modifier + ['!flex', 'flex!'], + ['flex!', 'flex!'], +])('%s => %s', async (candidate, result) => { + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + expect(migrateCanonicalizeCandidate(designSystem, {}, candidate)).toEqual(result) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-canonicalize-candidate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-canonicalize-candidate.ts new file mode 100644 index 000000000000..32d126467911 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-canonicalize-candidate.ts @@ -0,0 +1,29 @@ +import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' +import type { DesignSystem } from '../../../../tailwindcss/src/design-system' + +// Canonicalize the value to its minimal form. This will normalize whitespace, +// and print the important modifier `!` in the correct place. +// +// E.g.: +// +// ``` +// [display:_flex_] => [display:flex] +// [display:_flex] => [display:flex] +// [display:flex_] => [display:flex] +// [display:flex] => [display:flex] +// ``` +// +export function migrateCanonicalizeCandidate( + designSystem: DesignSystem, + _userConfig: Config | null, + rawCandidate: string, +) { + for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { + let canonicalizedCandidate = designSystem.printCandidate(readonlyCandidate) + if (canonicalizedCandidate !== rawCandidate) { + return canonicalizedCandidate + } + } + + return rawCandidate +} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 3a463fcfc307..15ce8211e70a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises' import path, { extname } from 'node:path' -import { parseCandidate } 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' @@ -14,6 +13,7 @@ import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection' import { migrateBareValueUtilities } from './migrate-bare-utilities' import { migrateBgGradient } from './migrate-bg-gradient' import { migrateCamelcaseInNamedValue } from './migrate-camelcase-in-named-value' +import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate' import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types' import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values' import { migrateImportant } from './migrate-important' @@ -42,6 +42,7 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateEmptyArbitraryValues, migratePrefix, migrateImportant, + migrateCanonicalizeCandidate, migrateBgGradient, migrateSimpleLegacyClasses, migrateCamelcaseInNamedValue, @@ -66,22 +67,9 @@ let migrateCached = new DefaultMap< >((designSystem) => { return new DefaultMap((userConfig) => { return new DefaultMap(async (rawCandidate) => { - let original = rawCandidate for (let migration of DEFAULT_MIGRATIONS) { rawCandidate = await migration(designSystem, userConfig, rawCandidate, undefined) } - - // If nothing changed, let's parse it again and re-print it. This will migrate - // pretty print candidates to the new format. If it did change, we already had - // to re-print it. - // - // E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)` - if (rawCandidate === original) { - for (let candidate of parseCandidate(rawCandidate, designSystem)) { - return designSystem.printCandidate(candidate) - } - } - return rawCandidate }) }) From 38bf86e263519c271a1ddbd8267dab0eb9202bc8 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 16:28:43 +0200 Subject: [PATCH 05/17] add `is-safe-migration` tests --- .../template/is-safe-migration.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts new file mode 100644 index 000000000000..af709ecafd3b --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts @@ -0,0 +1,32 @@ +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { expect, test } from 'vitest' +import { migrateCandidate } from './migrate' + +test('does not replace classes in invalid positions', async () => { + let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { + base: __dirname, + }) + + async function shouldNotReplace(example: string, candidate = '!border') { + expect( + await migrateCandidate(designSystem, {}, candidate, { + contents: example, + start: example.indexOf(candidate), + end: example.indexOf(candidate) + candidate.length, + }), + ).toEqual(candidate) + } + + await shouldNotReplace(`let notBorder = !border \n`) + await shouldNotReplace(`{ "foo": !border.something + ""}\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) + await shouldNotReplace(`
\n`) +}) From d512a41ec1d6a2588afc4c64ca31fa9cf7d9542a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 16:29:20 +0200 Subject: [PATCH 06/17] remove `migrateImportant` This is now implicitly handled by the `migrateCanonicalizeCandidate` migration because we are parsing and printing the candidate. --- .../template/migrate-important.test.ts | 68 ------------------- .../codemods/template/migrate-important.ts | 48 ------------- .../src/codemods/template/migrate.ts | 2 - 3 files changed, 118 deletions(-) delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.test.ts delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.test.ts deleted file mode 100644 index bc84538aaa35..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import { expect, test } from 'vitest' -import { migrateImportant } from './migrate-important' - -test.each([ - ['!flex', 'flex!'], - ['min-[calc(1000px+12em)]:!flex', 'min-[calc(1000px+12em)]:flex!'], - ['md:!block', 'md:block!'], - - // Does not change non-important candidates - ['bg-blue-500', 'bg-blue-500'], - ['min-[calc(1000px+12em)]:flex', 'min-[calc(1000px+12em)]:flex'], -])('%s => %s', async (candidate, result) => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - expect( - migrateImportant(designSystem, {}, candidate, { - contents: `"${candidate}"`, - start: 1, - end: candidate.length + 1, - }), - ).toEqual(result) -}) - -test('does not match false positives', async () => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - expect( - migrateImportant(designSystem, {}, '!border', { - contents: `let notBorder = !border\n`, - start: 16, - end: 16 + '!border'.length, - }), - ).toEqual('!border') -}) - -test('does not replace classes in invalid positions', async () => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - function shouldNotReplace(example: string, candidate = '!border') { - expect( - migrateImportant(designSystem, {}, candidate, { - contents: example, - start: example.indexOf(candidate), - end: example.indexOf(candidate) + candidate.length, - }), - ).toEqual(candidate) - } - - shouldNotReplace(`let notBorder = !border \n`) - shouldNotReplace(`{ "foo": !border.something + ""}\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) - shouldNotReplace(`
\n`) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts deleted file mode 100644 index 4b40449236a7..000000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-important.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { parseCandidate } from '../../../../tailwindcss/src/candidate' -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { isSafeMigration } from './is-safe-migration' - -// In v3 the important modifier `!` sits in front of the utility itself, not -// before any of the variants. In v4, we want it to be at the end of the utility -// so that it's always in the same location regardless of whether you used -// variants or not. -// -// So this: -// -// !flex md:!block -// -// Should turn into: -// -// flex! md:block! -export function migrateImportant( - designSystem: DesignSystem, - _userConfig: Config | null, - rawCandidate: string, - location?: { - contents: string - start: number - end: number - }, -): string { - nextCandidate: for (let candidate of parseCandidate(rawCandidate, designSystem)) { - if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') { - // The important migration is one of the most broad migrations with a high - // potential of matching false positives since `!` is a valid character in - // most programming languages. Since v4 is technically backward compatible - // with v3 in that it can read `!` in the front of the utility too, we err - // on the side of caution and only migrate candidates that we are certain - // are inside of a string. - if (location && !isSafeMigration(rawCandidate, location, designSystem)) { - continue nextCandidate - } - - // The printCandidate function will already put the exclamation mark in - // the right place, so we just need to mark this candidate as requiring a - // migration. - return designSystem.printCandidate(candidate) - } - } - - return rawCandidate -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 15ce8211e70a..156f3f0f0e3a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -16,7 +16,6 @@ import { migrateCamelcaseInNamedValue } from './migrate-camelcase-in-named-value import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate' import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types' import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values' -import { migrateImportant } from './migrate-important' import { migrateLegacyArbitraryValues } from './migrate-legacy-arbitrary-values' import { migrateLegacyClasses } from './migrate-legacy-classes' import { migrateMaxWidthScreen } from './migrate-max-width-screen' @@ -41,7 +40,6 @@ export type Migration = ( export const DEFAULT_MIGRATIONS: Migration[] = [ migrateEmptyArbitraryValues, migratePrefix, - migrateImportant, migrateCanonicalizeCandidate, migrateBgGradient, migrateSimpleLegacyClasses, From e874a5e87cc69c9b1d458f8a8fec198e77cb493c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 16:30:49 +0200 Subject: [PATCH 07/17] move safe migration tests to `is-safe-migration` tests --- .../template/is-safe-migration.test.ts | 22 ++++++++++ .../template/migrate-legacy-classes.test.ts | 41 ------------------- 2 files changed, 22 insertions(+), 41 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts index af709ecafd3b..86e0aaa64434 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts @@ -29,4 +29,26 @@ test('does not replace classes in invalid positions', async () => { await shouldNotReplace(`
\n`) await shouldNotReplace(`
\n`) await shouldNotReplace(`
\n`) + + await shouldNotReplace(`let notShadow = shadow \n`, 'shadow') + await shouldNotReplace(`{ "foo": shadow.something + ""}\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace(`
\n`, 'shadow') + await shouldNotReplace( + `
\n`, + 'shadow', + ) + + // Next.js Image placeholder cases + await shouldNotReplace(``, 'blur') + await shouldNotReplace(``, 'blur') + await shouldNotReplace(``, 'blur') }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts index 5637c5cef5fc..5b204386f844 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.test.ts @@ -1,7 +1,6 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import { expect, test, vi } from 'vitest' import * as versions from '../../utils/version' -import { isSafeMigration } from './is-safe-migration' import { migrateLegacyClasses } from './migrate-legacy-classes' vi.spyOn(versions, 'isMajor').mockReturnValue(true) @@ -44,43 +43,3 @@ test.each([ expect(await migrateLegacyClasses(designSystem, {}, candidate)).toEqual(result) }) - -test('does not replace classes in invalid positions', async () => { - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - async function shouldNotReplace(example: string, candidate = 'shadow') { - let location = { - contents: example, - start: example.indexOf(candidate), - end: example.indexOf(candidate) + candidate.length, - } - - // Skip this migration if we think that the migration is unsafe - if (location && !isSafeMigration(candidate, location, designSystem)) { - return candidate - } - - expect(await migrateLegacyClasses(designSystem, {}, candidate)).toEqual(candidate) - } - - await shouldNotReplace(`let notShadow = shadow \n`) - await shouldNotReplace(`{ "foo": shadow.something + ""}\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - await shouldNotReplace(`
\n`) - - // Next.js Image placeholder cases - await shouldNotReplace(``, 'blur') - await shouldNotReplace(``, 'blur') - await shouldNotReplace(``, 'blur') -}) From ebeff4f577b2eaa8288834a0ebae31ee73c767f4 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 16:31:53 +0200 Subject: [PATCH 08/17] drop `location` from `Migration` type --- .../@tailwindcss-upgrade/src/codemods/template/migrate.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 156f3f0f0e3a..65503d0cac58 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -30,11 +30,6 @@ export type Migration = ( designSystem: DesignSystem, userConfig: Config | null, rawCandidate: string, - location?: { - contents: string - start: number - end: number - }, ) => string | Promise export const DEFAULT_MIGRATIONS: Migration[] = [ @@ -66,7 +61,7 @@ let migrateCached = new DefaultMap< return new DefaultMap((userConfig) => { return new DefaultMap(async (rawCandidate) => { for (let migration of DEFAULT_MIGRATIONS) { - rawCandidate = await migration(designSystem, userConfig, rawCandidate, undefined) + rawCandidate = await migration(designSystem, userConfig, rawCandidate) } return rawCandidate }) From 660202ab4ef4ef347cf1a124aa7b03f4d6dc3805 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 16:39:36 +0200 Subject: [PATCH 09/17] add failing tests for #17974 --- .../src/codemods/template/is-safe-migration.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts index 86e0aaa64434..455b686604e8 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts @@ -1,6 +1,8 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import { expect, test } from 'vitest' +import { expect, test, vi } from 'vitest' +import * as versions from '../../utils/version' import { migrateCandidate } from './migrate' +vi.spyOn(versions, 'isMajor').mockReturnValue(true) test('does not replace classes in invalid positions', async () => { let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { @@ -51,4 +53,9 @@ test('does not replace classes in invalid positions', async () => { await shouldNotReplace(``, 'blur') await shouldNotReplace(``, 'blur') await shouldNotReplace(``, 'blur') + + // https://github.com/tailwindlabs/tailwindcss/issues/17974 + await shouldNotReplace('
', '!duration') + await shouldNotReplace('
', '!duration') + await shouldNotReplace('
', '!visible') }) From b6d36eebcec0822ab188fd49526bb9a0aa09650a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 16:50:34 +0200 Subject: [PATCH 10/17] =?UTF-8?q?do=20not=20perform=20migrations=20in=20`:?= =?UTF-8?q?attr=3D"=E2=80=A6"`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Except if the `attr` is a `class` --- .../src/codemods/template/is-safe-migration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts index 3a49a63906bd..59a7d87d9944 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts @@ -8,6 +8,7 @@ const CONDITIONAL_TEMPLATE_SYNTAX = [ /v-else-if=['"]$/, /v-if=['"]$/, /v-show=['"]$/, + /(? Date: Wed, 14 May 2025 16:51:07 +0200 Subject: [PATCH 11/17] verify that the migrated class actually exists When you have something like: ``` if (!duration) {} ``` It could be that we migrate this to: ``` if (duration!) {} ``` Hopefully our safety checks do not do that though. But in the event that they do, the `!duration` should not be converted to `duration!` because that class simply doesn't exist. It wil parse correctly as: ``` [ { "kind": "functional", "root": "duration", "modifier": null, "value": null, "variants": [], "important": true, "raw": "!duration" } ] ``` And that's because the `duration-` _does_ work and produces output. But this on its won won't produce any output at all. If that's the case, then we can throw away any migrations related to this and return the original value. --- .../src/codemods/template/migrate.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 65503d0cac58..4c0a26a83320 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -25,6 +25,7 @@ import { migratePrefix } from './migrate-prefix' import { migrateSimpleLegacyClasses } from './migrate-simple-legacy-classes' import { migrateThemeToVar } from './migrate-theme-to-var' import { migrateVariantOrder } from './migrate-variant-order' +import { computeUtilitySignature } from './signatures' export type Migration = ( designSystem: DesignSystem, @@ -60,9 +61,18 @@ let migrateCached = new DefaultMap< >((designSystem) => { return new DefaultMap((userConfig) => { return new DefaultMap(async (rawCandidate) => { + let original = rawCandidate + for (let migration of DEFAULT_MIGRATIONS) { rawCandidate = await migration(designSystem, userConfig, rawCandidate) } + + // Verify that the candidate actually makes sense at all. E.g.: `duration` + // is not a valid candidate, but it will parse because `duration-` + // exists. + let signature = computeUtilitySignature.get(designSystem).get(rawCandidate) + if (typeof signature !== 'string') return original + return rawCandidate }) }) From a4959248a88dc96cf57fe2a862c917ec415ddcd1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 18:49:52 +0200 Subject: [PATCH 12/17] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6aada039ea0..ac51465e6a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure that running the Standalone build does not leave temporary files behind ([#17981](https://github.com/tailwindlabs/tailwindcss/pull/17981)) - Fix `-rotate-*` utilities with arbitrary values ([#18014](https://github.com/tailwindlabs/tailwindcss/pull/18014)) - Upgrade: Change casing of utilities with named values to kebab-case to match updated theme variables ([#18017](https://github.com/tailwindlabs/tailwindcss/pull/18017)) +- Upgrade: Fix unsafe migrations in Vue files ([#18025](https://github.com/tailwindlabs/tailwindcss/pull/18025)) ### Added - Upgrade: Migrate bare values to named values ([#18000](https://github.com/tailwindlabs/tailwindcss/pull/18000)) +- Upgrade: Make candidate template migrations faster using caching ([#18025](https://github.com/tailwindlabs/tailwindcss/pull/18025)) ## [4.1.6] - 2025-05-09 From d526f331ccd4fd57db6194b01998ea220854b99c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 14 May 2025 19:31:30 +0200 Subject: [PATCH 13/17] improve safe migration check When a candidate doesn't parse, we throw it away. However, if you are in a Tailwind CSS v3 project then it could be that we are dealing with classes that don't exist in Tailwind CSS v4 and will be migrated later. It could also be that we are dealing with legacy syntax, e.g.: `tw__flex` if you have a custom variant separator. This fixes that by only bailing in Tailwind CSS v4 and up. --- .../codemods/template/is-safe-migration.ts | 85 ++++++++++--------- .../@tailwindcss-upgrade/src/utils/version.ts | 9 ++ 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts index 59a7d87d9944..4d6f9fbb0054 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts @@ -1,5 +1,6 @@ import { parseCandidate } from '../../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import * as version from '../../utils/version' const QUOTES = ['"', "'", '`'] const LOGICAL_OPERATORS = ['&&', '||', '?', '===', '==', '!=', '!==', '>', '>=', '<', '<='] @@ -23,51 +24,59 @@ export function isSafeMigration( ): boolean { let [candidate] = Array.from(parseCandidate(rawCandidate, designSystem)) - // If we can't parse the candidate, then it's not a candidate at all. - if (!candidate) { - return false - } - - // When we have variants, we can assume that the candidate is safe to migrate - // because that requires colons. + // If we can't parse the candidate, then it's not a candidate at all. However, + // we could be dealing with legacy classes like `tw__flex` in Tailwind CSS v3 + // land, which also wouldn't parse. // - // E.g.: `hover:focus:flex` - if (candidate.variants.length > 0) { - return true - } - - // When we have an arbitrary property, the candidate has such a particular - // structure it's very likely to be safe. + // So let's only skip if we couldn't parse and we are not in Tailwind CSS v3. // - // E.g.: `[color:red]` - if (candidate.kind === 'arbitrary') { + if (!candidate && version.isGreaterThan(3)) { return true } - // A static candidate is very likely safe if it contains a dash. - // - // E.g.: `items-center` - if (candidate.kind === 'static' && candidate.root.includes('-')) { - return true - } + // Parsed a candidate succesfully, verify if it's a valid candidate + else if (candidate) { + // When we have variants, we can assume that the candidate is safe to migrate + // because that requires colons. + // + // E.g.: `hover:focus:flex` + if (candidate.variants.length > 0) { + return true + } - // A functional candidate is very likely safe if it contains a value (which - // implies a `-`). Or if the root contains a dash. - // - // E.g.: `bg-red-500`, `bg-position-20` - if ( - (candidate.kind === 'functional' && candidate.value !== null) || - (candidate.kind === 'functional' && candidate.root.includes('-')) - ) { - return true - } + // When we have an arbitrary property, the candidate has such a particular + // structure it's very likely to be safe. + // + // E.g.: `[color:red]` + if (candidate.kind === 'arbitrary') { + return true + } - // If the candidate contains a modifier, it's very likely to be safe because - // it implies that it contains a `/`. - // - // E.g.: `text-sm/7` - if (candidate.kind === 'functional' && candidate.modifier) { - return true + // A static candidate is very likely safe if it contains a dash. + // + // E.g.: `items-center` + if (candidate.kind === 'static' && candidate.root.includes('-')) { + return true + } + + // A functional candidate is very likely safe if it contains a value (which + // implies a `-`). Or if the root contains a dash. + // + // E.g.: `bg-red-500`, `bg-position-20` + if ( + (candidate.kind === 'functional' && candidate.value !== null) || + (candidate.kind === 'functional' && candidate.root.includes('-')) + ) { + return true + } + + // If the candidate contains a modifier, it's very likely to be safe because + // it implies that it contains a `/`. + // + // E.g.: `text-sm/7` + if (candidate.kind === 'functional' && candidate.modifier) { + return true + } } let currentLineBeforeCandidate = '' diff --git a/packages/@tailwindcss-upgrade/src/utils/version.ts b/packages/@tailwindcss-upgrade/src/utils/version.ts index e36ba06ba998..6794b1a81aeb 100644 --- a/packages/@tailwindcss-upgrade/src/utils/version.ts +++ b/packages/@tailwindcss-upgrade/src/utils/version.ts @@ -11,6 +11,15 @@ export function isMajor(version: number) { return semver.satisfies(installedTailwindVersion(), `>=${version}.0.0 <${version + 1}.0.0`) } +/** + * Must be of greater than the current major version including minor and patch. + * + * E.g.: `isGreaterThan(3)` + */ +export function isGreaterThan(version: number) { + return semver.gte(installedTailwindVersion(), `${version + 1}.0.0`) +} + let cache = new DefaultMap((base) => { let tailwindVersion = getPackageVersionSync('tailwindcss', base) if (!tailwindVersion) throw new Error('Tailwind CSS is not installed') From ac423ec651246eba33d6cdbb9ff8426da00844ac Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 May 2025 11:43:49 +0200 Subject: [PATCH 14/17] make `:` optional Otherwise the normal `class` would also be considered invalid, which is not what we want. --- .../src/codemods/template/is-safe-migration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts index 4d6f9fbb0054..dd540d5c57e7 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts @@ -9,7 +9,7 @@ const CONDITIONAL_TEMPLATE_SYNTAX = [ /v-else-if=['"]$/, /v-if=['"]$/, /v-show=['"]$/, - /(? Date: Thu, 15 May 2025 12:05:01 +0200 Subject: [PATCH 15/17] ensure `group` and `peer` are valid during v3 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to properly parse the value, we already had to make sure that `group` and `peer` exist in the framework during a Tailwind CSS v3 → Tailwind CSS v4 migration. We now also verify that the actual produced class is valid, for this we use `@apply` internally, but because of the `return null` this was considered invalid. This fixes that by making sure that we have some declarations, in this case I used a random key like `--phantom-class: group`. Then to make sure that `group` and `group/foo` are considered distinct classes, I also added the modifier to the equation. --- .../src/codemods/template/migrate-prefix.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts index 3e014817c6d2..77603285029b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-prefix.ts @@ -1,3 +1,4 @@ +import { decl } from '../../../../tailwindcss/src/ast' import { parseCandidate, type Candidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' @@ -16,8 +17,18 @@ export function migratePrefix( if (!version.isMajor(3)) return rawCandidate if (!seenDesignSystems.has(designSystem)) { - designSystem.utilities.functional('group', () => null) - designSystem.utilities.functional('peer', () => null) + designSystem.utilities.functional('group', (value) => [ + // To ensure that `@apply group` works when computing a signature + decl('--phantom-class', 'group'), + // To ensure `group` and `group/foo` are considered different classes + decl('--phantom-modifier', value.modifier?.value), + ]) + designSystem.utilities.functional('peer', (value) => [ + // To ensure that `@apply peer` works when computing a signature + decl('--phantom-class', 'peer'), + // To ensure `peer` and `peer/foo` are considered different classes + decl('--phantom-modifier', value.modifier?.value), + ]) seenDesignSystems.add(designSystem) } From 535248fe50832b64ec073fd12a9ade523c4d9f5a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 May 2025 12:21:13 +0200 Subject: [PATCH 16/17] fix integration test The color is called `superRed` which is migrated to `super-red`, but we used the color `red-superRed` and expected `red-super-red` which doesn't exist in the theme and therefore wasn't migrated. --- integrations/upgrade/js-config.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 93d239af0280..7b539c4e4050 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -151,7 +151,7 @@ test(
-
+
`, 'node_modules/my-external-lib/src/template.html': html`
@@ -169,7 +169,7 @@ test(
-
+
--- src/input.css --- @import 'tailwindcss'; From 8fc683bb4976ae26f6cf778b6a4df69a7b7de441 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 May 2025 12:30:27 +0200 Subject: [PATCH 17/17] trigger CI