From aa817fb6de92a674867cdee80ec224dbc3d44b9e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=B3zsa=20Zolt=C3=A1n?=
<67325669+rozsazoltan@users.noreply.github.com>
Date: Tue, 10 Jun 2025 16:38:31 +0200
Subject: [PATCH] fix: don't break CSS keywords when formatting math
expressions (#18220)
Fixes #18219
## Summary
In an arbitrary value, if there's a non-numeric character both before
and after a hyphen, there's no need for a space.
## Test plan
`decodeArbitraryValue` will correctly format special CSS values like
`fit-content`. I believe spaces are only necessary if there's a digit
either before or after the hyphen.
```js
decodeArbitraryValue('min(fit-content,calc(100dvh-4rem))')
```
This way, the result of the following arbitrary value will also be
correct:
```html
```
```css
.min-h-\[min\(fit-content\,calc\(100dvh-4rem\)\)\] {
min-height: min(fit-content, calc(100dvh - 4rem));
}
```
---------
Co-authored-by: Jordan Pittman
Co-authored-by: Robin Malfait
---
CHANGELOG.md | 1 +
.../src/utils/decode-arbitrary-value.test.ts | 24 ++-
.../tailwindcss/src/utils/math-operators.ts | 139 +++++++++++-------
3 files changed, 109 insertions(+), 55 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f75160c1b..1d0d5697b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgrade: migrate CSS variable shorthand if fallback value contains function call ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184))
- Upgrade: Migrate negative arbitrary values to negative bare values, e.g.: `mb-[-32rem]` → `-mb-128` ([#18212](https://github.com/tailwindlabs/tailwindcss/pull/18212))
- Upgrade: Do not migrate `blur` in `wire:model.blur` ([#18216](https://github.com/tailwindlabs/tailwindcss/pull/18216))
+- Don't add spaces around CSS dashed idents when formatting math expressions ([#18220](https://github.com/tailwindlabs/tailwindcss/pull/18220))
## [4.1.8] - 2025-05-27
diff --git a/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts b/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts
index 2dccd897f..19025d5b4 100644
--- a/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts
+++ b/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts
@@ -84,6 +84,22 @@ describe('adds spaces around math operators', () => {
['calc(theme(spacing.foo-2))', 'calc(theme(spacing.foo-2))'],
['calc(theme(spacing.foo-bar))', 'calc(theme(spacing.foo-bar))'],
+ // Preserving CSS keyword tokens like fit-content without splitting around hyphens in complex expressions
+ ['min(fit-content,calc(100dvh-4rem))', 'min(fit-content, calc(100dvh - 4rem))'],
+ [
+ 'min(theme(spacing.foo-bar),fit-content,calc(20*calc(40-30)))',
+ 'min(theme(spacing.foo-bar), fit-content, calc(20 * calc(40 - 30)))',
+ ],
+ [
+ 'min(fit-content,calc(100dvh-4rem)-calc(50dvh--2px))',
+ 'min(fit-content, calc(100dvh - 4rem) - calc(50dvh - -2px))',
+ ],
+ ['min(-3.4e-2-var(--foo),calc-size(auto))', 'min(-3.4e-2 - var(--foo), calc-size(auto))'],
+ [
+ 'clamp(-10e3-var(--foo),calc-size(max-content),var(--foo)+-10e3)',
+ 'clamp(-10e3 - var(--foo), calc-size(max-content), var(--foo) + -10e3)',
+ ],
+
// A negative number immediately after a `,` should not have spaces inserted
['clamp(-3px+4px,-3px+4px,-3px+4px)', 'clamp(-3px + 4px, -3px + 4px, -3px + 4px)'],
@@ -93,6 +109,12 @@ describe('adds spaces around math operators', () => {
// Prevent formatting inside `env()` functions
['calc(env(safe-area-inset-bottom)*2)', 'calc(env(safe-area-inset-bottom) * 2)'],
+ // Handle dashed functions that look like known dashed idents
+ [
+ 'fit-content(min(max-content,max(min-content,calc(20px+1em))))',
+ 'fit-content(min(max-content, max(min-content, calc(20px + 1em))))',
+ ],
+
// Should format inside `calc()` nested in `env()`
[
'env(safe-area-inset-bottom,calc(10px+20px))',
@@ -122,7 +144,7 @@ describe('adds spaces around math operators', () => {
// round(…) function
['round(1+2,1+3)', 'round(1 + 2, 1 + 3)'],
- ['round(to-zero,1+2,1+3)', 'round(to-zero,1 + 2, 1 + 3)'],
+ ['round(to-zero,1+2,1+3)', 'round(to-zero, 1 + 2, 1 + 3)'],
// Nested parens in non-math functions don't format their contents
['env((safe-area-inset-bottom))', 'env((safe-area-inset-bottom))'],
diff --git a/packages/tailwindcss/src/utils/math-operators.ts b/packages/tailwindcss/src/utils/math-operators.ts
index e6e83fe79..523fcce20 100644
--- a/packages/tailwindcss/src/utils/math-operators.ts
+++ b/packages/tailwindcss/src/utils/math-operators.ts
@@ -1,3 +1,18 @@
+const LOWER_A = 0x61
+const LOWER_Z = 0x7a
+const LOWER_E = 0x65
+const UPPER_E = 0x45
+const ZERO = 0x30
+const NINE = 0x39
+const ADD = 0x2b
+const SUB = 0x2d
+const MUL = 0x2a
+const DIV = 0x2f
+const OPEN_PAREN = 0x28
+const CLOSE_PAREN = 0x29
+const COMMA = 0x2c
+const SPACE = 0x20
+
const MATH_FUNCTIONS = [
'calc',
'min',
@@ -20,9 +35,6 @@ const MATH_FUNCTIONS = [
'round',
]
-const KNOWN_DASHED_FUNCTIONS = ['anchor-size']
-const DASHED_FUNCTIONS_REGEX = new RegExp(`(${KNOWN_DASHED_FUNCTIONS.join('|')})\\(`, 'g')
-
export function hasMathFn(input: string) {
return input.indexOf('(') !== -1 && MATH_FUNCTIONS.some((fn) => input.includes(`${fn}(`))
}
@@ -33,25 +45,36 @@ export function addWhitespaceAroundMathOperators(input: string) {
return input
}
- // Replace known functions with a placeholder
- let hasKnownFunctions = false
- if (KNOWN_DASHED_FUNCTIONS.some((fn) => input.includes(fn))) {
- DASHED_FUNCTIONS_REGEX.lastIndex = 0
- input = input.replace(DASHED_FUNCTIONS_REGEX, (_, fn) => {
- hasKnownFunctions = true
- return `$${KNOWN_DASHED_FUNCTIONS.indexOf(fn)}$(`
- })
- }
-
let result = ''
let formattable: boolean[] = []
+ let valuePos = null
+ let lastValuePos = null
+
for (let i = 0; i < input.length; i++) {
- let char = input[i]
+ let char = input.charCodeAt(i)
+
+ // Track if we see a number followed by a unit, then we know for sure that
+ // this is not a function call.
+ if (char >= ZERO && char <= NINE) {
+ valuePos = i
+ }
+
+ // If we saw a number before, and we see normal a-z character, then we
+ // assume this is a value such as `123px`
+ else if (valuePos !== null && char >= LOWER_A && char <= LOWER_Z) {
+ valuePos = i
+ }
+
+ // Once we see something else, we reset the value position
+ else {
+ lastValuePos = valuePos
+ valuePos = null
+ }
// Determine if we're inside a math function
- if (char === '(') {
- result += char
+ if (char === OPEN_PAREN) {
+ result += input[i]
// Scan backwards to determine the function name. This assumes math
// functions are named with lowercase alphanumeric characters.
@@ -60,9 +83,9 @@ export function addWhitespaceAroundMathOperators(input: string) {
for (let j = i - 1; j >= 0; j--) {
let inner = input.charCodeAt(j)
- if (inner >= 48 && inner <= 57) {
+ if (inner >= ZERO && inner <= NINE) {
start = j // 0-9
- } else if (inner >= 97 && inner <= 122) {
+ } else if (inner >= LOWER_A && inner <= LOWER_Z) {
start = j // a-z
} else {
break
@@ -91,76 +114,84 @@ export function addWhitespaceAroundMathOperators(input: string) {
// We've exited the function so format according to the parent function's
// type.
- else if (char === ')') {
- result += char
+ else if (char === CLOSE_PAREN) {
+ result += input[i]
formattable.shift()
}
// Add spaces after commas in math functions
- else if (char === ',' && formattable[0]) {
+ else if (char === COMMA && formattable[0]) {
result += `, `
continue
}
// Skip over consecutive whitespace
- else if (char === ' ' && formattable[0] && result[result.length - 1] === ' ') {
+ else if (char === SPACE && formattable[0] && result.charCodeAt(result.length - 1) === SPACE) {
continue
}
// Add whitespace around operators inside math functions
- else if ((char === '+' || char === '*' || char === '/' || char === '-') && formattable[0]) {
+ else if ((char === ADD || char === MUL || char === DIV || char === SUB) && formattable[0]) {
let trimmed = result.trimEnd()
- let prev = trimmed[trimmed.length - 1]
+ let prev = trimmed.charCodeAt(trimmed.length - 1)
+ let prevPrev = trimmed.charCodeAt(trimmed.length - 2)
+ let next = input.charCodeAt(i + 1)
+
+ // Do not add spaces for scientific notation, e.g.: `-3.4e-2`
+ if ((prev === LOWER_E || prev === UPPER_E) && prevPrev >= ZERO && prevPrev <= NINE) {
+ result += input[i]
+ continue
+ }
// If we're preceded by an operator don't add spaces
- if (prev === '+' || prev === '*' || prev === '/' || prev === '-') {
- result += char
+ else if (prev === ADD || prev === MUL || prev === DIV || prev === SUB) {
+ result += input[i]
continue
}
// If we're at the beginning of an argument don't add spaces
- else if (prev === '(' || prev === ',') {
- result += char
+ else if (prev === OPEN_PAREN || prev === COMMA) {
+ result += input[i]
continue
}
// Add spaces only after the operator if we already have spaces before it
- else if (input[i - 1] === ' ') {
- result += `${char} `
+ else if (input.charCodeAt(i - 1) === SPACE) {
+ result += `${input[i]} `
}
- // Add spaces around the operator
+ // Add spaces around the operator, if...
+ else if (
+ // Previous is a digit
+ (prev >= ZERO && prev <= NINE) ||
+ // Next is a digit
+ (next >= ZERO && next <= NINE) ||
+ // Previous is end of a function call (or parenthesized expression)
+ prev === CLOSE_PAREN ||
+ // Next is start of a parenthesized expression
+ next === OPEN_PAREN ||
+ // Next is an operator
+ next === ADD ||
+ next === MUL ||
+ next === DIV ||
+ next === SUB ||
+ // Previous position was a value (+ unit)
+ (lastValuePos !== null && lastValuePos === i - 1)
+ ) {
+ result += ` ${input[i]} `
+ }
+
+ // Everything else
else {
- result += ` ${char} `
+ result += input[i]
}
}
- // Skip over `to-zero` when in a math function.
- //
- // This is specifically to handle this value in the round(…) function:
- //
- // ```
- // round(to-zero, 1px)
- // ^^^^^^^
- // ```
- //
- // This is because the first argument is optionally a keyword and `to-zero`
- // contains a hyphen and we want to avoid adding spaces inside it.
- else if (formattable[0] && input.startsWith('to-zero', i)) {
- let start = i
- i += 7
- result += input.slice(start, i + 1)
- }
-
// Handle all other characters
else {
- result += char
+ result += input[i]
}
}
- if (hasKnownFunctions) {
- return result.replace(/\$(\d+)\$/g, (fn, idx) => KNOWN_DASHED_FUNCTIONS[idx] ?? fn)
- }
-
return result
}