Do not migrate legacy classes with custom values (#14976)

This PR fixes an issue where we migrated classes such as `rounded` to
`rounded-sm` (see:
https://github.com/tailwindlabs/tailwindcss/pull/14875)

However, if you override the values in your `tailwind.config.js` file,
then the migration might not be correct.

This PR makes sure to only migrate the classes if you haven't overridden
the values in your `tailwind.config.js` file.

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
Robin Malfait 2024-11-14 11:31:05 +01:00 committed by GitHub
parent dd85aadc2c
commit 49484f0491
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 496 additions and 110 deletions

View File

@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Ensure that CSS inside Svelte `<style>` blocks always run the expected Svelte processors when using the Vite extension ([#14981](https://github.com/tailwindlabs/tailwindcss/pull/14981))
- _Upgrade (experimental)_: Ensure it's safe to migrate `blur`, `rounded`, or `shadow` ([#14979](https://github.com/tailwindlabs/tailwindcss/pull/14979))
- _Upgrade (experimental)_: Do not rename classes using custom defined theme values ([#14976](https://github.com/tailwindlabs/tailwindcss/pull/14976))
## [4.0.0-alpha.33] - 2024-11-11

View File

@ -1,5 +1,5 @@
import { expect } from 'vitest'
import { candidate, css, html, js, json, test } from '../utils'
import { candidate, css, html, js, json, test, ts } from '../utils'
test(
'error when no CSS file with @tailwind is used',
@ -1642,3 +1642,200 @@ test(
expect(pkg.devDependencies['prettier-plugin-tailwindcss']).not.toEqual('0.5.0')
},
)
test(
'only migrate legacy classes when it is safe to do so',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3.4.14",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"prettier-plugin-tailwindcss": "0.5.0"
}
}
`,
'tailwind.config.js': js`
module.exports = {
content: ['./*.html'],
theme: {
// Overrides the default boxShadow entirely so none of the
// migrations are safe.
boxShadow: {
DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
},
extend: {
// Changes the "before" class definition. 'blur' -> 'blur-sm' is
// not safe because 'blur' has a custom value.
//
// But 'blur-sm' -> 'blur-xs' is safe because 'blur-xs' uses the
// default value.
blur: {
DEFAULT: 'var(--custom-default-blur)',
},
// Changes the "after" class definition. 'rounded' -> 'rounded-sm' is
// not safe because 'rounded-sm' has a custom value.
borderRadius: {
sm: 'var(--custom-rounded-sm)',
},
},
},
}
`,
'index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'index.html': html`
<div>
<div class="shadow shadow-sm shadow-xs"></div>
<div class="blur blur-sm"></div>
<div class="rounded rounded-sm"></div>
</div>
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade --force')
// Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(`
"
--- index.html ---
<div>
<div class="shadow shadow-sm shadow-xs"></div>
<div class="blur blur-xs"></div>
<div class="rounded rounded-sm"></div>
</div>
--- index.css ---
@import 'tailwindcss';
@theme {
--shadow-*: initial;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--blur: var(--custom-default-blur);
--radius-sm: var(--custom-rounded-sm);
}
/*
The default border color has changed to \`currentColor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
"
`)
},
)
test(
'make suffix-less migrations safe (e.g.: `blur`, `rounded`, `shadow`)',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3.4.14",
"@tailwindcss/upgrade": "workspace:^"
},
"devDependencies": {
"prettier-plugin-tailwindcss": "0.5.0"
}
}
`,
'tailwind.config.js': js`
module.exports = {
content: ['./*.{html,tsx}'],
}
`,
'index.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'index.html': html`
<div class="rounded blur shadow"></div>
`,
'example-component.tsx': ts`
type Star = [
x: number,
y: number,
dim?: boolean,
blur?: boolean,
rounded?: boolean,
shadow?: boolean,
]
function Star({ point: [cx, cy, dim, blur, rounded, shadow] }: { point: Star }) {
return <svg class="rounded shadow blur" filter={blur ? 'url(…)' : undefined} />
}
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade --force')
// Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(`
"
--- index.html ---
<div class="rounded-sm blur-sm shadow-sm"></div>
--- index.css ---
@import 'tailwindcss';
/*
The default border color has changed to \`currentColor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
--- example-component.tsx ---
type Star = [
x: number,
y: number,
dim?: boolean,
blur?: boolean,
rounded?: boolean,
shadow?: boolean,
]
function Star({ point: [cx, cy, dim, blur, rounded, shadow] }: { point: Star }) {
return <svg class="rounded-sm shadow-sm blur-sm" filter={blur ? 'url(…)' : undefined} />
}
"
`)
},
)

View File

@ -421,6 +421,20 @@ export function test(
options.onTestFinished(dispose)
// Make it a git repository, and commit all files
if (only || debug) {
try {
await context.exec('git init', { cwd: root })
await context.exec('git add --all', { cwd: root })
await context.exec('git commit -m "before migration"', { cwd: root })
} catch (error: any) {
console.error(error)
console.error(error.stdout?.toString())
console.error(error.stderr?.toString())
throw error
}
}
return await testCallback(context)
},
)

View File

@ -34,17 +34,26 @@ export function migrateAtApply({
return [...variants, utility].join(':')
})
// If we have a valid designSystem and config setup, we can run all
// candidate migrations on each utility
params = params.map((param) => migrateCandidate(designSystem, userConfig, param))
return async () => {
// If we have a valid designSystem and config setup, we can run all
// candidate migrations on each utility
params = await Promise.all(
params.map(async (param) => await migrateCandidate(designSystem, userConfig, param)),
)
atRule.params = params.join('').trim()
atRule.params = params.join('').trim()
}
}
return {
postcssPlugin: '@tailwindcss/upgrade/migrate-at-apply',
OnceExit(root) {
root.walkAtRules('apply', migrate)
async OnceExit(root) {
let migrations: (() => void)[] = []
root.walkAtRules('apply', (atRule) => {
migrations.push(migrate(atRule))
})
await Promise.allSettled(migrations.map((m) => m()))
},
}
}

View File

@ -2,19 +2,7 @@ import type { Config } from 'tailwindcss'
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from '../candidates'
const QUOTES = ['"', "'", '`']
const LOGICAL_OPERATORS = ['&&', '||', '===', '==', '!=', '!==', '>', '>=', '<', '<=']
const CONDITIONAL_TEMPLATE_SYNTAX = [
// Vue
/v-else-if=['"]$/,
/v-if=['"]$/,
/v-show=['"]$/,
// Alpine
/x-if=['"]$/,
/x-show=['"]$/,
]
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
@ -46,56 +34,8 @@ export function important(
// 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) {
let currentLineBeforeCandidate = ''
for (let i = location.start - 1; i >= 0; i--) {
let char = location.contents.at(i)!
if (char === '\n') {
break
}
currentLineBeforeCandidate = char + currentLineBeforeCandidate
}
let currentLineAfterCandidate = ''
for (let i = location.end; i < location.contents.length; i++) {
let char = location.contents.at(i)!
if (char === '\n') {
break
}
currentLineAfterCandidate += char
}
// Heuristic 1: Require the candidate to be inside quotes
let isQuoteBeforeCandidate = QUOTES.some((quote) =>
currentLineBeforeCandidate.includes(quote),
)
let isQuoteAfterCandidate = QUOTES.some((quote) =>
currentLineAfterCandidate.includes(quote),
)
if (!isQuoteBeforeCandidate || !isQuoteAfterCandidate) {
continue nextCandidate
}
// Heuristic 2: Disallow object access immediately following the candidate
if (currentLineAfterCandidate[0] === '.') {
continue nextCandidate
}
// Heuristic 3: Disallow logical operators preceding or following the candidate
for (let operator of LOGICAL_OPERATORS) {
if (
currentLineAfterCandidate.trim().startsWith(operator) ||
currentLineBeforeCandidate.trim().endsWith(operator)
) {
continue nextCandidate
}
}
// Heuristic 4: Disallow conditional template syntax
for (let rule of CONDITIONAL_TEMPLATE_SYNTAX) {
if (rule.test(currentLineBeforeCandidate)) {
continue nextCandidate
}
}
if (location && !isSafeMigration(location)) {
continue nextCandidate
}
// The printCandidate function will already put the exclamation mark in

View File

@ -0,0 +1,36 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { expect, test } from 'vitest'
import { legacyClasses } from './legacy-classes'
test.each([
['shadow', 'shadow-sm'],
['shadow-sm', 'shadow-xs'],
['shadow-xs', 'shadow-2xs'],
['inset-shadow', 'inset-shadow-sm'],
['inset-shadow-sm', 'inset-shadow-xs'],
['inset-shadow-xs', 'inset-shadow-2xs'],
['drop-shadow', 'drop-shadow-sm'],
['drop-shadow-sm', 'drop-shadow-xs'],
['rounded', 'rounded-sm'],
['rounded-sm', 'rounded-xs'],
['blur', 'blur-sm'],
['blur-sm', 'blur-xs'],
['blur!', 'blur-sm!'],
['hover:blur', 'hover:blur-sm'],
['hover:blur!', 'hover:blur-sm!'],
['hover:blur-sm', 'hover:blur-xs'],
['blur-sm!', 'blur-xs!'],
['hover:blur-sm!', 'hover:blur-xs!'],
])('%s => %s', async (candidate, result) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(await legacyClasses(designSystem, {}, candidate)).toEqual(result)
})

View File

@ -0,0 +1,156 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import path from 'node:path'
import url from 'node:url'
import type { Config } from 'tailwindcss'
import type { Candidate } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { printCandidate } from '../candidates'
import { isSafeMigration } from '../is-safe-migration'
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const LEGACY_CLASS_MAP = new Map([
['shadow', 'shadow-sm'],
['shadow-sm', 'shadow-xs'],
['shadow-xs', 'shadow-2xs'],
['inset-shadow', 'inset-shadow-sm'],
['inset-shadow-sm', 'inset-shadow-xs'],
['inset-shadow-xs', 'inset-shadow-2xs'],
['drop-shadow', 'drop-shadow-sm'],
['drop-shadow-sm', 'drop-shadow-xs'],
['rounded', 'rounded-sm'],
['rounded-sm', 'rounded-xs'],
['blur', 'blur-sm'],
['blur-sm', 'blur-xs'],
])
const THEME_KEYS = new Map([
['shadow', '--shadow'],
['shadow-sm', '--shadow-sm'],
['shadow-xs', '--shadow-xs'],
['shadow-2xs', '--shadow-2xs'],
['drop-shadow', '--drop-shadow'],
['drop-shadow-sm', '--drop-shadow-sm'],
['drop-shadow-xs', '--drop-shadow-xs'],
['rounded', '--radius'],
['rounded-sm', '--radius-sm'],
['rounded-xs', '--radius-xs'],
['blur', '--blur'],
['blur-sm', '--blur-sm'],
['blur-xs', '--blur-xs'],
])
const DESIGN_SYSTEMS = new DefaultMap((base) => {
return __unstable__loadDesignSystem('@import "tailwindcss";', { base })
})
export async function legacyClasses(
designSystem: DesignSystem,
_userConfig: Config,
rawCandidate: string,
location?: {
contents: string
start: number
end: number
},
): Promise<string> {
let defaultDesignSystem = await DESIGN_SYSTEMS.get(__dirname)
function* migrate(rawCandidate: string) {
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 = printCandidate(designSystem, baseCandidate)
// Find the new base candidate string. `blur` -> `blur-sm`
let newBaseCandidateString = LEGACY_CLASS_MAP.get(baseCandidateString)
if (!newBaseCandidateString) continue
// Parse the new base candidate string into an actual candidate AST.
let [newBaseCandidate] = designSystem.parseCandidate(newBaseCandidateString)
if (!newBaseCandidate) continue
// Re-apply the variants and important flag from the original candidate.
// E.g.: `hover:blur!` -> `blur` -> `blur-sm` -> `hover:blur-sm!`
let newCandidate = structuredClone(newBaseCandidate) as Candidate
newCandidate.variants = candidate.variants
newCandidate.important = candidate.important
yield [
candidate,
newCandidate,
THEME_KEYS.get(baseCandidateString),
THEME_KEYS.get(newBaseCandidateString),
] as const
}
}
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
}
if (fromThemeKey && toThemeKey) {
// Migrating something that resolves to a value in the theme.
let customFrom = designSystem.resolveThemeValue(fromThemeKey)
let defaultFrom = defaultDesignSystem.resolveThemeValue(fromThemeKey)
let customTo = designSystem.resolveThemeValue(toThemeKey)
let defaultTo = defaultDesignSystem.resolveThemeValue(toThemeKey)
// The new theme value is not defined, which means we can't safely
// migrate the utility.
if (customTo === undefined) {
continue
}
// The "from" theme value changed compared to the default theme value.
if (customFrom !== defaultFrom) {
continue
}
// The "to" theme value changed compared to the default theme value.
if (customTo !== defaultTo) {
continue
}
}
return printCandidate(designSystem, toCandidate)
}
return rawCandidate
}

View File

@ -16,23 +16,6 @@ test.each([
['max-lg:hover:decoration-slice!', 'max-lg:hover:box-decoration-slice!'],
['max-lg:hover:!decoration-slice', 'max-lg:hover:box-decoration-slice!'],
['shadow', 'shadow-sm'],
['shadow-sm', 'shadow-xs'],
['shadow-xs', 'shadow-2xs'],
['inset-shadow', 'inset-shadow-sm'],
['inset-shadow-sm', 'inset-shadow-xs'],
['inset-shadow-xs', 'inset-shadow-2xs'],
['drop-shadow', 'drop-shadow-sm'],
['drop-shadow-sm', 'drop-shadow-xs'],
['rounded', 'rounded-sm'],
['rounded-sm', 'rounded-xs'],
['blur', 'blur-sm'],
['blur-sm', 'blur-xs'],
['focus:outline-none', 'focus:outline-hidden'],
])('%s => %s', async (candidate, result) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {

View File

@ -7,30 +7,15 @@ import { printCandidate } from '../candidates'
const LEGACY_CLASS_MAP = {
'overflow-clip': 'text-clip',
'overflow-ellipsis': 'text-ellipsis',
'flex-grow': 'grow',
'flex-grow-0': 'grow-0',
'flex-shrink': 'shrink',
'flex-shrink-0': 'shrink-0',
'decoration-clone': 'box-decoration-clone',
'decoration-slice': 'box-decoration-slice',
shadow: 'shadow-sm',
'shadow-sm': 'shadow-xs',
'shadow-xs': 'shadow-2xs',
'inset-shadow': 'inset-shadow-sm',
'inset-shadow-sm': 'inset-shadow-xs',
'inset-shadow-xs': 'inset-shadow-2xs',
'drop-shadow': 'drop-shadow-sm',
'drop-shadow-sm': 'drop-shadow-xs',
rounded: 'rounded-sm',
'rounded-sm': 'rounded-xs',
blur: 'blur-sm',
'blur-sm': 'blur-xs',
'outline-none': 'outline-hidden',
}
@ -41,7 +26,7 @@ export function simpleLegacyClasses(
_userConfig: Config,
rawCandidate: string,
): string {
// Prepare design system with the legacy classes
// Prepare design system with the unknown legacy classes
if (!SEEDED.has(designSystem)) {
for (let old in LEGACY_CLASS_MAP) {
designSystem.utilities.static(old, () => [])

View File

@ -0,0 +1,62 @@
const QUOTES = ['"', "'", '`']
const LOGICAL_OPERATORS = ['&&', '||', '?', '===', '==', '!=', '!==', '>', '>=', '<', '<=']
const CONDITIONAL_TEMPLATE_SYNTAX = [
// Vue
/v-else-if=['"]$/,
/v-if=['"]$/,
/v-show=['"]$/,
// Alpine
/x-if=['"]$/,
/x-show=['"]$/,
]
export function isSafeMigration(location: { contents: string; start: number; end: number }) {
let currentLineBeforeCandidate = ''
for (let i = location.start - 1; i >= 0; i--) {
let char = location.contents.at(i)!
if (char === '\n') {
break
}
currentLineBeforeCandidate = char + currentLineBeforeCandidate
}
let currentLineAfterCandidate = ''
for (let i = location.end; i < location.contents.length; i++) {
let char = location.contents.at(i)!
if (char === '\n') {
break
}
currentLineAfterCandidate += char
}
// Heuristic 1: Require the candidate to be inside quotes
let isQuoteBeforeCandidate = QUOTES.some((quote) => currentLineBeforeCandidate.includes(quote))
let isQuoteAfterCandidate = QUOTES.some((quote) => currentLineAfterCandidate.includes(quote))
if (!isQuoteBeforeCandidate || !isQuoteAfterCandidate) {
return false
}
// Heuristic 2: Disallow object access immediately following the candidate
if (currentLineAfterCandidate[0] === '.') {
return false
}
// Heuristic 3: Disallow logical operators preceding or following the candidate
for (let operator of LOGICAL_OPERATORS) {
if (
currentLineAfterCandidate.trim().startsWith(operator) ||
currentLineBeforeCandidate.trim().endsWith(operator)
) {
return false
}
}
// Heuristic 4: Disallow conditional template syntax
for (let rule of CONDITIONAL_TEMPLATE_SYNTAX) {
if (rule.test(currentLineBeforeCandidate)) {
return false
}
}
return true
}

View File

@ -8,6 +8,7 @@ import { automaticVarInjection } from './codemods/automatic-var-injection'
import { bgGradient } from './codemods/bg-gradient'
import { important } from './codemods/important'
import { legacyArbitraryValues } from './codemods/legacy-arbitrary-values'
import { legacyClasses } from './codemods/legacy-classes'
import { maxWidthScreen } from './codemods/max-width-screen'
import { modernizeArbitraryValues } from './codemods/modernize-arbitrary-values'
import { prefix } from './codemods/prefix'
@ -25,13 +26,14 @@ export type Migration = (
start: number
end: number
},
) => string
) => string | Promise<string>
export const DEFAULT_MIGRATIONS: Migration[] = [
prefix,
important,
bgGradient,
simpleLegacyClasses,
legacyClasses,
maxWidthScreen,
themeToVar,
variantOrder, // Has to happen before migrations that modify variants
@ -41,7 +43,7 @@ export const DEFAULT_MIGRATIONS: Migration[] = [
modernizeArbitraryValues,
]
export function migrateCandidate(
export async function migrateCandidate(
designSystem: DesignSystem,
userConfig: Config,
rawCandidate: string,
@ -51,9 +53,9 @@ export function migrateCandidate(
start: number
end: number
},
): string {
): Promise<string> {
for (let migration of DEFAULT_MIGRATIONS) {
rawCandidate = migration(designSystem, userConfig, rawCandidate, location)
rawCandidate = await migration(designSystem, userConfig, rawCandidate, location)
}
return rawCandidate
}
@ -69,7 +71,7 @@ export default async function migrateContents(
let changes: StringChange[] = []
for (let { rawCandidate, start, end } of candidates) {
let migratedCandidate = migrateCandidate(designSystem, userConfig, rawCandidate, {
let migratedCandidate = await migrateCandidate(designSystem, userConfig, rawCandidate, {
contents,
start,
end,