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:
parent
829cdc930a
commit
d4f24c5f16
4 changed files with 83 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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%'],
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue