Skip to content

Commit d4f24c5

Browse files
authored
Fix invalid canonicalization where 0<unit> was migrated to 0 (tailwindlabs#20127)
This PR fixes a bug in the canonicalization process when we simplify / fold declarations that contain `0<unit>` values. The reason we even try to fold these in the first place is to simplify values such as `m-[0rem]` to `m-0`. The more values we can fold/canonicalize, the better we can suggest replacements _if_ they are the same. One thing we know in CSS is that if you have a `<length>` type, and that value is `0<unit>`, then we can safely change that to just `0`. ```css width: 0rem; width: 0; /* `0` is a <length> */ ``` However, if this was part of a `calc(…)` (or another CSS math function), then this could make the calc expression invalid: - `calc(1rem + 0px)` → `calc(1rem + 0)` — this goes from _valid_ to _invalid_ At runtime the `1rem` can be converted to a `px` based valued, then `16px + 0px` makes sense. Adding `0` without unit does not. - `calc(1rem * 0px)` → `calc(1rem * 0)` — this goes from _invalid_ to _valid_ At runtime the `1rem` can be converted to a `px` based value, but `16px * 0px` would result in `0px^2` which doesn't make sense either. We will still normalize values such as `-0.0rem` to just `0rem`, but not `0` if we know it's unsafe to do so. If we end up with top-level `calc(…)` expressions that can be folded, then we will try to do that: - `calc(0px * -1)` → `0` - `calc(calc(0px * -1) + 1rem)` → `calc(0px + 1rem)` Notice that the inner `calc(…)` was folded to `0px` not `0` because that would make the `calc(0 + 1rem)` invalid. Additionally, we could potentially fold the `calc(0px + 1rem)` to just `1rem`, but we have to make sure that we don't introduce valid values from invalid values `calc(0s + 1rem)` would be invalid, but folding it to `1rem` would make it valid which is not good. Fixes: tailwindlabs/tailwindcss-intellisense#1579
1 parent 829cdc9 commit d4f24c5

4 files changed

Lines changed: 83 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- Ensure `drop-shadow-*` color utilities work with custom shadow values containing `calc(…)` ([#20080](https://github.com/tailwindlabs/tailwindcss/pull/20080))
2121
- Fix 'Sourcemap is likely to be incorrect' warnings when using `@tailwindcss/vite` ([#20103](https://github.com/tailwindlabs/tailwindcss/pull/20103))
2222
- Ensure `@tailwindcss/webpack` can be installed in Rspack projects without requiring `webpack` as a peer dependency ([#20027](https://github.com/tailwindlabs/tailwindcss/pull/20027))
23+
- Canonicalization: don't suggest invalid `calc(…)` expressions (e.g. `px-[calc(1rem+0px)]``px-[calc(1rem+0)]`) ([#20127](https://github.com/tailwindlabs/tailwindcss/pull/20127))
2324

2425
## [4.3.0] - 2026-05-08
2526

packages/tailwindcss/src/canonicalize-candidates.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,12 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
288288
['[font-weight:400]', 'font-normal'],
289289
['[line-height:0]', 'leading-0'],
290290
['[border-style:solid]', 'border-solid'],
291+
292+
// Do not constant fold `0<unit>` to `0` when the type is unknown (which
293+
// is often the case with CSS variables)
294+
['[--foo:0px]', '[--foo:0px]'],
295+
['[--foo:calc(0px*1)]', '[--foo:calc(0px*1)]'],
296+
['[--foo:calc(0*1rem)]', '[--foo:calc(0*1rem)]'],
291297
])(testName, { timeout }, async (candidate, expected) => {
292298
let input = css`
293299
@import 'tailwindcss';
@@ -1467,4 +1473,19 @@ describe('regressions', () => {
14671473
).toEqual(expect.arrayContaining(['border-[1.5px]', 'flex']))
14681474
},
14691475
)
1476+
1477+
// https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1579
1478+
test('does not suggest invalid alternative when canonicalizing calc expressions', async () => {
1479+
let designSystem = await designSystems.get(__dirname).get(css`
1480+
@import 'tailwindcss';
1481+
`)
1482+
1483+
let options: CanonicalizeOptions = {
1484+
collapse: true,
1485+
logicalToPhysical: true,
1486+
rem: 16,
1487+
}
1488+
1489+
expect(designSystem.canonicalizeCandidates(['px-[calc(1rem+0px)]'], options)).toEqual(['px-4'])
1490+
})
14701491
})

packages/tailwindcss/src/constant-fold-declaration.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ it.each([
6262
['calc(3rem * 2dvh)'],
6363
['calc(5rem / 17px)'],
6464
['calc(2rem * calc(3px * var(--foo)))'],
65+
['calc(1rem + 0px + var(--foo))'],
6566
])('should not constant fold different units `%s`', (input) => {
6667
expect(constantFoldDeclaration(input)).toBe(input)
6768
})
@@ -77,7 +78,6 @@ it.each([
7778
['calc(var(--foo) * 0)'],
7879
['calc(calc(var(--spacing, 0.25rem) * 32) * 0)'],
7980
['calc(var(--spacing, 0.25rem) * -0)'],
80-
['calc(-0px * -1)'],
8181

8282
// Zeroes
8383
['0px'],
@@ -88,11 +88,24 @@ it.each([
8888
['+0'],
8989
['-0.0rem'],
9090
['+0.00rem'],
91-
])('should constant fold `%s` to `0`', (input) => {
91+
92+
// Expressions
93+
['calc(-0px * -1)'],
94+
['calc(-1 * -0px)'],
95+
])('should constant fold `%s` to `0` (%#)', (input) => {
9296
expect(constantFoldDeclaration(input)).toBe('0')
9397
})
9498

9599
it.each([
100+
// Expressions, keep unit when they are nested
101+
//
102+
// TODO: We might be able to fold this further to just `1rem`, but we can't do
103+
// that for any `0<unit>`. E.g.: `calc(0s + 1rem)` which is invalid, would
104+
// become valid if we just use `1rem`.
105+
['calc(calc(0px * -1) + 1rem)', 'calc(0px + 1rem)'],
106+
['calc(calc(-1 * 0px) + 1rem)', 'calc(0px + 1rem)'],
107+
108+
// Non-foldable units
96109
['0deg', '0deg'],
97110
['0rad', '0deg'],
98111
['0%', '0%'],

packages/tailwindcss/src/constant-fold-declaration.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ export function constantFoldDeclarationAst(
2323
let folded = false
2424

2525
walk(ast, {
26-
exit(valueNode) {
26+
exit(valueNode, ctx) {
2727
// Canonicalize dimensions to their simplest form. This includes:
2828
// - Convert `-0`, `+0`, `0.0`, … to `0`
29-
// - Convert `-0px`, `+0em`, `0.0rem`, … to `0`
29+
// - Convert `-0px`, `+0em`, `0.0rem`, … to `0<unit>`
3030
// - Convert units to an equivalent unit
3131
if (
3232
valueNode.kind === 'word' &&
@@ -36,6 +36,24 @@ export function constantFoldDeclarationAst(
3636
if (canonical === null) return // Couldn't be canonicalized, nothing to do
3737
if (canonical === valueNode.value) return // Already in canonical form, nothing to do
3838

39+
// We need to be careful with `0` because `0<unit>` can only be
40+
// converted to `0` if we're dealing with a `<length>` type.
41+
if (canonical === '0') {
42+
// When used inside of a function such as `calc(…)`, then this isn't
43+
// always safe to convert to `0`.
44+
//
45+
// E.g.:
46+
// - `calc(0px + 1rem)` → `calc(0 + 1rem)` this goes from valid to invalid
47+
// - `calc(0px * 1rem)` → `calc(0 * 1rem)` this goes from invalid to valid
48+
if (ctx.parent?.kind === 'function') {
49+
let withUnit = canonicalizeDimension(valueNode.value, rem, false)
50+
if (withUnit === null) return
51+
52+
folded = true
53+
return WalkAction.ReplaceSkip(ValueParser.word(withUnit))
54+
}
55+
}
56+
3957
folded = true
4058
return WalkAction.ReplaceSkip(ValueParser.word(canonical))
4159
}
@@ -74,6 +92,26 @@ export function constantFoldDeclarationAst(
7492
return WalkAction.ReplaceSkip(ValueParser.word('0'))
7593
}
7694

95+
// Fold `0<unit> * something-without-unit` to just `0<unit>`, inside of a function such as `calc(…)`
96+
if (operator === '*' && lhs?.[0] === 0 && lhs?.[1] !== null && rhs?.[1] === null) {
97+
folded = true
98+
if (ctx.parent?.kind === 'function') {
99+
return WalkAction.ReplaceSkip(ValueParser.word(`0${lhs[1]}`))
100+
} else {
101+
return WalkAction.ReplaceSkip(ValueParser.word('0'))
102+
}
103+
}
104+
105+
// Fold `something-without-unit * 0<unit>` to just `0<unit>`, inside of a function such as `calc(…)`
106+
if (operator === '*' && rhs?.[0] === 0 && rhs?.[1] !== null && lhs?.[1] === null) {
107+
folded = true
108+
if (ctx.parent?.kind === 'function') {
109+
return WalkAction.ReplaceSkip(ValueParser.word(`0${rhs[1]}`))
110+
} else {
111+
return WalkAction.ReplaceSkip(ValueParser.word('0'))
112+
}
113+
}
114+
77115
if (operator === '*') {
78116
// Multiplying by `1` can always unwrap the other side, even when that
79117
// side is an expression like `var(--foo)` that we can't fully fold.
@@ -142,8 +180,8 @@ export function constantFoldDeclarationAst(
142180
return
143181
}
144182

145-
// `+` requires that the units are the same. Adding a unitless
146-
// value to a value with a unit is not allowed.
183+
// `+` requires that both values being unitless, or both values
184+
// have a unit. Mixed units are valid, but we won't fold these.
147185
//
148186
// - `2 + 3` → valid
149187
// - `4rem + 5` → invalid
@@ -273,7 +311,10 @@ function canonicalizeDimension(
273311
if (unit === null) return `${value}` // Already unitless, nothing to do
274312

275313
// Replace `0<length>` units with just `0`
276-
if (value === 0 && isLength(input)) return '0'
314+
if (value === 0 && isLength(input)) {
315+
if (normalizeUnit) return '0'
316+
else return `0${unit}` // Keep unit
317+
}
277318

278319
// Only normalize into base units when necessary
279320
if (!normalizeUnit) return `${input}`

0 commit comments

Comments
 (0)