`not-*` バリアントによる `@container` クエリの否定が正しく生成されるように修正

tailwindlabs/tailwindcss

カスタムバリアントに @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(…) の両形式を等価に扱えるようになり、カスタムバリアントの組み合わせの信頼性が高まっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
348ef665

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
3回 (改善を経て承認)
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という理想的な3部構成が明確に適用されており、非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```typescript:packages/tailwindcss/src/variants.ts)やGitHubへのリンク記法([#20058](URL))がガイドライン通り正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

AST、アットルール、バリアントといった専門用語を前提としており、説明も簡潔で、対象読者である専門知識を持つエンジニアに最適なレベルで書かれています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論・各論・結論で構成され、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が徹底されています。非常に読みやすい構造です。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されている変更前後のコードやテストケースが、提供されたDiff情報とファイルパスを含めて正確に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PR内で使用されている「AST」「ValueParser」「style()クエリ」といった技術用語を、文脈に沿って正確に使用できています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

「文字列操作の限界をAST解析で克服した」という修正の核心を、PR情報に基づいて技術的に正確かつ論理的に解説できています。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべてPRのタイトル、Description、Diff内のコードによって裏付けられており、根拠のない推測や憶測(ハルシネーション)は一切見られませんでした。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#20059)、Issue番号(#20058)、ファイルパスなどの数値・固有名詞がすべて正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルはPRの主題を的確に要約しており、記事全体の内容と完全に一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報に記載のないバージョン情報やリリース予定などの外部知識を持ち込むことなく、提供された情報源に忠実に記述されています。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「修正されました」といった時間表現がPRの内容(完了した修正)と一致しており、事実関係を正確に伝えています。