From d4f24c5f161b553dd99aef294ef6635093d1c440 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 29 May 2026 14:43:26 +0200 Subject: [PATCH] Fix invalid canonicalization where `0` was migrated to `0` (#20127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes a bug in the canonicalization process when we simplify / fold declarations that contain `0` 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 `` type, and that value is `0`, then we can safely change that to just `0`. ```css width: 0rem; width: 0; /* `0` is a */ ``` 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 --- CHANGELOG.md | 1 + .../src/canonicalize-candidates.test.ts | 21 ++++++++ .../src/constant-fold-declaration.test.ts | 17 ++++++- .../src/constant-fold-declaration.ts | 51 +++++++++++++++++-- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df1374316..29a613bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 81a53929f..6c568c51c 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -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` 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']) + }) }) diff --git a/packages/tailwindcss/src/constant-fold-declaration.test.ts b/packages/tailwindcss/src/constant-fold-declaration.test.ts index c87eb4dd6..c48b64b34 100644 --- a/packages/tailwindcss/src/constant-fold-declaration.test.ts +++ b/packages/tailwindcss/src/constant-fold-declaration.test.ts @@ -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`. 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%'], diff --git a/packages/tailwindcss/src/constant-fold-declaration.ts b/packages/tailwindcss/src/constant-fold-declaration.ts index c86b79188..7c54ae414 100644 --- a/packages/tailwindcss/src/constant-fold-declaration.ts +++ b/packages/tailwindcss/src/constant-fold-declaration.ts @@ -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` // - 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` can only be + // converted to `0` if we're dealing with a `` 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 * something-without-unit` to just `0`, 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` to just `0`, 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` 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}`