正規化キャッシュの汚染による順序依存バグを修正
Tailwind CSSの canonicalizeCandidates 関数において、複数回の呼び出しでキャッシュが汚染され、正規化結果が呼び出し順序に依存する不具合が修正されました。この問題は、eslint-plugin-better-tailwindcssなどのツールで非決定的な正規化結果を引き起こしていました。
背景
canonicalizeCandidates は、Tailwindのクラス名を正規化する関数です。{ collapse: true } オプションを指定すると、h-4 と w-4 を size-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.ts の collapseCandidates 関数内です。line-height と font-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() チェックによる事前ガードで解決しています。これにより、並列処理環境でのリント順序に依存しない一貫した正規化結果が保証されます。