Skip to content

Conversation

@RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented Dec 1, 2025

This PR improves the canonicalization when using text-* and leading-* utilities together.

When using classes such as:

<div class="text-sm leading-7"></div>

Then the canonical way of writing this is:

<div class="text-sm/7"></div>

Similarly, if you already have a modifier applied, and add a new line-height utility. It will also combine them into the canonical form:

<div class="text-sm/6 leading-7"></div>

becomes:

<div class="text-sm/7"></div>

This is because the final CSS output of text-sm/6 leading-7 is:

/*! tailwindcss v4.1.16 | MIT License | https://tailwindcss.com */
.text-sm\/6 {
  font-size: var(--text-sm, 0.875rem);
  line-height: calc(var(--spacing, 0.25rem) * 6);
}
.leading-7 {
  --tw-leading: calc(var(--spacing, 0.25rem) * 7);
  line-height: calc(var(--spacing, 0.25rem) * 7);
}
@property --tw-leading {
  syntax: "*";
  inherits: false;
}

Where the line-height of the leading-7 class wins over the line-height of the text-sm/6 class.

Implementation

On the fly pre-computation

Right now, we are not using any AST based transformations yet and instead rely on a pre-computed list. However, with arbitrary values we don't have pre-computed values for text-sm/123 for example.

What we do instead is if we see a utility that sets line-height and other utilities set font-size then we pre-compute those computations on the fly.

We will prefer named font-sizes (such as sm, lg, etc). We will also prefer bare values for line-height (such as 7) over arbitrary values (such as [123px]).

Canonicalization of the CSS AST

