Canonicalization constant folding and handling zeros (#19095)

The main goal of this PR was to support canonicalization of zero like
values. We essentially want to canonicalize `-mt-0` as `mt-0`, but also
`mt-[0px]`, `mt-[0rem]`, and other length-like units to just `mt-0`.

To do this, we had to handle 2 things:

1. We introduced some more constant folding, including making `0px` and
`0rem` fold to `0`. We only do this for length units. We also normalize
`-0`, `+0`, `-0.0` and so on to `0`.
2. While pre-computing utilities in our lookup table, we make sure that
we prefer `mt-0` over `-mt-0` if both result in the same signature.

Moved some of the constant folding logic into its own function and added
a bunch of separate tests for it.

## Test plan

Added more unit tests where we normalize different zero-like values to
`0`.

Running the canonicalization logic:
```js
designSystem.canonicalizeCandidates([
  '-m-0',
  '-m-[-0px]',
  '-m-[-0rem]',
  '-m-[0px]',
  '-m-[0rem]',
  'm-0',
  'm-[-0px]',
  'm-[-0rem]',
  'm-[0px]',
  'm-[0rem]',
  'm-[calc(var(--spacing)*0)]',
  'm-[--spacing(0)]',
  'm-[--spacing(0.0)]',
  'm-[+0]',
  'm-[-0]',
  '-m-[-0]',
  '-m-[+0]',
]) // → ['m-0']
```

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Robin Malfait 2025-10-09 20:09:29 +02:00 • committed by GitHub
parent 3aadba7cc1
commit 01d1e98259
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 362 additions and 55 deletions

View file

@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Suppress Lightning CSS warnings about `:deep`, `:slotted`, and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094))
- Fix resolving theme keys when starting with the name of another theme key in JS configs and plugins ([#19097](https://github.com/tailwindlabs/tailwindcss/pull/19097))
- Allow named groups in combination with `not-*`, `has-*`, and `in-*` ([#19100](https://github.com/tailwindlabs/tailwindcss/pull/19100))
- Upgrade: Canonicalize utilities containing `0` values ([#19095](https://github.com/tailwindlabs/tailwindcss/pull/19095))
## [4.1.14] - 2025-10-01

View file

@ -42,7 +42,7 @@ export function createConverter(designSystem: DesignSystem, { prettyPrint = fals
}
// If we see a `/`, we have a modifier
else if (child.kind === 'separator' && child.value.trim() === '/') {
else if (child.kind === 'word' && child.value === '/') {
themeModifierCount += 1
return ValueParser.ValueWalkAction.Stop
}

View file

@ -61,9 +61,9 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
// handle the `0px * -1` case which translates to `0px` not `-0px`.
//
// This translation is actually fine, because now, we will prefer the
// non-negative version first so we can replace `-mt-[0px]` with `mt-[0px]`.
['mt-[0px]', 'mt-[0px]'],
['-mt-[0px]', 'mt-[0px]'],
// non-negative version first so we can replace `-mt-[0px]` with `mt-0`.
['mt-[0px]', 'mt-0'],
['-mt-[0px]', 'mt-0'],
// Shorthand CSS Variables should be converted to the new syntax, even if
// the fallback contains functions. The fallback should also be migrated to

View file

@ -1049,13 +1049,6 @@ const printArbitraryValueCache = new DefaultMap<string, string>((input) => {
drop.add(next)
}
// The value parser handles `/` as a separator in some scenarios. E.g.:
// `theme(colors.red/50%)`. Because of this, we have to handle this case
// separately.
else if (node.kind === 'separator' && node.value.trim() === '/') {
node.value = '/'
}
// Leading and trailing whitespace
else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') {
if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) {

View file

@ -675,6 +675,30 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
['grid-cols-[subgrid]', 'grid-cols-subgrid'],
['grid-rows-[subgrid]', 'grid-rows-subgrid'],
// Handle zeroes
['m-[0]', 'm-0'],
['m-[0px]', 'm-0'],
['m-[0rem]', 'm-0'],
['-m-[0]', 'm-0'],
['-m-[0px]', 'm-0'],
['-m-[0rem]', 'm-0'],
['m-[-0]', 'm-0'],
['m-[-0px]', 'm-0'],
['m-[-0rem]', 'm-0'],
['-m-[-0]', 'm-0'],
['-m-[-0px]', 'm-0'],
['-m-[-0rem]', 'm-0'],
['[margin:0]', 'm-0'],
['[margin:-0]', 'm-0'],
['[margin:0px]', 'm-0'],
// Not a length-unit, can't safely constant fold
['[margin:0%]', 'm-[0%]'],
// Only 50-200% (inclusive) are valid:
// https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch#percentage
['font-stretch-[50%]', 'font-stretch-50%'],

View file

@ -264,7 +264,7 @@ const converterCache = new DefaultMap((ds: DesignSystem) => {
}
// If we see a `/`, we have a modifier
else if (child.kind === 'separator' && child.value.trim() === '/') {
else if (child.kind === 'word' && child.value === '/') {
themeModifierCount += 1
return ValueParser.ValueWalkAction.Stop
}

View file

@ -0,0 +1,94 @@
import { expect, it } from 'vitest'
import { constantFoldDeclaration } from './constant-fold-declaration'
it.each([
// Simple expression
['calc(1 + 1)', '2'],
['calc(3 - 2)', '1'],
['calc(2 * 3)', '6'],
['calc(8 / 2)', '4'],
// Nested
['calc(1 + calc(1 + 1))', '3'],
['calc(3 - calc(1 + 2))', '0'],
['calc(2 * calc(1 + 3))', '8'],
['calc(8 / calc(2 + 2))', '2'],
['calc(1 + (1 + 1))', '3'],
['calc(3 - (1 + 2))', '0'],
['calc(2 * (1 + 3))', '8'],
['calc(8 / (2 + 2))', '2'],
// With units
['calc(1rem * 2)', '2rem'],
['calc(2rem - 0.5rem)', '1.5rem'],
['calc(3rem * 6)', '18rem'],
['calc(5rem / 2)', '2.5rem'],
// Nested partial evaluation
['calc(calc(1 + 2) + 2rem)', 'calc(3 + 2rem)'],
// Evaluation only handles two operands right now, this can change in the future
['calc(1 + 2 + 3)', 'calc(1 + 2 + 3)'],
])('should constant fold `%s` into `%s`', (input, expected) => {
expect(constantFoldDeclaration(input)).toBe(expected)
})
it.each([
['calc(1rem * 2%)'],
['calc(1rem * 2px)'],
['calc(2rem - 6)'],
['calc(3rem * 3dvw)'],
['calc(3rem * 2dvh)'],
['calc(5rem / 17px)'],
])('should not constant fold different units `%s`', (input) => {
expect(constantFoldDeclaration(input)).toBe(input)
})
it.each([
['calc(0 * 100vw)'],
['calc(0 * calc(1 * 2))'],
['calc(0 * var(--foo))'],
['calc(0 * calc(var(--spacing) * 32))'],
['calc(100vw * 0)'],
['calc(calc(1 * 2) * 0)'],
['calc(var(--foo) * 0)'],
['calc(calc(var(--spacing, 0.25rem) * 32) * 0)'],
['calc(var(--spacing, 0.25rem) * -0)'],
['calc(-0px * -1)'],
// Zeroes
['0px'],
['0rem'],
['0em'],
['0dvh'],
['-0'],
['+0'],
['-0.0rem'],
['+0.00rem'],
])('should constant fold `%s` to `0`', (input) => {
expect(constantFoldDeclaration(input)).toBe('0')
})
it.each([
['0deg', '0deg'],
['0rad', '0rad'],
['0%', '0%'],
['0turn', '0turn'],
['0fr', '0fr'],
['0ms', '0ms'],
['0s', '0s'],
['-0.0deg', '0deg'],
['-0.0rad', '0rad'],
['-0.0%', '0%'],
['-0.0turn', '0turn'],
['-0.0fr', '0fr'],
['-0.0ms', '0ms'],
['-0.0s', '0s'],
])('should not fold non-foldable units to `0`. Constant fold `%s` into `%s`', (input, expected) => {
expect(constantFoldDeclaration(input)).toBe(expected)
})
it('should not constant fold when dividing by `0`', () => {
expect(constantFoldDeclaration('calc(123rem / 0)')).toBe('calc(123rem / 0)')
})

View file

@ -0,0 +1,126 @@
import { dimensions } from './utils/dimensions'
import { isLength } from './utils/infer-data-type'
import * as ValueParser from './value-parser'
// Assumption: We already assume that we receive somewhat valid `calc()`
// expressions. So we will see `calc(1 + 1)` and not `calc(1+1)`
export function constantFoldDeclaration(input: string): string {
let folded = false
let valueAst = ValueParser.parse(input)
ValueParser.walkDepth(valueAst, (valueNode, { replaceWith }) => {
// Convert `-0`, `+0`, `0.0`, … to `0`
// Convert `-0px`, `+0em`, `0.0rem`, … to `0`
if (
valueNode.kind === 'word' &&
valueNode.value !== '0' && // Already `0`, nothing to do
((valueNode.value[0] === '-' && valueNode.value[1] === '0') || // `-0…`
(valueNode.value[0] === '+' && valueNode.value[1] === '0') || // `+0…`
valueNode.value[0] === '0') // `0…`
) {
let dimension = dimensions.get(valueNode.value)
if (dimension === null) return // This shouldn't happen
if (dimension[0] !== 0) return // Not a zero value, nothing to do
// Replace length units with just `0`
if (dimension[1] === null || isLength(valueNode.value)) {
folded = true
replaceWith(ValueParser.word('0'))
return
}
// Replace other units with `0<unit>`, e.g. `0%`, `0fr`, `0s`, …
else if (valueNode.value !== `0${dimension[1]}`) {
folded = true
replaceWith(ValueParser.word(`0${dimension[1]}`))
return
}
}
// Constant fold `calc()` expressions with two operands and one operator
else if (
valueNode.kind === 'function' &&
(valueNode.value === 'calc' || valueNode.value === '')
) {
// [
// { kind: 'word', value: '0.25rem' }, 0
// { kind: 'separator', value: ' ' }, 1
// { kind: 'word', value: '*' }, 2
// { kind: 'separator', value: ' ' }, 3
// { kind: 'word', value: '256' } 4
// ]
if (valueNode.nodes.length !== 5) return
let lhs = dimensions.get(valueNode.nodes[0].value)
let operator = valueNode.nodes[2].value
let rhs = dimensions.get(valueNode.nodes[4].value)
// Nullify entire expression when multiplying by `0`, e.g.: `calc(0 * 100vw)` -> `0`
//
// TODO: Ensure it's safe to do so based on the data types?
if (
operator === '*' &&
((lhs?.[0] === 0 && lhs?.[1] === null) || // 0 * something
(rhs?.[0] === 0 && rhs?.[1] === null)) // something * 0
) {
folded = true
replaceWith(ValueParser.word('0'))
return
}
// We're not dealing with dimensions, so we can't fold this
if (lhs === null || rhs === null) {
return
}
switch (operator) {
case '*': {
if (
lhs[1] === rhs[1] || // Same Units, e.g.: `1rem * 2rem`, `8 * 6`
(lhs[1] === null && rhs[1] !== null) || // Unitless * Unit, e.g.: `2 * 1rem`
(lhs[1] !== null && rhs[1] === null) // Unit * Unitless, e.g.: `1rem * 2`
) {
folded = true
replaceWith(ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? ''}`))
}
break
}
case '+': {
if (
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `1rem + 2rem`, `8 + 6`
) {
folded = true
replaceWith(ValueParser.word(`${lhs[0] + rhs[0]}${lhs[1] ?? ''}`))
}
break
}
case '-': {
if (
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `2rem - 1rem`, `8 - 6`
) {
folded = true
replaceWith(ValueParser.word(`${lhs[0] - rhs[0]}${lhs[1] ?? ''}`))
}
break
}
case '/': {
if (
rhs[0] !== 0 && // Don't divide by zero
((lhs[1] === null && rhs[1] === null) || // Unitless / Unitless, e.g.: `8 / 2`
(lhs[1] !== null && rhs[1] === null)) // Unit / Unitless, e.g.: `1rem / 2`
) {
folded = true
replaceWith(ValueParser.word(`${lhs[0] / rhs[0]}${lhs[1] ?? ''}`))
}
break
}
}
}
})
return folded ? ValueParser.toCss(valueAst) : input
}

View file

@ -1,11 +1,11 @@
import { substituteAtApply } from './apply'
import { atRule, styleRule, toCss, walk, type AstNode } from './ast'
import { printArbitraryValue } from './candidate'
import { constantFoldDeclaration } from './constant-fold-declaration'
import { CompileAstFlags, type DesignSystem } from './design-system'
import * as SelectorParser from './selector-parser'
import { ThemeOptions } from './theme'
import { DefaultMap } from './utils/default-map'
import { dimensions } from './utils/dimensions'
import { isValidSpacingMultiplier } from './utils/infer-data-type'
import * as ValueParser from './value-parser'
@ -208,39 +208,7 @@ export const computeUtilitySignature = new DefaultMap<
// → `calc(0.25rem * 4)` ← this is the case we will see
// after inlining the variable
// → `1rem`
if (node.value.includes('calc')) {
let folded = false
let valueAst = ValueParser.parse(node.value)
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
if (valueNode.kind !== 'function') return
if (valueNode.value !== 'calc') return
// [
// { kind: 'word', value: '0.25rem' }, 0
// { kind: 'separator', value: ' ' }, 1
// { kind: 'word', value: '*' }, 2
// { kind: 'separator', value: ' ' }, 3
// { kind: 'word', value: '256' } 4
// ]
if (valueNode.nodes.length !== 5) return
if (valueNode.nodes[2].kind !== 'word' && valueNode.nodes[2].value !== '*') return
let parsed = dimensions.get(valueNode.nodes[0].value)
if (parsed === null) return
let [value, unit] = parsed
let multiplier = Number(valueNode.nodes[4].value)
if (Number.isNaN(multiplier)) return
folded = true
replaceWith(ValueParser.parse(`${value * multiplier}${unit}`))
})
if (folded) {
node.value = ValueParser.toCss(valueAst)
}
}
node.value = constantFoldDeclaration(node.value)
// We will normalize the `node.value`, this is the same kind of logic
// we use when printing arbitrary values. It will remove unnecessary
@ -277,6 +245,17 @@ export const preComputedUtilities = new DefaultMap<DesignSystem, DefaultMap<stri
for (let [className, meta] of ds.getClassList()) {
let signature = signatures.get(className)
if (typeof signature !== 'string') continue
// Skip the utility if `-{utility}-0` has the same signature as
// `{utility}-0` (its positive version). This will prefer positive values
// over negative values.
if (className[0] === '-' && className.endsWith('-0')) {
let positiveSignature = signatures.get(className.slice(1))
if (typeof positiveSignature === 'string' && signature === positiveSignature) {
continue
}
}
lookup.get(signature).push(className)
for (let modifier of meta.modifiers) {

View file

@ -1,6 +1,6 @@
import { DefaultMap } from '../../../tailwindcss/src/utils/default-map'
const DIMENSION_REGEX = /^(?<value>-?(?:\d*\.)?\d+)(?<unit>[a-z]+|%)$/i
const DIMENSION_REGEX = /^(?<value>[-+]?(?:\d*\.)?\d+)(?<unit>[a-z]+|%)?$/i
// Parse a dimension such as `64rem` into `[64, 'rem']`.
export const dimensions = new DefaultMap((input) => {
@ -10,11 +10,11 @@ export const dimensions = new DefaultMap((input) => {
let value = match.groups?.value
if (value === undefined) return null
let unit = match.groups?.unit
if (unit === undefined) return null
let valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber)) return null
let unit = match.groups?.unit
if (unit === undefined) return [valueAsNumber, null] as const
return [valueAsNumber, unit] as const
})

View file

@ -233,7 +233,7 @@ const LENGTH_UNITS = [
const IS_LENGTH = new RegExp(`^${HAS_NUMBER.source}(${LENGTH_UNITS.join('|')})$`)
function isLength(value: string): boolean {
export function isLength(value: string): boolean {
return IS_LENGTH.test(value) || hasMathFn(value)
}

View file

@ -89,7 +89,7 @@ describe('parse', () => {
value: 'theme',
nodes: [
{ kind: 'word', value: 'colors.red.500' },
{ kind: 'separator', value: '/' },
{ kind: 'word', value: '/' },
{ kind: 'function', value: 'var', nodes: [{ kind: 'word', value: '--opacity' }] },
],
},

View file

@ -17,7 +17,7 @@ export type ValueSeparatorNode = {
export type ValueAstNode = ValueWordNode | ValueFunctionNode | ValueSeparatorNode
type ValueParentNode = ValueFunctionNode | null
function word(value: string): ValueWordNode {
export function word(value: string): ValueWordNode {
return {
kind: 'word',
value,
@ -115,6 +115,71 @@ export function walk(
}
}
export function walkDepth(
ast: ValueAstNode[],
visit: (
node: ValueAstNode,
utils: {
parent: ValueParentNode
replaceWith(newNode: ValueAstNode | ValueAstNode[]): void
},
) => void | ValueWalkAction,
parent: ValueParentNode = null,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
if (node.kind === 'function') {
if (walkDepth(node.nodes, visit, node) === ValueWalkAction.Stop) {
return ValueWalkAction.Stop
}
}
let replacedNode = false
let replacedNodeOffset = 0
let status =
visit(node, {
parent,
replaceWith(newNode) {
if (replacedNode) return
replacedNode = true
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
replacedNodeOffset = 0
} else if (newNode.length === 1) {
ast[i] = newNode[0]
replacedNodeOffset = 1
} else {
ast.splice(i, 1, ...newNode)
replacedNodeOffset = newNode.length
}
} else {
ast[i] = newNode
}
},
}) ?? ValueWalkAction.Continue
// We want to visit or skip the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop will
// process this position (containing the replaced node) again.
if (replacedNode) {
if (status === ValueWalkAction.Continue) {
i--
} else {
i += replacedNodeOffset - 1
}
continue
}
// Stop the walk entirely
if (status === ValueWalkAction.Stop) return ValueWalkAction.Stop
// Skip visiting the children of this node
if (status === ValueWalkAction.Skip) continue
}
}
export function toCss(ast: ValueAstNode[]) {
let css = ''
for (const node of ast) {
@ -172,6 +237,33 @@ export function parse(input: string) {
break
}
// Typically for math operators, they have to have spaces around them. But
// there are situations in `theme(colors.red.500/10)` where we use `/`
// without spaces. Let's make sure this is a separate word as well.
case SLASH: {
// 1. Handle everything before the separator as a word
// Handle everything before the closing paren as a word
if (buffer.length > 0) {
let node = word(buffer)
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
buffer = ''
}
// 2. Track the `/` as a word on its own
let node = word(input[i])
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
break
}
// Space and commas are bundled into separators
//
// E.g.:
@ -186,7 +278,6 @@ export function parse(input: string) {
case GREATER_THAN:
case LESS_THAN:
case NEWLINE:
case SLASH:
case SPACE:
case TAB: {
// 1. Handle everything before the separator as a word
@ -213,7 +304,6 @@ export function parse(input: string) {
peekChar !== GREATER_THAN &&
peekChar !== LESS_THAN &&
peekChar !== NEWLINE &&
peekChar !== SLASH &&
peekChar !== SPACE &&
peekChar !== TAB
) {