Skip to content

Commit 021b7fe

Browse files
Canonicalization: prevent inlining CSS-wide keywords (#20417)
This PR fixes an issue where canonicalization suggestions in intellisense result in 'weird' suggestions. ``` The class text-foreground/60 can be written as text-default-soft-hover ``` If we look at the CSS provided by the issue, this doesn't immediately make sense: ```css @theme { --color-foreground: var(--foreground); --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent); } :root { --foreground: oklch(0.2103 0.0059 285.89); /* near-black */ --default: oklch(94% 0.001 286.375); /* light gray */ } ``` But it turns out that when you use Uniwind (React Native) with HeroUI, that the setup looks more like this: ```css @import 'tailwindcss'; @theme { --foreground: unset; --default: unset; } @theme inline { --color-foreground: var(--foreground); --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent); } ``` During the canonicalization step, we inline all the `@theme` values, the reason for this is that `text-[#fff]` can be turned into `text-white` even though they look slightly different: ```css .text-\[\#fff\] { color: #fff; } .text-white { color: var(--color-white, #fff); } ``` But when we inline them, it looks like this: ```css .text-\[\#fff\] { color: #fff; } .text-white { color: #fff; } ``` Internally, we use signatures to make sure that they are safe to be subtituted with eachother. In this case, the signatures will look like this: ```css .x { color: #fff; } .x { color: #fff; } ``` If we now look at the signatures of the original issue, you would see: ```css /* text-foreground/60 */ .x { color: color-mix(in_oklab,unset_60%,transparent); } /* text-default-soft-hover */ .x { color: color-mix(in_oklab,unset_60%,transparent); } ``` That's because the `@theme` variables were inlined, resulting in the exact same signature, thus we consider them the same. This PR makes sure to never inline [CSS-wide keywords](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords) (such as `unset`) and therefore keeping the CSS variable reference: ```css /* text-foreground/60 */ .x { color: color-mix(in_oklab,var(--foreground)_60%,transparent); } /* text-default-soft-hover */ .x { color: color-mix(in_oklab,var(--default)_60%,transparent); } ``` Fixes: tailwindlabs/tailwindcss-intellisense#1610 ## Test plan 1. Existing tests pass 2. Added a regression test Checked manually in the regression repo Before: <img width="1358" height="421" alt="image" src="https://github.com/user-attachments/assets/8ab3de97-17e4-4e11-b335-bb37218ff2ee" /> After: <img width="1177" height="247" alt="image" src="https://github.com/user-attachments/assets/cbbfcf04-3ed3-4fa7-a3eb-ef607239f4ad" /> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent f7f58f0 commit 021b7fe

3 files changed

Lines changed: 53 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- Skip ignored directories entirely when computing watch globs (`scanner.globs`), instead of walking their full contents on every rebuild ([#20408](https://github.com/tailwindlabs/tailwindcss/pull/20408))
2929
- Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389))
3030
- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414))
31+
- Canonicalization: don't merge utilities that reference different theme variables set to CSS-wide keywords like `unset` ([#20417](https://github.com/tailwindlabs/tailwindcss/pull/20417))
3132

3233
## [4.3.3] - 2026-07-16
3334

packages/tailwindcss/src/canonicalize-candidates.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1638,4 +1638,26 @@ describe('regressions', () => {
16381638
'lg:flex',
16391639
])
16401640
})
1641+
1642+
// https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610
1643+
test('does not merge utilities whose theme variables resolve to CSS-wide keywords', async () => {
1644+
let designSystem = await __unstable__loadDesignSystem(
1645+
css`
1646+
@tailwind utilities;
1647+
@theme {
1648+
--foreground: unset;
1649+
--default: unset;
1650+
}
1651+
@theme inline {
1652+
--color-foreground: var(--foreground);
1653+
--color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent);
1654+
}
1655+
`,
1656+
{ base: __dirname },
1657+
)
1658+
1659+
expect(
1660+
designSystem.canonicalizeCandidates(['text-foreground/60', 'text-default-soft-hover']),
1661+
).toEqual(['text-foreground/60', 'text-default-soft-hover'])
1662+
})
16411663
})

packages/tailwindcss/src/canonicalize-candidates.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2563,6 +2563,25 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
25632563
return ast
25642564
}
25652565

2566+
// Variables whose theme value is a CSS-wide keyword (e.g.: `unset`) are never
2567+
// inlined. These are typically registered as a placeholder to be re-assigned at
2568+
// runtime, so two variables that share such a value are not interchangeable.
2569+
//
2570+
// E.g.:
2571+
//
2572+
// ```css
2573+
// @theme {
2574+
// --foreground: unset;
2575+
// --background: unset;
2576+
// }
2577+
// ```
2578+
//
2579+
// Inlining would make `text-(--foreground)` and `text-(--background)` produce
2580+
// the same signature `color: unset`, even though they are different at runtime.
2581+
//
2582+
// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords
2583+
const CSS_WIDE_KEYWORDS = ['initial', 'inherit', 'revert', 'revert-layer', 'revert-rule', 'unset']
2584+
25662585
// Resolve theme values to their inlined value.
25672586
//
25682587
// E.g.:
@@ -2578,8 +2597,8 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
25782597
// }
25792598
// ```
25802599
//
2581-
// Which conveniently will be equivalent to: `text-red-500` when we inline
2582-
// the value.
2600+
// Which conveniently will be equivalent to: `text-red-500` when we inline the
2601+
// value.
25832602
//
25842603
// Without inlining:
25852604
// ```css
@@ -2595,13 +2614,13 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
25952614
// }
25962615
// ```
25972616
//
2598-
// Recently we made sure that utilities like `text-red-500` also generate
2599-
// the fallback value for usage in `@reference` mode.
2617+
// Recently we made sure that utilities like `text-red-500` also generate the
2618+
// fallback value for usage in `@reference` mode.
26002619
//
2601-
// The second assumption is that if you use `var(--key, fallback)` that
2602-
// happens to match a known variable _and_ its inlined value. Then we can
2603-
// replace it with the inlined variable. This allows us to handle custom
2604-
// `@theme` and `@theme inline` definitions.
2620+
// The second assumption is that if you use `var(--key, fallback)` that happens
2621+
// to match a known variable _and_ its inlined value. Then we can replace it
2622+
// with the inlined variable. This allows us to handle custom `@theme` and
2623+
// `@theme inline` definitions.
26052624
function resolveVariablesInValue(value: string, designSystem: DesignSystem): string {
26062625
let changed = false
26072626
let valueAst = ValueParser.parse(value)
@@ -2630,6 +2649,9 @@ function resolveVariablesInValue(value: string, designSystem: DesignSystem): str
26302649
seen.add(variable)
26312650
if (variableValue === undefined) return // Couldn't resolve the variable
26322651

2652+
// CSS-wide keywords are never inlined
2653+
if (CSS_WIDE_KEYWORDS.includes(variableValue.toLowerCase())) return
2654+
26332655
// Inject variable fallbacks when no fallback is present yet.
26342656
//
26352657
// A fallback could consist of multiple values.

0 commit comments

Comments
 (0)