Canonicalization: combine text-* and leading-* classes (#19396)

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

When using classes such as:
```html
<div class="text-sm leading-7"></div>
```

Then the canonical way of writing this is:
```html
<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:
```html
<div class="text-sm/6 leading-7"></div>
```
becomes:
```html
<div class="text-sm/7"></div>
```

This is because the final CSS output of `text-sm/6 leading-7` is:
```css
/*! 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:
```css
.x {
  font-size: 14px;          /* From text-sm/6 */
  line-height: 24px;        /* From text-sm/6 */
  line-height: 28px;        /* From leading-7 */
}
```

↓↓↓↓↓↓↓↓↓

```css
.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.

<img width="1010" height="1099" alt="image"
src="https://github.com/user-attachments/assets/d2775692-a442-4604-8371-21dacf16ebfc"
/>
This commit is contained in:
Robin Malfait 2025-12-01 16:01:24 +01:00 committed by GitHub
parent 243615e3f2
commit 229121dd14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 209 additions and 65 deletions

View File

@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve backwards compatibility for `content` theme key from JS configs ([#19381](https://github.com/tailwindlabs/tailwindcss/pull/19381))
- Upgrade: Handle `future` and `experimental` config keys ([#19344](https://github.com/tailwindlabs/tailwindcss/pull/19344))
- Try to canonicalize any arbitrary utility to a bare value ([#19379](https://github.com/tailwindlabs/tailwindcss/pull/19379))
- Canonicalization: combine `text-*` and `leading-*` classes ([#19396](https://github.com/tailwindlabs/tailwindcss/pull/19396))
### Added

View File

@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, test } from 'vitest'
import { __unstable__loadDesignSystem } from '.'
import { cartesian } from './cartesian'
import type { CanonicalizeOptions } from './intellisense'
import { DefaultMap } from './utils/default-map'
@ -54,7 +55,7 @@ const DEFAULT_CANONICALIZATION_OPTIONS: CanonicalizeOptions = {
}
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
let testName = '`%s``%s` (%#)'
let testName = '%s → %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
@ -1025,37 +1026,69 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
})
})
test.each([
// 4 to 1
['mt-1 mr-1 mb-1 ml-1', 'm-1'],
describe('combine to shorthand utilities', () => {
test.each([
// 4 to 1
['mt-1 mr-1 mb-1 ml-1', 'm-1'],
// 2 to 1
['mt-1 mb-1', 'my-1'],
// 2 to 1
['mt-1 mb-1', 'my-1'],
// Different order as above
['mb-1 mt-1', 'my-1'],
// Different order as above
['mb-1 mt-1', 'my-1'],
// To completely different utility
['w-4 h-4', 'size-4'],
// To completely different utility
['w-4 h-4', 'size-4'],
// Do not touch if not operating on the same variants
['hover:w-4 h-4', 'hover:w-4 h-4'],
// Do not touch if not operating on the same variants
['hover:w-4 h-4', 'hover:w-4 h-4'],
// Arbitrary properties to combined class
['[width:_16px_] [height:16px]', 'size-4'],
// Arbitrary properties to combined class
['[width:_16px_] [height:16px]', 'size-4'],
// Arbitrary properties to combined class with modifier
['[font-size:14px] [line-height:1.625]', 'text-sm/relaxed'],
])(
'should canonicalize multiple classes `%s` into a shorthand `%s`',
{ timeout },
async (candidates, expected) => {
// Arbitrary properties to combined class with modifier
['[font-size:14px] [line-height:1.625]', 'text-sm/relaxed'],
])(testName, { timeout }, async (candidates, expected) => {
let input = css`
@import 'tailwindcss';
`
await expectCombinedCanonicalization(input, candidates, expected)
},
)
})
})
describe('font-size/line-height to text-{x}/{y}', () => {
test.each([
...Array.from(
cartesian(
['[font-size:14px]', 'text-[14px]', 'text-[14px]/6', 'text-sm', 'text-sm/6'],
['[line-height:28px]', 'leading-[28px]', 'leading-7'],
),
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-sm/7']),
...Array.from(
cartesian(
['[font-size:15px]', 'text-[15px]', 'text-[15px]/6'],
['[line-height:28px]', 'leading-[28px]', 'leading-7'],
),
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-[15px]/7']),
...Array.from(
cartesian(
['[font-size:14px]', 'text-[14px]', 'text-[14px]/6', 'text-sm', 'text-sm/6'],
['[line-height:28.5px]', 'leading-[28.5px]'],
),
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-sm/[28.5px]']),
...Array.from(
cartesian(
['[font-size:15px]', 'text-[15px]', 'text-[15px]/6'],
['[line-height:28.5px]', 'leading-[28.5px]'],
),
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-[15px]/[28.5px]']),
])(testName, { timeout }, async (candidates, expected) => {
let input = css`
@import 'tailwindcss';
`
await expectCombinedCanonicalization(input, candidates.trim(), expected)
})
})
})
describe('theme to var', () => {

View File

@ -107,7 +107,10 @@ interface DesignSystem extends BaseDesignSystem {
}
}
export function prepareDesignSystemStorage(baseDesignSystem: BaseDesignSystem): DesignSystem {
export function prepareDesignSystemStorage(
baseDesignSystem: BaseDesignSystem,
options?: CanonicalizeOptions,
): DesignSystem {
let designSystem = baseDesignSystem as DesignSystem
designSystem.storage[SIGNATURE_OPTIONS_KEY] ??= createSignatureOptionsCache()
@ -116,7 +119,7 @@ export function prepareDesignSystemStorage(baseDesignSystem: BaseDesignSystem):
designSystem.storage[CANONICALIZE_VARIANT_KEY] ??= createCanonicalizeVariantCache()
designSystem.storage[CANONICALIZE_UTILITY_KEY] ??= createCanonicalizeUtilityCache()
designSystem.storage[CONVERTER_KEY] ??= createConverterCache(designSystem)
designSystem.storage[SPACING_KEY] ??= createSpacingCache(designSystem)
designSystem.storage[SPACING_KEY] ??= createSpacingCache(designSystem, options)
designSystem.storage[UTILITY_SIGNATURE_KEY] ??= createUtilitySignatureCache(designSystem)
designSystem.storage[STATIC_UTILITIES_KEY] ??= createStaticUtilitiesCache()
designSystem.storage[UTILITY_PROPERTIES_KEY] ??= createUtilityPropertiesCache(designSystem)
@ -144,7 +147,7 @@ export function createSignatureOptions(
if (options?.collapse) features |= SignatureFeatures.ExpandProperties
if (options?.logicalToPhysical) features |= SignatureFeatures.LogicalToPhysical
let designSystem = prepareDesignSystemStorage(baseDesignSystem)
let designSystem = prepareDesignSystemStorage(baseDesignSystem, options)
return designSystem.storage[SIGNATURE_OPTIONS_KEY].get(options?.rem ?? null).get(features)
}
@ -255,6 +258,56 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st
computeUtilitiesPropertiesLookup.get(candidate),
)
// Hard-coded optimization: if any candidate sets `line-height` and another
// candidate sets `font-size`, we pre-compute the `text-*` utilities with
// this line-height to try and collapse to those combined values.
if (candidatePropertiesValues.some((x) => x.has('line-height'))) {
let fontSizeNames = designSystem.theme.keysInNamespaces(['--text'])
if (fontSizeNames.length > 0) {
let interestingLineHeights = new Set<string | number>()
let seenLineHeights = new Set<string>()
for (let pairs of candidatePropertiesValues) {
for (let lineHeight of pairs.get('line-height')) {
if (seenLineHeights.has(lineHeight)) continue
seenLineHeights.add(lineHeight)
let bareValue = designSystem.storage[SPACING_KEY]?.get(lineHeight) ?? null
if (bareValue !== null) {
if (isValidSpacingMultiplier(bareValue)) {
interestingLineHeights.add(bareValue)
for (let name of fontSizeNames) {
computeUtilitiesPropertiesLookup.get(`text-${name}/${bareValue}`)
}
} else {
interestingLineHeights.add(lineHeight)
for (let name of fontSizeNames) {
computeUtilitiesPropertiesLookup.get(`text-${name}/[${lineHeight}]`)
}
}
}
}
}
let seenFontSizes = new Set<string>()
for (let pairs of candidatePropertiesValues) {
for (let fontSize of pairs.get('font-size')) {
if (seenFontSizes.has(fontSize)) continue
seenFontSizes.add(fontSize)
for (let lineHeight of interestingLineHeights) {
if (isValidSpacingMultiplier(lineHeight)) {
computeUtilitiesPropertiesLookup.get(`text-[${fontSize}]/${lineHeight}`)
} else {
computeUtilitiesPropertiesLookup.get(`text-[${fontSize}]/[${lineHeight}]`)
}
}
}
}
}
}
// 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.
@ -262,17 +315,20 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st
// E.g.: `margin-top` → `mt-1`, `my-1`, `m-1`
let otherUtilities = candidatePropertiesValues.map((propertyValues) => {
let result: Set<string> | null = null
for (let [property, values] of propertyValues) {
for (let value of values) {
let otherUtilities = staticUtilities.get(property).get(value)
if (result === null) result = new Set(otherUtilities)
else result = intersection(result, otherUtilities)
// The moment no other utilities match, we can stop searching because
// all intersections with an empty set will remain empty.
if (result!.size === 0) return result!
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)
}
}
if (result === null) result = otherUtilities
else result = intersection(result, otherUtilities)
// The moment no other utilities match, we can stop searching because
// all intersections with an empty set will remain empty.
if (result!.size === 0) return result!
}
return result!
})
@ -286,11 +342,10 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st
// E.g.: `mt-1` and `text-red-500` cannot be collapsed because there is no 3rd
// utility with overlapping property/value combinations.
let linked = new DefaultMap<number, Set<number>>((key) => new Set<number>([key]))
let otherUtilitiesArray = Array.from(otherUtilities)
for (let i = 0; i < otherUtilitiesArray.length; i++) {
let current = otherUtilitiesArray[i]
for (let j = i + 1; j < otherUtilitiesArray.length; j++) {
let other = otherUtilitiesArray[j]
for (let i = 0; i < otherUtilities.length; i++) {
let current = otherUtilities[i]
for (let j = i + 1; j < otherUtilities.length; j++) {
let other = otherUtilities[j]
for (let property of current) {
if (other.has(property)) {
@ -881,17 +936,25 @@ function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candida
const SPACING_KEY = Symbol()
function createSpacingCache(
designSystem: DesignSystem,
options?: CanonicalizeOptions,
): DesignSystem['storage'][typeof SPACING_KEY] {
let spacingMultiplier = designSystem.resolveThemeValue('--spacing')
if (spacingMultiplier === undefined) return null
spacingMultiplier = constantFoldDeclaration(spacingMultiplier, options?.rem ?? null)
let parsed = dimensions.get(spacingMultiplier)
if (!parsed) return null
let [value, unit] = parsed
return new DefaultMap<string, number | null>((input) => {
let parsed = dimensions.get(input)
// If we already know that the spacing multiplier is 0, all spacing
// multipliers will also be 0. No need to even try and parse/canonicalize
// the input value.
if (value === 0) return null
let parsed = dimensions.get(constantFoldDeclaration(input, options?.rem ?? null))
if (!parsed) return null
let [myValue, myUnit] = parsed
@ -998,30 +1061,12 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO
candidate.kind === 'functional' &&
candidate.value?.kind === 'arbitrary'
) {
let spacingMultiplier = designSystem.resolveThemeValue('--spacing')
if (spacingMultiplier !== undefined) {
// Canonicalizing the spacing multiplier allows us to handle both
// `--spacing: 0.25rem` and `--spacing: 4px` values correctly.
let canonicalizedSpacingMultiplier = constantFoldDeclaration(
spacingMultiplier,
options.signatureOptions.rem,
)
let canonicalizedValue = constantFoldDeclaration(value, options.signatureOptions.rem)
let valueDimension = dimensions.get(canonicalizedValue)
let spacingMultiplierDimension = dimensions.get(canonicalizedSpacingMultiplier)
if (
valueDimension &&
spacingMultiplierDimension &&
valueDimension[1] === spacingMultiplierDimension[1] && // Ensure the units match
spacingMultiplierDimension[0] !== 0
) {
let bareValue = `${valueDimension[0] / spacingMultiplierDimension[0]}`
if (isValidSpacingMultiplier(bareValue)) {
yield Object.assign({}, candidate, {
value: { kind: 'named', value: bareValue, fraction: null },
})
}
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 },
})
}
}
}
@ -2093,6 +2138,26 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
},
exit(node) {
if (node.kind === 'rule' || node.kind === 'at-rule') {
// Remove declarations that are re-defined again later.
//
// This could maybe result in unwanted behavior (because similar
// properties typically exist for backwards compatibility), but for
// signature purposes we can assume that the last declaration wins.
if (node.nodes.length > 1) {
let seen = new Set<string>()
for (let i = node.nodes.length - 1; i >= 0; i--) {
let child = node.nodes[i]
if (child.kind !== 'declaration') continue
if (child.value === undefined) continue
if (seen.has(child.property)) {
node.nodes.splice(i, 1)
}
seen.add(child.property)
}
}
// Sort declarations alphabetically by property name
node.nodes.sort((a, b) => {
if (a.kind !== 'declaration') return 0
if (b.kind !== 'declaration') return 0

View File

@ -0,0 +1,45 @@
type CartesianInput = readonly unknown[][]
type CartesianResult<T extends CartesianInput> = T extends [
infer Head extends unknown[],
...infer Tail extends CartesianInput,
]
? [Head[number], ...CartesianResult<Tail>]
: []
export function* cartesian<T extends CartesianInput>(...sets: T): Generator<CartesianResult<T>> {
let n = sets.length
if (n === 0) return
// If any input set is empty, the Cartesian product is empty.
if (sets.some((set) => set.length === 0)) {
return
}
// Index lookup
let idx = Array(n).fill(0)
while (true) {
// Compute current combination
let result = [] as CartesianResult<T>
for (let i = 0; i < n; i++) {
result[i] = sets[i][idx[i]]
}
yield result
// Update index vector
let k = n - 1
while (k >= 0) {
idx[k]++
if (idx[k] < sets[k].length) {
break
}
idx[k] = 0
k--
}
if (k < 0) {
return
}
}
}