Skip to content

Design system driven upgrade migrations #17831

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 39 commits into from
May 2, 2025
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
815a83c
add `Writable` types helper
RobinMalfait Apr 30, 2025
40fa9d7
add `memcpy` util
RobinMalfait Apr 30, 2025
551bbed
add signatures
RobinMalfait Apr 30, 2025
1d32882
add optimize modifier migration
RobinMalfait Apr 30, 2025
7838f27
add drop unnecessary data types migration
RobinMalfait Apr 30, 2025
48d449d
add arbitrary variants migration
RobinMalfait Apr 30, 2025
e064185
add arbitrary utilities migration
RobinMalfait Apr 30, 2025
b6a2a55
export candidate types
RobinMalfait Apr 30, 2025
56303f7
use new migrations
RobinMalfait Apr 30, 2025
24de47d
use memcpy
RobinMalfait Apr 30, 2025
7f23841
use migrate arbitrary value to bare value using signatures
RobinMalfait Apr 30, 2025
a2585e9
improve printing of candidates
RobinMalfait Apr 30, 2025
52de5ed
update apply migration test
RobinMalfait Apr 30, 2025
271f803
move test
RobinMalfait Apr 30, 2025
3705e1a
add walk variants util
RobinMalfait Apr 30, 2025
49da4f0
remove hardcoded list of variants
RobinMalfait Apr 30, 2025
1f69c0f
ensure incomputable signatures result in unique value
RobinMalfait Apr 30, 2025
5af9fa5
`--tw-sort` is the property, not the value
RobinMalfait Apr 30, 2025
fe5d717
prevent infinitely parsing the same value
RobinMalfait Apr 30, 2025
457e9b5
improve signature generation for variants
RobinMalfait Apr 30, 2025
100f524
update integration tests
RobinMalfait Apr 30, 2025
0f4c724
add tests to migrate to more specific utilities
RobinMalfait Apr 30, 2025
6aeecd6
try to migrate both arbitrary properties and arbitrary values
RobinMalfait Apr 30, 2025
fa15d91
handle negative in arbitrary scale
RobinMalfait Apr 30, 2025
f88fec3
update changelog
RobinMalfait Apr 30, 2025
892a417
prefer bare values over arbitrary values
RobinMalfait Apr 30, 2025
ba360ab
convert arbitrary rem value to bare value
RobinMalfait May 1, 2025
ccd8053
abstract parsing dimensions
RobinMalfait May 1, 2025
5d8b626
Update packages/@tailwindcss-upgrade/src/utils/dimension.ts
RobinMalfait May 1, 2025
34565f0
rename variables / comments
RobinMalfait May 1, 2025
42b621b
use existing `getClassList`
RobinMalfait May 1, 2025
1e9ce41
move printing candidate to core
RobinMalfait May 1, 2025
013f0b5
Update CHANGELOG.md
RobinMalfait May 2, 2025
5573c88
use try/finally for extra safety
RobinMalfait May 2, 2025
d5ac5cd
update changelog
RobinMalfait May 2, 2025
f2653ac
drop level of nesting
RobinMalfait May 2, 2025
cd92ff1
use `printModifier` helper
RobinMalfait May 2, 2025
987b9e7
add basic tests for _all_ migrations
RobinMalfait May 2, 2025
fbabe79
rename `memcpy` to `replaceObject`
RobinMalfait May 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Added

- Upgrade: Automatically convert candidates with arbitrary values to their utilities ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))

### Fixed

- Ensure negative arbitrary `scale` values generate negative values ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))

## [4.1.5] - 2025-04-30

### Added

- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.\* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
- Add `h-lh` / `min-h-lh` / `max-h-lh` utilities ([#17790](https://github.com/tailwindlabs/tailwindcss/pull/17790))
- Transition `display`, `visibility`, `content-visibility`, `overlay`, and `pointer-events` when using `transition` to simplify `@starting-style` usage ([#17812](https://github.com/tailwindlabs/tailwindcss/pull/17812))

Expand Down
2 changes: 1 addition & 1 deletion integrations/upgrade/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ test(
"
--- ./src/index.html ---
<div
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:[color:red] tw:in-[.tw\\:group]:flex"
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:text-[red] tw:in-[.tw\\:group]:flex"
></div>
<div
class="tw:group tw:group/foo tw:peer tw:peer/foo tw:group-hover:flex tw:group-hover/foo:flex tw:peer-hover:flex tw:peer-hover/foo:flex"
Expand Down
24 changes: 12 additions & 12 deletions integrations/upgrade/js-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,12 +1329,12 @@ describe('border compatibility', () => {
"
--- src/index.html ---
<div
class="[width:--spacing(2)]
[width:--spacing(4.5)]
[width:var(--spacing-5_5)]
[width:--spacing(13)]
[width:var(--spacing-100)]
[width:var(--spacing-miami)]"
class="w-2
w-4.5
w-5.5
w-13
w-100
w-miami"
></div>

--- src/input.css ---
Expand Down Expand Up @@ -1439,12 +1439,12 @@ describe('border compatibility', () => {
"
--- src/index.html ---
<div
class="[width:var(--spacing-2)]
[width:var(--spacing-4_5)]
[width:var(--spacing-5_5)]
[width:var(--spacing-13)]
[width:var(--spacing-100)]
[width:var(--spacing-miami)]"
class="w-2
w-4.5
w-5.5
w-13
w-100
w-miami"
></div>

--- src/input.css ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ it('should apply all candidate migration when migrating with a config', async ()
`),
).toMatchInlineSnapshot(`
".foo {
@apply tw:flex! tw:[color:var(--my-color)] tw:bg-linear-to-t;
@apply tw:flex! tw:text-(--my-color) tw:bg-linear-to-t;
}"
`)
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import { spliceChangesIntoString } from '../../utils/splice-changes-into-string'
import { extractRawCandidates, printCandidate } from './candidates'
import { extractRawCandidates } from './candidates'

let html = String.raw

Expand Down Expand Up @@ -190,7 +190,7 @@ describe('printCandidate()', () => {

// Sometimes we will have a functional and a static candidate for the same
// raw input string (e.g. `-inset-full`). Dedupe in this case.
let cleaned = new Set([...candidates].map((c) => printCandidate(designSystem, c)))
let cleaned = new Set([...candidates].map((c) => designSystem.printCandidate(c)))

expect([...cleaned]).toEqual([result])
})
Expand Down
273 changes: 0 additions & 273 deletions packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import { Scanner } from '@tailwindcss/oxide'
import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'

export async function extractRawCandidates(
content: string,
Expand All @@ -16,273 +13,3 @@ export async function extractRawCandidates(
}
return candidates
}

export function printCandidate(designSystem: DesignSystem, candidate: Candidate) {
let parts: string[] = []

for (let variant of candidate.variants) {
parts.unshift(printVariant(variant))
}

// Handle prefix
if (designSystem.theme.prefix) {
parts.unshift(designSystem.theme.prefix)
}

let base: string = ''

// Handle static
if (candidate.kind === 'static') {
base += candidate.root
}

// Handle functional
if (candidate.kind === 'functional') {
base += candidate.root

if (candidate.value) {
if (candidate.value.kind === 'arbitrary') {
if (candidate.value !== null) {
let isVarValue = isVar(candidate.value.value)
let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']

if (candidate.value.dataType) {
base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}`
} else {
base += `-${open}${printArbitraryValue(value)}${close}`
}
}
} else if (candidate.value.kind === 'named') {
base += `-${candidate.value.value}`
}
}
}

// Handle arbitrary
if (candidate.kind === 'arbitrary') {
base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]`
}

// Handle modifier
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
if (candidate.modifier) {
let isVarValue = isVar(candidate.modifier.value)
let value = isVarValue ? candidate.modifier.value.slice(4, -1) : candidate.modifier.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']

if (candidate.modifier.kind === 'arbitrary') {
base += `/${open}${printArbitraryValue(value)}${close}`
} else if (candidate.modifier.kind === 'named') {
base += `/${candidate.modifier.value}`
}
}
}

// Handle important
if (candidate.important) {
base += '!'
}

parts.push(base)

return parts.join(':')
}

function printVariant(variant: Variant) {
// Handle static variants
if (variant.kind === 'static') {
return variant.root
}

// Handle arbitrary variants
if (variant.kind === 'arbitrary') {
return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]`
}

let base: string = ''

// Handle functional variants
if (variant.kind === 'functional') {
base += variant.root
// `@` is a special case for functional variants. We want to print: `@lg`
// instead of `@-lg`
let hasDash = variant.root !== '@'
if (variant.value) {
if (variant.value.kind === 'arbitrary') {
let isVarValue = isVar(variant.value.value)
let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']

base += `${hasDash ? '-' : ''}${open}${printArbitraryValue(value)}${close}`
} else if (variant.value.kind === 'named') {
base += `${hasDash ? '-' : ''}${variant.value.value}`
}
}
}

// Handle compound variants
if (variant.kind === 'compound') {
base += variant.root
base += '-'
base += printVariant(variant.variant)
}

// Handle modifiers
if (variant.kind === 'functional' || variant.kind === 'compound') {
if (variant.modifier) {
if (variant.modifier.kind === 'arbitrary') {
base += `/[${printArbitraryValue(variant.modifier.value)}]`
} else if (variant.modifier.kind === 'named') {
base += `/${variant.modifier.value}`
}
}
}

return base
}

function printArbitraryValue(input: string) {
let ast = ValueParser.parse(input)

let drop = new Set<ValueParser.ValueAstNode>()

ValueParser.walk(ast, (node, { parent }) => {
let parentArray = parent === null ? ast : (parent.nodes ?? [])

// Handle operators (e.g.: inside of `calc(…)`)
if (
node.kind === 'word' &&
// Operators
(node.value === '+' || node.value === '-' || node.value === '*' || node.value === '/')
) {
let idx = parentArray.indexOf(node) ?? -1

// This should not be possible
if (idx === -1) return

let previous = parentArray[idx - 1]
if (previous?.kind !== 'separator' || previous.value !== ' ') return

let next = parentArray[idx + 1]
if (next?.kind !== 'separator' || next.value !== ' ') return

drop.add(previous)
drop.add(next)
}

// The value parser handles `/` as a separator in some scenarios. E.g.:
// `theme(colors.red/50%)`. Because of this, we have to handle this case
// separately.
else if (node.kind === 'separator' && node.value.trim() === '/') {
node.value = '/'
}

// Leading and trailing whitespace
else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') {
if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) {
drop.add(node)
}
}

// Whitespace around `,` separators can be removed.
// E.g.: `min(1px , 2px)` -> `min(1px,2px)`
else if (node.kind === 'separator' && node.value.trim() === ',') {
node.value = ','
}
})

if (drop.size > 0) {
ValueParser.walk(ast, (node, { replaceWith }) => {
if (drop.has(node)) {
drop.delete(node)
replaceWith([])
}
})
}

recursivelyEscapeUnderscores(ast)

return ValueParser.toCss(ast)
}

