Skip to content

Commit 01d1e98

Browse files
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>
1 parent 3aadba7 commit 01d1e98

File tree

13 files changed

+362
-55
lines changed

13 files changed

+362
-55
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Suppress Lightning CSS warnings about `:deep`, `:slotted`, and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094))
1818
- 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))
1919
- Allow named groups in combination with `not-*`, `has-*`, and `in-*` ([#19100](https://github.com/tailwindlabs/tailwindcss/pull/19100))
20+
- Upgrade: Canonicalize utilities containing `0` values ([#19095](https://github.com/tailwindlabs/tailwindcss/pull/19095))
2021

2122
## [4.1.14] - 2025-10-01
2223

packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function createConverter(designSystem: DesignSystem, { prettyPrint = fals
4242
}
4343

4444
// If we see a `/`, we have a modifier
45-
else if (child.kind === 'separator' && child.value.trim() === '/') {
45+
else if (child.kind === 'word' && child.value === '/') {
4646
themeModifierCount += 1
4747
return ValueParser.ValueWalkAction.Stop
4848
}

packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
6161
// handle the `0px * -1` case which translates to `0px` not `-0px`.
6262
//
6363
// This translation is actually fine, because now, we will prefer the
64-
// non-negative version first so we can replace `-mt-[0px]` with `mt-[0px]`.
65-
['mt-[0px]', 'mt-[0px]'],
66-
['-mt-[0px]', 'mt-[0px]'],
64+
// non-negative version first so we can replace `-mt-[0px]` with `mt-0`.
65+
['mt-[0px]', 'mt-0'],
66+
['-mt-[0px]', 'mt-0'],
6767

6868
// Shorthand CSS Variables should be converted to the new syntax, even if
6969
// the fallback contains functions. The fallback should also be migrated to

packages/tailwindcss/src/candidate.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,13 +1049,6 @@ const printArbitraryValueCache = new DefaultMap<string, string>((input) => {
10491049
drop.add(next)
10501050
}
10511051

1052-
// The value parser handles `/` as a separator in some scenarios. E.g.:
1053-
// `theme(colors.red/50%)`. Because of this, we have to handle this case
1054-
// separately.
1055-
else if (node.kind === 'separator' && node.value.trim() === '/') {
1056-
node.value = '/'
1057-
}
1058-
10591052
// Leading and trailing whitespace
10601053
else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') {
10611054
if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) {

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,30 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
675675
['grid-cols-[subgrid]', 'grid-cols-subgrid'],
676676
['grid-rows-[subgrid]', 'grid-rows-subgrid'],
677677

678+
// Handle zeroes
679+
['m-[0]', 'm-0'],
680+
['m-[0px]', 'm-0'],
681+
['m-[0rem]', 'm-0'],
682+
683+
['-m-[0]', 'm-0'],
684+
['-m-[0px]', 'm-0'],
685+
['-m-[0rem]', 'm-0'],
686+
687+
['m-[-0]', 'm-0'],
688+
['m-[-0px]', 'm-0'],
689+
['m-[-0rem]', 'm-0'],
690+
691+
['-m-[-0]', 'm-0'],
692+
['-m-[-0px]', 'm-0'],
693+
['-m-[-0rem]', 'm-0'],
694+
695+
['[margin:0]', 'm-0'],
696+
['[margin:-0]', 'm-0'],
697+
['[margin:0px]', 'm-0'],
698+
699+
// Not a length-unit, can't safely constant fold
700+
['[margin:0%]', 'm-[0%]'],
701+
678702
// Only 50-200% (inclusive) are valid:
679703
// https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch#percentage
680704
['font-stretch-[50%]', 'font-stretch-50%'],

packages/tailwindcss/src/canonicalize-candidates.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ const converterCache = new DefaultMap((ds: DesignSystem) => {
264264
}
265265

266266
// If we see a `/`, we have a modifier
267-
else if (child.kind === 'separator' && child.value.trim() === '/') {
267+
else if (child.kind === 'word' && child.value === '/') {
268268
themeModifierCount += 1
269269
return ValueParser.ValueWalkAction.Stop
270270
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { expect, it } from 'vitest'
2+
import { constantFoldDeclaration } from './constant-fold-declaration'
3+
4+
it.each([
5+
// Simple expression
6+
['calc(1 + 1)', '2'],
7+
['calc(3 - 2)', '1'],
8+
['calc(2 * 3)', '6'],
9+
['calc(8 / 2)', '4'],
10+
11+
// Nested
12+
['calc(1 + calc(1 + 1))', '3'],
13+
['calc(3 - calc(1 + 2))', '0'],
14+
['calc(2 * calc(1 + 3))', '8'],
15+
['calc(8 / calc(2 + 2))', '2'],
16+
['calc(1 + (1 + 1))', '3'],
17+
['calc(3 - (1 + 2))', '0'],
18+
['calc(2 * (1 + 3))', '8'],
19+
['calc(8 / (2 + 2))', '2'],
20+
21+
// With units
22+
['calc(1rem * 2)', '2rem'],
23+
['calc(2rem - 0.5rem)', '1.5rem'],
24+
['calc(3rem * 6)', '18rem'],
25+
['calc(5rem / 2)', '2.5rem'],
26+
27+
// Nested partial evaluation
28+
['calc(calc(1 + 2) + 2rem)', 'calc(3 + 2rem)'],
29+
30+
// Evaluation only handles two operands right now, this can change in the future
31+
['calc(1 + 2 + 3)', 'calc(1 + 2 + 3)'],
32+
])('should constant fold `%s` into `%s`', (input, expected) => {
33+
expect(constantFoldDeclaration(input)).toBe(expected)
34+
})
35+
36+
it.each([
37+
['calc(1rem * 2%)'],
38+
['calc(1rem * 2px)'],
39+
['calc(2rem - 6)'],
40+
['calc(3rem * 3dvw)'],
41+
['calc(3rem * 2dvh)'],
42+
['calc(5rem / 17px)'],
43+
])('should not constant fold different units `%s`', (input) => {
44+
expect(constantFoldDeclaration(input)).toBe(input)
45+
})
46+
47+
it.each([
48+
['calc(0 * 100vw)'],
49+
['calc(0 * calc(1 * 2))'],
50+
['calc(0 * var(--foo))'],
51+
['calc(0 * calc(var(--spacing) * 32))'],
52+
53+
['calc(100vw * 0)'],
54+
['calc(calc(1 * 2) * 0)'],
55+
['calc(var(--foo) * 0)'],
56+
['calc(calc(var(--spacing, 0.25rem) * 32) * 0)'],
57+
['calc(var(--spacing, 0.25rem) * -0)'],
58+
['calc(-0px * -1)'],
59+
60+
// Zeroes
61+
['0px'],
62+
['0rem'],
63+
['0em'],
64+
['0dvh'],
65+
['-0'],
66+
['+0'],
67+
['-0.0rem'],
68+
['+0.00rem'],
69+
])('should constant fold `%s` to `0`', (input) => {
70+
expect(constantFoldDeclaration(input)).toBe('0')
71+
})
72+
73+
it.each([
74+
['0deg', '0deg'],
75+
['0rad', '0rad'],
76+
['0%', '0%'],
77+
['0turn', '0turn'],
78+
['0fr', '0fr'],
79+
['0ms', '0ms'],
80+
['0s', '0s'],
81+
['-0.0deg', '0deg'],
82+
['-0.0rad', '0rad'],
83+
['-0.0%', '0%'],
84+
['-0.0turn', '0turn'],
85+
['-0.0fr', '0fr'],
86+
['-0.0ms', '0ms'],
87+
['-0.0s', '0s'],
88+
])('should not fold non-foldable units to `0`. Constant fold `%s` into `%s`', (input, expected) => {
89+
expect(constantFoldDeclaration(input)).toBe(expected)
90+
})
91+
92+
it('should not constant fold when dividing by `0`', () => {
93+
expect(constantFoldDeclaration('calc(123rem / 0)')).toBe('calc(123rem / 0)')
94+
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { dimensions } from './utils/dimensions'
2+
import { isLength } from './utils/infer-data-type'
3+
import * as ValueParser from './value-parser'
4+
5+
// Assumption: We already assume that we receive somewhat valid `calc()`
6+
// expressions. So we will see `calc(1 + 1)` and not `calc(1+1)`
7+
export function constantFoldDeclaration(input: string): string {
8+
let folded = false
9+
let valueAst = ValueParser.parse(input)
10+
11+
ValueParser.walkDepth(valueAst, (valueNode, { replaceWith }) => {
12+
// Convert `-0`, `+0`, `0.0`, … to `0`
13+
// Convert `-0px`, `+0em`, `0.0rem`, … to `0`
14+
if (
15+
valueNode.kind === 'word' &&
16+
valueNode.value !== '0' && // Already `0`, nothing to do
17+
((valueNode.value[0] === '-' && valueNode.value[1] === '0') || // `-0…`
18+
(valueNode.value[0] === '+' && valueNode.value[1] === '0') || // `+0…`
19+
valueNode.value[0] === '0') // `0…`
20+
) {
21+
let dimension = dimensions.get(valueNode.value)
22+
if (dimension === null) return // This shouldn't happen
23+
24+
if (dimension[0] !== 0) return // Not a zero value, nothing to do
25+
26+
// Replace length units with just `0`
27+
if (dimension[1] === null || isLength(valueNode.value)) {
28+
folded = true
29+
replaceWith(ValueParser.word('0'))
30+
return
31+
}
32+
33+
// Replace other units with `0<unit>`, e.g. `0%`, `0fr`, `0s`, …
34+
else if (valueNode.value !== `0${dimension[1]}`) {
35+
folded = true
36+
replaceWith(ValueParser.word(`0${dimension[1]}`))
37+
return
38+
}
39+
}
40+
41+
// Constant fold `calc()` expressions with two operands and one operator
42+
else if (
43+
valueNode.kind === 'function' &&
44+
(valueNode.value === 'calc' || valueNode.value === '')
45+
) {
46+
// [
47+
// { kind: 'word', value: '0.25rem' }, 0
48+
// { kind: 'separator', value: ' ' }, 1
49+
// { kind: 'word', value: '*' }, 2
50+
// { kind: 'separator', value: ' ' }, 3
51+
// { kind: 'word', value: '256' } 4
52+
// ]
53+
if (valueNode.nodes.length !== 5) return
54+
55+
let lhs = dimensions.get(valueNode.nodes[0].value)
56+
let operator = valueNode.nodes[2].value
57+
let rhs = dimensions.get(valueNode.nodes[4].value)
58+
59+
// Nullify entire expression when multiplying by `0`, e.g.: `calc(0 * 100vw)` -> `0`
60+
//
61+
// TODO: Ensure it's safe to do so based on the data types?
62+
if (
63+
operator === '*' &&
64+
((lhs?.[0] === 0 && lhs?.[1] === null) || // 0 * something
65+
(rhs?.[0] === 0 && rhs?.[1] === null)) // something * 0
66+
) {
67+
folded = true
68+
replaceWith(ValueParser.word('0'))
69+
return
70+
}
71+
72+
// We're not dealing with dimensions, so we can't fold this
73+
if (lhs === null || rhs === null) {
74+
return
75+
}
76+
77+
switch (operator) {
78+
case '*': {
79+
if (
80+
lhs[1] === rhs[1] || // Same Units, e.g.: `1rem * 2rem`, `8 * 6`
81+
(lhs[1] === null && rhs[1] !== null) || // Unitless * Unit, e.g.: `2 * 1rem`
82+
(lhs[1] !== null && rhs[1] === null) // Unit * Unitless, e.g.: `1rem * 2`
83+
) {
84+
folded = true
85+
replaceWith(ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? ''}`))
86+
}
87+
break
88+
}
89+
90+
case '+': {
91+
if (
92+
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `1rem + 2rem`, `8 + 6`
93+
) {
94+
folded = true
95+
replaceWith(ValueParser.word(`${lhs[0] + rhs[0]}${lhs[1] ?? ''}`))
96+
}
97+
break
98+
}
99+
100+
case '-': {
101+
if (
102+
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `2rem - 1rem`, `8 - 6`
103+
) {
104+
folded = true
105+
replaceWith(ValueParser.word(`${lhs[0] - rhs[0]}${lhs[1] ?? ''}`))
106+
}
107+
break
108+
}
109+
110+
case '/': {
111+
if (
112+
rhs[0] !== 0 && // Don't divide by zero
113+
((lhs[1] === null && rhs[1] === null) || // Unitless / Unitless, e.g.: `8 / 2`
114+
(lhs[1] !== null && rhs[1] === null)) // Unit / Unitless, e.g.: `1rem / 2`
115+
) {
116+
folded = true
117+
replaceWith(ValueParser.word(`${lhs[0] / rhs[0]}${lhs[1] ?? ''}`))
118+
}
119+
break
120+
}
121+
}
122+
}
123+
})
124+
125+
return folded ? ValueParser.toCss(valueAst) : input
126+
}

packages/tailwindcss/src/signatures.ts

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { substituteAtApply } from './apply'
22
import { atRule, styleRule, toCss, walk, type AstNode } from './ast'
33
import { printArbitraryValue } from './candidate'
4+
import { constantFoldDeclaration } from './constant-fold-declaration'
45
import { CompileAstFlags, type DesignSystem } from './design-system'
56
import * as SelectorParser from './selector-parser'
67
import { ThemeOptions } from './theme'
78
import { DefaultMap } from './utils/default-map'
8-
import { dimensions } from './utils/dimensions'
99
import { isValidSpacingMultiplier } from './utils/infer-data-type'
1010
import * as ValueParser from './value-parser'
1111

@@ -208,39 +208,7 @@ export const computeUtilitySignature = new DefaultMap<
208208
// → `calc(0.25rem * 4)` ← this is the case we will see
209209
// after inlining the variable
210210
// → `1rem`
211-
if (node.value.includes('calc')) {
212-
let folded = false
213-
let valueAst = ValueParser.parse(node.value)
214-
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
215-
if (valueNode.kind !== 'function') return
216-
if (valueNode.value !== 'calc') return
217-
218-
// [
219-
// { kind: 'word', value: '0.25rem' }, 0
220-
// { kind: 'separator', value: ' ' }, 1
221-
// { kind: 'word', value: '*' }, 2
222-
// { kind: 'separator', value: ' ' }, 3
223-
// { kind: 'word', value: '256' } 4
224-
// ]
225-
if (valueNode.nodes.length !== 5) return
226-
if (valueNode.nodes[2].kind !== 'word' && valueNode.nodes[2].value !== '*') return
227-
228-
let parsed = dimensions.get(valueNode.nodes[0].value)
229-
if (parsed === null) return
230-
231-
let [value, unit] = parsed
232-
233-
let multiplier = Number(valueNode.nodes[4].value)
234-
if (Number.isNaN(multiplier)) return
235-
236-
folded = true
237-
replaceWith(ValueParser.parse(`${value * multiplier}${unit}`))
238-
})
239-
240-
if (folded) {
241-
node.value = ValueParser.toCss(valueAst)
242-
}
243-
}
211+
node.value = constantFoldDeclaration(node.value)
244212

245213
// We will normalize the `node.value`, this is the same kind of logic
246214
// we use when printing arbitrary values. It will remove unnecessary
@@ -277,6 +245,17 @@ export const preComputedUtilities = new DefaultMap<DesignSystem, DefaultMap<stri
277245
for (let [className, meta] of ds.getClassList()) {
278246
let signature = signatures.get(className)
279247
if (typeof signature !== 'string') continue
248+
249+
// Skip the utility if `-{utility}-0` has the same signature as
250+
// `{utility}-0` (its positive version). This will prefer positive values
251+
// over negative values.
252+
if (className[0] === '-' && className.endsWith('-0')) {
253+
let positiveSignature = signatures.get(className.slice(1))
254+
if (typeof positiveSignature === 'string' && signature === positiveSignature) {
255+
continue
256+
}
257+
}
258+
280259
lookup.get(signature).push(className)
281260

282261
for (let modifier of meta.modifiers) {

0 commit comments

Comments
 (0)