From 7482d47a54072159b17f1884b1f803f3e53ce02d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 19 Mar 2026 19:53:16 +0100 Subject: [PATCH] Add canonicalizations for `tracking-*` utilities (#19827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for canonicalizations for `tracking-*` utilities. This one is a bit of a funny one, if you take a look at the linked issue, there is a beautiful table: | Utility Name | Value | Arbitrary Value | Throws Suggestion | | - | -: | - | - | | tracking-tighter | -0.05em | tracking-[-0.05em] | ✗ | | tracking-tight | -0.025em | tracking-[-0.025em] | ✗ | | tracking-normal | 0em | tracking-[0em] | ✗ | | tracking-wide | 0.025em | tracking-[0.025em] | ✗ | | tracking-wider | 0.05em | tracking-[0.05em] | ✗ | | tracking-widest | 0.1em | tracking-[0.1em] | ✓ | It doesn't really make sense to _why_ only the `tracking-widest` one is properly suggested here. Until you look a little bit closer. Turns out that `-tracking-tighter` is equivalent to `tracking-wider`, `-tracking-tight` is equivalent to `tracking-wide` and so on. The way the canonicalization works internally is by generating a signature for a given utility class. If two utilities have the exact same signature, we can consider them the same. In this case `tracking-widest` and `tracking-[0.1em]` have the same signature. One of the rules we have internally is that if we find more than one replacement utility then we don't really know what to do, so we bail. Because if you get `foo` or `bar`, which one do you pick? If we refer to this above table again, the moment we want to canonicalize the `tracking-[-0.05em]` we get two suggestions: `tracking-tighter` and `-tracking-wider`, since we don't know what to do, we bail and we don't suggest anything. So the reason that `tracking-widest` _was_ suggested is just because we don't have a `-tracking-tightest`. How do we fix this? Well, since we have `tracking-*` and `-tracking-*` utilities, I wanted to deprecate the `-tracking-*` ones for named utilities (where the values come from your theme) because that doesn't really make sense. However, we have this exact pattern documented here: https://tailwindcss.com/docs/letter-spacing#using-negative-values Which means that I can't just deprecate those utilities. image Instead, I added a different rule which says that if you get multiple possible replacements, then we prefer the "positive" one, the one without the `-`. Also added some additional checks to make sure that if you get `foo`, `-bar`, `baz`, that we also bail because we know that we should prefer `foo` or `baz` over `-bar`, but we don't know if we should pick `foo` or `baz`... This additional rule does solve the original issue, and we already prefer possible values over negative values in other places (related to bare values). Fixes: https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1558 ## Test plan 1. Existing tests pass 2. Added regression tests to make sure that the table from above _does_ get canonicalized correctly into the expected values. --- CHANGELOG.md | 4 ++ .../src/canonicalize-candidates.test.ts | 34 +++++++++++ .../src/canonicalize-candidates.ts | 56 +++++++++++++++---- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9c103bc6..3b639aed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - _Experimental_: Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901)) +### Fixed + +- Improve canonicalizations for `tracking-*` utilities ([#19827](https://github.com/tailwindlabs/tailwindcss/pull/19827)) + ## [4.2.2] - 2026-03-18 ### Fixed diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 4b6ecbfd6..fef9eee08 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -1120,6 +1120,40 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', await expectCombinedCanonicalization(input, candidates.trim(), expected) }) }) + + // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1558 + test.each([ + ['tracking-[-0.05em]', 'tracking-tighter'], + ['tracking-[-0.025em]', 'tracking-tight'], + ['tracking-[0em]', 'tracking-normal'], + ['tracking-[0.025em]', 'tracking-wide'], + ['tracking-[0.05em]', 'tracking-wider'], + ['tracking-[0.1em]', 'tracking-widest'], + + // Negative values that don't make sense + // See: https://tailwindcss.com/docs/letter-spacing#using-negative-values + ['-tracking-tighter', 'tracking-wider'], + ['-tracking-tight', 'tracking-wide'], + ['-tracking-normal', 'tracking-normal'], + ['-tracking-wide', 'tracking-tight'], + ['-tracking-wider', 'tracking-tighter'], + ])(testName, { timeout }, async (candidate, expected) => { + await expectCanonicalization( + css` + @import 'tailwindcss'; + @theme { + --tracking-tighter: -0.05em; + --tracking-tight: -0.025em; + --tracking-normal: 0em; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --tracking-widest: 0.1em; + } + `, + candidate, + expected, + ) + }) }) describe('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 566b4a3f7..eaa055b25 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1128,11 +1128,29 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO // Find a corresponding utility for the same signature let replacements = utilities.get(targetSignature) - // Multiple utilities can map to the same signature. Not sure how to migrate - // this one so let's just skip it for now. - // - // TODO: Do we just migrate to the first one? - if (replacements.length > 1) return + // Multiple utilities can map to the same signature. + if (replacements.length > 1) { + // Prefer positive values over negative values + let maybeReplacement: string | undefined = undefined + for (let replacement of replacements) { + if (replacement[0] === '-') continue // Skip negative values + + // If multiple non-negative replacements exists then we are unsure + // what to do, so let's bail. + if (maybeReplacement) return + + // Consider this replacement + maybeReplacement = replacement + } + + if (maybeReplacement) { + for (let replacementCandidate of parseCandidate(designSystem, maybeReplacement)) { + yield replacementCandidate + } + } + + return + } // If we didn't find any replacement utilities, let's try to strip the // modifier and find a replacement then. If we do, we can try to re-add the @@ -1353,11 +1371,29 @@ function bareValueUtilities(candidate: Candidate, options: InternalCanonicalizeO // Find a corresponding utility for the same signature let replacements = utilities.get(targetSignature) - // Multiple utilities can map to the same signature. Not sure how to migrate - // this one so let's just skip it for now. - // - // TODO: Do we just migrate to the first one? - if (replacements.length > 1) return + // Multiple utilities can map to the same signature. + if (replacements.length > 1) { + // Prefer positive values over negative values + let maybeReplacement: string | undefined = undefined + for (let replacement of replacements) { + if (replacement[0] === '-') continue // Skip negative values + + // If multiple non-negative replacements exists then we are unsure + // what to do, so let's bail. + if (maybeReplacement) return + + // Consider this replacement + maybeReplacement = replacement + } + + if (maybeReplacement) { + for (let replacementCandidate of parseCandidate(designSystem, maybeReplacement)) { + yield replacementCandidate + } + } + + return + } // If we didn't find any replacement utilities, let's try to strip the // modifier and find a replacement then. If we do, we can try to re-add the