function simplifyArbitraryVariant(input: string) {
let ast = ValueParser.parse(input)

// &:is(…)
if (
ast.length === 3 &&
// &
ast[0].kind === 'word' &&
ast[0].value === '&' &&
// :
ast[1].kind === 'separator' &&
ast[1].value === ':' &&
// is(…)
ast[2].kind === 'function' &&
ast[2].value === 'is'
) {
return ValueParser.toCss(ast[2].nodes)
}

return input
}

function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
for (let node of ast) {
switch (node.kind) {
case 'function': {
if (node.value === 'url' || node.value.endsWith('_url')) {
// Don't decode underscores in url() but do decode the function name
node.value = escapeUnderscore(node.value)
break
}

if (
node.value === 'var' ||
node.value.endsWith('_var') ||
node.value === 'theme' ||
node.value.endsWith('_theme')
) {
node.value = escapeUnderscore(node.value)
for (let i = 0; i < node.nodes.length; i++) {
recursivelyEscapeUnderscores([node.nodes[i]])
}
break
}

node.value = escapeUnderscore(node.value)
recursivelyEscapeUnderscores(node.nodes)
break
}
case 'separator':
node.value = escapeUnderscore(node.value)
break
case 'word': {
// Dashed idents and variables `var(--my-var)` and `--my-var` should not
// have underscores escaped
if (node.value[0] !== '-' && node.value[1] !== '-') {
node.value = escapeUnderscore(node.value)
}
break
}
default:
never(node)
}
}
}

function isVar(value: string) {
let ast = ValueParser.parse(value)
return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var'
}

function never(value: never): never {
throw new Error(`Unexpected value: ${value}`)
}

function escapeUnderscore(value: string): string {
return value
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
.replaceAll(' ', '_') // Replace spaces with underscores
}
Loading