Skip to content

Commit 34f708a

Browse files
Upgrade: Reduce number of false-positive migrations of the important modifier (#14737)
The important candidate migration is one of the most broad we have since it matches for any utility that are prefixed with an exclamation mark. When running the codemodes on our example projects, we noticed that this was instead creating false-positives with candidates used in code positions, e.g: ```ts export default { shouldNotUse: !border.shouldUse, } ``` To prevent false-positives, this PR adds a heuristics to detect wether or not a candidate is used in a non-code position. We do this by checking the character before and after the modifier and only allow quotes or spaces. This can cause candidates to not migrate that are valid Tailwind CSS classes, e.g.: ```ts let classNames = `!underline${isHovered ? ' font-bold' : ''}` ``` This, however, is not a big issue since v4 can parse the v3 important prefix too.
1 parent 02cb52a commit 34f708a

File tree

5 files changed

+116
-4
lines changed

5 files changed

+116
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
- _Upgrade (experimental)_: Ensure legacy theme values ending in `1` (like `theme(spacing.1)`) are correctly migrated to custom properties ([#14724](https://github.com/tailwindlabs/tailwindcss/pull/14724))
2727
- _Upgrade (experimental)_: Migrate arbitrary values to bare values for the `from-*`, `via-*`, and `to-*` utilities ([#14725](https://github.com/tailwindlabs/tailwindcss/pull/14725))
2828
- _Upgrade (experimental)_: Ensure `layer(utilities)` is removed from `@import` to keep `@utility` top-level ([#14738](https://github.com/tailwindlabs/tailwindcss/pull/14738))
29+
- _Upgrade (experimental)_: Don't migrate important modifiers that are actually logical negations (e.g. `let foo = !border` to `let foo = border!`) ([#14737](https://github.com/tailwindlabs/tailwindcss/pull/14737))
2930

3031
### Changed
3132

integrations/upgrade/js-config.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ test(
7272
@tailwind components;
7373
@tailwind utilities;
7474
`,
75+
'src/test.js': ts`
76+
export default {
77+
shouldNotUse: !border.shouldUse,
78+
}
79+
`,
7580
'node_modules/my-external-lib/src/template.html': html`
7681
<div class="text-red-500">
7782
Hello world!
@@ -82,7 +87,7 @@ test(
8287
async ({ exec, fs }) => {
8388
await exec('npx @tailwindcss/upgrade')
8489

85-
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
90+
expect(await fs.dumpFiles('src/**/*.{css,js}')).toMatchInlineSnapshot(`
8691
"
8792
--- src/input.css ---
8893
@import 'tailwindcss';
@@ -134,6 +139,11 @@ test(
134139
}
135140
}
136141
}
142+
143+
--- src/test.js ---
144+
export default {
145+
shouldNotUse: !border.shouldUse,
146+
}
137147
"
138148
`)
139149

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

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,39 @@ test.each([
1515
base: __dirname,
1616
})
1717

18-
expect(important(designSystem, {}, candidate)).toEqual(result)
18+
expect(
19+
important(designSystem, {}, candidate, {
20+
contents: `"${candidate}"`,
21+
start: 1,
22+
end: candidate.length + 1,
23+
}),
24+
).toEqual(result)
25+
})
26+
27+
test('does not match false positives', async () => {
28+
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
29+
base: __dirname,
30+
})
31+
32+
expect(
33+
important(designSystem, {}, '!border', {
34+
contents: `let notBorder = !border\n`,
35+
start: 16,
36+
end: 16 + '!border'.length,
37+
}),
38+
).toEqual('!border')
39+
})
40+
41+
test('does not match false positives with spaces at the end of the line', async () => {
42+
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
43+
base: __dirname,
44+
})
45+
46+
expect(
47+
important(designSystem, {}, '!border', {
48+
contents: `let notBorder = !border \n`,
49+
start: 16,
50+
end: 16 + '!border'.length,
51+
}),
52+
).toEqual('!border')
1953
})

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,50 @@ export function important(
1919
designSystem: DesignSystem,
2020
_userConfig: Config,
2121
rawCandidate: string,
22+
location?: {
23+
contents: string
24+
start: number
25+
end: number
26+
},
2227
): string {
2328
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
2429
if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') {
30+
// The important migration is one of the most broad migrations with a high
31+
// potential of matching false positives since `!` is a valid character in
32+
// most programming languages. Since v4 is technically backward compatible
33+
// with v3 in that it can read `!` in the front of the utility too, we err
34+
// on the side of caution and only migrate candidates that we are certain
35+
// are inside of a string.
36+
if (location) {
37+
let isQuoteBeforeCandidate = false
38+
for (let i = location.start - 1; i >= 0; i--) {
39+
let char = location.contents.at(i)!
40+
if (char === '\n') {
41+
break
42+
}
43+
if (isQuote(char)) {
44+
isQuoteBeforeCandidate = true
45+
break
46+
}
47+
}
48+
49+
let isQuoteAfterCandidate = false
50+
for (let i = location.end; i < location.contents.length; i++) {
51+
let char = location.contents.at(i)!
52+
if (char === '\n') {
53+
break
54+
}
55+
if (isQuote(char)) {
56+
isQuoteAfterCandidate = true
57+
break
58+
}
59+
}
60+
61+
if (!isQuoteBeforeCandidate || !isQuoteAfterCandidate) {
62+
continue
63+
}
64+
}
65+
2566
// The printCandidate function will already put the exclamation mark in
2667
// the right place, so we just need to mark this candidate as requiring a
2768
// migration.
@@ -31,3 +72,14 @@ export function important(
3172

3273
return rawCandidate
3374
}
75+
76+
function isQuote(char: string) {
77+
switch (char) {
78+
case '"':
79+
case "'":
80+
case '`':
81+
return true
82+
default:
83+
return false
84+
}
85+
}

packages/@tailwindcss-upgrade/src/template/migrate.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export type Migration = (
1717
designSystem: DesignSystem,
1818
userConfig: Config,
1919
rawCandidate: string,
20+
location?: {
21+
contents: string
22+
start: number
23+
end: number
24+
},
2025
) => string
2126

2227
export const DEFAULT_MIGRATIONS: Migration[] = [
@@ -34,9 +39,15 @@ export function migrateCandidate(
3439
designSystem: DesignSystem,
3540
userConfig: Config,
3641
rawCandidate: string,
42+
// Location is only set when migrating a candidate from a source file
43+
location?: {
44+
contents: string
45+
start: number
46+
end: number
47+
},
3748
): string {
3849
for (let migration of DEFAULT_MIGRATIONS) {
39-
rawCandidate = migration(designSystem, userConfig, rawCandidate)
50+
rawCandidate = migration(designSystem, userConfig, rawCandidate, location)
4051
}
4152
return rawCandidate
4253
}
@@ -52,7 +63,11 @@ export default async function migrateContents(
5263
let changes: StringChange[] = []
5364

5465
for (let { rawCandidate, start, end } of candidates) {
55-
let migratedCandidate = migrateCandidate(designSystem, userConfig, rawCandidate)
66+
let migratedCandidate = migrateCandidate(designSystem, userConfig, rawCandidate, {
67+
contents,
68+
start,
69+
end,
70+
})
5671

5772
if (migratedCandidate === rawCandidate) {
5873
continue

0 commit comments

Comments
 (0)