正規化キャッシュの汚染による順序依存バグを修正

tailwindlabs/tailwindcss

Tailwind CSSの canonicalizeCandidates 関数において、複数回の呼び出しでキャッシュが汚染され、正規化結果が呼び出し順序に依存する不具合が修正されました。この問題は、eslint-plugin-better-tailwindcssなどのツールで非決定的な正規化結果を引き起こしていました。

背景

canonicalizeCandidates は、Tailwindのクラス名を正規化する関数です。{ collapse: true } オプションを指定すると、h-4w-4size-4 に統合するような最適化を行います。schoero/eslint-plugin-better-tailwindcss#321 では、並列処理環境下でこの正規化結果が不安定になる問題が報告されました。

並列処理を行うリンターでは、ファイルのリント順序が実行ごとに変わります。この順序の違いにより、同じクラス名の組み合わせでも size-4 に統合される場合とされない場合が発生していました。eslint-plugin-better-tailwindcssは canonicalizeCandidates を利用してクラス名を正規化するため、この順序依存性が直接的な問題となりました。

再現例では、['text-red-500', 'h-4', 'w-4'] の正規化結果が、事前に ['mb-4', 'text-4xl']['text-red-500', 'mb-4'] を正規化したかどうかで変わります。事前の正規化を行った後では、size-4 への統合が行われず ['text-red-500', 'h-4', 'w-4'] のまま返されていました。

技術的な変更

DefaultMap の自動挿入によるキャッシュ汚染を防ぐため、get() を呼び出す前に has() でキーの存在を確認する変更が行われました。DefaultMap.get は存在しないキーへのアクセス時に新しいエントリを自動挿入するため、中間的な参照操作が共有キャッシュを汚染していました。

変更箇所は packages/tailwindcss/src/canonicalize-candidates.tscollapseCandidates 関数内です。line-heightfont-size のプロパティ値を収集する2つのループで、pairs.has() チェックを追加しています:

let interestingLineHeights = new Set<string | number>()
let seenLineHeights = new Set<string>()
for (let pairs of candidatePropertiesValues) {
  if (!pairs.has('line-height')) continue

  for (let lineHeight of pairs.get('line-height')) {
    if (seenLineHeights.has(lineHeight)) continue
    seenLineHeights.add(lineHeight)
    // ...
  }
}
let seenFontSizes = new Set<string>()
for (let pairs of candidatePropertiesValues) {
  if (!pairs.has('font-size')) continue

  for (let fontSize of pairs.get('font-size')) {
    if (seenFontSizes.has(fontSize)) continue
    seenFontSizes.add(fontSize)
    // ...
  }
}

pairs.has() による事前チェックを追加することで、pairs.get() を呼び出す前にキーの存在を確認します。これにより、DefaultMap が存在しないキーに対して空のエントリを挿入する動作を回避できます。

テストケースは packages/tailwindcss/src/canonicalize-candidates.test.ts に2つ追加されました。1つ目は ['underline', 'h-4', 'w-4', 'text-sm']['underline', 'text-sm', 'size-4'] に正規化されることを確認します。2つ目は、事前の正規化呼び出しが ['underline', 'h-4', 'w-4'] の正規化結果に影響しないことを検証します:

test('collapse canonicalization is not affected by previous calls', { timeout }, async () => {
  let designSystem = await designSystems.get(__dirname).get(css`
    @import 'tailwindcss';
  `)

  let options: CanonicalizeOptions = {
    collapse: true,
    logicalToPhysical: true,
    rem: 16,
  }

  let target = ['underline', 'h-4', 'w-4']

  expect(designSystem.canonicalizeCandidates(target, options)).toEqual(['underline', 'size-4'])

  designSystem.canonicalizeCandidates(['mb-4', 'text-sm'], options)
  designSystem.canonicalizeCandidates(['underline', 'mb-4'], options)

  expect(designSystem.canonicalizeCandidates(target, options)).toEqual(['underline', 'size-4'])
  expect(designSystem.canonicalizeCandidates(target.concat('text-sm'), options)).toEqual([
    'underline',
    'text-sm',
    'size-4',
  ])
})

このテストは、mainブランチでは2回目の expect で失敗し、修正後は成功します。

設計判断

pairs.has() による事前チェック を追加する方式が採用されました。

PR内では、DefaultMap を完全に Map に置き換え、Map.prototype.getOrInsert のポリフィルを用意する方針が述べられています。しかし、実際のコード変更は最小限に抑えられ、問題のある2箇所のループに has() チェックを追加するだけで対応しています。DefaultMap 自体は残されており、その自動挿入機能を必要とする箇所では引き続き使用されています。

pairs.get() の呼び出しを pairs.has() で保護することで、存在しないキーへのアクセスを防ぎます。これにより、DefaultMap の自動挿入動作がトリガーされず、中間的な参照操作がキャッシュ状態を変更しなくなります。変更箇所を問題のある2つのループに限定することで、正規化ロジック全体への影響を最小化しています。

まとめ

本PRは、キャッシュの読み取り操作が書き込みを引き起こす副作用を除去した変更です。DefaultMap の自動挿入機能が意図せず共有状態を変更していた問題を、has() チェックによる事前ガードで解決しています。これにより、並列処理環境でのリント順序に依存しない一貫した正規化結果が保証されます。

記事メタデータ

Generated by:
Claude Sonnet 4.5 for DiffDaily

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

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

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```typescript:path/to/file.ts)やGitHubのIssue/PRリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

キャッシュ汚染やDefaultMapの副作用といった専門的な内容を、過度な単純化をせず、専門知識を持つエンジニア向けに適切に解説しています。

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

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

各セクションが総論→各論の構成になっており、各段落はトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られています。これにより、記事の骨子を素早く把握できます。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロックは、提供されたDiff情報と正確に一致しています。ファイルパスの指定も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「キャッシュ汚染」「順序依存」「DefaultMap」などの技術用語が、PRの文脈に沿って正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

「DefaultMap.getの副作用がキャッシュ汚染の原因である」という説明は、PRのDescriptionとコード変更から裏付けられる正確なものです。技術的な因果関係が論理的に説明されています。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのDescription、Diff、関連Issueから裏付けられています。特に、「設計判断」セクションでPR Descriptionの記述と実際のコード変更の差異を正確に指摘しており、ハルシネーションではなく深い分析ができています。

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

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

PR番号(#19675)や関連Issue番号(schoero/eslint-plugin-better-tailwindcss#321)が正確に記載されています。

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

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

記事のタイトル「正規化キャッシュの汚染による順序依存バグを修正」は、PRのタイトル「fix(canonicalize): prevent collapse cache pollution across calls」の内容を、より具体的に分かりやすく表現しており、主題と一致しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に限定されており、バージョンサポート状況やリリース日程といったPR外の知識の追加はありません。

時間表現の正確性 ✓ PASS

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

記事内に時間表現の歪曲は見られず、事実関係が正確に記述されています。