From 021b7fe6f239ea6351c80bec84eb8ca91acfcb2f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 14 Aug 2026 12:16:00 +0200 Subject: [PATCH] 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: https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610 ## Test plan 1. Existing tests pass 2. Added a regression test Checked manually in the regression repo Before: image After: image --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + .../src/canonicalize-candidates.test.ts | 22 +++++++++++ .../src/canonicalize-candidates.ts | 38 +++++++++++++++---- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 036c2c3b4..175a1daa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) - Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389)) - `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414)) +- 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)) ## [4.3.3] - 2026-07-16 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 550a9c9a2..567f0ad0d 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -1638,4 +1638,26 @@ describe('regressions', () => { 'lg:flex', ]) }) + + // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610 + test('does not merge utilities whose theme variables resolve to CSS-wide keywords', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --foreground: unset; + --default: unset; + } + @theme inline { + --color-foreground: var(--foreground); + --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent); + } + `, + { base: __dirname }, + ) + + expect( + designSystem.canonicalizeCandidates(['text-foreground/60', 'text-default-soft-hover']), + ).toEqual(['text-foreground/60', 'text-default-soft-hover']) + }) }) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index ad2648e12..d1dfc09de 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -2563,6 +2563,25 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si return ast } +// Variables whose theme value is a CSS-wide keyword (e.g.: `unset`) are never +// inlined. These are typically registered as a placeholder to be re-assigned at +// runtime, so two variables that share such a value are not interchangeable. +// +// E.g.: +// +// ```css +// @theme { +// --foreground: unset; +// --background: unset; +// } +// ``` +// +// Inlining would make `text-(--foreground)` and `text-(--background)` produce +// the same signature `color: unset`, even though they are different at runtime. +// +// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords +const CSS_WIDE_KEYWORDS = ['initial', 'inherit', 'revert', 'revert-layer', 'revert-rule', 'unset'] + // Resolve theme values to their inlined value. // // E.g.: @@ -2578,8 +2597,8 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si // } // ``` // -// Which conveniently will be equivalent to: `text-red-500` when we inline -// the value. +// Which conveniently will be equivalent to: `text-red-500` when we inline the +// value. // // Without inlining: // ```css @@ -2595,13 +2614,13 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si // } // ``` // -// Recently we made sure that utilities like `text-red-500` also generate -// the fallback value for usage in `@reference` mode. +// Recently we made sure that utilities like `text-red-500` also generate the +// fallback value for usage in `@reference` mode. // -// The second assumption is that if you use `var(--key, fallback)` that -// happens to match a known variable _and_ its inlined value. Then we can -// replace it with the inlined variable. This allows us to handle custom -// `@theme` and `@theme inline` definitions. +// The second assumption is that if you use `var(--key, fallback)` that happens +// to match a known variable _and_ its inlined value. Then we can replace it +// with the inlined variable. This allows us to handle custom `@theme` and +// `@theme inline` definitions. function resolveVariablesInValue(value: string, designSystem: DesignSystem): string { let changed = false let valueAst = ValueParser.parse(value) @@ -2630,6 +2649,9 @@ function resolveVariablesInValue(value: string, designSystem: DesignSystem): str seen.add(variable) if (variableValue === undefined) return // Couldn't resolve the variable + // CSS-wide keywords are never inlined + if (CSS_WIDE_KEYWORDS.includes(variableValue.toLowerCase())) return + // Inject variable fallbacks when no fallback is present yet. // // A fallback could consist of multiple values.