Fix theme() in JS plugins returning unresolved object instead of DEFAULT value (#20299)

## Summary

When a CSS theme key defined via `@theme` (or a JS config's `theme`
object) shares a dash-separated prefix with a sibling key — e.g.
`--color-foo` and `--color-foo-bar` — calling `theme('colors.foo')` from
inside a JS plugin (`addUtilities`, `addComponents`, etc.) does not
resolve to the `foo` value. Instead it returns an internal
disambiguation object shaped like `{ DEFAULT: 'red', bar: 'blue',
__CSS_VALUES__: {...} }`, because there's no way to tell from CSS custom
property names alone whether `foo-bar` is a sibling key or a nested
sub-key of `foo`.

This same ambiguity was already fixed for the CSS-embedded `theme()`
function in #19097 (which unwraps to the `DEFAULT` key when present),
and the changelog entry for that PR states it fixes this "in JS configs
**and plugins**" — but the fix only touched `apply-compat-hooks.ts`'s
`resolveThemeValue`, not `createThemeFn`'s `theme` function that's
exposed directly to plugins in `plugin-functions.ts`. This PR closes
that gap by applying the same DEFAULT-unwrapping there.

Without this fix, passing the raw object into `addUtilities` (a very
natural thing to do, since a plugin author expects a string) produces
broken CSS — the reserved `DEFAULT` key gets mangled into a garbage
property name (`-d-e-f-a-u-l-t`) by the kebab-case conversion, and the
internal `__CSS_VALUES__` bookkeeping leaks into the generated
stylesheet.

### Minimal reproduction

```js
// tailwind.config.js (registered via @config, or any @plugin-registered plugin)
const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    plugin(function ({ addUtilities, theme }) {
      addUtilities({
        '.example-foo': { color: theme('colors.foo') },
      })
    }),
  ],
}
```
```css
@import "tailwindcss";
@config "./tailwind.config.js";
@theme {
  --color-foo: red;
  --color-foo-bar: blue;
}
```

**Before:**
```css
.example-foo color {
  -d-e-f-a-u-l-t: red;
  bar: blue;
}
.example-foo color __CSS_VALUES__ {
  -d-e-f-a-u-l-t: 0;
  bar: 0;
}
```

**After:**
```css
.example-foo {
  color: red;
}
```

## Test plan

- Added a regression test in
`packages/tailwindcss/src/compat/plugin-api.test.ts` ("theme() resolves
the DEFAULT value when a bare CSS theme key shares a prefix with a
sibling key")
- Verified via `pnpm --filter tailwindcss exec vitest run` that this
test fails with the exact broken output shown above when the fix is
reverted, and passes once it's applied
- Ran the full `tailwindcss` package test suite (`pnpm --filter
tailwindcss exec vitest run`) — 4684 tests passing, no regressions
- Verified formatting on the changed files with `npx prettier --check`

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
한국 2026-07-02 22:20:14 +09:00 • committed by GitHub
parent b53fa096c9
commit 04588b1e8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 58 additions and 0 deletions

View file

@ -635,6 +635,46 @@ describe('theme', async () => {
})
})
test('theme() resolves the DEFAULT value when a bare CSS theme key shares a prefix with a sibling key', async () => {
expect(
await run(
['example-foo', 'example-foo-bar'],
css`
@tailwind utilities;
@theme {
--color-foo: red;
--color-foo-bar: blue;
}
@plugin "my-plugin";
`,
{
loadModule: async (_id, base) => {
return {
path: '',
base,
module: plugin(function ({ addUtilities, theme }) {
addUtilities({
'.example-foo': { color: theme('colors.foo') },
'.example-foo-bar': { color: theme('colors.foo-bar') },
})
}),
}
},
},
),
).toMatchInlineSnapshot(`
"
.example-foo {
color: red;
}
.example-foo-bar {
color: #00f;
}
"
`)
})
test('all necessary theme keys support bare values', async () => {
expect(
await run(

View file

@ -100,6 +100,23 @@ export function createThemeFn(
return [base, extra]
}
// If `--color-foo` and `--color-foo-bar` are both defined, `colors.foo`
// produces a synthetic object:
//
// ```ts
// { DEFAULT: 'red', bar: 'blue', __CSS_VALUES__: { DEFAULT: 0, bar: 0 } }
// ```
//
// Prefer `DEFAULT` instead of exposing the object to plugin code.
if (
cssValue !== null &&
typeof cssValue === 'object' &&
!Array.isArray(cssValue) &&
'DEFAULT' in cssValue
) {
return cssValue.DEFAULT
}
// Values from CSS take precedence over values from the config
return cssValue ?? configValue
})()