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:
parent
46df7ee2fc
commit
16e94cbf7f
4 changed files with 214 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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)')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue