Fix resolving colors via theme(…) in compat mode with nested objects (#19097)

This PR fixes an issue when loading (nested) colors from a config file
and later referencing it via the `theme(…)` function in CSS.

Given a config like this:

```js
module.exports = {
  theme: {
    colors: {
      foo: 'var(--foo-foo)',
      'foo-bar': 'var(--foo-foo-bar)',
    },
  },
}
```

We internally map this into the design system. The issue here is that
the `foo` and `foo-bar` are overlapping and it behaves more like this:

```js
{
  foo: {
    DEFAULT: 'var(--foo-foo)',
    bar: 'var(--foo-foo-bar)'
  },
}
```

So while we can easily resolve `colors.foo-bar`, the `colors.foo` would
result in the object with a `DEFAULT` key. This PR solves that by using
the `DEFAULT` key if we end up with an object that has it.

If you end up resolving an object (`theme(colors)`) then the behavior is
unchanged.


## Test plan

1. Added a test based on the config in the issue (which failed before
this fix).
2. Also simplified the test case after identifying the problem (with the
`DEFAULT` key).

Fixes: #19091
This commit is contained in:
Robin Malfait 2025-10-09 19:10:32 +02:00 • committed by GitHub
parent 561983d7e5
commit 0c14df1a15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 93 additions and 10 deletions

View file

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix Safari devtools rendering issue due to `color-mix` fallback ([#19069](https://github.com/tailwindlabs/tailwindcss/pull/19069))
- Suppress Lightning CSS warnings about `:deep`, `:slotted` and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094))
- Fix resolving theme keys when starting with the name of another theme key in JS configs and plugins ([#19097](https://github.com/tailwindlabs/tailwindcss/pull/19097))
## [4.1.14] - 2025-10-01

View file

@ -16,7 +16,7 @@ export function migrateMediaScreen({
if (!designSystem || !userConfig) return
let { resolvedConfig } = resolveConfig(designSystem, [
{ base: '', config: userConfig, reference: false },
{ base: '', config: userConfig, reference: false, src: undefined },
])
let screens = resolvedConfig?.theme?.screens || {}

View file

@ -309,15 +309,29 @@ function upgradeToFullPluginSupport({
let resolvedValue = sharedPluginApi.theme(path, undefined)
// When a tuple is returned, return the first element
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
// When a tuple is returned, return the first element
return resolvedValue[0]
} else if (Array.isArray(resolvedValue)) {
// Arrays get serialized into a comma-separated lists
}
// Arrays get serialized into a comma-separated lists
else if (Array.isArray(resolvedValue)) {
return resolvedValue.join(', ')
} else if (typeof resolvedValue === 'string') {
// Otherwise only allow string values here, objects (and namespace maps)
// are treated as non-resolved values for the CSS `theme()` function.
}
// 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') {
return resolvedValue
}
}

View file

@ -12,7 +12,7 @@ test('Config files can add content', async () => {
`
let compiler = await compile(input, {
loadModule: async () => ({ module: { content: ['./file.txt'] }, base: '/root' }),
loadModule: async () => ({ module: { content: ['./file.txt'] }, base: '/root', path: '' }),
})
expect(compiler.sources).toEqual([{ base: '/root', pattern: './file.txt', negated: false }])
@ -25,7 +25,7 @@ test('Config files can change dark mode (media)', async () => {
`
let compiler = await compile(input, {
loadModule: async () => ({ module: { darkMode: 'media' }, base: '/root' }),
loadModule: async () => ({ module: { darkMode: 'media' }, base: '/root', path: '' }),
})
expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
@ -45,7 +45,7 @@ test('Config files can change dark mode (selector)', async () => {
`
let compiler = await compile(input, {
loadModule: async () => ({ module: { darkMode: 'selector' }, base: '/root' }),
loadModule: async () => ({ module: { darkMode: 'selector' }, base: '/root', path: '' }),
})
expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
@ -68,6 +68,7 @@ test('Config files can change dark mode (variant)', async () => {
loadModule: async () => ({
module: { darkMode: ['variant', '&:where(:not(.light))'] },
base: '/root',
path: '',
}),
})
@ -101,6 +102,7 @@ test('Config files can add plugins', async () => {
],
},
base: '/root',
path: '',
}),
})
@ -128,6 +130,7 @@ test('Plugins loaded from config files can contribute to the config', async () =
],
},
base: '/root',
path: '',
}),
})
@ -157,6 +160,7 @@ test('Config file presets can contribute to the config', async () => {
],
},
base: '/root',
path: '',
}),
})
@ -198,6 +202,7 @@ test('Config files can affect the theme', async () => {
],
},
base: '/root',
path: '',
}),
})
@ -212,6 +217,48 @@ test('Config files can affect the theme', async () => {
`)
})
// https://github.com/tailwindlabs/tailwindcss/issues/19091
test('Accessing a default color if a sub-color exists via CSS should work as expected', async () => {
let input = css`
@tailwind utilities;
@config "./config.js";
.example {
color: theme('colors.foo-bar');
border-color: theme('colors.foo');
}
`
let compiler = await compile(input, {
loadModule: async () => ({
module: {
theme: {
// Internally this object gets converted to something like:
// ```
// {
// foo: { DEFAULT: 'var(--foo-foo)', bar: 'var(--foo-foo-bar)' },
// }
// ```
colors: {
foo: 'var(--foo-foo)',
'foo-bar': 'var(--foo-foo-bar)',
},
},
},
base: '/root',
path: '',
}),
})
expect(compiler.build([])).toMatchInlineSnapshot(`
".example {
color: var(--foo-foo-bar);
border-color: var(--foo-foo);
}
"
`)
})
test('Variants in CSS overwrite variants from plugins', async () => {
let input = css`
@tailwind utilities;
@ -231,6 +278,7 @@ test('Variants in CSS overwrite variants from plugins', async () => {
],
},
base: '/root',
path: '',
}),
})
@ -317,6 +365,7 @@ describe('theme callbacks', () => {
],
} satisfies Config,
base: '/root',
path: '',
}),
})
@ -391,6 +440,7 @@ describe('theme overrides order', () => {
},
},
base: '/root',
path: '',
}),
})
@ -442,6 +492,7 @@ describe('theme overrides order', () => {
},
} satisfies Config,
base: '/root',
path: '',
}
} else {
return {
@ -460,6 +511,7 @@ describe('theme overrides order', () => {
)
}),
base: '/root',
path: '',
}
}
},
@ -562,6 +614,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -596,6 +649,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -631,6 +685,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -669,6 +724,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -708,6 +764,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -745,6 +802,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -779,6 +837,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -806,6 +865,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -840,6 +900,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -875,6 +936,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -913,6 +975,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -952,6 +1015,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -989,6 +1053,7 @@ describe('default font family compatibility', () => {
},
},
base: '/root',
path: '',
}),
})
@ -1021,6 +1086,7 @@ test('creates variants for `data`, `supports`, and `aria` theme options at the s
},
},
base: '/root',
path: '',
}),
})
@ -1113,6 +1179,7 @@ test('merges css breakpoints with js config screens', async () => {
},
},
base: '/root',
path: '',
}),
})
@ -1596,6 +1663,7 @@ test('handles setting theme keys to null', async () => {
},
},
base: '/root',
path: '',
}
},
},