From 8c779899bbc94f9f642db47cafdd363dd094984a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 5 May 2026 17:08:24 +0200 Subject: [PATCH] Ensure math operators are surrounded by whitespace in arbitrary values (#20011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes an issue where some `calc(…)` expressions become invalid after we canonicalize them to a different syntax. Let's say you start with: `left-[calc(-1*(var(--my-var1)+var(--my-var2)))]`, then the produced AST for this candidate looks like this: ```js [ { "kind": "functional", "root": "left", "modifier": null, "value": { "kind": "arbitrary", "dataType": null, "value": "calc(-1 * (var(--my-var1) + var(--my-var2)))" }, "variants": [], "important": false, "raw": "left-[calc(-1*(var(--my-var1)+var(--my-var2)))]" } ] ``` Notice that the `+` in between the vars already contain spaces. And the generated CSS looks like this: ```css .left-\[calc\(-1\*\(var\(--my-var1\)\+var\(--my-var2\)\)\)\] { left: calc(-1 * (var(--my-var1) + var(--my-var2))); } ``` Again, the `+` has spaces aroudn it. However, we canoncialize this syntax where we remove the `calc(-1 * )` and move the `-` in front: `-left-[(var(--my-var1)+var(--my-var2))]`, which should behave the same, but it did not. The parsed value for this looks like: ```js [ { "kind": "functional", "root": "-left", "modifier": null, "value": { "kind": "arbitrary", "dataType": null, "value": "(var(--my-var1)+var(--my-var2))" }, "variants": [], "important": false, "raw": "-left-[(var(--my-var1)+var(--my-var2))]" } ] ``` Notice that the `+` does not contain spaces around it. That's because we add them when we parse arbitrary values and when we are in a `calc(…)` expression. But the `calc( * -1)` is added later, so at this point, no spaces are added yet. This also means that the generated CSS currently looks like: ```css .-left-\[\(var\(--my-var1\)\+var\(--my-var2\)\)\] { left: calc((var(--my-var1)+var(--my-var2)) * -1); } ``` Which is invalid. To solve this, we will make sure to add the whitespace around operators when we re-insert the `calc( * -1)`. With this fix, the CSS looks like this: ```css .-left-\[\(var\(--my-var1\)\+var\(--my-var2\)\)\] { left: calc((var(--my-var1) + var(--my-var2)) * -1); } ``` Which is correct again. --- There are a few other issues that need a bit more work, but are not required for this fix. 1. Can we get rid of the additional `(` and `)` parens? E.g.: ```diff - left-[calc(-1*(var(--my-var1)+var(--my-var2)))] - -left-[(var(--my-var1)+var(--my-var2))] + -left-[var(--my-var1)+var(--my-var2)] ``` Right now this means that we should use `calc(() * -1)` instead of `calc( * -1)` since `` can be an expression on its own. This could lead to unwanted behavior, but this will be a follow up PR _if_ it's worth it. 2. Why did we even allow this canoncialization from A to B if it's not the same result? This last question is a bit more scary, but it has to do with how we compare results. We create a signature where we normalize a bunch of values to make sure that we can compare them. As a silly example `calc(var(--a) + var(--b))` and `calc(var(--b) + var(--a))` will result in the same value, therefor should have the same signature and should be swappable. But what's happening is that the signature of `left-[calc(-1*(var(--my-var1)+var(--my-var2)))]` and `-left-[(var(--my-var1)+var(--my-var2))]` result in: ``` /* Signature of: left-[calc(-1*(var(--my-var1)+var(--my-var2)))] */ .x { left: calc((var(--my-var1)+var(--my-var2))*-1); } /* Signature of: -left-[(var(--my-var1)+var(--my-var2))] */ .x { left: calc((var(--my-var1)+var(--my-var2))*-1); } ``` This gets rid of whitespace to store less data, but this is obviously wrong now, so we need to improve the signatures around this. That said, that will be a follow up PR as well because this requires much more testing. But this PR at least fixes the #20010 issue because we will properly insert the whitespace around the math operators. Fixes: #20010 ## Test plan 1. Added a regression test to make sure that the new canonicalized syntax results in the correct CSS. Before the fix, the test would fail: image 2. Existing tests still pass --- CHANGELOG.md | 1 + packages/tailwindcss/src/utilities.test.ts | 7 +++++++ packages/tailwindcss/src/utilities.ts | 6 +++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ba50dcd..cb433421f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Export missing `PluginWithConfig` type from `tailwindcss/plugin` to fix errors when inferring plugin config types ([#19707](https://github.com/tailwindlabs/tailwindcss/pull/19707)) - Ensure `start` and `end` legacy utilities without values do not generate CSS ([#20003](https://github.com/tailwindlabs/tailwindcss/pull/20003)) - Ensure `--value(…)` is required in functional `@utility` definitions ([#20005](https://github.com/tailwindlabs/tailwindcss/pull/20005)) +- Canonicalization: preserve required whitespace around operators in negated arbitrary values (e.g. `-left-[(var(--a)+var(--b))]`) ([#20011](https://github.com/tailwindlabs/tailwindcss/pull/20011)) ## [4.2.4] - 2026-04-21 diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 219302ae3..6b018f672 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -1109,6 +1109,9 @@ test('left', async () => { 'left-4', '-left-4', 'left-[4px]', + + // https://github.com/tailwindlabs/tailwindcss/issues/20010 + '-left-[(var(--my-var1)+var(--my-var2))]', ], ), ).toMatchInlineSnapshot(` @@ -1121,6 +1124,10 @@ test('left', async () => { left: calc(var(--spacing-4) * -1); } + .-left-\\[\\(var\\(--my-var1\\)\\+var\\(--my-var2\\)\\)\\] { + left: calc((var(--my-var1) + var(--my-var2)) * -1); + } + .-left-full { left: -100%; } diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 565b64523..c40f3be93 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -24,6 +24,7 @@ import { isValidOpacityValue, isValidSpacingMultiplier, } from './utils/infer-data-type' +import { addWhitespaceAroundMathOperators } from './utils/math-operators' import { replaceShadowColors } from './utils/replace-shadow-colors' import { segment } from './utils/segment' import * as ValueParser from './value-parser' @@ -450,7 +451,10 @@ export function createUtilities(theme: Theme) { if (value === null) return // Negate the value if the candidate has a negative prefix. - return desc.handle(negative ? `calc(${value} * -1)` : value, dataType) + return desc.handle( + negative ? addWhitespaceAroundMathOperators(`calc(${value} * -1)`) : value, + dataType, + ) } }