Make createCompatConfig theme mappings safe to spread when a namespace returns a string (#20399)

When building the legacy config compat layer, namespace mappings like
`fontSize` → `text`, `boxShadow` → `shadow`, `animation` → `animate`,
etc. spread the result of `theme(namespace, {})` directly:

```ts
...(theme('text', {}) ?? {})
```

Some of these namespaces can return a **string** (e.g. `theme('text',
{})` → `'1rem'`). Spreading a string produces char-indexed keys (`{ '0':
'1', '1': 'r', ... }`) instead of a `{ DEFAULT: ... }` entry, silently
corrupting the compat config output.

This PR adds a small `spreadTheme` helper that normalizes the return
value before spreading:

- `string` → `{ DEFAULT: value }`
- plain object → `{ ...value }`
- anything else → `{}`

and updates all namespace mappings to use it. Includes unit tests for
the helper plus integration tests for the affected namespaces and the
string-return regression cases.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Ridwan Olanrewaju 2026-08-10 16:22:33 +01:00 • committed by GitHub
parent 46df7ee2fc
commit 16e94cbf7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 214 additions and 11 deletions

View file

@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w<flex>`, `%w|flex|`), including in Slim and Haml templates ([#20387](https://github.com/tailwindlabs/tailwindcss/pull/20387))
- Preserve whitespace in `--default(…)` values in custom functional utilities (e.g. `--default(box alphabetic)` no longer becomes `boxalphabetic`) ([#20392](https://github.com/tailwindlabs/tailwindcss/pull/20392))
- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/discussions/20397))
- Ensure root `theme('…')` namespace lookups in JavaScript plugins and config files return the full namespace object instead of the value of its `DEFAULT` key ([#20399](https://github.com/tailwindlabs/tailwindcss/pull/20399))
## [4.3.3] - 2026-07-16

View file

@ -309,6 +309,19 @@ function upgradeToFullPluginSupport({
let resolvedValue = sharedPluginApi.theme(path, undefined)
// If we're dealing with an object that has the `DEFAULT` key, unwrap it to
// the default value first. This happens for namespace root lookups where
// the CSS theme defines a bare value, e.g. `--shadow` resolved via
// `theme('shadow')`.
if (
typeof resolvedValue === 'object' &&
resolvedValue !== null &&
!Array.isArray(resolvedValue) &&
'DEFAULT' in resolvedValue
) {
resolvedValue = resolvedValue.DEFAULT
}
// When a tuple is returned, return the first element
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
return resolvedValue[0]
@ -319,16 +332,6 @@ function upgradeToFullPluginSupport({
return resolvedValue.join(', ')
}
// If we're dealing with an object that has the `DEFAULT` key, return the
// default value
else if (
typeof resolvedValue === 'object' &&
resolvedValue !== null &&
'DEFAULT' in resolvedValue
) {
return resolvedValue.DEFAULT
}
// Otherwise only allow string values here, objects (and namespace maps)
// are treated as non-resolved values for the CSS `theme()` function.
else if (typeof resolvedValue === 'string') {

View file

@ -0,0 +1,189 @@
import { describe, expect, test } from 'vitest'
import { buildDesignSystem } from '../../design-system'
import { Theme } from '../../theme'
import { createCompatConfig } from './create-compat-config'
import { resolveConfig } from './resolve-config'
function buildCompatConfig(cssValues: Record<string, string>) {
let theme = new Theme()
for (let [key, value] of Object.entries(cssValues)) {
theme.add(key, value)
}
let design = buildDesignSystem(theme)
let { resolvedConfig } = resolveConfig(design, [
{ config: createCompatConfig(design.theme), base: '/root', reference: true, src: undefined },
])
return resolvedConfig
}
describe('theme namespace lookups', () => {
test('a namespace that only defines a bare value resolves to `{ DEFAULT: … }`, not a string', () => {
// When the CSS theme only defines `--text` (a single bare value),
// `theme('text', {})` used to return the string `'1rem'`.
//
// Spreading that string, like the compat config does, produced char-indexed
// garbage (`{ '0': '1', '1': 'r', … }`) instead of `{ DEFAULT: '1rem' }`.
let config = buildCompatConfig({ '--text': '1rem' })
expect(config.theme?.fontSize?.DEFAULT).toBe('1rem')
expect(config.theme?.fontSize).not.toHaveProperty('0')
})
test('a bare value is kept alongside suffixed values in the same namespace', () => {
let config = buildCompatConfig({
'--text': '1rem',
'--text-sm': '0.875rem',
})
expect(config.theme?.fontSize?.DEFAULT).toBe('1rem')
expect(config.theme?.fontSize?.sm).toBe('0.875rem')
})
test('a naive spread of a theme namespace in a user config is safe', () => {
let theme = new Theme()
theme.add('--text', '1rem')
let design = buildDesignSystem(theme)
let { resolvedConfig } = resolveConfig(design, [
{
config: {
theme: {
extend: {
fontSize: ({ theme }: any) => ({ ...theme('text', {}) }),
},
},
},
base: '/root',
reference: true,
src: undefined,
},
])
expect(resolvedConfig.theme?.fontSize?.DEFAULT).toBe('1rem')
expect(resolvedConfig.theme?.fontSize).not.toHaveProperty('0')
})
test('namespace root lookups resolve to an object, key lookups resolve to the value', () => {
let theme = new Theme()
theme.add('--text', '1rem')
theme.add('--animate-spin', 'spin 1s linear infinite')
let design = buildDesignSystem(theme)
let root: unknown
let rootWithDefault: unknown
let leaf: unknown
resolveConfig(design, [
{
config: {
theme: {
extend: {
fontSize: ({ theme }: any) => {
root = theme('text')
rootWithDefault = theme('text', {})
leaf = theme('animate.spin')
return {}
},
},
},
},
base: '/root',
reference: true,
src: undefined,
},
])
// A namespace root lookup always resolves to an object, like in v3, even
// when the namespace only contains a bare value
expect(root).toMatchObject({ DEFAULT: '1rem' })
expect(rootWithDefault).toMatchObject({ DEFAULT: '1rem' })
// Lookups of a specific key resolve to the value itself
expect(leaf).toBe('spin 1s linear infinite')
})
test('lookups of a specific key still resolve to the value itself', () => {
let theme = new Theme()
theme.add('--animate-spin', 'spin 1s linear infinite')
let design = buildDesignSystem(theme)
let { resolvedConfig } = resolveConfig(design, [
{
config: {
theme: {
extend: {
animation: ({ theme }: any) => ({ spin: theme('animate.spin') }),
},
},
},
base: '/root',
reference: true,
src: undefined,
},
])
expect(resolvedConfig.theme?.animation?.spin).toBe('spin 1s linear infinite')
})
})
describe('createCompatConfig namespace mappings', () => {
test('fontSize maps from CSS text namespace', () => {
let config = buildCompatConfig({ '--text-base': '1rem' })
expect(config.theme?.fontSize?.base).toBe('1rem')
})
test('boxShadow maps from CSS shadow namespace', () => {
let config = buildCompatConfig({ '--shadow-md': '0 4px 6px #000' })
expect(config.theme?.boxShadow?.md).toBe('0 4px 6px #000')
})
test('animation maps from CSS animate namespace', () => {
let config = buildCompatConfig({ '--animate-spin': 'spin 1s linear infinite' })
expect(config.theme?.animation?.spin).toBe('spin 1s linear infinite')
})
test('aspectRatio maps from CSS aspect namespace', () => {
let config = buildCompatConfig({ '--aspect-square': '1 / 1' })
expect(config.theme?.aspectRatio?.square).toBe('1 / 1')
})
test('borderRadius maps from CSS radius namespace', () => {
let config = buildCompatConfig({ '--radius-lg': '0.5rem' })
expect(config.theme?.borderRadius?.lg).toBe('0.5rem')
})
test('screens maps from CSS breakpoint namespace', () => {
let config = buildCompatConfig({ '--breakpoint-sm': '640px' })
expect(config.theme?.screens?.sm).toBe('640px')
})
test('letterSpacing maps from CSS tracking namespace', () => {
let config = buildCompatConfig({ '--tracking-wide': '0.025em' })
expect(config.theme?.letterSpacing?.wide).toBe('0.025em')
})
test('lineHeight maps from CSS leading namespace', () => {
let config = buildCompatConfig({ '--leading-relaxed': '1.75' })
expect(config.theme?.lineHeight?.relaxed).toBe('1.75')
})
test('maxWidth maps from the container namespace in the default theme', () => {
let config = buildCompatConfig({})
// The default theme has container.sm = '24rem'
expect(config.theme?.maxWidth?.sm).toBe('24rem')
})
test('transitionDuration gets DEFAULT from --default-transition-duration', () => {
let config = buildCompatConfig({ '--default-transition-duration': '150ms' })
expect(config.theme?.transitionDuration?.DEFAULT).toBe('150ms')
})
test('transitionTimingFunction gets DEFAULT from --default-transition-timing-function', () => {
let config = buildCompatConfig({
'--default-transition-timing-function': 'cubic-bezier(0.4, 0, 0.2, 1)',
})
expect(config.theme?.transitionTimingFunction?.DEFAULT).toBe('cubic-bezier(0.4, 0, 0.2, 1)')
})
})

View file

@ -108,7 +108,12 @@ export function createThemeFn(
// ```
//
// Prefer `DEFAULT` instead of exposing the object to plugin code.
//
// This only applies to lookups of a specific key (e.g. `colors.foo`). A
// namespace root lookup (e.g. `theme('shadow')`) resolves to the whole
// namespace as an object, like in v3.
if (
keypath.length > 1 &&
cssValue !== null &&
typeof cssValue === 'object' &&
!Array.isArray(cssValue) &&
@ -225,7 +230,12 @@ function readFromCss(
// The request looked like `theme('animation.spin')` and was turned into a
// lookup for `--animation-spin-*` which had only one entry which means it
// should be returned directly.
if ('DEFAULT' in obj && Object.keys(obj).length === 1) {
//
// This only applies to lookups of a specific key (`path.length > 1`). A
// namespace root lookup like `theme('shadow')` resolves to the whole
// namespace as an object, like in v3, so it must keep the object shape (`{
// DEFAULT: … }`).
if (path.length > 1 && 'DEFAULT' in obj && Object.keys(obj).length === 1) {
return [obj.DEFAULT as any, optionsObj.DEFAULT ?? ThemeOptions.NONE] as const
}