From 229121dd1476d38d5916b2ada01a37df1bf33e33 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 1 Dec 2025 16:01:24 +0100 Subject: [PATCH] Canonicalization: combine `text-*` and `leading-*` classes (#19396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR improves the canonicalization when using `text-*` and `leading-*` utilities together. When using classes such as: ```html
``` Then the canonical way of writing this is: ```html
``` 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
``` becomes: ```html
``` 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. image --- CHANGELOG.md | 1 + .../src/canonicalize-candidates.test.ts | 77 ++++++--- .../src/canonicalize-candidates.ts | 151 +++++++++++++----- packages/tailwindcss/src/cartesian.ts | 45 ++++++ 4 files changed, 209 insertions(+), 65 deletions(-) create mode 100644 packages/tailwindcss/src/cartesian.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f52b544..8399fa1c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index fd5826c4d..b8ca59d67 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -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', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 437ec60b4..612129985 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -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() + let seenLineHeights = new Set() + 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() + 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 | 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() + 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>((key) => new Set([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((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() + 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 diff --git a/packages/tailwindcss/src/cartesian.ts b/packages/tailwindcss/src/cartesian.ts new file mode 100644 index 000000000..f0f7bbba4 --- /dev/null +++ b/packages/tailwindcss/src/cartesian.ts @@ -0,0 +1,45 @@ +type CartesianInput = readonly unknown[][] + +type CartesianResult = T extends [ + infer Head extends unknown[], + ...infer Tail extends CartesianInput, +] + ? [Head[number], ...CartesianResult] + : [] + +export function* cartesian(...sets: T): Generator> { + 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 + 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 + } + } +}