Skip to content

fix: don't break CSS keywords when formatting math expressions #18220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jun 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 23 additions & 1 deletion packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'],

Expand All @@ -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))',
Expand Down Expand Up @@ -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))'],
Expand Down
139 changes: 85 additions & 54 deletions packages/tailwindcss/src/utils/math-operators.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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}(`))
}
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
else {
result += ` ${char} `
// 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]} `
}
}

// 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)
// Everything else
else {
result += input[i]
}
}

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