diff --git a/CHANGELOG.md b/CHANGELOG.md index 79baa9c65..5a18ebc87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, `%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 diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 6a9f580b6..036b9d88e 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -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') { diff --git a/packages/tailwindcss/src/compat/config/create-compat-config.test.ts b/packages/tailwindcss/src/compat/config/create-compat-config.test.ts new file mode 100644 index 000000000..aa1b21cb7 --- /dev/null +++ b/packages/tailwindcss/src/compat/config/create-compat-config.test.ts @@ -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) { + 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)') + }) +}) diff --git a/packages/tailwindcss/src/compat/plugin-functions.ts b/packages/tailwindcss/src/compat/plugin-functions.ts index ee795c40f..bbd5fac46 100644 --- a/packages/tailwindcss/src/compat/plugin-functions.ts +++ b/packages/tailwindcss/src/compat/plugin-functions.ts @@ -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 }