Improve compatibility with special default values in JS configs (#19348)

Fixes #19345

In v3 the `ringColor.DEFAULT` option was used as the default color for
ring utilities (when it was defined). This currently gets translated as
`--ring-color` but that doesn't work this way in v4. Instead it should
translate to `--default-ring-color` and *not* `--ring-color`.

I've also tweaked the upgrade tool to handle this properly as well.
This commit is contained in:
Jordan Pittman 2025-11-28 15:45:10 -05:00 • committed by GitHub
parent af481175e7
commit 764275143e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 97 additions and 27 deletions

View file

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support environment API in `@tailwindcss/vite` ([#18970](https://github.com/tailwindlabs/tailwindcss/pull/18970))
- Preserve case of theme keys from JS configs and plugins ([#19337](https://github.com/tailwindlabs/tailwindcss/pull/19337))
- Write source maps correctly on the CLI when using `--watch` ([#19373](https://github.com/tailwindlabs/tailwindcss/pull/19373))
- Handle special defaults (like `ringColor.DEFAULT`) in JS configs ([#19348](https://github.com/tailwindlabs/tailwindcss/pull/19348))
- Upgrade: Handle `future` and `experimental` config keys ([#19344](https://github.com/tailwindlabs/tailwindcss/pull/19344))
- Try to canonicalize any arbitrary utility to a bare value ([#19379](https://github.com/tailwindlabs/tailwindcss/pull/19379))

View file

@ -2347,8 +2347,7 @@ test(
--shadow-*: initial;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--ring-width-*: initial;
--ring-width: 4px;
--default-ring-width: 4px;
--blur: var(--custom-default-blur);

View file

@ -34,6 +34,9 @@ test(
steel: 'rgb(70 130 180 / <alpha-value>)',
smoke: 'rgba(245, 245, 245, var(--smoke-alpha, <alpha-value>))',
},
ringColor: {
DEFAULT: '#c0ffee',
},
opacity: {
superOpaque: '0.95',
},
@ -191,6 +194,8 @@ test(
--color-steel: rgb(70 130 180);
--color-smoke: rgba(245, 245, 245, var(--smoke-alpha, 1));
--default-ring-color: #c0ffee;
--opacity-*: initial;
--opacity-superOpaque: 95%;

View file

@ -237,16 +237,21 @@ async function migrateTheme(
prevSectionKey = sectionKey
}
if (resetNamespaces.has(key[0]) && resetNamespaces.get(key[0]) === false) {
resetNamespaces.set(key[0], true)
let property = keyPathToCssProperty([key[0]])
if (property !== null) {
themeSection.push(` ${escape(`--${property}`)}-*: initial;`)
}
}
let property = keyPathToCssProperty(key)
if (property !== null) {
if (
!property.startsWith('default-') &&
resetNamespaces.has(key[0]) &&
resetNamespaces.get(key[0]) === false
) {
resetNamespaces.set(key[0], true)
let ns = keyPathToCssProperty([key[0]])
if (ns !== null) {
themeSection.push(` ${escape(`--${ns}`)}-*: initial;`)
}
}
themeSection.push(` ${escape(`--${property}`)}: ${value};`)
}
}

View file

@ -59,7 +59,7 @@ const THEME_KEYS = new Map([
['backdrop-blur-sm', '--backdrop-blur-sm'],
['backdrop-blur-xs', '--backdrop-blur-xs'],
['ring', '--ring-width'],
['ring', '--default-ring-width'],
['ring-3', '--ring-width-3'],
])

View file

@ -31,6 +31,16 @@ test('config values can be merged into the theme', () => {
normal: '0 1px 3px black',
},
borderWidth: {
DEFAULT: '1.5px',
},
outlineWidth: {
DEFAULT: '2.5px',
},
ringWidth: {
DEFAULT: '3.5px',
},
borderRadius: {
sm: '0.33rem',
},
@ -57,6 +67,10 @@ test('config values can be merged into the theme', () => {
'2xl': ['2rem'],
},
ringColor: {
DEFAULT: '#fff',
},
letterSpacing: {
superWide: '0.25em',
},
@ -77,8 +91,12 @@ test('config values can be merged into the theme', () => {
},
transitionTimingFunction: {
DEFAULT: 'ease-in-out',
fast: 'cubic-bezier(0, 0.55, 0.45, 1)',
},
transitionDuration: {
DEFAULT: '1234ms',
},
},
},
base: '/root',
@ -125,6 +143,23 @@ test('config values can be merged into the theme', () => {
expect(theme.resolve('100%', ['--width'])).toEqual('100%')
expect(theme.resolve('9xs', ['--container'])).toEqual('6rem')
expect(theme.resolve('fast', ['--ease'])).toEqual('cubic-bezier(0, 0.55, 0.45, 1)')
expect(theme.get(['--border'])).toEqual(null)
expect(theme.get(['--default-border-width'])).toEqual('1.5px')
expect(theme.get(['--outline'])).toEqual(null)
expect(theme.get(['--default-outline-width'])).toEqual('2.5px')
expect(theme.get(['--ring-color'])).toEqual(null)
expect(theme.get(['--default-ring-color'])).toEqual('#fff')
expect(theme.get(['--ring-width'])).toEqual(null)
expect(theme.get(['--default-ring-width'])).toEqual('3.5px')
expect(theme.get(['--default-transition-duration'])).toEqual('1234ms')
expect(theme.get(['--ease'])).toEqual(null)
expect(theme.get(['--default-transition-timing-function'])).toEqual('ease-in-out')
})
test('will reset default theme values with overwriting theme values', () => {

View file

@ -147,32 +147,57 @@ export function themeableValues(config: ResolvedConfig['theme']): [string[], unk
return toAdd
}
const SPECIAL_DEFAULT_KEYS: Record<string, string> = {
borderWidth: 'border-width',
outlineWidth: 'outline-width',
ringColor: 'ring-color',
ringWidth: 'ring-width',
transitionDuration: 'transition-duration',
transitionTimingFunction: 'transition-timing-function',
}
const OLD_TO_NEW_NAMESPACE: Record<string, string> = {
animation: 'animate',
aspectRatio: 'aspect',
borderRadius: 'radius',
boxShadow: 'shadow',
colors: 'color',
containers: 'container',
fontFamily: 'font',
fontSize: 'text',
letterSpacing: 'tracking',
lineHeight: 'leading',
maxWidth: 'container',
screens: 'breakpoint',
transitionTimingFunction: 'ease',
}
const IS_VALID_KEY = /^[a-zA-Z0-9-_%/\.]+$/
export function keyPathToCssProperty(path: string[]) {
// In some special cases the `DEFAULT` key did not map to a "default" utility
// e.g. `ringColor.DEFAULT` wasn't *just* used for `ring`. It was used for
// all ring utilities as the color when one wasn't specified.
//
// We place these specialty values under the `--default-*` namespace to signal
// that they are defaults used by (potentially) multiple utilities.
let specialDefault = SPECIAL_DEFAULT_KEYS[path[0]]
if (specialDefault && path[1] === 'DEFAULT') return `default-${specialDefault}`
// The legacy container component config should not be included in the Theme
if (path[0] === 'container') return null
path = path.slice()
if (path[0] === 'animation') path[0] = 'animate'
if (path[0] === 'aspectRatio') path[0] = 'aspect'
if (path[0] === 'borderRadius') path[0] = 'radius'
if (path[0] === 'boxShadow') path[0] = 'shadow'
if (path[0] === 'colors') path[0] = 'color'
if (path[0] === 'containers') path[0] = 'container'
if (path[0] === 'fontFamily') path[0] = 'font'
if (path[0] === 'fontSize') path[0] = 'text'
if (path[0] === 'letterSpacing') path[0] = 'tracking'
if (path[0] === 'lineHeight') path[0] = 'leading'
if (path[0] === 'maxWidth') path[0] = 'container'
if (path[0] === 'screens') path[0] = 'breakpoint'
if (path[0] === 'transitionTimingFunction') path[0] = 'ease'
for (let part of path) {
if (!IS_VALID_KEY.test(part)) return null
}
// Map old v3 namespaces to new theme namespaces
let ns = OLD_TO_NEW_NAMESPACE[path[0]]
if (ns) {
path = path.slice()
path[0] = ns
}
return (
path
// [1] should move into the nested object tuple. To create the CSS variable