`not-*` バリアントによる `@container` クエリの否定が正しく生成されるように修正
カスタムバリアントに @container style(…) を使った場合、not-* バリアントが不正な CSS を生成するバグが修正されました。文字列ベースの解析から AST ベースの解析へと変更することで、クエリの種類を正確に判定できるようになっています。
背景
#20058 が発端です。@container style(…) を使ったカスタムバリアントに not-* を組み合わせると、not キーワードの挿入位置が誤っており、ブラウザが解釈できない無効な CSS が出力されていました。
問題を再現する最小構成は次のとおりです。@custom-variant has-b を定義して not-has-b:bg-green-200 を使うと、生成される CSS は以下のようになっていました。
.not-has-b\:bg-green-200 {
@container style(--b) not {
background-color: var(--color-green-200);
}
}
not は @container の条件部(クエリ)の前に置く必要がありますが、実際には条件部の後ろに付加されており、CSS として無効な構文になっていました。
技術的な変更
根本原因は negateConditions 関数における文字列パターンマッチの前提条件にありました。従来の実装は、@container のクエリ部が必ず ( で始まるという暗黙の仮定を持っており、condition.startsWith('(') でその判定を行っていました。しかし style(…) のような関数記法は ( ではなく s で始まるため、この分岐に到達できず not が末尾に追記されていました。
変更前:
function negateConditions(ruleName: string, conditions: string[]) {
return conditions.map((condition) => {
condition = condition.trim()
let parts = segment(condition, ' ')
// @media not {query}
// @supports not {query}
// @container not {query}
if (parts[0] === 'not') {
return parts.slice(1).join(' ')
}
// @container {query} → クエリが `(` で始まる場合のみ `not` を前置
if (ruleName === '@container' && condition.startsWith('(')) {
return `not ${condition}`
}
// ...
})
}
この condition.startsWith('(') というチェックが問題の核心です。style(--b) は ( ではなく s から始まるため、このガード条件をパスできず、後続のフォールバック処理によって not がクエリの末尾に追加されていました。
修正では ValueParser モジュールを新たにインポートし、条件文字列を AST にパースしてノードの種類(kind)で判定するように変更されています。
変更後:
function negateConditions(ruleName: string, conditions: string[]) {
return conditions.map((condition) => {
switch (ruleName) {
case '@container': {
let ast = ValueParser.parse(condition.trim())
// @container {query} → 先頭ノードが function なら `not` を前置
if (ast.length >= 1 && ast[0].kind === 'function') {
return `not ${condition}`
}
// @container not {query} → 先頭の `not` と区切りを除去
else if (
ast.length >= 3 &&
ast[0].kind === 'word' && ast[0].value === 'not' &&
ast[2].kind === 'function'
) {
ast.splice(0, 2)
return ValueParser.toCss(ast)
}
// @container {name} not {query} → インデックス 2 以降を操作
// ...
}
}
})
}
ここで重要なのは kind === 'function' の判定です。ValueParser は (…) も style(…) も同じ function ノードとして扱うため、どちらの形式でも正しく機能します。さらに、名前付きコンテナ(@container foo style(--c) {…})や既に not が付いている場合(@container not style(--b) {…})、名前付き+否定済み(@container bar not style(--d) {…})のケースもすべて網羅されています。
テストでは以下の 4 パターンが追加されました。
-
@container style(--a) { @slot; }— 通常の style クエリ(バグの直接再現) -
@container not style(--b) { @slot; }— 既に否定済みのケース(否定を解除する) -
@container foo style(--c) { @slot; }— 名前付きコンテナ -
@container bar not style(--d) { @slot; }— 名前付き+否定済み
設計判断
文字列操作から AST 操作への移行が本 PR の核心的な設計判断です。
従来のアプローチは condition.startsWith('(') という先頭文字のチェックでクエリの種類を判定していました。これは (…) 形式のクエリには機能しますが、style(…) のように先頭文字が異なる関数記法に対しては破綻します。一方、ValueParser を使えばトークンの種別(word / function / separator など)が明確になり、表記の差異に左右されない判定が可能です。
@container 以外のアットルール(@media、@supports)については引き続き従来の文字列ベースの処理が適用されます。これらのルールは style(…) 関数を使わず単純なキーワード列で構成されるため、AST 解析が不要であると判断されています。@container ケースのみを switch で分岐することで、変更の影響範囲を最小限に抑えています。
まとめ
本 PR は、CSS Container Queries における style(…) 関数記法という特殊な構文に対し、startsWith('(') という文字列パターンマッチの限界を AST 解析で克服した修正です。not-* バリアントの自動否定ロジックが (…) と style(…) の両形式を等価に扱えるようになり、カスタムバリアントの組み合わせの信頼性が高まっています。