管理画面のサイドバーが丸ごと消えました。ナビが出ないので、その中にしかないベンチマーク画面に辿り着けません。ウィンドウ幅をどう変えても出てこない。
テストは 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.css に Tailwind のフルビルドを同梱していました。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 あり・変種なし」
危険なのはスタイルシートの中身の差ではなく、形です。
- 自分のレスポンシブ変種を持っているシートは、順序で負けても壊れません。自分の
md:ルールが後ろの media ブロックに入るからです - base ユーティリティを持たないシートは、そもそも影を落とせません
- 両方が揃ったとき — base ユーティリティを持ち、変種を持たない — だけ、順序が単独で勝敗を決めます
このとき危険にさらされる面積は小さくありません。今回のリポジトリでは、display を上書きするアプリ側のレスポンシブ規則が 1,023 件ありました。実際に症状が出たのは要素側が両方のクラスを持つ 18 箇所だけですが、それは display:none が全消しで目立ったからです。gap や padding がブレークポイントで変わらないといった静かな崩れは、誰も気づきません。
修正は 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} が入っていないか、一度覗いてみる価値はあります。
