バイト単位で同一のCSSルールが喧嘩する — 差分では見つからないカスケードのバグ

バイト単位で同一のCSSルールが喧嘩する — 差分では見つからないカスケードのバグ

管理画面のサイドバーが丸ごと消えました。ナビが出ないので、その中にしかないベンチマーク画面に辿り着けません。ウィンドウ幅をどう変えても出てこない。

テストは 8802 件すべて緑でした。型チェックも lint も通ります。

結論から書くと、原因はサードパーティの CSS がアプリの CSS より後に読み込まれていたことでした。そして厄介なのは、衝突していた 2 つのルールがバイト単位で同一だったことです。宣言を diff しても何も出ません。

まず「本当に消えているのか」を確かめる

コード上、そのサイドバーは hidden md:flex を持つ 224px の列です。Tailwind の md は 768px なので、それ以上の幅なら display: flex になるはずです。

推測せず、ブラウザで直接聞きました。

const r = [...document.querySelectorAll('div')].find(d => d.className.includes('224px'));
console.log(r ? 'DOM にある / display=' + getComputedStyle(r).display : 'DOM に無い', window.innerWidth);

返ってきたのは DOM にある / display=none 990 です。

990px は 768px を超えています。 DOM にはいる。CSS も生成されている(配信中の CSS に .md\:flex{display:flex}@media (min-width: 768px) の中に実在することを確認済み)。それでも none

犯人は「別のセレクタ」だった

アプリ側の CSS を見ると、順序自体は正しく並んでいます。

.hidden{display:none}                              ← byte 21,124
@media(min-width:768px){.md\:flex{display:flex}}   ← byte 169,781

後から来る .md\:flex が勝つ。ここは問題ありません。

問題はその後ろに、サードパーティのスタイルシートがもう一枚読み込まれていたことです。デザインシステムのパッケージが dist/style.cssTailwind のフルビルドを同梱していました。preflight のリセットと base ユーティリティ一式、その中に .hidden{display:none}。そしてレスポンシブ変種は一つも入っていませんmd\:flex は 0 件)。

つまりカスケードはこうなります。

アプリ    .hidden{display:none}
アプリ    @media(min-width:768px){.md\:flex{display:flex}}
ベンダー  .hidden{display:none}      ← 全部の後

同じ詳細度なら後勝ちです。ベンダーの .hidden が、アプリの .md\:flex を殴ります。結果、hidden md:flexどの幅でも un-hide されません

ここが非自明なところです。 喧嘩していたのは「食い違う 2 つの .hidden」ではありません。2 つの .hidden は完全に同一です。実際に戦っていたのは .hidden@media{.md\:flex} という別々のセレクタで、勝敗を決めたのはソース順だけでした。

だから CSS を diff しても見つかりません。 実際、2 枚のスタイルシートの重複セレクタ 104 件を機械的に突き合わせても、宣言が本当に違うのは 14 件で、そのほとんどは minifier による 150ms.15s のような表記差でした。

罠を作る形は「base あり・変種なし」

危険なのはスタイルシートの中身の差ではなく、です。

このとき危険にさらされる面積は小さくありません。今回のリポジトリでは、display を上書きするアプリ側のレスポンシブ規則が 1,023 件ありました。実際に症状が出たのは要素側が両方のクラスを持つ 18 箇所だけですが、それは display:none が全消しで目立ったからです。gappadding がブレークポイントで変わらないといった静かな崩れは、誰も気づきません。

修正は 1 行、ただし理由を書く

ベンダーを先に、自分を後に読むだけです。

// Vendor CSS first, ours last. `@tne-ai/compass-ui` ships a FULL Tailwind build —
// preflight plus base utilities including `.hidden{display:none}` — but none of the
// responsive variants. Loaded after ours it wins on source order at equal
// specificity, so `hidden md:flex` never un-hides and the element stays gone at
// every width. `compassOpenUiLibrary` imports it too; the duplicate is deduped and
// this earlier evaluation is what fixes the order.
import "@tne-ai/compass-ui/styles.css";
import "./styles/globals.css";

同じ CSS の二重 import はバンドラが最初の評価点にまとめるので、深い側のモジュールから import を消す必要はありません。モジュールの自己完結性を壊さずに順序だけ直せます。

ガードは「差分」ではなく「形」を見る

1 行の修正は、次の誰かが簡単に戻せます。しかも誰も気づきません — テストは 8802 件緑のままバグが存在していたのですから。

なのでテストを足しました。ポイントは、宣言の差分を見ないことです。今回のバグは同一のルール同士で起きたので、差分ベースの検査は何も報告しません。代わりに、罠を作る形そのものを探します。

const shipsUnvariantedBaseUtilities = (css: string): boolean => {
  const hasBase = /\.hidden\s*\{\s*display\s*:\s*none/.test(css) && /\.flex\s*\{\s*display\s*:\s*flex/.test(css);
  const hasVariants = /\.(sm|md|lg|xl)\\:/.test(css);
  return hasBase && !hasVariants;
};

そのうえで「この形のシートは entry point で、自分の CSS より先に import されていること」を assert します。フィクスチャではなく実際の依存を読むので、将来この形の新しいパッケージが入っても捕まります。

書いたテストは、壊して確かめる

このテストは 2 通りに壊して赤くなることを確認しました。import 順を元に戻すと順序の assertion が、entry point の import を消すと(元のバグの状態)もう一方が、それぞれ赤くなります。

もう一つ、忘れやすい確認があります。検出器が空振りしていないかです。shipsUnvariantedBaseUtilities が全部 false を返せば、テストは何も認識しないまま緑になります。これは「成功に見える失敗」なので、実際の 4 枚に対して答えを並べました。問題のパッケージだけ true、Blueprint 2 枚と xterm は false。区別ができています。

おまけ: ベンダープレフィックスは慌てなくてよい

順序を入れ替えると、ベンダー側が持っていた -webkit-background-clip が失われます。Safari でグラデーション文字が消える、あの症状の元です。

ここも推測せず autoprefixer を両ターゲットで走らせました。

development  →  background-clip: text
production   →  -webkit-background-clip: text;  background-clip: text

production ビルドでは付きます。dev で付かないのは現行 Safari が無接頭辞で対応済みだからで、正しい挙動でした。心配しかけましたが、対応不要です。


一番の教訓は、テストが全部緑でも CSS の読み込み順は誰も見ていない、ということでした。ユニットテストはルールが動くことを証明しますが、スタイルシートが何番目に注入されるかは observe していません。1 行の import 順に、18 要素の可視性がぶら下がっていたわけです。

同じ形のパッケージは珍しくないと思います。コンポーネントライブラリを入れるとき、その dist/*.css.flex{display:flex} が入っていないか、一度覗いてみる価値はあります。

この記事をシェア

関連記事

記事一覧に戻る