Skip to content

Commit ba66740

Browse files
[@tailwindcss/upgrade] Don’t migrate inline style properties (#19918)
Prevent the upgrade tool from rewriting CSS properties inside inline `style` attributes. This fixes cases like `style="flex-grow: 1"` being changed to `style="grow: 1"` and adds regression tests. --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 73f3a6a commit ba66740

3 files changed

Lines changed: 139 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986))
2222
- Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. `-mt-[20in]``mt-[-20in]`, not `mt-[-1920px]`) ([#19988](https://github.com/tailwindlabs/tailwindcss/pull/19988))
2323
- Canonicalization: migrate arbitrary `:has()` variants from `[&:has(…)]` to `has-[…]` ([#19991](https://github.com/tailwindlabs/tailwindcss/pull/19991))
24+
- Upgrade: don’t migrate inline `style` attributes ([#19918](https://github.com/tailwindlabs/tailwindcss/pull/19918))
2425

2526
## [4.2.4] - 2026-04-21
2627

packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ describe('is-safe-migration', async () => {
4848
[`<div v-show="shadow"></div>\n`, 'shadow'],
4949
[`<div x-if="shadow"></div>\n`, 'shadow'],
5050
[`<div style={{filter: 'drop-shadow(30px 10px 4px #4444dd)'}}/>\n`, 'shadow'],
51+
[`<div style="flex-grow: 1"></div>\n`, 'flex-grow'],
52+
[`<div style='flex-shrink: 0'></div>\n`, 'flex-shrink'],
53+
[`<div style=" flex-shrink: 0"></div>\n`, 'flex-shrink'],
54+
[`<div style="\nflex-shrink: 0\n"></div>\n`, 'flex-shrink'],
5155

5256
// Next.js Image placeholder cases
5357
[`<Image placeholder="blur" src="/image.jpg" />`, 'blur'],

packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts

Lines changed: 134 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,38 @@ export function isSafeMigration(
6666
}
6767
}
6868

69+
let currentLineBeforeCandidate = ''
70+
for (let i = location.start - 1; i >= 0; i--) {
71+
let char = location.contents.at(i)!
72+
if (char === '\n') {
73+
break
74+
}
75+
currentLineBeforeCandidate = char + currentLineBeforeCandidate
76+
}
77+
let currentLineAfterCandidate = ''
78+
for (let i = location.end; i < location.contents.length; i++) {
79+
let char = location.contents.at(i)!
80+
if (char === '\n') {
81+
break
82+
}
83+
currentLineAfterCandidate += char
84+
}
85+
86+
// Inline `style="..."` attributes can contain CSS property names that look
87+
// like valid utility candidates, such as `flex-grow`.
88+
{
89+
let ranges = inlineStyleAttributeValueRanges.get(location.contents)
90+
91+
for (let i = 0; i < ranges.length; i += 2) {
92+
let start = ranges[i]
93+
let end = ranges[i + 1]
94+
95+
if (location.start >= start && location.end <= end) {
96+
return false
97+
}
98+
}
99+
}
100+
69101
let [candidate] = parseCandidate(rawCandidate, designSystem)
70102

71103
// If we can't parse the candidate, then it's not a candidate at all. However,
@@ -123,23 +155,6 @@ export function isSafeMigration(
123155
}
124156
}
125157

126-
let currentLineBeforeCandidate = ''
127-
for (let i = location.start - 1; i >= 0; i--) {
128-
let char = location.contents.at(i)!
129-
if (char === '\n') {
130-
break
131-
}
132-
currentLineBeforeCandidate = char + currentLineBeforeCandidate
133-
}
134-
let currentLineAfterCandidate = ''
135-
for (let i = location.end; i < location.contents.length; i++) {
136-
let char = location.contents.at(i)!
137-
if (char === '\n') {
138-
break
139-
}
140-
currentLineAfterCandidate += char
141-
}
142-
143158
// Heuristic: Require the candidate to be inside quotes
144159
let isQuoteBeforeCandidate = isMiddleOfString(currentLineBeforeCandidate)
145160
let isQuoteAfterCandidate = isMiddleOfString(currentLineAfterCandidate)
@@ -218,6 +233,14 @@ const BACKSLASH = 0x5c
218233
const DOUBLE_QUOTE = 0x22
219234
const SINGLE_QUOTE = 0x27
220235
const BACKTICK = 0x60
236+
const TAB = 0x09
237+
const NEWLINE = 0x0a
238+
const FORM_FEED = 0x0c
239+
const CARRIAGE_RETURN = 0x0d
240+
const SPACE = 0x20
241+
const SLASH = 0x2f
242+
const EQUALS = 0x3d
243+
const GREATER_THAN = 0x3e
221244

222245
function isMiddleOfString(line: string): boolean {
223246
let currentQuote: number | null = null
@@ -248,3 +271,97 @@ function isMiddleOfString(line: string): boolean {
248271

249272
return currentQuote !== null
250273
}
274+
275+
const inlineStyleAttributeValueRanges = new DefaultMap((source: string) => {
276+
let ranges: number[] = []
277+
let offset = 0
278+
279+
while (true) {
280+
let tagStart = source.indexOf('<', offset)
281+
if (tagStart === -1) return ranges
282+
283+
let tagEnd = source.indexOf('>', tagStart + 1)
284+
if (tagEnd === -1) return ranges
285+
286+
offset = tagEnd + 1
287+
288+
for (let i = tagStart + 1; i < tagEnd; i++) {
289+
let char = source.charCodeAt(i)
290+
291+
if (
292+
char === SPACE ||
293+
char === TAB ||
294+
char === NEWLINE ||
295+
char === CARRIAGE_RETURN ||
296+
char === FORM_FEED
297+
) {
298+
continue
299+
}
300+
301+
let start = i
302+
while (i < tagEnd) {
303+
let char = source.charCodeAt(i)
304+
if (
305+
char === SPACE ||
306+
char === TAB ||
307+
char === NEWLINE ||
308+
char === CARRIAGE_RETURN ||
309+
char === FORM_FEED ||
310+
char === EQUALS ||
311+
char === GREATER_THAN ||
312+
char === SLASH
313+
) {
314+
break
315+
}
316+
317+
i++
318+
}
319+
320+
let attribute = source.slice(start, i).toLowerCase()
321+
if (attribute !== 'style' && attribute !== ':style') continue
322+
323+
while (i < tagEnd) {
324+
let char = source.charCodeAt(i)
325+
if (
326+
char !== SPACE &&
327+
char !== TAB &&
328+
char !== NEWLINE &&
329+
char !== CARRIAGE_RETURN &&
330+
char !== FORM_FEED
331+
) {
332+
break
333+
}
334+
335+
i++
336+
}
337+
338+
if (source[i] !== '=') continue
339+
340+
i++
341+
while (i < tagEnd) {
342+
let char = source.charCodeAt(i)
343+
if (
344+
char !== SPACE &&
345+
char !== TAB &&
346+
char !== NEWLINE &&
347+
char !== CARRIAGE_RETURN &&
348+
char !== FORM_FEED
349+
) {
350+
break
351+
}
352+
353+
i++
354+
}
355+
356+
let quote = source[i]
357+
if (quote !== '"' && quote !== "'") continue
358+
359+
let valueStart = i + 1
360+
let valueEnd = source.indexOf(quote, valueStart)
361+
if (valueEnd === -1 || valueEnd > tagEnd) break
362+
363+
ranges.push(valueStart, valueEnd)
364+
i = valueEnd
365+
}
366+
}
367+
})

0 commit comments

Comments
 (0)