Upgrade: Migrate spacing scale (#14905)

This PR adds migrations for the recent changes to the `--spacing` scale
done in #12263.

There are a few steps that we do to ensure we have the best upgrade
experience:

- If you are overwriting the `spacing` theme with custom values, we now
check if the new values are multiplies of the default spacing scale.
When they are, we can safely remove the overwrite.
- If you are extending the `spacing` theme, we will unset the default
`--spacing` scale and only use the values you provided.
- Any `theme()` function calls are replaced with `calc(var(--spacing) *
multiplier)` unless the values are extending the default scale.

One caveat here is for `theme()` key which can not be replaced with
`var()` (e.g. in `@media` attribute positions). These will not be able
to be replaced with `calc()` either so the following needs to stay
unmigrated:

```css
@media (max-width: theme(spacing.96)) {
  .foo {
    color: red;
  }
}
```

## Test plan

We are mainly testing two scenarios: The JS config _extends_ the
`spacing` namespace and the JS config _overwrites_ the `spacing`
namespace. For both cases we have added an integration test each to
ensure this works as expected. The test contains a mixture of keys (some
of it matching the default multiples, some don't, some have different
scales, and some use non-numeric identifiers). In addition to asserting
on the created CSS `@theme`, we also ensure that `theme()` calls are
properly replaced.

---------

Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
This commit is contained in:
Philipp Spiess 2024-11-07 20:25:21 +01:00 committed by GitHub
parent 28e46badf7
commit 95c4877200
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 387 additions and 49 deletions

View File

@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Upgrade (experimental)_: Rename `drop-shadow` to `drop-shadow-sm` and `drop-shadow-sm` to `drop-shadow-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
- _Upgrade (experimental)_: Rename `rounded` to `rounded-sm` and `rounded-sm` to `rounded-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
- _Upgrade (experimental)_: Rename `blur` to `blur-sm` and `blur-sm` to `blur-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))
- _Upgrade (experimental)_: Migrate `theme()` usage and JS config files to use the new `--spacing` multiplier where possible ([#14905](https://github.com/tailwindlabs/tailwindcss/pull/14905))
### Fixed

View File

@ -1415,4 +1415,260 @@ describe('border compatibility', () => {
`)
},
)
test(
'migrates extended spacing keys',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': ts`
import { type Config } from 'tailwindcss'
export default {
content: ['./src/**/*.html'],
theme: {
extend: {
spacing: {
2: '0.5rem',
4.5: '1.125rem',
5.5: '1.375em', // Units are different from --spacing scale
13: '3.25rem',
100: '100px',
miami: '1337px',
},
},
},
} satisfies Config
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
.container {
width: theme(spacing.2);
width: theme(spacing[4.5]);
width: theme(spacing[5.5]);
width: theme(spacing[13]);
width: theme(spacing[100]);
width: theme(spacing.miami);
}
`,
'src/index.html': html`
<div
class="[width:theme(spacing.2)]
[width:theme(spacing[4.5])]
[width:theme(spacing[5.5])]
[width:theme(spacing[13])]
[width:theme(spacing[100])]
[width:theme(spacing.miami)]"
></div>
`,
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- src/index.html ---
<div
class="[width:calc(var(--spacing)*2)]
[width:calc(var(--spacing)*4.5)]
[width:var(--spacing-5_5)]
[width:calc(var(--spacing)*13)]
[width:var(--spacing-100)]
[width:var(--spacing-miami)]"
></div>
--- src/input.css ---
@import 'tailwindcss';
@theme {
--spacing-100: 100px;
--spacing-5_5: 1.375em;
--spacing-miami: 1337px;
}
/*
The default border color has changed to \`currentColor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
/*
Form elements have a 1px border by default in Tailwind CSS v4, so we've
added these compatibility styles to make sure everything still looks the
same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add \`border-0\` to
any form elements that shouldn't have a border.
*/
@layer base {
input:where(:not([type='button'], [type='reset'], [type='submit'])),
select,
textarea {
border-width: 0;
}
}
.container {
width: calc(var(--spacing) * 2);
width: calc(var(--spacing) * 4.5);
width: var(--spacing-5_5);
width: calc(var(--spacing) * 13);
width: var(--spacing-100);
width: var(--spacing-miami);
}
"
`)
},
)
test(
'retains overwriting spacing scale',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': ts`
import { type Config } from 'tailwindcss'
export default {
content: ['./src/**/*.html'],
theme: {
spacing: {
2: '0.5rem',
4.5: '1.125rem',
5.5: '1.375em',
13: '3.25rem',
100: '100px',
miami: '1337px',
},
},
} satisfies Config
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
.container {
width: theme(spacing.2);
width: theme(spacing[4.5]);
width: theme(spacing[5.5]);
width: theme(spacing[13]);
width: theme(spacing[100]);
width: theme(spacing.miami);
}
`,
'src/index.html': html`
<div
class="[width:theme(spacing.2)]
[width:theme(spacing[4.5])]
[width:theme(spacing[5.5])]
[width:theme(spacing[13])]
[width:theme(spacing[100])]
[width:theme(spacing.miami)]"
></div>
`,
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- 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)]"
></div>
--- src/input.css ---
@import 'tailwindcss';
@theme {
--spacing-*: initial;
--spacing-2: 0.5rem;
--spacing-13: 3.25rem;
--spacing-100: 100px;
--spacing-4_5: 1.125rem;
--spacing-5_5: 1.375em;
--spacing-miami: 1337px;
}
/*
The default border color has changed to \`currentColor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
/*
Form elements have a 1px border by default in Tailwind CSS v4, so we've
added these compatibility styles to make sure everything still looks the
same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add \`border-0\` to
any form elements that shouldn't have a border.
*/
@layer base {
input:where(:not([type='button'], [type='reset'], [type='submit'])),
select,
textarea {
border-width: 0;
}
}
.container {
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);
}
"
`)
},
)
})

