Skip to content

Commit df6209a

Browse files
authored
Canonicalize negative arbitrary values (#19858)
This PR adds a few more canonicalizations for some cases I noticed on our templates. When dealing with arbitrary values, and the utility is a "negative" utility, then we will try to put the `-` inside of the arbitrary value: ```diff - -left-[9rem] + left-[-9rem] ``` The idea is that the arbitrary value is already an escape hatch for when a value is not available by default. The `-` in front uses an implicit `calc(<expression> * -1)` which might be confusion if you have an value like this already. This also can allow for some further optimizations. For example ```diff - -mt-[492px] ↓↓↓↓↓↓↓ Into a simpler arbitrary value + mt-[-492px] ↓↓↓↓↓↓↓ Into a bare value + mt-123 ``` This PR also improve the constant folding of calc expressions a bit more such that nested calc expressions with 2 constants and an unknown can be folded. Bit of a mouthful, but it allows us to handle this: ```diff - mt-[calc(-1*calc(-1*var(--foo)))] ↓↓↓↓↓↓↓ The -1 * -1 becomes a no-op + mt-[var(--foo)] ↓↓↓↓↓↓↓ Into the shorthand for CSS variables + mt-(--foo) ``` Now that we can handle moving the `-` into the arbitrary value, there are also cases where we can get the `-` _out_ of the arbitrary value: ```diff - mt-[calc(-1*var(--foo))] ↓↓↓↓↓↓↓ Simplify calc, move `-` to the front + -mt-[var(--foo)] ↓↓↓↓↓↓↓ Into the shorthand for CSS variables + -mt-(--foo) ``` Another missing piece that this PR adds is the concept of canonicalizing or normalizing calc expressions. This is a separate step used when calculating the signature for each utility. This allows us to normalize `calc(-1*var(--foo))` and `calc(var(--foo)*-1)`. Without this they would not be considered the same, but not it will. It's only used when comparing values, it won't unify the actual arbitrary values with this logic (at least for now). With the additional constant folding logic and the canonicalization when comparing signatures it unlocks the necessary power to perform the above transformations. ## Test plan 1. Existing tests still pass 2. Added additional tests for the constant folding logic 3. Added tests for the canonicalization of calc expressions 4. Added new tests where we move the `-` inside the value, or move the `-` outside of the arbitrary value.
1 parent 52fd421 commit df6209a

File tree

7 files changed

+437
-23
lines changed

7 files changed

+437
-23
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3030
- Canonicalization: migrate `overflow-ellipsis` into `text-ellipsis` ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
3131
- Canonicalization: migrate `start-full``inset-s-full`, `start-auto``inset-s-auto`, `start-px``inset-s-px`, and `start-<number>``inset-s-<number>` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
3232
- Canonicalization: migrate `end-full``inset-e-full`, `end-auto``inset-e-auto`, `end-px``inset-e-px`, and `end-<number>``inset-e-<number>` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
33+
- Canonicalization: move the `-` sign inside the arbitrary value `-left-[9rem]``left-[-9rem]` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858))
34+
- Canonicalization: move the `-` sign outside the arbitrary value `ml-[calc(-1*var(--width))]``-ml-(--width)` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858))
3335