Another thing we had to do is to make sure that when multiple declarations of the same property exist, that we only keep the last one. In the real world, multiple declarations of the same value is typically used for fallback values (e.g.: background-color: #fff; background-color: oklab(255 255 255 / 1);).

But for our use case, I believe we can safely remove the earlier declarations to make the most modern and thus the last declaration win.

Trying combinations based on property only

One small change we had to make is that we try combinations of utilities based on property only instead of property and value. This is important for cases such as text-sm/6 leading-7. These 2 classes will set a lin-height of 24px and 28px respectively so they will never match.

However, once combined together, there will be 2 line-height values, and the last one wins. The signature of text-sm/6 leading-7 becomes:

.x {
  font-size: 14px;          /* From text-sm/6 */
  line-height: 24px;        /* From text-sm/6 */
  line-height: 28px;        /* From leading-7 */
}

↓↓↓↓↓↓↓↓↓

.x {
  font-size: 14px;          /* From text-sm/6 */
  line-height: 28px;        /* From leading-7 */
}

This now shows that just text-sm/7 is the canonical form. Because it produces the same final CSS output.

Test plan

  1. All existing tests pass
  2. Added a bunch of new tests where we combine text-* and leading-* utilities with named, bare and arbitrary values. Even with existing modifiers on the text utilities.
image

Later declarations will win, e.g.:
```
.foo {
  color: red;
  color: blue;
}
```

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

```
.x {
  color: blue;
}
```
It was already an array and we're not mutating it during each for-loop
either so there is no need to create an unnecessary copy.
We were combining utilities that were setting the same property _and_
the same value before testing.

This is correct for most of the utilities, but it will break down the
moment you want to combine values like this:

```
text-sm/6 leading-7
```

The `text-sm/6` would set a `line-height: 24px` but the `leading-7`
would set a `line-height: 28px`. Yet, if we combine both utilities the
`leading-7` will win, and the winning combination will become `text-sm/7`.

While this will lead to more combinations to try, the list will still be
limited to a handful.
This will allow us to re-use the cached logic and allows different
spacing values like `--spacing: 0.25rem` or `--spacing: 4px`.

This will also canonicalize the incoming value such that an incoming
`px` value and a `rem` based spacing value can be compared. This
conversion only works if the `options.rem` is provided.

Canonicalization of expressions still happen regardless.
In the unlikely event that the `--spacing` value ends up to be `0`, then
we would get a divide by 0 error. Or in JavaScript... Infinity.

The bigger advantage is that we don't even have to parse the incoming
input when the spacing value is `0` because it will be invalid anyway.
@RobinMalfait RobinMalfait marked this pull request as ready for review December 1, 2025 12:26
@RobinMalfait RobinMalfait requested a review from a team as a code owner December 1, 2025 12:26
@coderabbitai
Copy link

coderabbitai bot commented Dec 1, 2025

Walkthrough

Adds a changelog entry and a new cartesian product utility. Tests for canonicalization were reorganized and expanded with data-driven cases. API updates: prepareDesignSystemStorage and createSignatureOptions now accept an optional CanonicalizeOptions. Spacing canonicalization now handles rem via options and uses a SPACING cache lookup; arbitrary spacing canonicalization uses constant-folding. canonicalizeAst removes redefined declarations and sorts properties for deterministic signatures. collapseCandidates gains an optimization to precompute text-* utilities when font-size and line-height candidates coexist.

Pre-merge checks

✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: combining text-* and leading-* classes into canonical form.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the motivation, implementation approach, and test coverage.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1a38ea2 and 6219997.

📒 Files selected for processing (1)
  • packages/tailwindcss/src/cartesian.ts (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Linux / upgrade
  • GitHub Check: Linux
  • GitHub Check: Linux / cli
  • GitHub Check: Linux / vite
  • GitHub Check: Linux / postcss
🔇 Additional comments (1)
packages/tailwindcss/src/cartesian.ts (1)

1-45: Cartesian generator and empty-set behavior look correct

The type-level CartesianResult<T> and the index-based generator logic are consistent and produce the expected combinations. The early returns for n === 0 and when any input set is empty correctly avoid yielding invalid combinations and address the prior empty-set concern. No further changes needed here from my side.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/tailwindcss/src/canonicalize-candidates.ts (3)

110-123: Thread CanonicalizeOptions consistently into storage prep and tighten SPACING cache behavior

You’re now letting prepareDesignSystemStorage/createSpacingCache close over options?.rem and using that for both the root --spacing multiplier and individual lookups, which is exactly what’s needed for rem-aware canonicalization. A couple of details are worth tightening:

  1. SPACING_KEY lifetime vs options.rem

    • SPACING_KEY is cached once per DesignSystem instance and created via ??=:
      designSystem.storage[SPACING_KEY] ??= createSpacingCache(designSystem, options)
    • createSpacingCache bakes options?.rem into the returned DefaultMap (both for the root multiplier and for each input lookup).
    • However, createCanonicalizeOptions and canonicalizeCandidates still call:
      let designSystem = prepareDesignSystemStorage(baseDesignSystem)
      without passing options. If any code path calls prepareDesignSystemStorage first without options (or with a different rem) before createSignatureOptions runs, SPACING_KEY will be initialised with that “first” rem and never rebuilt, even if later canonicalization requests pass a different rem.

    This is probably fine if a DesignSystem is only ever used with a single CanonicalizeOptions instance, but it’s an implicit invariant. To make this more robust and self-documenting, I’d recommend:

    function createCanonicalizeOptions(
      baseDesignSystem: BaseDesignSystem,
      signatureOptions: SignatureOptions,
      options?: CanonicalizeOptions,
    ) {
      let features = Features.None
      if (options?.collapse) features |= Features.CollapseUtilities
    
  • let designSystem = prepareDesignSystemStorage(baseDesignSystem)
  • let designSystem = prepareDesignSystemStorage(baseDesignSystem, options)
    }

export function canonicalizeCandidates(
baseDesignSystem: BaseDesignSystem,
candidates: string[],
options?: CanonicalizeOptions,
): string[] {
let signatureOptions = createSignatureOptions(baseDesignSystem, options)
let canonicalizeOptions = createCanonicalizeOptions(baseDesignSystem, signatureOptions, options)

  • let designSystem = prepareDesignSystemStorage(baseDesignSystem)
  • let designSystem = prepareDesignSystemStorage(baseDesignSystem, options)
That guarantees the first `prepareDesignSystemStorage` call for a given `DesignSystem` instance sees the same `CanonicalizeOptions` that drove `signatureOptions`, and prevents subtle mismatches if someone reuses a `DesignSystem` with different `rem` values later.

If you foresee truly different `rem` values against the same `DesignSystem`, an alternative would be to key the spacing cache by `rem` (e.g. `DefaultMap<number | null, DefaultMap<string, number | null>>`) instead of closing over it, but the above change is a minimal fix.

2. **Spacing cache behavior when `--spacing` is 0**

The short-circuit in `createSpacingCache`:
```ts
if (value === 0) return null

is good to avoid division by zero, but it also means a theme with --spacing: 0 effectively disables spacing-based canonicalization. That’s probably acceptable, just calling it out as an intentional tradeoff.

  1. Type / sign of bareValue when turning arbitrary functional values into named

    In arbitraryUtilities’s tryReplacements you now do:

    let bareValue = designSystem.storage[SPACING_KEY]?.get(value) ?? null
    if (bareValue !== null) {
      if (isValidSpacingMultiplier(bareValue)) {
        yield Object.assign({}, candidate, {
          value: { kind: 'named', value: bareValue, fraction: null },
        })
      }
    }
    • SPACING_KEY returns a numeric ratio (number | null), and NamedUtilityValue['value'] is typically treated as a string elsewhere (e.g. tryValueReplacements always sets value: string). To keep the representation consistent (and avoid any TS friction), it’d be safer to normalise this:

  • if (bareValue !== null) {
  •  if (isValidSpacingMultiplier(bareValue)) {
    
  •    yield Object.assign({}, candidate, {
    
  •      value: { kind: 'named', value: bareValue, fraction: null },
    
  •    })
    
  •  }
    
  • }
  • if (bareValue !== null && isValidSpacingMultiplier(bareValue)) {
  •  let normalized = String(bareValue)
    
  •  yield Object.assign({}, candidate, {
    
  •    value: { kind: 'named', value: normalized, fraction: null },
    
  •  })
    
  • }
    
    
  • If value could ever be negative, SPACING_KEY will return a negative ratio; isValidSpacingMultiplier currently accepts that, and you’d end up with a named value like -4. That likely isn’t what you want (you already handle signed multipliers via rootPrefix further down when using spacingMultiplier). If negative inputs are possible here, consider guarding on bareValue >= 0 and letting the existing spacingMultiplier/rootPrefix logic own the signed case.

Also applies to: 150-150, 166-177, 179-188, 936-957, 1064-1070


261-309: Text/leading precompute optimization looks sound; consider aligning comments and documenting the property-only intersection

The new hard-coded optimization around line-height/font-size and text-* precomputation in collapseGroup is a nice way to surface text-size/line-height combos for collapseCandidates without bloating the generic path. A few minor, mostly documentation-level nits:

  1. Property-only intersection vs outdated comment

    • The comment above still says:

      // For each property, lookup other utilities that also set this property and
      // this exact value. If multiple properties are used, use the intersection of
      // each property.
    • But the implementation now intentionally unions over all values per property:

      for (let property of propertyValues.keys()) {
        let otherUtilities = new Set<string>()
        for (let group of staticUtilities.get(property).values()) {
          for (let candidate of group) {
            otherUtilities.add(candidate)
          }
        }
        ...
        result = intersection(result, otherUtilities)
      }

    That’s the right move for cases like text-sm/6 leading-7, where values differ but the final cascaded CSS is representable as a single text-* utility. It’d be good to update the comment to say “same property” rather than “same property and value” to avoid confusing future readers.

  2. Reliance on signatures for safety

    With property-only intersections, the “is this collapse safe?” check now relies entirely on:

    let collapsedSignature = signatures.get(comboKey)
    ...
    let signature = signatures.get(replacement)
    if (signature !== collapsedSignature) continue

    That’s correct and matches the PR description (“last declaration wins” semantics), but it’s worth documenting somewhere near this block that we deliberately ignore value-level equality during candidate discovery and let signature equality gate correctness.

  3. Micro-optimisation (optional)

    fontSizeNames = designSystem.theme.keysInNamespaces(['--text']) combined with interestingLineHeights can lead to O(|text-sizes| * |line-heights|) precomputation for every group with at least one line-height. That’s probably fine given typical scales, but if this ever becomes hot, you could consider restricting fontSizeNames to the sizes actually present in candidatePropertiesValues (the second loop already does this for arbitrary font-size values).

Overall the optimization is well-contained and matches the intended text-*/leading-* canonicalization behavior.

Also applies to: 318-332, 345-347


2141-2165: Declaration dedup + sort in canonicalizeAst works; tighten sort behavior for mixed node kinds

The new exit logic in canonicalizeAst:

  • Walks declarations from the end and removes earlier declarations of the same property, keeping only the last one.
  • Then sorts node.nodes lexicographically by property for declarations.

This is a good fit for signature computation:

  • It matches the “last declaration wins” behavior needed to treat, e.g., leading-7 text-sm and text-sm/7 as equivalent when their final CSS matches.
  • Removing earlier fallbacks also simplifies signatures so we can reason at the “final behaviour” level rather than caring about legacy value sequences.

Two small concerns / suggestions:

  1. Comparator behavior for non-declaration children

    node.nodes.sort((a, b) => {
      if (a.kind !== 'declaration') return 0
      if (b.kind !== 'declaration') return 0
      return a.property.localeCompare(b.property)
    })
    • If node.nodes can ever contain a mix of declarations and other node types (e.g. nested rules/at-rules inside an at-rule), this comparator doesn’t define a strict ordering for comparisons involving non-declarations (always returns 0). That means the relative order of declarations vs non-declarations is effectively left to the JS engine’s sort implementation.

    • Since these ASTs are only used for signatures, changing that order probably doesn’t break runtime CSS, but for determinism it’d be safer to make the intent explicit, e.g.:

      node.nodes.sort((a, b) => {
        let aIsDecl = a.kind === 'declaration'
        let bIsDecl = b.kind === 'declaration'
        if (aIsDecl && bIsDecl) {
          return a.property.localeCompare(b.property)
        }
        if (aIsDecl !== bIsDecl) {
          // Always keep non-declarations before declarations (or vice versa)
          return aIsDecl ? 1 : -1
        }
        return 0
      })

    This preserves non-declaration relative ordering while still providing deterministic ordering for declarations.

  2. Fallback sequences vs canonical equivalence

    By dropping earlier declarations of the same property, you’re intentionally treating:

    .x { color: legacy-fallback; color: final; }

    and

    .x { color: final; }

    as the same signature. That matches the “canonicalization equals final cascaded result” philosophy used elsewhere in this file, but it might be worth adding a brief comment to that effect above the dedup block so it’s clear this is a deliberate tradeoff (and not an accidental loss of fallback information).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 243615e and 1a38ea2.

📒 Files selected for processing (4)
  • CHANGELOG.md (1 hunks)
  • packages/tailwindcss/src/canonicalize-candidates.test.ts (3 hunks)
  • packages/tailwindcss/src/canonicalize-candidates.ts (8 hunks)
  • packages/tailwindcss/src/cartesian.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/tailwindcss/src/canonicalize-candidates.test.ts (1)
packages/tailwindcss/src/cartesian.ts (1)
  • cartesian (10-40)
packages/tailwindcss/src/canonicalize-candidates.ts (5)
packages/tailwindcss/src/intellisense.ts (1)
  • CanonicalizeOptions (7-7)
packages/tailwindcss/src/design-system.ts (1)
  • DesignSystem (33-65)
packages/tailwindcss/src/utils/infer-data-type.ts (1)
  • isValidSpacingMultiplier (356-358)
packages/tailwindcss/src/constant-fold-declaration.ts (1)
  • constantFoldDeclaration (8-115)
packages/tailwindcss/src/utils/default-map.ts (1)
  • DefaultMap (5-20)
🔇 Additional comments (4)
CHANGELOG.md (1)

23-23: Changelog entry is clear and consistent

The new “Canonicalization: combine text-* and leading-* classes” entry is accurate, matches surrounding style, and correctly references the PR number.

packages/tailwindcss/src/canonicalize-candidates.test.ts (3)

5-65: cartesian import and test name formatting look good

Importing cartesian from ./cartesian is correct for the new helper, and the simplified testName = '%s → %s (%#)' still preserves useful information in Vitest output while avoiding extra backticks. No issues here.


1029-1057: Shorthand-combination tests are well targeted

The new “combine to shorthand utilities” block exercises key canonicalization paths (margins to m-*/my-*, w+h to size-*, arbitrary props to shorthand, and the text-sm/relaxed case) and correctly uses expectCombinedCanonicalization so multiple candidates are canonicalized together. The behavior around differing variants (e.g. hover:w-4 h-4) is also covered. Looks solid.


1059-1091: cartesian‑driven font-size/line-height coverage is robust

Using cartesian to generate all combinations of equivalent font-size and line-height forms gives thorough coverage of the new text-{x}/{y} canonicalization without duplicating test data. Trimming the padded candidate string before splitting ensures the extra spaces only affect test titles, not behavior. The expected outputs (text-sm/7, text-[15px]/7, etc.) align with the configured theme values earlier in the file. No issues.

@RobinMalfait RobinMalfait merged commit 229121d into main Dec 1, 2025
7 checks passed
@RobinMalfait RobinMalfait deleted the feat/canonicalize-font-size-line-height branch December 1, 2025 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants