From ba667400e81246444beb2f0d4351fe99e06e4de8 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Wed, 29 Apr 2026 10:18:02 -0300 Subject: [PATCH] =?UTF-8?q?[@tailwindcss/upgrade]=20Don=E2=80=99t=20migrat?= =?UTF-8?q?e=20inline=20style=20properties=20(#19918)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + .../template/is-safe-migration.test.ts | 4 + .../codemods/template/is-safe-migration.ts | 151 ++++++++++++++++-- 3 files changed, 139 insertions(+), 17 deletions(-) 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 + } + } +})