Skip to content

Commit 9f5a87a

Browse files
Ensure it's safe to perform suffix-less migrations (#14979)
This PR makes sure that migrations from suffix-less candidates (e.g.: `rounded`, `blur`, `shadow`) are safe to be migrated. In some code snippets that's not always the case. Given the following code snippet: ```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 shadow blur" filter={blur ? 'url(…)' : undefined} /> } ``` Without this change, it would result in: ```tsx type Star = [ x: number, y: number, dim?: boolean, blur-sm?: boolean, rounded-sm?: boolean, shadow-sm?: boolean, ] function Star({ point: [cx, cy, dim, blur-sm, rounded-sm, shadow-sm] }: { point: Star }) { return <svg class="rounded-sm shadow-sm blur-sm" filter={blur-sm ? 'url(…)' : undefined} /> } ``` But with this change, it results in: ```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} /> } ``` Notice how the classes inside the `class` attribute _are_ converted, but the ones in the types or as part of the JavaScript code (e.g.: `filter={blur ? 'url(…)' : undefined}`) are not. --------- Co-authored-by: Philipp Spiess <hello@philippspiess.com>
1 parent 553207e commit 9f5a87a

File tree

5 files changed

+173
-71
lines changed

5 files changed

+173
-71
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616

1717
- _Upgrade (experimental)_: Do not migrate legacy classes with custom values ([#14976](https://github.com/tailwindlabs/tailwindcss/pull/14976))
18+
- _Upgrade (experimental)_: Ensure it's safe to migrate `blur`, `rounded`, or `shadow` ([#14979](https://github.com/tailwindlabs/tailwindcss/pull/14979))
1819

1920
## [4.0.0-alpha.33] - 2024-11-11
2021

integrations/upgrade/index.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from 'vitest'
2-
import { candidate, css, html, js, json, test } from '../utils'
2+
import { candidate, css, html, js, json, test, ts } from '../utils'
33

44
test(
55
'error when no CSS file with @tailwind is used',
@@ -1747,3 +1747,95 @@ test(
17471747
`)
17481748
},
17491749
)
1750+
1751+
test(
1752+
'make suffix-less migrations safe (e.g.: `blur`, `rounded`, `shadow`)',
1753+
{
1754+
fs: {
1755+
'package.json': json`
1756+
{
1757+
"dependencies": {
1758+
"tailwindcss": "^3.4.14",
1759+
"@tailwindcss/upgrade": "workspace:^"
1760+
},
1761+
"devDependencies": {
1762+
"prettier-plugin-tailwindcss": "0.5.0"
1763+
}
1764+
}
1765+
`,
1766+
'tailwind.config.js': js`
1767+
module.exports = {
1768+
content: ['./*.{html,tsx}'],
1769+
}
1770+
`,
1771+
'index.css': css`
1772+
@tailwind base;
1773+
@tailwind components;
1774+
@tailwind utilities;
1775+
`,
1776+
'index.html': html`
1777+
<div class="rounded blur shadow"></div>
1778+
`,
1779+
'example-component.tsx': ts`
1780+
type Star = [
1781+
x: number,
1782+
y: number,
1783+
dim?: boolean,
1784+
blur?: boolean,
1785+
rounded?: boolean,
1786+
shadow?: boolean,
1787+
]
1788+
1789+
function Star({ point: [cx, cy, dim, blur, rounded, shadow] }: { point: Star }) {
1790+
return <svg class="rounded shadow blur" filter={blur ? 'url(…)' : undefined} />
1791+
}
1792+
`,
1793+
},
1794+
},
1795+
async ({ fs, exec }) => {
1796+
await exec('npx @tailwindcss/upgrade --force')
1797+
1798+
// Files should not be modified
1799+
expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(`
1800+
"
1801+
--- index.html ---
1802+
<div class="rounded-sm blur-sm shadow-sm"></div>
1803+
1804+
--- index.css ---
1805+
@import 'tailwindcss';
1806+
1807+
/*
1808+
The default border color has changed to \`currentColor\` in Tailwind CSS v4,
1809+
so we've added these compatibility styles to make sure everything still
1810+
looks the same as it did with Tailwind CSS v3.
1811+
1812+
If we ever want to remove these styles, we need to add an explicit border
1813+
color utility to any element that depends on these defaults.
1814+
*/
1815+
@layer base {
1816+
*,
1817+
::after,
1818+
::before,
1819+
::backdrop,
1820+
::file-selector-button {
1821+
border-color: var(--color-gray-200, currentColor);
1822+
}
1823+
}
1824+
1825+
--- example-component.tsx ---
1826+
type Star = [
1827+
x: number,
1828+
y: number,
1829+
dim?: boolean,
1830+
blur?: boolean,
1831+
rounded?: boolean,
1832+
shadow?: boolean,
1833+
]
1834+
1835+
function Star({ point: [cx, cy, dim, blur, rounded, shadow] }: { point: Star }) {
1836+
return <svg class="rounded-sm shadow-sm blur-sm" filter={blur ? 'url(…)' : undefined} />
1837+
}
1838+
"
1839+
`)
1840+
},
1841+
)

packages/@tailwindcss-upgrade/src/template/codemods/important.ts

Lines changed: 3 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,7 @@ import type { Config } from 'tailwindcss'
22
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
33
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
44
import { printCandidate } from '../candidates'
5-
6-
const QUOTES = ['"', "'", '`']
7-
const LOGICAL_OPERATORS = ['&&', '||', '===', '==', '!=', '!==', '>', '>=', '<', '<=']
8-
const CONDITIONAL_TEMPLATE_SYNTAX = [
9-
// Vue
10-
/v-else-if=['"]$/,
11-
/v-if=['"]$/,
12-
/v-show=['"]$/,
13-
14-
// Alpine
15-
/x-if=['"]$/,
16-
/x-show=['"]$/,
17-
]
5+
import { isSafeMigration } from '../is-safe-migration'
186

197
// In v3 the important modifier `!` sits in front of the utility itself, not
208
// 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(
4634
// with v3 in that it can read `!` in the front of the utility too, we err
4735
// on the side of caution and only migrate candidates that we are certain
4836
// are inside of a string.
49-
if (location) {
50-
let currentLineBeforeCandidate = ''
51-
for (let i = location.start - 1; i >= 0; i--) {
52-
let char = location.contents.at(i)!
53-
if (char === '\n') {
54-
break
55-
}
56-
currentLineBeforeCandidate = char + currentLineBeforeCandidate
57-
}
58-
let currentLineAfterCandidate = ''
59-
for (let i = location.end; i < location.contents.length; i++) {
60-
let char = location.contents.at(i)!
61-
if (char === '\n') {
62-
break
63-
}
64-
currentLineAfterCandidate += char
65-
}
66-
67-
// Heuristic 1: Require the candidate to be inside quotes
68-
let isQuoteBeforeCandidate = QUOTES.some((quote) =>
69-
currentLineBeforeCandidate.includes(quote),
70-
)
71-
let isQuoteAfterCandidate = QUOTES.some((quote) =>
72-
currentLineAfterCandidate.includes(quote),
73-
)
74-
if (!isQuoteBeforeCandidate || !isQuoteAfterCandidate) {
75-
continue nextCandidate
76-
}
77-
78-
// Heuristic 2: Disallow object access immediately following the candidate
79-
if (currentLineAfterCandidate[0] === '.') {
80-
continue nextCandidate
81-
}
82-
83-
// Heuristic 3: Disallow logical operators preceding or following the candidate
84-
for (let operator of LOGICAL_OPERATORS) {
85-
if (
86-
currentLineAfterCandidate.trim().startsWith(operator) ||
87-
currentLineBeforeCandidate.trim().endsWith(operator)
88-
) {
89-
continue nextCandidate
90-
}
91-
}
92-
93-
// Heuristic 4: Disallow conditional template syntax
94-
for (let rule of CONDITIONAL_TEMPLATE_SYNTAX) {
95-
if (rule.test(currentLineBeforeCandidate)) {
96-
continue nextCandidate
97-
}
98-
}
37+
if (location && !isSafeMigration(location)) {
38+
continue nextCandidate
9939
}
10040

10141
// The printCandidate function will already put the exclamation mark in

packages/@tailwindcss-upgrade/src/template/codemods/legacy-classes.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Config } from 'tailwindcss'
55
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
66
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
77
import { printCandidate } from '../candidates'
8+
import { isSafeMigration } from '../is-safe-migration'
89

910
const __filename = url.fileURLToPath(import.meta.url)
1011
const __dirname = path.dirname(__filename)
@@ -56,6 +57,11 @@ export async function legacyClasses(
5657
designSystem: DesignSystem,
5758
_userConfig: Config,
5859
rawCandidate: string,
60+
location?: {
61+
contents: string
62+
start: number
63+
end: number
64+
},
5965
): Promise<string> {
6066
// Ensure the "old" classes exist as static utilities to make the migration
6167
// easier because the "root" will point to the full class.
@@ -70,13 +76,14 @@ export async function legacyClasses(
7076

7177
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
7278
if (candidate.kind === 'static' && Object.hasOwn(LEGACY_CLASS_MAP, candidate.root)) {
79+
let newRoot = LEGACY_CLASS_MAP[candidate.root as keyof typeof LEGACY_CLASS_MAP]
80+
81+
if (location && !candidate.root.includes('-') && !isSafeMigration(location)) {
82+
continue
83+
}
84+
7385
let fromThemeKey = THEME_KEYS[candidate.root as keyof typeof THEME_KEYS]
74-
let toThemeKey =
75-
THEME_KEYS[
76-
LEGACY_CLASS_MAP[
77-
candidate.root as keyof typeof LEGACY_CLASS_MAP
78-
] as keyof typeof THEME_KEYS
79-
]
86+
let toThemeKey = THEME_KEYS[newRoot as keyof typeof THEME_KEYS]
8087

8188
if (fromThemeKey && toThemeKey) {
8289
// Migrating something that resolves to a value in the theme.
@@ -104,7 +111,7 @@ export async function legacyClasses(
104111

105112
return printCandidate(designSystem, {
106113
...candidate,
107-
root: LEGACY_CLASS_MAP[candidate.root as keyof typeof LEGACY_CLASS_MAP],
114+
root: newRoot,
108115
})
109116
}
110117
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const QUOTES = ['"', "'", '`']
2+
const LOGICAL_OPERATORS = ['&&', '||', '?', '===', '==', '!=', '!==', '>', '>=', '<', '<=']
3+
const CONDITIONAL_TEMPLATE_SYNTAX = [
4+
// Vue
5+
/v-else-if=['"]$/,
6+
/v-if=['"]$/,
7+
/v-show=['"]$/,
8+
9+
// Alpine
10+
/x-if=['"]$/,
11+
/x-show=['"]$/,
12+
]
13+
14+
export function isSafeMigration(location: { contents: string; start: number; end: number }) {
15+
let currentLineBeforeCandidate = ''
16+
for (let i = location.start - 1; i >= 0; i--) {
17+
let char = location.contents.at(i)!
18+
if (char === '\n') {
19+
break
20+
}
21+
currentLineBeforeCandidate = char + currentLineBeforeCandidate
22+
}
23+
let currentLineAfterCandidate = ''
24+
for (let i = location.end; i < location.contents.length; i++) {
25+
let char = location.contents.at(i)!
26+
if (char === '\n') {
27+
break
28+
}
29+
currentLineAfterCandidate += char
30+
}
31+
32+
// Heuristic 1: Require the candidate to be inside quotes
33+
let isQuoteBeforeCandidate = QUOTES.some((quote) => currentLineBeforeCandidate.includes(quote))
34+
let isQuoteAfterCandidate = QUOTES.some((quote) => currentLineAfterCandidate.includes(quote))
35+
if (!isQuoteBeforeCandidate || !isQuoteAfterCandidate) {
36+
return false
37+
}
38+
39+
// Heuristic 2: Disallow object access immediately following the candidate
40+
if (currentLineAfterCandidate[0] === '.') {
41+
return false
42+
}
43+
44+
// Heuristic 3: Disallow logical operators preceding or following the candidate
45+
for (let operator of LOGICAL_OPERATORS) {
46+
if (
47+
currentLineAfterCandidate.trim().startsWith(operator) ||
48+
currentLineBeforeCandidate.trim().endsWith(operator)
49+
) {
50+
return false
51+
}
52+
}
53+
54+
// Heuristic 4: Disallow conditional template syntax
55+
for (let rule of CONDITIONAL_TEMPLATE_SYNTAX) {
56+
if (rule.test(currentLineBeforeCandidate)) {
57+
return false
58+
}
59+
}
60+
61+
return true
62+
}

0 commit comments

Comments
 (0)