この記事で扱うこと
5 年超の旧ブラウザで、あるページのインタラクションが 全部 死ぬバグを直しながら学んだことをまとめる。特定の製品・数値は省き、どこでも通じる一般概念だけを扱う。最大の学びは「旧ブラウザ対応 = babel 一つ」という誤解を壊したことだ — 実際には構文ダウンレベル・ランタイムポリフィル・条件ロード・CSS フォールバック・実ブラウザゲートが協力する 多層契約 である。
今日扱ったこと
| 作業 | 何をしたかったか | やったこと | 結果 |
|---|---|---|---|
| 同時死の診断 | 複数の機能が一度に死ぬ理由を突き止めたかった | 三つとも イベントハンドラだと見てハイドレーション不発と特定 | バグ 3 個ではなく単一の根本原因 |
| 構文 vs API | 旧ブラウザでコードを生かしたかった | 構文は babel で下げ、存在しない関数はポリフィルで補う | 構文通過 ≠ API カバーを確認 |
| ポリフィル条件ロード | 最新ブラウザに負担なくポリフィルを渡したかった | feature-detect で旧ブラウザにだけ同期ロード | 最新ブラウザのコスト ≈ 0 |
| スモーク補強 | 回帰を実際に捕まえたかった | クリック・送信インタラクション + 広いコンソールフィルタを追加 | レンダーのみスモークの盲点を除去 |
1. 複数の機能が同時に死んだらハイドレーションを疑え
どんな状況だったか。 あるページでトグル、アカウントログイン、送信が 三つとも 動かなかった。一見バグ 3 個に見えたが、三つの共通点は全部クリック・送信のイベントハンドラだということだった。
核心概念。 ハイドレーション(hydration)は、サーバーが作った静的 HTML に、ブラウザで JavaScript があとからイベントを付けて生き返らせる過程である。これが終わる前はボタンはただの絵にすぎない。だからクライアント JS がそもそも実行できないと(構文のパース失敗や存在しない API 呼び出しで)、その画面の すべての ハンドラが一度に死ぬ。画面自体はサーバー HTML なので問題なく見え、さらに紛らわしい。
やったこと。 複数の症状を「バグ N 個」ではなく「一つの実行失敗」として仮説を立て、コンソールの最初のエラーを見た。特定の旧ブラウザでのみ再現する点も互換性が原因であることを指していた。
教訓。 複数の UI 機能が同時に死んだら、個別のバグではなくハイドレーション/バンドルのパースといった共通の根本原因をまず疑う。
2. 構文ダウンレベルとランタイムポリフィルは別々のレイヤーだ
どんな状況だったか。 旧ブラウザでコードが死ぬ原因は一つではなかった。構文を下げても、なお死ぬ箇所が残った。
核心概念。 二つの問題を区別しなければならない。(1) 新しい 構文(a?.b、a ?? b)は古いパーサが読めず SyntaxError でファイル全体が死ぬ — トランスパイル(babel)で古い構文に下げれば解決する。(2) 存在しない ランタイム関数(structuredClone、Array.prototype.at、String.prototype.replaceAll)はトランスパイルで生まれない — 呼んだ瞬間に is not a function が起きる。これはポリフィル(存在しない関数を JS で再実装して注入)でしか解決しない。構文通過は API カバーではない。
やったこと。 構文は @babel/preset-env にターゲットを与えてサードパーティ(node_modules)までダウンレベルし、存在しない API は core-js-compat にターゲットから欠損一覧を計算させてポリフィルバンドルを作った。ポリフィル一覧を人が手作業で管理するとドリフトするので、回帰テストで実使用 API の存在を強制した。
教訓。 構文ダウンレベルとランタイム API は必ず分離して考える。コンソールの最初のエラーが SyntaxError なら構文、TypeError: is not a function なら API の問題だ。
3. ポリフィルは旧ブラウザにだけ条件ロードする
どんな状況だったか。 ポリフィルバンドルは数十 KB なのに、これを全訪問者に渡すと、ポリフィルが不要な最新ブラウザ利用者(大多数)まで損をする。
核心概念。 feature detection(機能検知)は「このブラウザにこの機能があるか?」を 'at' in Array.prototype のように実際にテストすることだ(UA 文字列の推測より頑健)。<head> の小さな同期スクリプトが代表的な API を検査し、一つでも無いときだけポリフィルを同期ロードする。検知コストは数十バイトなので、最新ブラウザのコストは事実上 0 だ。
やったこと。 「ビルド時生成バンドル + feature-detect 条件ロード」を選んだ。エントリに静的に含めると最新ブラウザに常時コスト、UA ベースのサーバー動的生成はサーバー運用が必要で過剰だった。ターゲットが事実上「旧 vs 最新」の二分法なので条件ロードが最適だった。ポリフィルは外部 CDN ではなく同一オリジンから提供した(サプライチェーン攻撃の実績・可用性)。
教訓。 ポリフィルは feature-detect で旧ブラウザにだけ条件ロードし、最新ブラウザのコストを 0 に近づける。検知スクリプトはアプリバンドルより先に実行されなければならない。
→ feature-detect による差分ポリフィルロード
4. 互換性スモークは実際のインタラクションを実行しなければならない
どんな状況だったか。 既存のスモークテストはこのバグを捕まえられず通っていた。なぜ通ったのかが気になった。
核心概念。 ランタイムの TypeError はコードが実際に実行される時点でのみ発生する。スモークがレンダリングだけを検査(行数・テキスト)すると、肝心の壊れるハンドラは一度も実行されず緑で通る。また 5 年超の旧ブラウザは最新の自動化ツールのエンジンでは再現できないので、そのバージョンのブラウザ・ドライバのバイナリを固定バージョンで受け取って起動しなければならない。
やったこと。 ページごとのクリック・トグル・送信・ルーティングのインタラクションを追加し、コンソールエラーフィルタに referenceerror・is not a function・is not defined を入れつつ、ネットワーク性の Failed to fetch は誤検知なので除外した。ポリフィル存在チェックとインタラクションテストは役割が違うので分離した。
教訓。 レンダーだけを検査するスモークはハンドラが全部死んでも通る。実際のクリック・送信インタラクション + 広いコンソールエラーフィルタがあってこそ回帰を捕まえる。