`canonicalize` のコラプス処理が任意値(arbitrary value)に対応
px-[1.2rem] py-[1.2rem] のような任意値ペアが p-[1.2rem] にコラプスされない問題が修正されました。これにより、--stream モードで発生していた非決定的な出力も解消されます。
背景
canonicalize コマンドには、px-* と py-* のペアを p-* へ短縮するコラプス処理が実装されています。しかしこのコラプス処理は、名前付き値(named value)に対してのみ動作しており、任意値(arbitrary value)には一切適用されていませんでした。
この制限が #19835 の根本原因でした。--stream モードでは、同一ストリーム内で先に p-[1.2rem] が処理されると、そのショートハンドが STATIC_UTILITIES_KEY にキャッシュされる副作用が生じます。後続の行でこのキャッシュが参照されることで偶発的にコラプスが成功し、「単独で渡した場合はコラプスされないが、先行入力があるとコラプスされる」という非決定的な挙動が生まれていました。
問題の本質はストリームの状態管理ではなく、任意値のコラプスがそもそも未対応だったことにあります。--stream モードはエディタやフォーマッタとの統合用途で永続プロセスとして動作するため、この挙動は複数ファイルを処理する際に冪等性を損なうなど、実用上の深刻な問題を引き起こしていました。
技術的な変更
修正の核心は、canonicalize-candidates.ts の collapseCandidates 関数内にある1行のガード条件の変更です。
変更前:
if (
parsedCandidate.kind !== 'functional' ||
parsedCandidate.value?.kind !== 'named' // Necessary for bare values
) {
continue
}
変更後:
if (parsedCandidate.kind !== 'functional' || parsedCandidate.value === null) {
continue
}
変更前のガードは parsedCandidate.value?.kind !== 'named' という条件で、named 以外のすべての値種別——任意値(arbitrary value)を含む——を continue でスキップしていました。変更後は parsedCandidate.value === null、すなわちベア値(p-4 のように値を持たないケース)のみをスキップします。任意値は value が非 null であるため、コラプス処理のループに進むようになります。
コメントにあった「Necessary for bare values」という意図はそのまま保持されており、cloneCandidate と printCandidate が既に任意値を処理できていたため、根回り(root-swapping)のロジック自体は変更不要でした。また、dynamicUtilities のイテレーション対象は designSystem.utilities.keys('functional') という固定のルートセットであり、入力量に比例しないため、パフォーマンスへの影響は無視できるとされています。
テストケースでは、以下の2パターンが追加されています:
-
px-[1.2rem] py-[1.2rem]→p-[1.2rem](任意値→任意値へのコラプス) -
px-[30.75rem] py-[30.75rem]→p-123(任意値→名前付き値へのコラプス)
設計判断
最小限の変更で根本原因を修正するアプローチが取られました。
非決定的挙動の直接の原因は --stream モードのキャッシュ副作用ですが、修正はその副作用の制御ではなく、「任意値のコラプスをそもそも正しく動作させる」という根治策が選ばれています。コラプス処理が常に正しく機能するようになれば、キャッシュの有無に関わらず出力が一定になるためです。
変更は4行削除・1行追加という最小限の差分であり、既存の cloneCandidate / printCandidate の任意値対応を活用することで、ロジックの重複や新たな分岐の追加を避けています。
まとめ
本修正は、コラプス処理のガード条件を1行緩和することで、任意値に対する正しいショートハンド変換を実現しました。--stream モードの非決定的挙動はこの欠落に起因していたため、根本原因の修正によってエディタ統合やフォーマッタ用途での信頼性が向上します。