この記事で扱うこと
画像がリロードのたびに「あるときは見え、あるときは見えない」断続的なバグを捕まえる中で学んだ三つを整理する。特定の製品の話はなく、どこでも通じる一般概念(React フックのライフタイム、ブラウザ画像変換、サービスワーカーキャッシュ)だけを扱う。
核心の気づきは一つだった。このバグはサーバー・ネットワークではなく、クライアントのレンダリングタイミングの問題だった。 ファイルは正常なのに画面が崩れていた。
今日扱ったこと
| 作業 | 何をしたかったか | やったこと | 結果 |
|---|---|---|---|
| objectURL レース | 断続的な画像崩れをなくしたかった | blob→objectURL をなくしてサーバー URL を直接レンダリング | レースを根本から除去 + キャッシュ可能 |
| WebP フォールバック | 画像を軽くしつつ失敗しても崩れないように | webp 優先・onError フォールバック + テスト hang 防止ゲート | 漸進的向上、テスト安全 |
| サービスワーカーシェル | オフライン文書フォールバックを安全に | シェルフォールバックを navigate リクエストに限定 | 画像・API リクエストの汚染を除去 |
1. サーバーが渡す画像は objectURL で包むな
どういう状況だったか。 画像がリロードのたびにばらばらに崩れた。コンソールにネットワークエラーはなかった。最初はストレージの署名 URL の期限切れを疑ったが、画像はサーバープロキシ経路でダウンロードしており、期限の問題ではなかった。
核心の概念。 原因は objectURL のライフタイムレースだった。objectURL はブラウザメモリに入った画像データ(blob)を指す一時アドレスだ。URL.createObjectURL で作り、URL.revokeObjectURL で捨てる。ところがこの生成を useMemo(計算キャッシュ)に、解放を useEffect(クリーンアップフック)に分けて入れると、React がレンダリングを二度回す開発モード(StrictMode)やコミットを捨てる並行レンダリングで「解放 → そのアドレスを再び <img> にセット」の順序がずれる。<img> がすでに捨てた blob アドレスを指し、空の画像が表示される。useMemo はライフサイクルを管理する道具ではなく、ただの計算キャッシュなので、ここに副作用を入れると後始末のタイミングが保証されない。
やったこと。 最小パッチ(生成・解放を一つの effect にまとめる)ではなく、objectURL 自体をなくしてサーバー URL を直接 <img src> でレンダリングした。サーバーがすでに安定した URL を渡すのに blob で包む理由はなく、おまけにブラウザ・CDN・サービスワーカーのキャッシュまで受けられるようになった。prop が変わるときの state リセットは effect ではなく、レンダリング中の前 prop 比較(React 公式の escape hatch)で処理した。
教訓。 サーバーが URL で渡す画像は objectURL blob で包まず直接レンダリングする — レースが根本から消える。
2. ブラウザ専用 lib はテストを吊るしうる
どういう状況だったか。 画像を軽くしようと WebP(同じ画質をより小さく収めるフォーマット)を導入した。アップロード時に原本と WebP を両方保存し、ビューアは WebP を優先しつつ失敗したら原本にフォールバックしようとした。ところが WebP 変換ライブラリはブラウザの canvas に依存するのに、テスト環境(jsdom)には canvas がなく、変換が画像ロードを待ち続けて永遠に止まる危険があった。
核心の概念。 二つを学んだ。第一に、ブラウザ専用 lib をテストが回るコード経路に入れるときは 同期 capability ゲートが必要だ。重い作業を始める前に canvas.getContext('2d') != null のようなものを即座に(非同期の待ちなしに)確認し、なければすぐ null で諦めさせて jsdom が吊るされないようにする。第二に、ロード失敗のフォールバックは <picture><source type="webp"> ではなく <img onError> のステージマシンでなければならない。<picture> はブラウザが WebP を サポートしているかだけを見てソースを選び、そのファイルが 404 でもフォールバックしない。実際のロード失敗までフォールバックするには、onError で webp → 原本 → null の順序を自分で駆動しなければならない。
やったこと。 変換ラッパーが実行前に canvas サポートを同期確認し、jsdom/サーバーを即座に null で弾くようにし(テスト hang なし)、ビューアのフォールバックを onError のステージマシンで実装した。
教訓。 ブラウザ専用 API をテスト経路に入れるときは同期 capability ゲートで即座に no-op させて hang を防ぎ、ロード失敗のフォールバックは <picture> ではなく onError で。
3. サービスワーカーのシェルフォールバックは文書リクエストにだけ
どういう状況だったか。 プロダクションのサービスワーカーに別のバグがあった。すべての同一オリジン GET を横取りし、失敗時にアプリの外殻 HTML("/")へフォールバックするように書かれていて、写真・API リクエストが失敗すると HTML を返して <img> が崩れ、その HTML がキャッシュに無限に積まれた。
核心の概念。 サービスワーカーはページとネットワークの間でリクエストを横取りするバックグラウンドスクリプトだ。オフラインの「シェルフォールバック」(ネットワーク失敗時に画面の骨格 HTML を返す)は有用だが、request.mode === 'navigate' の文書遷移リクエストにだけ 掛けなければならない。request.mode はそのリクエストがページ遷移なのか画像のような下位リソースなのかを区別する印だ。これを見ずにすべてのリクエストにシェルを掛けると、画像の失敗に HTML を返してレンダリングが崩れる。
やったこと。 シェルフォールバックを navigate リクエストに限定し、画像・API・静的アセットはそれぞれの戦略(画像キャッシュ、API ネットワーク)に分岐した。既存の汚染分はキャッシュのバージョンを上げて purge した。
教訓。 サービスワーカーのシェルフォールバックは navigation 文書リクエスト限定 — 画像・API に HTML を返さない。
→ Service Worker のシェルフォールバックは navigation リクエストだけ