Skip to content
Closed
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 @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Canonicalization: prevent `collapse: true` from taking exponentially longer when classes are linked through a utility that sets many properties (e.g. `sr-only`) ([#20558](https://github.com/tailwindlabs/tailwindcss/pull/20558))
- Ensure `&` is substituted inside `:-moz-any(…)` and `:-webkit-any(…)` in custom variants (e.g. `@custom-variant firefox (:-moz-any(&));`) ([#20486](https://github.com/tailwindlabs/tailwindcss/pull/20486))
- Ensure the standalone macOS binaries have a valid code signature, so macOS 27 no longer kills them at launch ([#20578](https://github.com/tailwindlabs/tailwindcss/pull/20578))
- Canonicalization: round constant folded `calc(…)` results to get rid of floating point noise (e.g. `w-[calc(0.1px+0.2px)]` → `w-[0.3px]`, not `w-[0.30000000000000004px]`) ([#20580](https://github.com/tailwindlabs/tailwindcss/pull/20580))

## [4.3.3] - 2026-07-16

Expand Down
5 changes: 5 additions & 0 deletions packages/tailwindcss/src/canonicalize-candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,11 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
// https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1591
test.each([
['w-[calc(100%/3.5)]', 'w-[calc(100%/3.5)]'], // Not w-[28.571428571428573%]
['w-[calc(0.1px+0.2px)]', 'w-[0.3px]'], // Not w-[0.30000000000000004px]
['m-[calc(0.3px-0.1px)]', 'm-[0.2px]'], // Not m-[0.19999999999999998px]
['w-[calc(0.1*3px)]', 'w-[0.3px]'], // Not w-[0.30000000000000004px]
['w-[calc(0.3px/3)]', 'w-[0.1px]'], // Not w-[calc(0.3px/3)]
['w-[calc(0.1px+calc(0.2px+var(--foo)))]', 'w-[calc(0.3px+var(--foo))]'], // Not w-[calc(0.30000000000000004px+var(--foo))]
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
Expand Down
29 changes: 29 additions & 0 deletions packages/tailwindcss/src/constant-fold-declaration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,32 @@ it('should not constant fold when a computation has a high-precision result', ()
it('should constant fold division results with floating-point error after scaling', () => {
expect(constantFoldDeclaration('calc(29% / 100)')).toBe('0.29%')
})

it.each([
['calc(0.3px / 3)', '0.1px'],
['calc(0.3 / 3)', '0.1'],
])('should constant fold division `%s` with floating-point error into `%s`', (input, expected) => {
expect(constantFoldDeclaration(input)).toBe(expected)
})

it.each([
['calc(0.1px + 0.2px)', '0.3px'],
['calc(0.3px - 0.1px)', '0.2px'],
['calc(0.1 * 3px)', '0.3px'],
['calc(0.1rem * 3)', '0.3rem'],
['calc(0.1 + 0.2)', '0.3'],
['calc(3px * calc(0.1 * var(--foo)))', 'calc(0.3px * var(--foo))'],
['calc(0.1px + calc(0.2px + var(--foo)))', 'calc(0.3px + var(--foo))'],
['calc(calc(0.1px + 0.2px) / 2)', '0.15px'],
])('should constant fold `%s` without floating-point error into `%s`', (input, expected) => {
expect(constantFoldDeclaration(input)).toBe(expected)
})

// small values must not be rounded to `0` the result is left unfolded because
// the number is serialized using exponent notation, but it must keep its value
it.each([
['calc(calc(0.00000000001 * 1) * 100000000000)', 'calc(1e-11 * 100000000000)'],
['calc(100000000000000px * calc(0.000001 * 0.000001))', 'calc(100000000000000px * 1e-12)'],
])('should not round small values to `0` in `%s`', (input, expected) => {
expect(constantFoldDeclaration(input)).toBe(expected)
})
17 changes: 11 additions & 6 deletions packages/tailwindcss/src/constant-fold-declaration.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { dimensions } from './utils/dimensions'
import { isLength } from './utils/infer-data-type'
import { roundFloatNoise } from './utils/round-float-noise'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'

Expand Down Expand Up @@ -205,11 +206,11 @@ export function constantFoldDeclarationAst(
let combined: string
switch (operator) {
case '*': {
combined = `${constant[0] * known[0]}${constant[1] ?? known[1] ?? ''}`
combined = `${roundFloatNoise(constant[0] * known[0])}${constant[1] ?? known[1] ?? ''}`
break
}
case '+': {
combined = `${constant[0] + known[0]}${constant[1] ?? known[1] ?? ''}`
combined = `${roundFloatNoise(constant[0] + known[0])}${constant[1] ?? known[1] ?? ''}`
break
}

Expand Down Expand Up @@ -254,7 +255,7 @@ export function constantFoldDeclarationAst(
) {
folded = true
return WalkAction.ReplaceSkip(
ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? rhs[1] ?? ''}`),
ValueParser.word(`${roundFloatNoise(lhs[0] * rhs[0])}${lhs[1] ?? rhs[1] ?? ''}`),
)
}
break
Expand All @@ -265,7 +266,9 @@ export function constantFoldDeclarationAst(
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `1rem + 2rem`, `8 + 6`
) {
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(`${lhs[0] + rhs[0]}${lhs[1] ?? ''}`))
return WalkAction.ReplaceSkip(
ValueParser.word(`${roundFloatNoise(lhs[0] + rhs[0])}${lhs[1] ?? ''}`),
)
}
break
}
Expand All @@ -275,7 +278,9 @@ export function constantFoldDeclarationAst(
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `2rem - 1rem`, `8 - 6`
) {
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(`${lhs[0] - rhs[0]}${lhs[1] ?? ''}`))
return WalkAction.ReplaceSkip(
ValueParser.word(`${roundFloatNoise(lhs[0] - rhs[0])}${lhs[1] ?? ''}`),
)
}
break
}
Expand All @@ -286,7 +291,7 @@ export function constantFoldDeclarationAst(
((lhs[1] === null && rhs[1] === null) || // Unitless / Unitless, e.g.: `8 / 2`
(lhs[1] !== null && rhs[1] === null)) // Unit / Unitless, e.g.: `1rem / 2`
) {
let computed = lhs[0] / rhs[0]
let computed = roundFloatNoise(lhs[0] / rhs[0])

// Only fold with .xx precision, anything beyond that might be a bit too much.
//
Expand Down
35 changes: 35 additions & 0 deletions packages/tailwindcss/src/utils/round-float-noise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, it } from 'vitest'
import { roundFloatNoise } from './round-float-noise'

it.each([
[0.1 + 0.2, 0.3], // +
[0.3 - 0.1, 0.2], // -
[0.1 * 3, 0.3], // *
[0.3 / 3, 0.1], // /
[-(0.1 + 0.2), -0.3], // negative
])('should drop floating point noise from `%s` into `%s`', (input, expected) => {
expect(roundFloatNoise(input)).toBe(expected)
})

it.each([0, -0, 1, -1, 42, 100000000000000, 1e21])('should keep the integer `%s`', (input) => {
expect(roundFloatNoise(input)).toBe(input)
})

it.each([0.5, 0.25, 1.21, 28.5714285714286, -3.75])('should keep the clean value `%s`', (input) => {
expect(roundFloatNoise(input)).toBe(input)
})

it.each([1e-12, 1e-300, 1.5e-7, 123456789.123])(
'should keep small and large values intact `%s`',
(input) => {
expect(roundFloatNoise(input)).toBe(input)
},
)

it.each([Infinity, -Infinity])('should keep `%s`', (input) => {
expect(roundFloatNoise(input)).toBe(input)
})

it('should keep `NaN`', () => {
expect(roundFloatNoise(NaN)).toBeNaN()
})
8 changes: 8 additions & 0 deletions packages/tailwindcss/src/utils/round-float-noise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// rounds to 15 significant digits to drop floating-point noise, e.g.:
// 0.1 + 0.2 is 0.30000000000000004 in JS, but should be 0.3
// significant digits (not decimal places) are used so tiny values such as
// 1e-12 are preserved instead of collapsing to 0
export function roundFloatNoise(value: number) {
if (Number.isInteger(value)) return value
return Number(value.toPrecision(15))
}