View File

@ -12,10 +12,11 @@ import {
} from '../../tailwindcss/src/compat/apply-config-to-theme'
import { keyframesToRules } from '../../tailwindcss/src/compat/apply-keyframes-to-theme'
import { resolveConfig, type ConfigFile } from '../../tailwindcss/src/compat/config/resolve-config'
import type { ThemeConfig } from '../../tailwindcss/src/compat/config/types'
import type { ResolvedConfig, ThemeConfig } from '../../tailwindcss/src/compat/config/types'
import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { escape } from '../../tailwindcss/src/utils/escape'
import { isValidSpacingMultiplier } from '../../tailwindcss/src/utils/infer-data-type'
import { findStaticPlugins, type StaticPluginOptions } from './utils/extract-static-plugins'
import { info } from './utils/renderer'
@ -101,6 +102,8 @@ async function migrateTheme(
Array.from(replacedThemeKeys.entries()).map(([key]) => [key, false]),
)
removeUnnecessarySpacingKeys(designSystem, resolvedConfig, replacedThemeKeys)
let prevSectionKey = ''
let css = '\n@tw-bucket theme {\n'
css += `\n@theme {\n`
@ -317,3 +320,42 @@ function patternSourceFiles(source: { base: string; pattern: string }): string[]
scanner.scan()
return scanner.files
}
function removeUnnecessarySpacingKeys(
designSystem: DesignSystem,
resolvedConfig: ResolvedConfig,
replacedThemeKeys: Set<string>,
) {
// We want to keep the spacing scale as-is if the user is overwriting
if (replacedThemeKeys.has('spacing')) return
// Ensure we have a spacing multiplier
let spacingScale = designSystem.theme.get(['--spacing'])
if (!spacingScale) return
let [spacingMultiplier, spacingUnit] = splitNumberAndUnit(spacingScale)
if (!spacingMultiplier || !spacingUnit) return
if (spacingScale && !replacedThemeKeys.has('spacing')) {
for (let [key, value] of Object.entries(resolvedConfig.theme.spacing ?? {})) {
let [multiplier, unit] = splitNumberAndUnit(value as string)
if (multiplier === null) continue
if (!isValidSpacingMultiplier(key)) continue
if (unit !== spacingUnit) continue
if (parseFloat(multiplier) === Number(key) * parseFloat(spacingMultiplier)) {
delete resolvedConfig.theme.spacing[key]
designSystem.theme.clearNamespace(escape(`--spacing-${key.replaceAll('.', '_')}`), 0)
}
}
}
}
function splitNumberAndUnit(value: string): [string, string] | [null, null] {
let match = value.match(/^([0-9.]+)(.*)$/)
if (!match) {
return [null, null]
}
return [match[1], match[2]]
}

