ナミスマート合同会社

開いたままの SPA が、消えたファイルを要求して白くなる

CloudflareCloudflare PagesViteSPA

ステージング環境でデプロイした後、iOS の LINE 内蔵ブラウザで数日前から開いたままにして いたページだけが、リンクをタップした時点で白画面になった。エラーは 'text/html' is not a valid JavaScript MIME type。

何が起きているか

3 つの事実が重なると発生する。そして 4 つめの事実のせいで、更新ボタンを押しても直らない。 順に見ていく。

1. ページを開いている間、ブラウザは HTML を取り直さない。

このサイトは SPA(Single Page Application)という作りになっている。ページを移動しても、 サーバーから HTML を取り直さない。最初に index.html を 1 枚だけ読み込む。あとは JavaScript が画面の中身を描き替える。画面遷移は history.pushState で URL の表示を 変えているだけなので、HTTP のリクエストは発生しない。

JavaScript は Vite というビルドツールでまとめている。Vite は画面ごとのコードを別々の ファイルに分け、ファイル名に内容から計算したランダムな文字列(ハッシュ)を付ける。 Events-BqK3f1.js のような名前になる。中身が変われば名前も変わる。

どのファイル名を読むべきかは index.html の中に書かれている。3 日前に読み込まれたページは、 3 日前のファイル名を持ち続ける。遷移先の画面が import() で後から読み込まれる作りだと、 リンクをタップしたその瞬間に、初めて 3 日前のファイル名を要求する。

2. Cloudflare Pages は新しいデプロイを出すと、古いデプロイのアセットを配信から外す。

デプロイのたびにハッシュは変わる。Events-BqK3f1.js は消え、別名のファイルに置き換わる。 つまり、要求されたファイル名はもう存在しない。

3. SPA の catch-all リダイレクトが、存在しないファイルにも HTML を返す。

SPA では /events のようなページが実体のファイルとして存在しない。それでも URL を直接 開けなければ困る。そこでホスティング側に「どの URL に来ても index.html を返す」設定を 置く。Cloudflare Pages では _redirects というファイルに一行書く。

/*  /index.html  200

/* は「すべてのパス」、200 は「別の URL へ飛ばすのではなく index.html の中身を そのまま返す」という意味である。この一行はパスの中身を区別しない。アプリのページにも、 存在しない静的ファイルにも、同じように index.html を返す。

結果こうなる。/assets/Events-BqK3f1.js への GET は 404 にならず、index.html の中身が Content-Type: text/html で返る。Content-Type は「これは何のデータか」をサーバーが ブラウザに伝えるヘッダで、JavaScript なら text/javascript であるべきところである。 _redirects には優先順位のルールはあるが、静的ファイルだけを 404 にする書き方は 用意されていない。

ここでブラウザが実行を拒否する。

分割されたファイルは ES モジュールという形式で読み込まれる。この形式には仕様上の厳格な ルールがあり、応答の Content-Type が JavaScript のものでなければ、中身を読まずに実行を 拒否する。返ってきたのは text/html なので、中身を見るまでもなく失敗が確定する。これが 冒頭のエラー文である。

画面のコードは React.lazy で読み込んでいる。これは「その画面を開いた瞬間に対応する ファイルを取りに行く」仕組みで、取得に失敗すればその画面を描けない。画面は白いままになる。

4. Service Worker が、更新ボタンを押しても古い HTML を返す。

ここで普通は「リロードすれば直る」と考える。実際には直らなかった。Service Worker が いるためである。

Service Worker はブラウザに常駐して、そのサイトのリクエストを横から受け取る仕組みで、 オフラインでも開けるようにするために入れている。このサイトでは vite-plugin-pwa 経由の Workbox が index.html を precache(あらかじめ保存)し、ページの読み込みにはその保存分を 返す設定にしている(navigateFallback: '/index.html')。

つまり / へのリクエストはネットワークに出ない。更新ボタンを押しても、返ってくるのは 保存された古い index.html である。HTML が古いままなので、要求するファイル名も古いまま。 エラーは出続ける。

URL のクエリを変えて別のパスとして取りに行かせれば、precache のどの候補とも一致せず ネットワークに出る。後述する対処はこれを使っている。Service Worker を入れていない ページなら、単に開き直すだけで index.html が取り直され、この問題は起きない。

なぜ LINE の内蔵ブラウザだったのか

これが WKWebView という iOS の部品で動いていて、アプリを閉じている間も背面のページを 数日そのまま保持するためである。古い index.html を持った状態が長く続き、デプロイを 何度もまたぐ。LINE 固有の話ではない。通常のブラウザでもタブを開いたままにすれば 同じことになる。

対処

会員側は JavaScript を 1 ファイルにした

会員の導線は LINE のトークからの遷移だけで、ホーム画面へのインストールは使えない。 画面ごとの分割で節約できるのは初回の数十 KB だが、引き換えに「後から取りに行くファイル」が 故障の材料になる。Vite の build.rollupOptions.output.inlineDynamicImports で 1 ファイルに まとめた。JavaScript は 1 本(908 KB、gzip 246 KB)になり、初回の gzip は 60 KB から 246 KB に増える。後から取りに行くファイルが存在しないので、この経路は構造的に消える。

管理側は動的 import の入口を 1 か所に絞った

管理側はログイン画面が公開ページなので、そこだけは軽くしておきたい。ログイン後の 13 画面を 再輸出するだけのファイル src/admin-bundle.ts を作り、import() を書く場所をそこ 1 つに 限った。ログイン画面が静的に読むのは 2 ファイルで、どちらも index.html に <script> と modulepreload として書かれるため、HTML とバージョンが必ず一致する。JavaScript の ファイル数は 47 から 5 に減った。

manualChunks で pages/admin/ を 1 つのチャンクに振る方法は使えない。共有している react-router と Relay が admin 側のチャンクに移り、エントリがそれを静的に import するため、 dist/index.html の modulepreload に admin のチャンクが現れる。ログイン画面が読む量は 147 KB から 303 KB に増え、遅延読み込みが無意味になる。検証は grep modulepreload dist/index.html に遅延チャンクが出ないことで行う。

復帰したときに、配信中の HTML と照合する

分割を残す限り、また分割を戻したときのために、ページ側でバージョンを確認する。10 分以上 隠れていたページが復帰したら(visibilitychange と pageshow)、index.html を取得して 自分のエントリファイルの URL が含まれているかを見る。含まれていなければ読み直す。

const res = await fetch(`/?_v=${Date.now()}`, { cache: 'no-store' })
const html = await res.text()
if (!html.includes(new URL(import.meta.url).pathname)) {
  const url = new URL(location.href)
  url.searchParams.set('_r', String(Date.now()))
  location.replace(url.toString())
}

クエリを 2 か所に足しているのは、どちらも別の理由がある。

  • _v は Service Worker を回避するため。cache: 'no-store' は HTTP キャッシュを迂回するが Service Worker には届かない。Workbox の precache が古い index.html を返すと、この 照合は常に「最新」と判定する。Workbox が / に対して照合するキーの候補は / と /index.html なので、クエリを足すとどれにも一致せず、ネットワークへ出る。
  • _r は WKWebView のためで、location.reload() では同じ index.html が返ってくる。 URL を変えると新しいリソースとして取得される。

取得に失敗した場合と、取得した HTML にエントリへの参照が見つからない場合は、何もしない。 電波が切れているだけのときに Service Worker と Cache Storage を消すと、オフラインで開く ための precache を失う。

_headers について 1 つ

作業の途中で、ステージングの Strict-Transport-Security と X-Frame-Options、 Permissions-Policy、Content-Security-Policy-Report-Only が配信されていないことが分かった。 本番では 4 つとも返る。差は _headers の中の /* ブロックの数で、本番は 1 つ、 ステージングはセキュリティヘッダ用と CSP 用、それにデプロイスクリプトが X-Robots-Tag 用に 追加する 3 つ目があった。1 つにまとめると 4 つとも返るようになった。

_headers の書式が想定と違っても Pages は 500 を返さず、一部のルールが適用されないまま デプロイが成功する。デプロイのたびに curl -sI でヘッダを数えるほうが確実である。


ナミスマート合同会社

業界最速、最安、高品質なアプリ開発。開発のご相談はお問い合わせからどうぞ。

お問い合わせ