Skip to content

Canonicalize negative arbitrary values#19858

Merged
RobinMalfait merged 13 commits intomainfrom
feat/canonicalize-negative-arbitrary-values
Mar 26, 2026
Merged

Canonicalize negative arbitrary values#19858
RobinMalfait merged 13 commits intomainfrom
feat/canonicalize-negative-arbitrary-values

Conversation

@RobinMalfait
Copy link
Copy Markdown
Member

@RobinMalfait RobinMalfait commented Mar 26, 2026

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:

- -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

- -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:

- 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:

- 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 now 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.

When using `calc(-1 * 2rem)`, where were taking the unit from the `lhs`.
But we should be using the unit from the `rhs` instead.
Allows us to _not_ convert from an ast to a string and back
This essentially just sorts calc expressions (but only if it's still
correct). This allows us to say that 2 expressions with different
argument order is still the same.
This allows us to order arguments in calc expressions to ensure that 2
utilities are still considered the same.

It's a separate step and not part of constant folding just because we
don't really need to swap the order in the output. That might be too
confusing.

However, we might want to add that in the future because
`mt-[calc(var(--foo)+1px)]` and `mt-[1px+calc(var(--foo))]` would do the
same, but would generate 2 CSS classes.
When we use utilities like `-m-8`, we take the value of `m-8` and wrap
it in a `calc(<existing> * -1)`. Depending on the value of the `-m-*`,
it could be that we eventually end up with `calc(calc(<existing> * <foo>) * <bar>)`.

This code allows us to try and constant fold the `<foo>` and `<bar>`
parts.
This will compare the candidate AST or string based version and will do
so by using the signatures to verify if they are equivalent.
This will do a few things:

1. Constant fold the expression calc expression if any
2. Try moving the `-` inside the utility. E.g. `-mt-[9px]` → `mt-[-9px]`
3. Try moving the `-` outside the utility. E.g. `mt-[calc(-1*var(--foo))]` → `-mt-(--foo)`
These handle:

- `calc(x + (y + var(--unknown)))`
- `calc(x * (y * var(--unknown)))`

With some rules around units vs no units
@RobinMalfait RobinMalfait requested a review from a team as a code owner March 26, 2026 13:20
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

Walkthrough

Adds AST-based canonicalization and constant-folding for CSS calc() expressions. Introduces canonicalizeCalcExpressions and canonicalizeCalcExpressionsAst with tests, refactors constantFoldDeclaration to constantFoldDeclarationAst returning [folded, ast], and enhances calc handling and folding rules (including associativity and unit-aware combines). Integrates normalization into utility signature computation and candidate canonicalization (including an optimizer for arbitrary value expressions). Tests and changelog entries covering canonicalization and negative-arbitrary utility transformations were added.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Canonicalize negative arbitrary values' clearly and specifically summarizes the main change: adding canonicalization logic for handling negative arbitrary values.
Description check ✅ Passed The PR description clearly describes the canonicalization and optimization changes for negative arbitrary values and calc expression handling.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.


// Then sort dimensions numerically, and finally by unit for ties.
if (lhsValue !== rhsValue) {
return lhsValue - rhsValue > 0
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A localeCompare for 2 and 10 would result in 10 2 otherwise, so we compare the actual values here.

import { canonicalizeCalcExpressions } from './canonicalize-calc-expressions'

it.each([
['calc(-1 * var(--foo))', 'calc(var(--foo) * -1)'],
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the order matters to much but I did:

  1. Move constants to the end
  2. When comparing values without unit, sort by the value
  3. When it's not a dimension (function, var, ...) then sort alphabetically (localeCompare)

Comment on lines +464 to +470
['-mt-[var(--my-var)]', '-mt-(--my-var)'], // Keep as-is, but convert to shorthand
['mt-[calc(var(--my-var)*-1)]', '-mt-(--my-var)'], // Move `-` out
['mt-[calc(-1*var(--my-var))]', '-mt-(--my-var)'], // Move `-` out
['-mt-[calc(var(--my-var)*-1)]', 'mt-(--my-var)'], // Move `-` out
['-mt-[calc(-1*var(--my-var))]', 'mt-(--my-var)'], // Move `-` out
['mt-[calc(-1*calc(-1*var(--my-var)))]', 'mt-(--my-var)'], // Move `-` out
['-mt-[calc(-1*calc(-1*var(--my-var)))]', '-mt-(--my-var)'], // Move `-` out
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are obviously a bit silly. But needed to make sure that we always have correct utilities. While it's silly, it's also very satisfying to see the simplification here.

return new DefaultMap((options: SignatureOptions) => {
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options)

return function hasSameSignature(a: Candidate | string, b: Candidate | string): boolean {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use the same thing in a few places, so wanted to abstract it.

I think a future (but very low priority) is to compute the signature of the incoming candidate ahead of time. But since we're dealing with cached values it should be fine as-is.

Comment on lines +213 to +215
return WalkAction.ReplaceSkip(
ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? rhs[1] ?? ''}`),
)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test for this as well, but calc(2rem * 4) resulted in 8rem, but calc(4 * 2rem) resulted in 8 because we always took the unit from the lhs.

@RobinMalfait RobinMalfait merged commit df6209a into main Mar 26, 2026
9 checks passed
@RobinMalfait RobinMalfait deleted the feat/canonicalize-negative-arbitrary-values branch March 26, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant