同名の `@utility` を複数定義して値の型ごとに使い分けられるように
CSS の @utility に同じ名前で異なる値型のハンドラーを複数定義した場合、最初のハンドラーが null を返すと後続のハンドラーが試されずコンパイルが止まっていた問題が修正されました。これにより、foo-red-500(カラー)と foo-123(数値)のように同名のユーティリティクラスを値の型ごとに定義して共存させることができます。
背景
@utility foo-* を同じ名前で複数定義し、それぞれ異なる値型を扱うケースは、Tailwind CSS 組み込みの text-red-500(カラー)と text-2xl(フォントサイズ)のように自然なパターンです。しかし #16948 で報告されたように、このアプローチは期待通りに動作しませんでした。
問題の核心は、compile.ts 内でユーティリティのハンドラーをループする際の null の解釈にありました。ハンドラーが「この値は自分の担当ではない」を示すために null を返すと、コンパイルループがそこで完全に終了し、後続のハンドラーに制御が移りませんでした。定義の順序が foo-red-500 か foo-123 かのどちらが動くかを決定してしまっていたのです。
この挙動は matchUtilities(JSプラグイン)との区別がなかったことが原因です。matchUtilities では型を明示的に指定することで「この型以外は無効」というセマンティクスを持ちますが、CSS の @utility はそのような型オプションを持たないため、異なるセマンティクスが適切です。
技術的な変更
packages/tailwindcss/src/compile.ts の compileBaseUtility 関数内、null チェック箇所の挙動が utility.options?.types の有無によって分岐するように変更されました。
変更前:
let compiledNodes = utility.compileFn(candidate)
if (compiledNodes === undefined) continue
if (compiledNodes === null) return asts
asts.push(compiledNodes)
変更後:
let compiledNodes = utility.compileFn(candidate)
if (compiledNodes === undefined) continue
if (compiledNodes === null) {
// `null` means that the result is invalid and that this plugin should not
// result in any CSS, but that doesn't mean that subsequent plugins are
// invalid as well.
//
// However, for backwards compatibility with `matchUtilities` this means
// that we do need to bail entirely: plugins that handle a specific
// arbitrary value type prevent falling through to other plugins if the
// result is invalid for that plugin
if (utility.options?.types?.length) return asts
continue
}
asts.push(compiledNodes)
変更はシンプルで、null の意味を options.types の有無 で二分しています。types が指定されている場合(matchUtilities 由来)はこれまで通り return asts で即座に終了します。types がない場合(CSS @utility 由来)は continue で次のハンドラーへ進みます。この分岐は compileBaseUtility 内の2つのループブロック(通常ハンドラーと fallback ハンドラー)に同一の変更として適用されています。
追加されたテストでは以下のように同名の @utility を2つ定義し、カラー値と数値の両方が正しくコンパイルされることを検証しています。
@utility foo-* {
color: --value(--color-*);
}
@utility foo-* {
font-size: --spacing(--value(number));
}
foo-red-500 と foo-123 を入力した場合、それぞれ .foo-red-500 { color: var(--color-red-500); } と .foo-123 { font-size: calc(var(--spacing) * 123); } が出力されるようになります。
設計判断
null のセマンティクスをハンドラーの種別によって分離するアプローチが採用されました。
null が「無効な値」を示すシグナルであることは変わりませんが、「無効」の帰結が異なります。CSS @utility(型オプションなし)では「このハンドラーには合わないが、次を試す余地がある」と解釈され、matchUtilities(型オプションあり)では「この型では明示的に無効であり、後続も試さない」と解釈されます。後者の挙動はこれまでと同じです。
この設計判断は後方互換性を最優先にしています。matchUtilities を使う既存のJSプラグインにとって、型チェックの厳密さは意図的な機能です。ある型を指定して null を返したハンドラーの後に、別の型を期待する別のハンドラーが動いてしまうと、型安全性の保証が崩れます。utility.options?.types?.length という判定は、オプトアウトではなく既存の動作を明示的に保護するものです。
まとめ
compile.ts の null 処理に1行の条件分岐を加えることで、CSS @utility と JS matchUtilities の意味論の違いを明確に体現しました。@utility による同名マルチハンドラー定義は、Tailwind 組み込みユーティリティと同様のパターンをカスタムユーティリティでも実現できるようになり、matchUtilities の型安全性も完全に維持されます。