diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf22c654d..e7f943bc8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986))
- 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))
- Canonicalization: migrate arbitrary `:has()` variants from `[&:has(…)]` to `has-[…]` ([#19991](https://github.com/tailwindlabs/tailwindcss/pull/19991))
+- Upgrade: don’t migrate inline `style` attributes ([#19918](https://github.com/tailwindlabs/tailwindcss/pull/19918))
## [4.2.4] - 2026-04-21
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 ff12fc114..7cfb16c75 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
@@ -48,6 +48,10 @@ describe('is-safe-migration', async () => {
[`
\n`, 'shadow'],
[`\n`, 'shadow'],
[`\n`, 'shadow'],
+ [`\n`, 'flex-grow'],
+ [`\n`, 'flex-shrink'],
+ [`\n`, 'flex-shrink'],
+ [`\n`, 'flex-shrink'],
// Next.js Image placeholder cases
[``, 'blur'],
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 fa7ee7f4a..2c09f299f 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts
@@ -66,6 +66,38 @@ export function isSafeMigration(
}
}
+ 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
+ }
+
+ // Inline `style="..."` attributes can contain CSS property names that look
+ // like valid utility candidates, such as `flex-grow`.
+ {
+ let ranges = inlineStyleAttributeValueRanges.get(location.contents)
+
+ for (let i = 0; i < ranges.length; i += 2) {
+ let start = ranges[i]
+ let end = ranges[i + 1]
+
+ if (location.start >= start && location.end <= end) {
+ return false
+ }
+ }
+ }
+
let [candidate] = parseCandidate(rawCandidate, designSystem)
// If we can't parse the candidate, then it's not a candidate at all. However,
@@ -123,23 +155,6 @@ export function isSafeMigration(
}
}
- 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: Require the candidate to be inside quotes
let isQuoteBeforeCandidate = isMiddleOfString(currentLineBeforeCandidate)
let isQuoteAfterCandidate = isMiddleOfString(currentLineAfterCandidate)
@@ -218,6 +233,14 @@ const BACKSLASH = 0x5c
const DOUBLE_QUOTE = 0x22
const SINGLE_QUOTE = 0x27
const BACKTICK = 0x60
+const TAB = 0x09
+const NEWLINE = 0x0a
+const FORM_FEED = 0x0c
+const CARRIAGE_RETURN = 0x0d
+const SPACE = 0x20
+const SLASH = 0x2f
+const EQUALS = 0x3d
+const GREATER_THAN = 0x3e
function isMiddleOfString(line: string): boolean {
let currentQuote: number | null = null
@@ -248,3 +271,97 @@ function isMiddleOfString(line: string): boolean {
return currentQuote !== null
}
+
+const inlineStyleAttributeValueRanges = new DefaultMap((source: string) => {
+ let ranges: number[] = []
+ let offset = 0
+
+ while (true) {
+ let tagStart = source.indexOf('<', offset)
+ if (tagStart === -1) return ranges
+
+ let tagEnd = source.indexOf('>', tagStart + 1)
+ if (tagEnd === -1) return ranges
+
+ offset = tagEnd + 1
+
+ for (let i = tagStart + 1; i < tagEnd; i++) {
+ let char = source.charCodeAt(i)
+
+ if (
+ char === SPACE ||
+ char === TAB ||
+ char === NEWLINE ||
+ char === CARRIAGE_RETURN ||
+ char === FORM_FEED
+ ) {
+ continue
+ }
+
+ let start = i
+ while (i < tagEnd) {
+ let char = source.charCodeAt(i)
+ if (
+ char === SPACE ||
+ char === TAB ||
+ char === NEWLINE ||
+ char === CARRIAGE_RETURN ||
+ char === FORM_FEED ||
+ char === EQUALS ||
+ char === GREATER_THAN ||
+ char === SLASH
+ ) {
+ break
+ }
+
+ i++
+ }
+
+ let attribute = source.slice(start, i).toLowerCase()
+ if (attribute !== 'style' && attribute !== ':style') continue
+
+ while (i < tagEnd) {
+ let char = source.charCodeAt(i)
+ if (
+ char !== SPACE &&
+ char !== TAB &&
+ char !== NEWLINE &&
+ char !== CARRIAGE_RETURN &&
+ char !== FORM_FEED
+ ) {
+ break
+ }
+
+ i++
+ }
+
+ if (source[i] !== '=') continue
+
+ i++
+ while (i < tagEnd) {
+ let char = source.charCodeAt(i)
+ if (
+ char !== SPACE &&
+ char !== TAB &&
+ char !== NEWLINE &&
+ char !== CARRIAGE_RETURN &&
+ char !== FORM_FEED
+ ) {
+ break
+ }
+
+ i++
+ }
+
+ let quote = source[i]
+ if (quote !== '"' && quote !== "'") continue
+
+ let valueStart = i + 1
+ let valueEnd = source.indexOf(quote, valueStart)
+ if (valueEnd === -1 || valueEnd > tagEnd) break
+
+ ranges.push(valueStart, valueEnd)
+ i = valueEnd
+ }
+ }
+})