Fix invalid canonicalization where 0<unit> was migrated to 0 (#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:
https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1579
This commit is contained in:
Robin Malfait 2026-05-29 14:43:26 +02:00 • committed by GitHub
parent 829cdc930a
commit d4f24c5f16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 83 additions and 7 deletions

View file

@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure `drop-shadow-*` color utilities work with custom shadow values containing `calc(…)` ([#20080](https://github.com/tailwindlabs/tailwindcss/pull/20080))
- Fix 'Sourcemap is likely to be incorrect' warnings when using `@tailwindcss/vite` ([#20103](https://github.com/tailwindlabs/tailwindcss/pull/20103))
- Ensure `@tailwindcss/webpack` can be installed in Rspack projects without requiring `webpack` as a peer dependency ([#20027](https://github.com/tailwindlabs/tailwindcss/pull/20027))
- 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))
## [4.3.0] - 2026-05-08

View file

@ -288,6 +288,12 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
['[font-weight:400]', 'font-normal'],
['[line-height:0]', 'leading-0'],
['[border-style:solid]', 'border-solid'],
// Do not constant fold `0<unit>` to `0` when the type is unknown (which
// is often the case with CSS variables)
['[--foo:0px]', '[--foo:0px]'],
['[--foo:calc(0px*1)]', '[--foo:calc(0px*1)]'],
['[--foo:calc(0*1rem)]', '[--foo:calc(0*1rem)]'],
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
@ -1467,4 +1473,19 @@ describe('regressions', () => {
).toEqual(expect.arrayContaining(['border-[1.5px]', 'flex']))
},
)
// https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1579
test('does not suggest invalid alternative when canonicalizing calc expressions', async () => {
let designSystem = await designSystems.get(__dirname).get(css`
@import 'tailwindcss';
`)
let options: CanonicalizeOptions = {
collapse: true,
logicalToPhysical: true,
rem: 16,
}
expect(designSystem.canonicalizeCandidates(['px-[calc(1rem+0px)]'], options)).toEqual(['px-4'])
})
})

View file

@ -62,6 +62,7 @@ it.each([
['calc(3rem * 2dvh)'],
['calc(5rem / 17px)'],
['calc(2rem * calc(3px * var(--foo)))'],
['calc(1rem + 0px + var(--foo))'],
])('should not constant fold different units `%s`', (input) => {
expect(constantFoldDeclaration(input)).toBe(input)
})
@ -77,7 +78,6 @@ it.each([
['calc(var(--foo) * 0)'],
['calc(calc(var(--spacing, 0.25rem) * 32) * 0)'],
['calc(var(--spacing, 0.25rem) * -0)'],
['calc(-0px * -1)'],
// Zeroes
['0px'],
@ -88,11 +88,24 @@ it.each([
['+0'],
['-0.0rem'],
['+0.00rem'],
])('should constant fold `%s` to `0`', (input) => {
// Expressions
['calc(-0px * -1)'],
['calc(-1 * -0px)'],
])('should constant fold `%s` to `0` (%#)', (input) => {
expect(constantFoldDeclaration(input)).toBe('0')
})
it.each([
// Expressions, keep unit when they are nested
//
// TODO: We might be able to fold this further to just `1rem`, but we can't do
// that for any `0<unit>`. E.g.: `calc(0s + 1rem)` which is invalid, would
// become valid if we just use `1rem`.
['calc(calc(0px * -1) + 1rem)', 'calc(0px + 1rem)'],
['calc(calc(-1 * 0px) + 1rem)', 'calc(0px + 1rem)'],
// Non-foldable units
['0deg', '0deg'],
['0rad', '0deg'],
['0%', '0%'],

View file

@ -23,10 +23,10 @@ export function constantFoldDeclarationAst(
let folded = false
walk(ast, {
exit(valueNode) {
exit(valueNode, ctx) {
// Canonicalize dimensions to their simplest form. This includes:
// - Convert `-0`, `+0`, `0.0`, … to `0`
// - Convert `-0px`, `+0em`, `0.0rem`, … to `0`
// - Convert `-0px`, `+0em`, `0.0rem`, … to `0<unit>`
// - Convert units to an equivalent unit
if (
valueNode.kind === 'word' &&
@ -36,6 +36,24 @@ export function constantFoldDeclarationAst(
if (canonical === null) return // Couldn't be canonicalized, nothing to do
if (canonical === valueNode.value) return // Already in canonical form, nothing to do
// We need to be careful with `0` because `0<unit>` can only be
// converted to `0` if we're dealing with a `<length>` type.
if (canonical === '0') {
// When used inside of a function such as `calc(…)`, then this isn't
// always safe to convert to `0`.
//
// E.g.:
// - `calc(0px + 1rem)` → `calc(0 + 1rem)` this goes from valid to invalid
// - `calc(0px * 1rem)` → `calc(0 * 1rem)` this goes from invalid to valid
if (ctx.parent?.kind === 'function') {
let withUnit = canonicalizeDimension(valueNode.value, rem, false)
if (withUnit === null) return
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(withUnit))
}
}
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(canonical))
}
@ -74,6 +92,26 @@ export function constantFoldDeclarationAst(
return WalkAction.ReplaceSkip(ValueParser.word('0'))
}
// Fold `0<unit> * something-without-unit` to just `0<unit>`, inside of a function such as `calc(…)`
if (operator === '*' && lhs?.[0] === 0 && lhs?.[1] !== null && rhs?.[1] === null) {
folded = true
if (ctx.parent?.kind === 'function') {
return WalkAction.ReplaceSkip(ValueParser.word(`0${lhs[1]}`))
} else {
return WalkAction.ReplaceSkip(ValueParser.word('0'))
}
}
// Fold `something-without-unit * 0<unit>` to just `0<unit>`, inside of a function such as `calc(…)`
if (operator === '*' && rhs?.[0] === 0 && rhs?.[1] !== null && lhs?.[1] === null) {
folded = true
if (ctx.parent?.kind === 'function') {
return WalkAction.ReplaceSkip(ValueParser.word(`0${rhs[1]}`))
} else {
return WalkAction.ReplaceSkip(ValueParser.word('0'))
}
}
if (operator === '*') {
// Multiplying by `1` can always unwrap the other side, even when that
// side is an expression like `var(--foo)` that we can't fully fold.
@ -142,8 +180,8 @@ export function constantFoldDeclarationAst(
return
}
// `+` requires that the units are the same. Adding a unitless
// value to a value with a unit is not allowed.
// `+` requires that both values being unitless, or both values
// have a unit. Mixed units are valid, but we won't fold these.
//
// - `2 + 3` → valid
// - `4rem + 5` → invalid
@ -273,7 +311,10 @@ function canonicalizeDimension(
if (unit === null) return `${value}` // Already unitless, nothing to do
// Replace `0<length>` units with just `0`
if (value === 0 && isLength(input)) return '0'
if (value === 0 && isLength(input)) {
if (normalizeUnit) return '0'
else return `0${unit}` // Keep unit
}
// Only normalize into base units when necessary
if (!normalizeUnit) return `${input}`