View File

@ -9,14 +9,18 @@ test.each([
['[color:red]', '[color:red]'],
// Handle special cases around `.1` in the `theme(…)`
['[--value:theme(spacing.1)]', '[--value:var(--spacing-1)]'],
['[--value:theme(spacing.1)]', '[--value:calc(var(--spacing)*1)]'],
['[--value:theme(fontSize.xs.1.lineHeight)]', '[--value:var(--text-xs--line-height)]'],
['[--value:theme(spacing[1.25])]', '[--value:calc(var(--spacing)*1.25)]'],
// Should not convert invalid spacing values to calc
['[--value:theme(spacing[1.1])]', '[--value:theme(spacing[1.1])]'],
// Convert to `var(…)` if we can resolve the path
['[color:theme(colors.red.500)]', '[color:var(--color-red-500)]'], // Arbitrary property
['[color:theme(colors.red.500)]/50', '[color:var(--color-red-500)]/50'], // Arbitrary property + modifier
['bg-[theme(colors.red.500)]', 'bg-[var(--color-red-500)]'], // Arbitrary value
['bg-[size:theme(spacing.4)]', 'bg-[size:var(--spacing-4)]'], // Arbitrary value + data type hint
['bg-[size:theme(spacing.4)]', 'bg-[size:calc(var(--spacing)*4)]'], // Arbitrary value + data type hint
// Convert to `var(…)` if we can resolve the path, but keep fallback values
['bg-[theme(colors.red.500,red)]', 'bg-[var(--color-red-500,red)]'],
@ -79,15 +83,20 @@ test.each([
// Variants, we can't use `var(…)` especially inside of `@media(…)`. We can
// still upgrade the `theme(…)` to the modern syntax.
['max-[theme(spacing.4)]:flex', 'max-[theme(--spacing-4)]:flex'],
['max-[theme(screens.lg)]:flex', 'max-[theme(--breakpoint-lg)]:flex'],
// There are no variables for `--spacing` multiples, so we can't convert this
['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'],
// This test in itself doesn't make much sense. But we need to make sure
// that this doesn't end up as the modifier in the candidate itself.
['max-[theme(spacing.4/50)]:flex', 'max-[theme(--spacing-4/50)]:flex'],
['max-[theme(spacing.4/50)]:flex', 'max-[theme(spacing.4/50)]:flex'],
// `theme(…)` calls in another CSS function is replaced correctly.
// Additionally we remove unnecessary whitespace.
['grid-cols-[min(50%_,_theme(spacing.80))_auto]', 'grid-cols-[min(50%,var(--spacing-80))_auto]'],
[
'grid-cols-[min(50%_,_theme(spacing.80))_auto]',
'grid-cols-[min(50%,calc(var(--spacing)*80))_auto]',
],
// `theme(…)` calls valid in v3, but not in v4 should still be converted.
['[--foo:theme(transitionDuration.500)]', '[--foo:theme(transitionDuration.500)]'],
@ -113,43 +122,6 @@ test.each([
let designSystem = await __unstable__loadDesignSystem(
css`
@import 'tailwindcss';
@theme {
--spacing-px: 1px;
--spacing-0: 0px;
--spacing-0_5: 0.125rem;
--spacing-1: 0.25rem;
--spacing-1_5: 0.375rem;
--spacing-2: 0.5rem;
--spacing-2_5: 0.625rem;
--spacing-3: 0.75rem;
--spacing-3_5: 0.875rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-7: 1.75rem;
--spacing-8: 2rem;
--spacing-9: 2.25rem;
--spacing-10: 2.5rem;
--spacing-11: 2.75rem;
--spacing-12: 3rem;
--spacing-14: 3.5rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
--spacing-28: 7rem;
--spacing-32: 8rem;
--spacing-36: 9rem;
--spacing-40: 10rem;
--spacing-44: 11rem;
--spacing-48: 12rem;
--spacing-52: 13rem;
--spacing-56: 14rem;
--spacing-60: 15rem;
--spacing-64: 16rem;
--spacing-72: 18rem;
--spacing-80: 20rem;
--spacing-96: 24rem;
}
`,
{
base: __dirname,
@ -158,3 +130,55 @@ test.each([
expect(themeToVar(designSystem, {}, candidate)).toEqual(result)
})
test('extended space scale converts to var or calc', async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@import 'tailwindcss';
@theme {
--spacing-2: 2px;
--spacing-miami: 0.875rem;
}
`,
{
base: __dirname,
},
)
expect(themeToVar(designSystem, {}, '[--value:theme(spacing.1)]')).toEqual(
'[--value:calc(var(--spacing)*1)]',
)
expect(themeToVar(designSystem, {}, '[--value:theme(spacing.2)]')).toEqual(
'[--value:var(--spacing-2)]',
)
expect(themeToVar(designSystem, {}, '[--value:theme(spacing.miami)]')).toEqual(
'[--value:var(--spacing-miami)]',
)
expect(themeToVar(designSystem, {}, '[--value:theme(spacing.nyc)]')).toEqual(
'[--value:theme(spacing.nyc)]',
)
})
test('custom space scale converts to var', async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@import 'tailwindcss';
@theme {
--spacing-*: initial;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
}
`,
{
base: __dirname,
},
)
expect(themeToVar(designSystem, {}, '[--value:theme(spacing.1)]')).toEqual(
'[--value:var(--spacing-1)]',
)
expect(themeToVar(designSystem, {}, '[--value:theme(spacing.2)]')).toEqual(
'[--value:var(--spacing-2)]',
)
expect(themeToVar(designSystem, {}, '[--value:theme(spacing.3)]')).toEqual(
'[--value:theme(spacing.3)]',
)
})

View File

@ -7,6 +7,7 @@ import {
} from '../../../../tailwindcss/src/candidate'
import { keyPathToCssProperty } from '../../../../tailwindcss/src/compat/apply-config-to-theme'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
@ -199,9 +200,17 @@ export function createConverter(designSystem: DesignSystem, { prettyPrint = fals
function toVar(path: string, fallback?: string) {
let variable = pathToVariableName(path)
if (!variable) return null
if (variable) return fallback ? `var(${variable}, ${fallback})` : `var(${variable})`
return fallback ? `var(${variable}, ${fallback})` : `var(${variable})`
let keyPath = toKeyPath(path)
if (keyPath[0] === 'spacing' && designSystem.theme.get(['--spacing'])) {
let multiplier = keyPath[1]
if (!isValidSpacingMultiplier(multiplier)) return null
return 'calc(var(--spacing) * ' + multiplier + ')'
}
return null
}
function toTheme(path: string, fallback?: string) {

View File

@ -2,7 +2,7 @@ import { atRoot, atRule, decl, styleRule, type AstNode } from './ast'
import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate'
import type { Theme, ThemeKey } from './theme'
import { DefaultMap } from './utils/default-map'
import { inferDataType, isPositiveInteger } from './utils/infer-data-type'
import { inferDataType, isPositiveInteger, isValidSpacingMultiplier } from './utils/infer-data-type'
import { replaceShadowColors } from './utils/replace-shadow-colors'
import { segment } from './utils/segment'
@ -397,9 +397,7 @@ export function createUtilities(theme: Theme) {
handleBareValue: ({ value }) => {
let multiplier = theme.resolve(null, ['--spacing'])
if (!multiplier) return null
let num = Number(value)
if (num < 0 || num % 0.25 !== 0 || String(num) !== value) return null
if (!isValidSpacingMultiplier(value)) return null
return `calc(${multiplier} * ${value})`
},

View File

@ -328,3 +328,11 @@ export function isPositiveInteger(value: any) {
let num = Number(value)
return Number.isInteger(num) && num >= 0 && String(num) === String(value)
}
/**
* Returns true if the value is either a positive whole number or a multiple of 0.25.
*/
export function isValidSpacingMultiplier(value: any) {
let num = Number(value)
return num >= 0 && num % 0.25 === 0 && String(num) === String(value)
}