3436
## [4.2.2] - 2026-03-18
3537

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect, it } from 'vitest'
2+
import { canonicalizeCalcExpressions } from './canonicalize-calc-expressions'
3+
4+
it.each([
5+
['calc(-1 * var(--foo))', 'calc(var(--foo) * -1)'],
6+
['calc(1rem + var(--foo))', 'calc(var(--foo) + 1rem)'],
7+
['calc(2rem * calc(3px * var(--foo)))', 'calc(calc(var(--foo) * 3px) * 2rem)'],
8+
['calc(var(--b) + var(--a))', 'calc(var(--a) + var(--b))'],
9+
['calc(3px * 2rem)', 'calc(2rem * 3px)'],
10+
['calc(5px * 3px)', 'calc(3px * 5px)'],
11+
['calc(1 * 1rem)', 'calc(1rem * 1)'],
12+
['calc(10 + 2)', 'calc(2 + 10)'],
13+
])('`%s` → `%s` (%#)', (input, expected) => {
14+
expect(canonicalizeCalcExpressions(input)).toBe(expected)
15+
})
16+
17+
it.each([
18+
['calc(1rem - var(--foo))'],
19+
['calc(1rem / 2)'],
20+
['calc(var(--a) + 1rem)'],
21+
['calc(2rem * 3px)'],
22+
])('should keep `%s` (%#)', (input) => {
23+
expect(canonicalizeCalcExpressions(input)).toBe(input)
24+
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { dimensions } from './utils/dimensions'
2+
import * as ValueParser from './value-parser'
3+
import { walk, WalkAction } from './walk'
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 canonicalizeCalcExpressions(input: string): string {
8+
let [canonicalized, valueAst] = canonicalizeCalcExpressionsAst(ValueParser.parse(input))
9+
10+
return canonicalized ? ValueParser.toCss(valueAst) : input
11+
}
12+
13+
export function canonicalizeCalcExpressionsAst(
14+
ast: ValueParser.ValueAstNode[],
15+
): [canonicalized: boolean, ast: ValueParser.ValueAstNode[]] {
16+
let canonicalized = false
17+
18+
walk(ast, {
19+
exit(valueNode) {
20+
// We are only interested in binary expressions in `calc(…)` and `(…)`,
21+
// and only with the `*` or `+` operators.
22+
if (valueNode.kind !== 'function') return
23+
if (valueNode.value !== 'calc' && valueNode.value !== '') return
24+
if (valueNode.nodes.length !== 5) return
25+
if (valueNode.nodes[2].kind !== 'word') return
26+
if (valueNode.nodes[2].value !== '*' && valueNode.nodes[2].value !== '+') return
27+
28+
let lhs = valueNode.nodes[0]
29+
let rhs = valueNode.nodes[4]
30+
31+
if (shouldSwap(lhs, rhs)) {
32+
canonicalized = true
33+
34+
let replacement: ValueParser.ValueFunctionNode = {
35+
kind: 'function',
36+
value: valueNode.value,
37+
nodes: [
38+
rhs, // Now lhs
39+
valueNode.nodes[1], // Separator
40+
valueNode.nodes[2], // Operator
41+
valueNode.nodes[3], // Separator
42+
lhs, // Now rhs
43+
],
44+
}
45+
46+
return WalkAction.ReplaceSkip(replacement)
47+
}
48+
},
49+
})
50+
51+
return [canonicalized, ast]
52+
}
53+
54+
function shouldSwap(lhs: ValueParser.ValueAstNode, rhs: ValueParser.ValueAstNode): boolean {
55+
let lhsDimension = lhs.kind === 'word' ? dimensions.get(lhs.value) : null
56+
let rhsDimension = rhs.kind === 'word' ? dimensions.get(rhs.value) : null
57+
58+
if (lhsDimension !== null && rhsDimension === null) return true
59+
if (lhsDimension === null && rhsDimension !== null) return false
60+
61+
if (lhsDimension !== null && rhsDimension !== null) {
62+
let [lhsValue, lhsUnit] = lhsDimension
63+
let [rhsValue, rhsUnit] = rhsDimension
64+
65+
// Within dimensions, keep unit-bearing values ahead of unitless numbers so
66+
// `1rem` sorts before `1`.
67+
if (lhsUnit === null && rhsUnit !== null) return true
68+
if (lhsUnit !== null && rhsUnit === null) return false
69+
70+
// Then sort dimensions numerically, and finally by unit for ties.
71+
if (lhsValue !== rhsValue) {
72+
return lhsValue - rhsValue > 0
73+
}
74+
75+
if (lhsUnit !== rhsUnit) {
76+
return (lhsUnit ?? '').localeCompare(rhsUnit ?? '') > 0
77+
}
78+
}
79+
80+
// Both nodes are unknown values (not dimensions), sort them as strings
81+
return ValueParser.toCss([lhs]).localeCompare(ValueParser.toCss([rhs])) > 0
82+
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,28 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
446446
// Arbitrary percentage value must be a whole number. Should not migrate to
447447
// a bare value.
448448
['from-[2.5%]', 'from-[2.5%]'],
449+
450+
// Negative arbitrary values can be simplified
451+
// 1. Try to move the sign _inside_ the arbitrary value
452+
// 2. Try to move the sign _out_ of the arbitrary value
453+
['-mt-[12rem]', '-mt-48'], // Arbitrary value → bare value
454+
['-mt-[-12rem]', 'mt-48'], // Double negation
455+
['-mt-[12.34rem]', 'mt-[-12.34rem]'], // Move `-` inside
456+
['-mt-[-12.34rem]', 'mt-[12.34rem]'], // Move `-` inside, double negation
457+
['-mt-[12.34px]', 'mt-[-12.34px]'],
458+
['-mt-[-12.34px]', 'mt-[12.34px]'],
459+
['-mt-[492px]', '-mt-123'], // Moving inside, allows us to migrate to a bare value
460+
['-mt-[calc(-1*492px)]', 'mt-123'], // Double negation and constant folding into bare value
461+
['-mt-[-492px]', 'mt-123'],
462+
['-mt-[calc(-1*-492px)]', '-mt-123'], // Constant folding with calc expressions
463+
['-mt-(--my-var)', '-mt-(--my-var)'], // Keep as-is
464+
['-mt-[var(--my-var)]', '-mt-(--my-var)'], // Keep as-is, but convert to shorthand
465+
['mt-[calc(var(--my-var)*-1)]', '-mt-(--my-var)'], // Move `-` out
466+
['mt-[calc(-1*var(--my-var))]', '-mt-(--my-var)'], // Move `-` out
467+
['-mt-[calc(var(--my-var)*-1)]', 'mt-(--my-var)'], // Move `-` out
468+
['-mt-[calc(-1*var(--my-var))]', 'mt-(--my-var)'], // Move `-` out
469+
['mt-[calc(-1*calc(-1*var(--my-var)))]', 'mt-(--my-var)'], // Move `-` out
470+
['-mt-[calc(-1*calc(-1*var(--my-var)))]', '-mt-(--my-var)'], // Move `-` out
449471
])(testName, { timeout }, async (candidate, expected) => {
450472
let input = css`
451473
@import 'tailwindcss';

packages/tailwindcss/src/canonicalize-candidates.ts

Lines changed: 137 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import {
1111
type NamedUtilityValue,
1212
type Variant,
1313
} from './candidate'
14+
import { canonicalizeCalcExpressionsAst } from './canonicalize-calc-expressions'
1415
import { keyPathToCssProperty } from './compat/apply-config-to-theme'
15-
import { constantFoldDeclaration } from './constant-fold-declaration'
16+
import { constantFoldDeclaration, constantFoldDeclarationAst } from './constant-fold-declaration'
1617
import type { DesignSystem as BaseDesignSystem } from './design-system'
1718
import { CompileAstFlags } from './design-system'
1819
import { expandDeclaration } from './expand-declaration'
@@ -77,6 +78,10 @@ interface DesignSystem extends BaseDesignSystem {
7778
number | null, // Rem value
7879
DefaultMap<SignatureFeatures, SignatureOptions>
7980
>
81+
[COMPARE_CANDIDATES_KEY]: DefaultMap<
82+
SignatureOptions,
83+
(a: Candidate | string, b: Candidate | string) => boolean
84+
>
8085
[INTERNAL_OPTIONS_KEY]: DefaultMap<
8186
SignatureOptions,
8287
DefaultMap<Features, InternalCanonicalizeOptions>
@@ -126,6 +131,7 @@ export function prepareDesignSystemStorage(
126131
designSystem.storage[PRE_COMPUTED_UTILITIES_KEY] ??= createPreComputedUtilitiesCache(designSystem)
127132
designSystem.storage[VARIANT_SIGNATURE_KEY] ??= createVariantSignatureCache(designSystem)
128133
designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] ??= createPreComputedVariantsCache(designSystem)
134+
designSystem.storage[COMPARE_CANDIDATES_KEY] ??= createSignatureComparison(designSystem)
129135

130136
return designSystem
131137
}
@@ -139,6 +145,25 @@ function createSignatureOptionsCache(): DesignSystem['storage'][typeof SIGNATURE
139145
})
140146
}
141147

148+
const COMPARE_CANDIDATES_KEY = Symbol()
149+
function createSignatureComparison(designSystem: DesignSystem) {
150+
return new DefaultMap((options: SignatureOptions) => {
151+
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options)
152+
153+
return function hasSameSignature(a: Candidate | string, b: Candidate | string): boolean {
154+
let aCandidateString = typeof a === 'string' ? a : designSystem.printCandidate(a)
155+
let aSignature = signatures.get(aCandidateString)
156+
if (typeof aSignature !== 'string') return false
157+
158+
let bCandidateString = typeof b === 'string' ? b : designSystem.printCandidate(b)
159+
let bSignature = signatures.get(bCandidateString)
160+
if (typeof bSignature !== 'string') return false
161+
162+
return aSignature === bSignature
163+
}
164+
})
165+
}
166+
142167
export function createSignatureOptions(
143168
baseDesignSystem: BaseDesignSystem,
144169
options?: CanonicalizeOptions,
@@ -574,6 +599,7 @@ const UTILITY_CANONICALIZATIONS: UtilityCanonicalizationFunction[] = [
574599
bgGradientToLinear,
575600
themeToVarUtility,
576601
calcToSpacingFunction,
602+
optimizeArbitraryValueExpressions,
577603
arbitraryUtilities,
578604
bareValueUtilities,
579605
deprecatedUtilities,
@@ -1092,6 +1118,7 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO
10921118
let designSystem = options.designSystem
10931119
let utilities = designSystem.storage[PRE_COMPUTED_UTILITIES_KEY].get(options.signatureOptions)
10941120
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions)
1121+
let hasSameSignature = designSystem.storage[COMPARE_CANDIDATES_KEY].get(options.signatureOptions)
10951122

10961123
let targetCandidateString = designSystem.printCandidate(candidate)
10971124

@@ -1101,9 +1128,7 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO
11011128

11021129
// Try a few options to find a suitable replacement utility
11031130
for (let replacementCandidate of tryReplacements(targetSignature, candidate)) {
1104-
let replacementString = designSystem.printCandidate(replacementCandidate)
1105-
let replacementSignature = signatures.get(replacementString)
1106-
if (replacementSignature !== targetSignature) {
1131+
if (!hasSameSignature(candidate, replacementCandidate)) {
11071132
continue
11081133
}
11091134

@@ -1340,6 +1365,7 @@ function bareValueUtilities(candidate: Candidate, options: InternalCanonicalizeO
13401365
let designSystem = options.designSystem
13411366
let utilities = designSystem.storage[PRE_COMPUTED_UTILITIES_KEY].get(options.signatureOptions)
13421367
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions)
1368+
let hasSameSignature = designSystem.storage[COMPARE_CANDIDATES_KEY].get(options.signatureOptions)
13431369

13441370
let targetCandidateString = designSystem.printCandidate(candidate)
13451371

@@ -1349,9 +1375,7 @@ function bareValueUtilities(candidate: Candidate, options: InternalCanonicalizeO
13491375

13501376
// Try a few options to find a suitable replacement utility
13511377
for (let replacementCandidate of tryReplacements(targetSignature, candidate)) {
1352-
let replacementString = designSystem.printCandidate(replacementCandidate)
1353-
let replacementSignature = signatures.get(replacementString)
1354-
if (replacementSignature !== targetSignature) {
1378+
if (!hasSameSignature(candidate, replacementCandidate)) {
13551379
continue
13561380
}
13571381

@@ -1454,19 +1478,14 @@ function deprecatedUtilities(
14541478
options: InternalCanonicalizeOptions,
14551479
): Candidate {
14561480
let designSystem = options.designSystem
1457-
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions)
1481+
let hasSameSignature = designSystem.storage[COMPARE_CANDIDATES_KEY].get(options.signatureOptions)
14581482

14591483
let targetCandidateString = printUnprefixedCandidate(designSystem, candidate)
14601484

1461-
let legacySignature = signatures.get(targetCandidateString)
1462-
if (typeof legacySignature !== 'string') return candidate
1463-
14641485
for (let replacementString of tryDeprecatedUtilities(targetCandidateString)) {
1465-
let replacementSignature = signatures.get(replacementString)
1466-
if (typeof replacementSignature !== 'string') continue
1467-
1468-
// Not the same signature, not safe to migrate
1469-
if (legacySignature !== replacementSignature) continue
1486+
if (!hasSameSignature(candidate, replacementString)) {
1487+
continue
1488+
}
14701489

14711490
let [replacement] = parseCandidate(designSystem, replacementString)
14721491
return replacement
@@ -2081,6 +2100,95 @@ function modernizeArbitraryValuesVariant(
20812100
return result
20822101
}
20832102

2103+
// ---
2104+
2105+
function optimizeArbitraryValueExpressions(
2106+
candidate: Candidate,
2107+
options: InternalCanonicalizeOptions,
2108+
): Candidate {
2109+
if (candidate.kind !== 'functional' || candidate.value?.kind !== 'arbitrary') {
2110+
return candidate
2111+
}
2112+
2113+
let designSystem = options.designSystem
2114+
let hasSameSignature = designSystem.storage[COMPARE_CANDIDATES_KEY].get(options.signatureOptions)
2115+
2116+
let valueAst = ValueParser.parse(candidate.value.value)
2117+
2118+
// Start by constant folding the value expression, when dealing with `calc(…)`
2119+
if (valueAst.length === 1 && valueAst[0].kind === 'function' && valueAst[0].value === 'calc') {
2120+
let [folded, foldedValueAst] = constantFoldDeclarationAst(valueAst)
2121+
if (folded) {
2122+
let replacement = cloneCandidate(candidate)
2123+
replacement.value!.value = ValueParser.toCss(foldedValueAst)
2124+
2125+
if (hasSameSignature(candidate, replacement)) {
2126+
candidate = replacement
2127+
valueAst = foldedValueAst
2128+
}
2129+
}
2130+
}
2131+
2132+
// Move `-` sign into the arbitrary value itself
2133+
if (candidate.root[0] === '-') {
2134+
// We're dealing with a `var(…)`, keep as-is
2135+
if (valueAst.length === 1 && valueAst[0].kind === 'function' && valueAst[0].value === 'var') {
2136+
return candidate
2137+
}
2138+
2139+
// Move `* -1` inside, and try to constant fold to see if it's even worth
2140+
// updating the candidate or not.
2141+
let expressionAst = ValueParser.parse(`calc(${candidate.value!.value} * -1)`)
2142+
let [folded, foldedExpressionAst] = constantFoldDeclarationAst(expressionAst)
2143+
if (folded) {
2144+
let replacement = cloneCandidate(candidate)
2145+
2146+
replacement.root = replacement.root.slice(1) // Drop the leading `-`
2147+
replacement.value!.value = ValueParser.toCss(foldedExpressionAst)
2148+
2149+
if (hasSameSignature(candidate, replacement)) {
2150+
candidate = replacement
2151+
valueAst = foldedExpressionAst
2152+
}
2153+
}
2154+
}
2155+
2156+
// Move `-` sign out of the arbitrary value
2157+
if (valueAst.length === 1 && valueAst[0].kind === 'function' && valueAst[0].value === 'calc') {
2158+
let calcArgs = valueAst[0].nodes
2159+
2160+
// `calc(arg * -1)` or `calc(-1 * arg)`
2161+
if (
2162+
calcArgs.length === 5 &&
2163+
calcArgs[1].kind === 'separator' &&
2164+
calcArgs[1].value === ' ' &&
2165+
calcArgs[2].kind === 'word' &&
2166+
calcArgs[2].value === '*' &&
2167+
calcArgs[3].kind === 'separator' &&
2168+
calcArgs[3].value === ' '
2169+
) {
2170+
let arg =
2171+
calcArgs[4].kind === 'word' && calcArgs[4].value === '-1'
2172+
? calcArgs[0]
2173+
: calcArgs[0].kind === 'word' && calcArgs[0].value === '-1'
2174+
? calcArgs[4]
2175+
: null
2176+
2177+
if (arg) {
2178+
let replacement = cloneCandidate(candidate)
2179+
replacement.root = `-${candidate.root}`
2180+
replacement.value!.value = ValueParser.toCss([arg])
2181+
2182+
if (hasSameSignature(candidate, replacement)) {
2183+
candidate = replacement
2184+
}
2185+
}
2186+
}
2187+
}
2188+
2189+
return candidate
2190+
}
2191+
20842192
// ----
20852193

20862194
// Optimize the modifier
@@ -2274,6 +2382,8 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
22742382
node.value = resolveVariablesInValue(node.value, designSystem)
22752383
}
22762384

2385+
let valueAst = ValueParser.parse(node.value)
2386+
22772387
// Very basic `calc(…)` constant folding to handle the spacing scale
22782388
// multiplier:
22792389
//
@@ -2282,7 +2392,17 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
22822392
// → `calc(0.25rem * 4)` ← this is the case we will see
22832393
// after inlining the variable
22842394
// → `1rem`
2285-
node.value = constantFoldDeclaration(node.value, rem)
2395+
let [folded, foldedValueAst] = constantFoldDeclarationAst(valueAst, rem)
2396+
2397+
// Normalize `calc(…)` expressions such that arguments that are
2398+
// associatively equivalent are rendered in the same way.
2399+
//
2400+
// `calc(var(--foo) * -1)` === `calc(-1 * var(--foo))`
2401+
let [normalized, canonicalizedValueAst] = canonicalizeCalcExpressionsAst(foldedValueAst)
2402+
2403+
if (folded || normalized) {
2404+
node.value = ValueParser.toCss(canonicalizedValueAst)
2405+
}
22862406

22872407
// We will normalize the `node.value`, this is the same kind of logic
22882408
// we use when printing arbitrary values. It will remove unnecessary

0 commit comments

Comments
 (0)