Skip to content

Commit 229121d

Browse files
authored
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" />
1 parent 243615e commit 229121d

File tree

4 files changed

+208
-64
lines changed

4 files changed

+208
-64
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- Improve backwards compatibility for `content` theme key from JS configs ([#19381](https://github.com/tailwindlabs/tailwindcss/pull/19381))
2121
- Upgrade: Handle `future` and `experimental` config keys ([#19344](https://github.com/tailwindlabs/tailwindcss/pull/19344))
2222
- Try to canonicalize any arbitrary utility to a bare value ([#19379](https://github.com/tailwindlabs/tailwindcss/pull/19379))
23+
- Canonicalization: combine `text-*` and `leading-*` classes ([#19396](https://github.com/tailwindlabs/tailwindcss/pull/19396))
2324

2425
### Added
2526

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

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs'
22
import path from 'node:path'
33
import { describe, expect, test } from 'vitest'
44
import { __unstable__loadDesignSystem } from '.'
5+
import { cartesian } from './cartesian'
56
import type { CanonicalizeOptions } from './intellisense'
67
import { DefaultMap } from './utils/default-map'
78

@@ -54,7 +55,7 @@ const DEFAULT_CANONICALIZATION_OPTIONS: CanonicalizeOptions = {
5455
}
5556

5657
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
57-
let testName = '`%s``%s` (%#)'
58+
let testName = '%s%s (%#)'
5859
if (strategy === 'with-variant') {
5960
testName = testName.replaceAll('%s', 'focus:%s')
6061
} else if (strategy === 'important') {
@@ -1025,37 +1026,69 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
10251026
})
10261027
})
10271028

1028-
test.each([
1029-
// 4 to 1
1030-
['mt-1 mr-1 mb-1 ml-1', 'm-1'],
1029+
describe('combine to shorthand utilities', () => {
1030+
test.each([
1031+
// 4 to 1
1032+
['mt-1 mr-1 mb-1 ml-1', 'm-1'],
10311033

1032-
// 2 to 1
1033-
['mt-1 mb-1', 'my-1'],
1034+
// 2 to 1
1035+
['mt-1 mb-1', 'my-1'],
10341036

1035-
// Different order as above
1036-
['mb-1 mt-1', 'my-1'],
1037+
// Different order as above
1038+
['mb-1 mt-1', 'my-1'],
10371039

1038-
// To completely different utility
1039-
['w-4 h-4', 'size-4'],
1040+
// To completely different utility
1041+
['w-4 h-4', 'size-4'],
10401042

1041-
// Do not touch if not operating on the same variants
1042-
['hover:w-4 h-4', 'hover:w-4 h-4'],
1043+
// Do not touch if not operating on the same variants
1044+
['hover:w-4 h-4', 'hover:w-4 h-4'],
10431045

1044-
// Arbitrary properties to combined class
1045-
['[width:_16px_] [height:16px]', 'size-4'],
1046+
// Arbitrary properties to combined class
1047+
['[width:_16px_] [height:16px]', 'size-4'],
10461048

1047-
// Arbitrary properties to combined class with modifier
1048-
['[font-size:14px] [line-height:1.625]', 'text-sm/relaxed'],
1049-
])(
1050-
'should canonicalize multiple classes `%s` into a shorthand `%s`',
1051-
{ timeout },
1052-
async (candidates, expected) => {
1049+
// Arbitrary properties to combined class with modifier
1050+
['[font-size:14px] [line-height:1.625]', 'text-sm/relaxed'],
1051+
])(testName, { timeout }, async (candidates, expected) => {
10531052
let input = css`
10541053
@import 'tailwindcss';
10551054
`
10561055
await expectCombinedCanonicalization(input, candidates, expected)
1057-
},
1058-
)
1056+
})
1057+
})
1058+
1059+
describe('font-size/line-height to text-{x}/{y}', () => {
1060+
test.each([
1061+
...Array.from(
1062+
cartesian(
1063+
['[font-size:14px]', 'text-[14px]', 'text-[14px]/6', 'text-sm', 'text-sm/6'],
1064+
['[line-height:28px]', 'leading-[28px]', 'leading-7'],
1065+
),
1066+
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-sm/7']),
1067+
...Array.from(
1068+
cartesian(
1069+
['[font-size:15px]', 'text-[15px]', 'text-[15px]/6'],
1070+
['[line-height:28px]', 'leading-[28px]', 'leading-7'],
1071+
),
1072+
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-[15px]/7']),
1073+
...Array.from(
1074+
cartesian(
1075+
['[font-size:14px]', 'text-[14px]', 'text-[14px]/6', 'text-sm', 'text-sm/6'],
1076+
['[line-height:28.5px]', 'leading-[28.5px]'],
1077+
),
1078+
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-sm/[28.5px]']),
1079+
...Array.from(
1080+
cartesian(
1081+
['[font-size:15px]', 'text-[15px]', 'text-[15px]/6'],
1082+
['[line-height:28.5px]', 'leading-[28.5px]'],
1083+
),
1084+
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-[15px]/[28.5px]']),
1085+
])(testName, { timeout }, async (candidates, expected) => {
1086+
let input = css`
1087+
@import 'tailwindcss';
1088+
`
1089+
await expectCombinedCanonicalization(input, candidates.trim(), expected)
1090+
})
1091+
})
10591092
})
10601093

10611094
describe('theme to var', () => {

packages/tailwindcss/src/canonicalize-candidates.ts

Lines changed: 107 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ interface DesignSystem extends BaseDesignSystem {
107107
}
108108
}
109109

110-
export function prepareDesignSystemStorage(baseDesignSystem: BaseDesignSystem): DesignSystem {
110+
export function prepareDesignSystemStorage(
111+
baseDesignSystem: BaseDesignSystem,
112+
options?: CanonicalizeOptions,
113+
): DesignSystem {
111114
let designSystem = baseDesignSystem as DesignSystem
112115

113116
designSystem.storage[SIGNATURE_OPTIONS_KEY] ??= createSignatureOptionsCache()
@@ -116,7 +119,7 @@ export function prepareDesignSystemStorage(baseDesignSystem: BaseDesignSystem):
116119
designSystem.storage[CANONICALIZE_VARIANT_KEY] ??= createCanonicalizeVariantCache()
117120
designSystem.storage[CANONICALIZE_UTILITY_KEY] ??= createCanonicalizeUtilityCache()
118121
designSystem.storage[CONVERTER_KEY] ??= createConverterCache(designSystem)
119-
designSystem.storage[SPACING_KEY] ??= createSpacingCache(designSystem)
122+
designSystem.storage[SPACING_KEY] ??= createSpacingCache(designSystem, options)
120123
designSystem.storage[UTILITY_SIGNATURE_KEY] ??= createUtilitySignatureCache(designSystem)
121124
designSystem.storage[STATIC_UTILITIES_KEY] ??= createStaticUtilitiesCache()
122125
designSystem.storage[UTILITY_PROPERTIES_KEY] ??= createUtilityPropertiesCache(designSystem)
@@ -144,7 +147,7 @@ export function createSignatureOptions(
144147
if (options?.collapse) features |= SignatureFeatures.ExpandProperties
145148
if (options?.logicalToPhysical) features |= SignatureFeatures.LogicalToPhysical
146149

147-
let designSystem = prepareDesignSystemStorage(baseDesignSystem)
150+
let designSystem = prepareDesignSystemStorage(baseDesignSystem, options)
148151

149152
return designSystem.storage[SIGNATURE_OPTIONS_KEY].get(options?.rem ?? null).get(features)
150153
}
@@ -255,24 +258,77 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st
255258
computeUtilitiesPropertiesLookup.get(candidate),
256259
)
257260

261+
// Hard-coded optimization: if any candidate sets `line-height` and another
262+
// candidate sets `font-size`, we pre-compute the `text-*` utilities with
263+
// this line-height to try and collapse to those combined values.
264+
if (candidatePropertiesValues.some((x) => x.has('line-height'))) {
265+
let fontSizeNames = designSystem.theme.keysInNamespaces(['--text'])
266+
if (fontSizeNames.length > 0) {
267+
let interestingLineHeights = new Set<string | number>()
268+
let seenLineHeights = new Set<string>()
269+
for (let pairs of candidatePropertiesValues) {
270+
for (let lineHeight of pairs.get('line-height')) {
271+
if (seenLineHeights.has(lineHeight)) continue
272+
seenLineHeights.add(lineHeight)
273+
274+
let bareValue = designSystem.storage[SPACING_KEY]?.get(lineHeight) ?? null
275+
if (bareValue !== null) {
276+
if (isValidSpacingMultiplier(bareValue)) {
277+
interestingLineHeights.add(bareValue)
278+
279+
for (let name of fontSizeNames) {
280+
computeUtilitiesPropertiesLookup.get(`text-${name}/${bareValue}`)
281+
}
282+
} else {
283+
interestingLineHeights.add(lineHeight)
284+
285+
for (let name of fontSizeNames) {
286+
computeUtilitiesPropertiesLookup.get(`text-${name}/[${lineHeight}]`)
287+
}
288+
}
289+
}
290+
}
291+
}
292+
293+
let seenFontSizes = new Set<string>()
294+
for (let pairs of candidatePropertiesValues) {
295+
for (let fontSize of pairs.get('font-size')) {
296+
if (seenFontSizes.has(fontSize)) continue
297+
seenFontSizes.add(fontSize)
298+
299+
for (let lineHeight of interestingLineHeights) {
300+
if (isValidSpacingMultiplier(lineHeight)) {
301+
computeUtilitiesPropertiesLookup.get(`text-[${fontSize}]/${lineHeight}`)
302+
} else {
303+
computeUtilitiesPropertiesLookup.get(`text-[${fontSize}]/[${lineHeight}]`)
304+
}
305+
}
306+
}
307+
}
308+
}
309+
}
310+
258311
// For each property, lookup other utilities that also set this property and
259312
// this exact value. If multiple properties are used, use the intersection of
260313
// each property.
261314
//
262315
// E.g.: `margin-top` → `mt-1`, `my-1`, `m-1`
263316
let otherUtilities = candidatePropertiesValues.map((propertyValues) => {
264317
let result: Set<string> | null = null
265-
for (let [property, values] of propertyValues) {
266-
for (let value of values) {
267-
let otherUtilities = staticUtilities.get(property).get(value)
318+
for (let property of propertyValues.keys()) {
319+
let otherUtilities = new Set<string>()
320+
for (let group of staticUtilities.get(property).values()) {
321+
for (let candidate of group) {
322+
otherUtilities.add(candidate)
323+
}
324+
}
268325

269-
if (result === null) result = new Set(otherUtilities)
270-
else result = intersection(result, otherUtilities)
326+
if (result === null) result = otherUtilities
327+
else result = intersection(result, otherUtilities)
271328

272-
// The moment no other utilities match, we can stop searching because
273-
// all intersections with an empty set will remain empty.
274-
if (result!.size === 0) return result!
275-
}
329+
// The moment no other utilities match, we can stop searching because
330+
// all intersections with an empty set will remain empty.
331+
if (result!.size === 0) return result!
276332
}
277333
return result!
278334
})
@@ -286,11 +342,10 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st
286342
// E.g.: `mt-1` and `text-red-500` cannot be collapsed because there is no 3rd
287343
// utility with overlapping property/value combinations.
288344
let linked = new DefaultMap<number, Set<number>>((key) => new Set<number>([key]))
289-
let otherUtilitiesArray = Array.from(otherUtilities)
290-
for (let i = 0; i < otherUtilitiesArray.length; i++) {
291-
let current = otherUtilitiesArray[i]
292-
for (let j = i + 1; j < otherUtilitiesArray.length; j++) {
293-
let other = otherUtilitiesArray[j]
345+
for (let i = 0; i < otherUtilities.length; i++) {
346+
let current = otherUtilities[i]
347+
for (let j = i + 1; j < otherUtilities.length; j++) {
348+
let other = otherUtilities[j]
294349

295350
for (let property of current) {
296351
if (other.has(property)) {
@@ -881,17 +936,25 @@ function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candida
881936
const SPACING_KEY = Symbol()
882937
function createSpacingCache(
883938
designSystem: DesignSystem,
939+
options?: CanonicalizeOptions,
884940
): DesignSystem['storage'][typeof SPACING_KEY] {
885941
let spacingMultiplier = designSystem.resolveThemeValue('--spacing')
886942
if (spacingMultiplier === undefined) return null
887943

944+
spacingMultiplier = constantFoldDeclaration(spacingMultiplier, options?.rem ?? null)
945+
888946
let parsed = dimensions.get(spacingMultiplier)
889947
if (!parsed) return null
890948

891949
let [value, unit] = parsed
892950

893951
return new DefaultMap<string, number | null>((input) => {
894-
let parsed = dimensions.get(input)
952+
// If we already know that the spacing multiplier is 0, all spacing
953+
// multipliers will also be 0. No need to even try and parse/canonicalize
954+
// the input value.
955+
if (value === 0) return null
956+
957+
let parsed = dimensions.get(constantFoldDeclaration(input, options?.rem ?? null))
895958
if (!parsed) return null
896959

897960
let [myValue, myUnit] = parsed
@@ -998,30 +1061,12 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO
9981061
candidate.kind === 'functional' &&
9991062
candidate.value?.kind === 'arbitrary'
10001063
) {
1001-
let spacingMultiplier = designSystem.resolveThemeValue('--spacing')
1002-
if (spacingMultiplier !== undefined) {
1003-
// Canonicalizing the spacing multiplier allows us to handle both
1004-
// `--spacing: 0.25rem` and `--spacing: 4px` values correctly.
1005-
let canonicalizedSpacingMultiplier = constantFoldDeclaration(
1006-
spacingMultiplier,
1007-
options.signatureOptions.rem,
1008-
)
1009-
1010-
let canonicalizedValue = constantFoldDeclaration(value, options.signatureOptions.rem)
1011-
let valueDimension = dimensions.get(canonicalizedValue)
1012-
let spacingMultiplierDimension = dimensions.get(canonicalizedSpacingMultiplier)
1013-
if (
1014-
valueDimension &&
1015-
spacingMultiplierDimension &&
1016-
valueDimension[1] === spacingMultiplierDimension[1] && // Ensure the units match
1017-
spacingMultiplierDimension[0] !== 0
1018-
) {
1019-
let bareValue = `${valueDimension[0] / spacingMultiplierDimension[0]}`
1020-
if (isValidSpacingMultiplier(bareValue)) {
1021-
yield Object.assign({}, candidate, {
1022-
value: { kind: 'named', value: bareValue, fraction: null },
1023-
})
1024-
}
1064+
let bareValue = designSystem.storage[SPACING_KEY]?.get(value) ?? null
1065+
if (bareValue !== null) {
1066+
if (isValidSpacingMultiplier(bareValue)) {
1067+
yield Object.assign({}, candidate, {
1068+
value: { kind: 'named', value: bareValue, fraction: null },
1069+
})
10251070
}
10261071
}
10271072
}
@@ -2093,6 +2138,26 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
20932138
},
20942139
exit(node) {
20952140
if (node.kind === 'rule' || node.kind === 'at-rule') {
2141+
// Remove declarations that are re-defined again later.
2142+
//
2143+
// This could maybe result in unwanted behavior (because similar
2144+
// properties typically exist for backwards compatibility), but for
2145+
// signature purposes we can assume that the last declaration wins.
2146+
if (node.nodes.length > 1) {
2147+
let seen = new Set<string>()
2148+
for (let i = node.nodes.length - 1; i >= 0; i--) {
2149+
let child = node.nodes[i]
2150+
if (child.kind !== 'declaration') continue
2151+
if (child.value === undefined) continue
2152+
2153+
if (seen.has(child.property)) {
2154+
node.nodes.splice(i, 1)
2155+
}
2156+
seen.add(child.property)
2157+
}
2158+
}
2159+
2160+
// Sort declarations alphabetically by property name
20962161
node.nodes.sort((a, b) => {
20972162
if (a.kind !== 'declaration') return 0
20982163
if (b.kind !== 'declaration') return 0
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
type CartesianInput = readonly unknown[][]
2+
3+
type CartesianResult<T extends CartesianInput> = T extends [
4+
infer Head extends unknown[],
5+
...infer Tail extends CartesianInput,
6+
]
7+
? [Head[number], ...CartesianResult<Tail>]
8+
: []
9+
10+
export function* cartesian<T extends CartesianInput>(...sets: T): Generator<CartesianResult<T>> {
11+
let n = sets.length
12+
if (n === 0) return
13+
14+
// If any input set is empty, the Cartesian product is empty.
15+
if (sets.some((set) => set.length === 0)) {
16+
return
17+
}
18+
19+
// Index lookup
20+
let idx = Array(n).fill(0)
21+
22+
while (true) {
23+
// Compute current combination
24+
let result = [] as CartesianResult<T>
25+
for (let i = 0; i < n; i++) {
26+
result[i] = sets[i][idx[i]]
27+
}
28+
yield result
29+
30+
// Update index vector
31+
let k = n - 1
32+
while (k >= 0) {
33+
idx[k]++
34+
if (idx[k] < sets[k].length) {
35+
break
36+
}
37+
idx[k] = 0
38+
k--
39+
}
40+
41+
if (k < 0) {
42+
return
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)