`calc()` 内の `0<unit>` を誤って `0` に正規化するバグを修正
px-[calc(1rem+0px)] のような任意値クラスを正規化する際、calc() 内の 0px を 0 に書き換えることで有効なCSSが無効になる不具合を修正しました。正規化ロジックが適用される文脈(トップレベルか関数の引数か)を考慮するようになっています。
背景
Tailwind CSS v4の canonicalizeCandidates は、m-[0rem] を m-0 のように書き換える「正規化(canonicalization)」を行い、等価なクラスを検出して suggestCanonicalClasses によるリント提案を実現しています。この正規化の一環として、0<unit> 形式(例: 0px、0rem)を単純な 0 に畳み込む処理が実装されていました。
この畳み込みはトップレベルの <length> 型プロパティには安全です。しかし tailwindcss-intellisense#1579 で報告されたように、calc() の内部では同じ変換が無効なCSSを生成する原因になります。calc(1rem + 0px) は有効なCSSですが、0px を 0 に置き換えた calc(1rem + 0) はブラウザが拒否する無効な式です(CSS.supports("padding-inline", "calc(1rem + 0)") が false を返すことで確認できます)。
問題は演算子によっても挙動が異なります。加算(+)では有効から無効への劣化が起きる一方、乗算(*)では calc(1rem * 0px) はそもそも px² という無効な単位を生じる無効な式であり、calc(1rem * 0) は有効になります。単純な「0<unit> → 0」の一律変換では、このコンテキスト依存の正確さを表現できませんでした。
技術的な変更
constant-fold-declaration.ts の constantFoldDeclarationAst 関数に、正規化後の値が 0 になるケースでの文脈チェックが追加されました。
変更前は、canonicalizeDimension が 0 を返した時点で無条件に書き換えていました。
// 変更前(概略)
if (canonical === null) return
if (canonical === valueNode.value) return
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(canonical))
変更後は、canonical === '0' かつ親ノードが function(calc() など)の場合、単位なしの 0 への置き換えを行わず、代わりに canonicalizeDimension を false(単位保持モード)で呼び出して 0px のような単位付きの値を保持します。
if (canonical === '0') {
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))
このために exit コールバックのシグネチャも exit(valueNode) から exit(valueNode, ctx) に変更され、ウォーカーのコンテキスト(親ノードへの参照)を受け取れるようになっています。
乗算の畳み込みにも同様の文脈対応が追加されました。0<unit> * unitless のパターンは関数内では 0<unit> に、トップレベルでは 0 に畳み込まれます。
// 0<unit> * something-without-unit を折りたたむ
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'))
}
}
テストケースでは、この変更による具体的な挙動が文書化されています。
-
calc(-0px * -1)→0(トップレベルでは単位を除去) -
calc(calc(0px * -1) + 1rem)→calc(0px + 1rem)(ネストされたcalcでは単位を保持) -
px-[calc(1rem+0px)]→px-4(1rem = 16px、16px + 0px = 16px = 1rem = spacing-4として折りたたまれる)
またCSSカスタムプロパティ(--foo:0px)は型が不明なため、0<unit> → 0 の変換が行われなくなっています。[--foo:0px] の正規化候補は [--foo:0px] のまま維持されます。
設計判断
「文脈に応じた畳み込み」 の方針が採用され、変換の安全性を親ノードの種別で判定するアプローチが取られました。
PR本文では calc(0s + 1rem) のような型不一致の無効式を 1rem に畳み込む最適化の可能性にも言及していますが、意図的に実装を見送っています。無効なCSSを有効なCSSへと変換することで、本来ユーザーが意図しない正規化候補が提案されるリスクがあるためです。「無効から有効への昇格は行わない」という保守的な判断が一貫して適用されています。
CSSカスタムプロパティへの対処も同様の原則に基づいています。型システムが関与しない変数値については、正規化の安全性が保証できないため変換対象から除外されています。
まとめ
0<unit> の畳み込みに「親ノードが関数か否か」という文脈チェックを加えることで、suggestCanonicalClasses が無効なCSSを提案するケースを排除しました。「無効から有効への変換は行わない」という一貫した原則のもと、型の安全性が保証できないコンテキストでは積極的な最適化を意図的に避ける設計判断が随所に見られます。