Skip to content

Commit 16e94cb

Browse files
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>
1 parent 46df7ee commit 16e94cb

4 files changed

Lines changed: 214 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- 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))
2525
- 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))
2626
- 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))
27+
- 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))
2728

2829
## [4.3.3] - 2026-07-16
2930

packages/tailwindcss/src/compat/apply-compat-hooks.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,19 @@ function upgradeToFullPluginSupport({
309309

310310
let resolvedValue = sharedPluginApi.theme(path, undefined)
311311

312+
// If we're dealing with an object that has the `DEFAULT` key, unwrap it to
313+
// the default value first. This happens for namespace root lookups where
314+
// the CSS theme defines a bare value, e.g. `--shadow` resolved via
315+
// `theme('shadow')`.
316+
if (
317+
typeof resolvedValue === 'object' &&
318+
resolvedValue !== null &&
319+
!Array.isArray(resolvedValue) &&
320+
'DEFAULT' in resolvedValue
321+
) {
322+
resolvedValue = resolvedValue.DEFAULT
323+
}
324+
312325
// When a tuple is returned, return the first element
313326
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
314327
return resolvedValue[0]
@@ -319,16 +332,6 @@ function upgradeToFullPluginSupport({
319332
return resolvedValue.join(', ')
320333
}
321334

322-
// If we're dealing with an object that has the `DEFAULT` key, return the
323-
// default value
324-
else if (
325-
typeof resolvedValue === 'object' &&
326-
resolvedValue !== null &&
327-
'DEFAULT' in resolvedValue
328-
) {
329-
return resolvedValue.DEFAULT
330-
}
331-
332335
// Otherwise only allow string values here, objects (and namespace maps)
333336
// are treated as non-resolved values for the CSS `theme()` function.
334337
else if (typeof resolvedValue === 'string') {
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { buildDesignSystem } from '../../design-system'
3+
import { Theme } from '../../theme'
4+
import { createCompatConfig } from './create-compat-config'
5+
import { resolveConfig } from './resolve-config'
6+
7+
function buildCompatConfig(cssValues: Record<string, string>) {
8+
let theme = new Theme()
9+
for (let [key, value] of Object.entries(cssValues)) {
10+
theme.add(key, value)
11+
}
12+
let design = buildDesignSystem(theme)
13+
14+
let { resolvedConfig } = resolveConfig(design, [
15+
{ config: createCompatConfig(design.theme), base: '/root', reference: true, src: undefined },
16+
])
17+
18+
return resolvedConfig
19+
}
20+
21+
describe('theme namespace lookups', () => {
22+
test('a namespace that only defines a bare value resolves to `{ DEFAULT: … }`, not a string', () => {
23+
// When the CSS theme only defines `--text` (a single bare value),
24+
// `theme('text', {})` used to return the string `'1rem'`.
25+
//
26+
// Spreading that string, like the compat config does, produced char-indexed
27+
// garbage (`{ '0': '1', '1': 'r', … }`) instead of `{ DEFAULT: '1rem' }`.
28+
let config = buildCompatConfig({ '--text': '1rem' })
29+
30+
expect(config.theme?.fontSize?.DEFAULT).toBe('1rem')
31+
expect(config.theme?.fontSize).not.toHaveProperty('0')
32+
})
33+
34+
test('a bare value is kept alongside suffixed values in the same namespace', () => {
35+
let config = buildCompatConfig({
36+
'--text': '1rem',
37+
'--text-sm': '0.875rem',
38+
})
39+
40+
expect(config.theme?.fontSize?.DEFAULT).toBe('1rem')
41+
expect(config.theme?.fontSize?.sm).toBe('0.875rem')
42+
})
43+
44+
test('a naive spread of a theme namespace in a user config is safe', () => {
45+
let theme = new Theme()
46+
theme.add('--text', '1rem')
47+
let design = buildDesignSystem(theme)
48+
49+
let { resolvedConfig } = resolveConfig(design, [
50+
{
51+
config: {
52+
theme: {
53+
extend: {
54+
fontSize: ({ theme }: any) => ({ ...theme('text', {}) }),
55+
},
56+
},
57+
},
58+
base: '/root',
59+
reference: true,
60+
src: undefined,
61+
},
62+
])
63+
64+
expect(resolvedConfig.theme?.fontSize?.DEFAULT).toBe('1rem')
65+
expect(resolvedConfig.theme?.fontSize).not.toHaveProperty('0')
66+
})
67+
68+
test('namespace root lookups resolve to an object, key lookups resolve to the value', () => {
69+
let theme = new Theme()
70+
theme.add('--text', '1rem')
71+
theme.add('--animate-spin', 'spin 1s linear infinite')
72+
let design = buildDesignSystem(theme)
73+
74+
let root: unknown
75+
let rootWithDefault: unknown
76+
let leaf: unknown
77+
78+
resolveConfig(design, [
79+
{
80+
config: {
81+
theme: {
82+
extend: {
83+
fontSize: ({ theme }: any) => {
84+
root = theme('text')
85+
rootWithDefault = theme('text', {})
86+
leaf = theme('animate.spin')
87+
return {}
88+
},
89+
},
90+
},
91+
},
92+
base: '/root',
93+
reference: true,
94+
src: undefined,
95+
},
96+
])
97+
98+
// A namespace root lookup always resolves to an object, like in v3, even
99+
// when the namespace only contains a bare value
100+
expect(root).toMatchObject({ DEFAULT: '1rem' })
101+
expect(rootWithDefault).toMatchObject({ DEFAULT: '1rem' })
102+
103+
// Lookups of a specific key resolve to the value itself
104+
expect(leaf).toBe('spin 1s linear infinite')
105+
})
106+
107+
test('lookups of a specific key still resolve to the value itself', () => {
108+
let theme = new Theme()
109+
theme.add('--animate-spin', 'spin 1s linear infinite')
110+
let design = buildDesignSystem(theme)
111+
112+
let { resolvedConfig } = resolveConfig(design, [
113+
{
114+
config: {
115+
theme: {
116+
extend: {
117+
animation: ({ theme }: any) => ({ spin: theme('animate.spin') }),
118+
},
119+
},
120+
},
121+
base: '/root',
122+
reference: true,
123+
src: undefined,
124+
},
125+
])
126+
127+
expect(resolvedConfig.theme?.animation?.spin).toBe('spin 1s linear infinite')
128+
})
129+
})
130+
131+
describe('createCompatConfig namespace mappings', () => {
132+
test('fontSize maps from CSS text namespace', () => {
133+
let config = buildCompatConfig({ '--text-base': '1rem' })
134+
expect(config.theme?.fontSize?.base).toBe('1rem')
135+
})
136+
137+
test('boxShadow maps from CSS shadow namespace', () => {
138+
let config = buildCompatConfig({ '--shadow-md': '0 4px 6px #000' })
139+
expect(config.theme?.boxShadow?.md).toBe('0 4px 6px #000')
140+
})
141+
142+
test('animation maps from CSS animate namespace', () => {
143+
let config = buildCompatConfig({ '--animate-spin': 'spin 1s linear infinite' })
144+
expect(config.theme?.animation?.spin).toBe('spin 1s linear infinite')
145+
})
146+
147+
test('aspectRatio maps from CSS aspect namespace', () => {
148+
let config = buildCompatConfig({ '--aspect-square': '1 / 1' })
149+
expect(config.theme?.aspectRatio?.square).toBe('1 / 1')
150+
})
151+
152+
test('borderRadius maps from CSS radius namespace', () => {
153+
let config = buildCompatConfig({ '--radius-lg': '0.5rem' })
154+
expect(config.theme?.borderRadius?.lg).toBe('0.5rem')
155+
})
156+
157+
test('screens maps from CSS breakpoint namespace', () => {
158+
let config = buildCompatConfig({ '--breakpoint-sm': '640px' })
159+
expect(config.theme?.screens?.sm).toBe('640px')
160+
})
161+
162+
test('letterSpacing maps from CSS tracking namespace', () => {
163+
let config = buildCompatConfig({ '--tracking-wide': '0.025em' })
164+
expect(config.theme?.letterSpacing?.wide).toBe('0.025em')
165+
})
166+
167+
test('lineHeight maps from CSS leading namespace', () => {
168+
let config = buildCompatConfig({ '--leading-relaxed': '1.75' })
169+
expect(config.theme?.lineHeight?.relaxed).toBe('1.75')
170+
})
171+
172+
test('maxWidth maps from the container namespace in the default theme', () => {
173+
let config = buildCompatConfig({})
174+
// The default theme has container.sm = '24rem'
175+
expect(config.theme?.maxWidth?.sm).toBe('24rem')
176+
})
177+
178+
test('transitionDuration gets DEFAULT from --default-transition-duration', () => {
179+
let config = buildCompatConfig({ '--default-transition-duration': '150ms' })
180+
expect(config.theme?.transitionDuration?.DEFAULT).toBe('150ms')
181+
})
182+
183+
test('transitionTimingFunction gets DEFAULT from --default-transition-timing-function', () => {
184+
let config = buildCompatConfig({
185+
'--default-transition-timing-function': 'cubic-bezier(0.4, 0, 0.2, 1)',
186+
})
187+
expect(config.theme?.transitionTimingFunction?.DEFAULT).toBe('cubic-bezier(0.4, 0, 0.2, 1)')
188+
})
189+
})

packages/tailwindcss/src/compat/plugin-functions.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ export function createThemeFn(
108108
// ```
109109
//
110110
// Prefer `DEFAULT` instead of exposing the object to plugin code.
111+
//
112+
// This only applies to lookups of a specific key (e.g. `colors.foo`). A
113+
// namespace root lookup (e.g. `theme('shadow')`) resolves to the whole
114+
// namespace as an object, like in v3.
111115
if (
116+
keypath.length > 1 &&
112117
cssValue !== null &&
113118
typeof cssValue === 'object' &&
114119
!Array.isArray(cssValue) &&
@@ -225,7 +230,12 @@ function readFromCss(
225230
// The request looked like `theme('animation.spin')` and was turned into a
226231
// lookup for `--animation-spin-*` which had only one entry which means it
227232
// should be returned directly.
228-
if ('DEFAULT' in obj && Object.keys(obj).length === 1) {
233+
//
234+
// This only applies to lookups of a specific key (`path.length > 1`). A
235+
// namespace root lookup like `theme('shadow')` resolves to the whole
236+
// namespace as an object, like in v3, so it must keep the object shape (`{
237+
// DEFAULT: … }`).
238+
if (path.length > 1 && 'DEFAULT' in obj && Object.keys(obj).length === 1) {
229239
return [obj.DEFAULT as any, optionsObj.DEFAULT ?? ThemeOptions.NONE] as const
230240
}
231241

0 commit comments

Comments
 (0)