SPA(Single Page Application)を本番運用していて、こんな障害報告を受けたことはないでしょうか。
「さっきまで普通に使えていたのに、別のタブや設定画面を開いたら突然画面が真っ白になりました」
原因を調べてみると、コンソールに残っているのはお馴染みのエラーです。
TypeError: Failed to fetch dynamically imported module: https://example.com/assets/SettingsModal-B4Fll2ZS.js
いわゆる ChunkLoadError(チャンクロードエラー) です。
Vite や Webpack でコード分割(Code Splitting)を行っている場合、デプロイのたびにアセットファイル名にハッシュ(-B4Fll2ZS.js など)が付与されます。ユーザーが画面を開いたままの状態で新しいバージョンがデプロイされると、古い HTML が参照している過去のハッシュ付きチャンクが配信サーバー(Vercel, Cloudflare, S3等)から消滅し、404 になって画面がクラッシュする のです。
筆者が開発しているタスク管理SaaS「Taski」でも、初期ロードを高速化するために20以上のモーダルや重厚なタブ(ガントチャート・カンバン・設定)を遅延ロード(React.lazy)に切り替えた直後、この問題に直面しました。
この記事では、単に window.location.reload() を呼ぶだけでは防げない「白い画面の一瞬のチラつき」「無限リロードループ」「Sentryのエラークォータ浪費」までを完全に防ぐ、4重の自動復旧パイプライン設計 を解説します。
1. なぜ「ChunkLoadError」は起きるのか?
まずは問題のメカニズムを整理します。
ユーザーがリロードすれば最新の index.html(v2)が読み直されて解決するのですが、ユーザーにとっては**「突然アプリが壊れた」**ようにしか見えません。
2. 単純な対策が引き起こす「3つの罠」
「エラーが起きたらリロードすればいいのでは?」と考えるのが自然ですが、素朴な実装には落とし穴があります。
罠①:リロードが完了するまで「白い画面」や「エラー境界」が一瞬表示される
動的インポートが rejected になると、React の Suspense や ErrorBoundary にエラーが伝播します。リロードを発火してもページ遷移には数百ミリ秒かかるため、その間ユーザーにクラッシュ画面を見せてしまいます。
罠②:ネットワーク切断時やCDN不調時の「無限リロードループ」
本当に回線が切れていたりCDNがダウンしている場合、リロードしても再び 404 になります。対策なしに window.location.reload() を呼ぶと、ブラウザが狂ったように無限リロードを繰り返し、ユーザーが操作不能 に陥ります。
罠③:Sentry などのエラー監視ツールが過渡的エラーで埋まる
デプロイ跨ぎのエラーは自動復旧できる「正常な過渡現象」です。しかし素朴に放置すると、デプロイのたびに数十〜数百件の ChunkLoadError が Sentry に飛んでアラートが鳴り響き、月間のエラー送信クォータを食い潰します。
3. 実践した「4重の自動復旧パイプライン」
Taski では、これらすべての課題を解決するために以下の4段階のアーキテクチャを構築しました。
4. コード解説
① エラーを握りつぶして白い画面を防ぐ safeLazy
通常の React.lazy(() => import('./Component')) は、Promise が reject されると上位の ErrorBoundary へ例外をスローします。
そこで、動的インポートの失敗時にエラーをキャッチし、空のダミーコンポーネント(() => null)を返すラッパー関数 safeLazy を用意します。
// src/App.tsx
/**
* デプロイ跨ぎによるチャンクロードエラー時にも TypeError を投げず、
* vite:preloadError による window.location.reload() を安全に待機するための safeLazy
*/
function safeLazy<M, T extends React.ComponentType<any>>(
loader: () => Promise<M>,
selector: (m: M) => T
): React.LazyExoticComponent<T> {
return React.lazy(() =>
loader()
.then((m) => {
const Comp = m ? selector(m) : null;
return { default: Comp || ((() => null) as unknown as T) };
})
.catch((err) => {
// エラーをコンソールに残しつつ、例外をスローしない
console.warn('Chunk load error, waiting for auto-reload:', err);
return { default: (() => null) as unknown as T };
})
);
}
// 使い方
const SettingsModal = safeLazy(
() => import('./components/SettingsModal'),
(m) => m.SettingsModal
);
const GanttChart = safeLazy(
() => import('./components/GanttChart'),
(m) => m.GanttChart
);
ポイント
safeLazy が () => null を返すことで、React の描画ツリーは壊れません。後述の window.location.reload() が完了するまでのコンマ数秒間、画面が真っ白になることなく現在の画面をキープしたまま、最新版へとスムーズにリロードされます。
② Vite公式イベントとブラウザ標準エラーの二重検知
Vite には、動的インポートの失敗を検知するための専用カスタムイベント vite:preloadError が用意されています。
これに加え、Safari や一部の古い環境でも取りこぼさないよう、グローバルの error イベントでもフォールバック検知を行います。
// src/main.tsx
let isReloadingForChunk = false;
// 1. Vite 公式のプリロードエラーイベント
window.addEventListener('vite:preloadError', (event) => {
isReloadingForChunk = true;
event.preventDefault(); // デフォルトのエラーログ出力を抑制
triggerSafeReload();
});
// 2. ブラウザ標準のエラーイベント(フォールバック)
window.addEventListener('error', (event) => {
const msg = event?.message || '';
if (
msg.includes('Failed to fetch dynamically imported module') ||
msg.includes('Importing a module script failed') ||
msg.includes('error loading dynamically imported module')
) {
isReloadingForChunk = true;
triggerSafeReload();
}
});
③ 無限リロードを防ぐ「10秒クールダウン」設計
ネットワークが物理的に切断されている場合にブラウザがリロードループに陥らないよう、sessionStorage を使って**「直近10秒以内にすでにリロードしたか」**をチェックします。
// src/main.tsx
function triggerSafeReload() {
const reloadKey = 'taski_chunk_reload_retry';
const lastReload = sessionStorage.getItem(reloadKey);
const now = Date.now();
// 10秒以内にすでにリロードされていたらループとみなして中断
if (lastReload && now - parseInt(lastReload, 10) < 10000) {
console.error('Chunk reload loop detected. Stopping automatic reload.');
return;
}
sessionStorage.setItem(reloadKey, now.toString());
window.location.reload();
}
ユーザーが1回アクセスしたデプロイ跨ぎなら、1回目のリロードで最新の HTML とチャンクを取得して即座に解決します。もし万が一サーバー障害等で404が続いても、10秒間はリロードがブロックされるため、ブラウザのフリーズを防ぐことができます。
④ Sentry のエラー送信を安全にインターセプト
自動復旧中の過渡的な ChunkLoadError が Sentry に飛んでアラートを汚染しないよう、Sentry の beforeSend フックで除外します。
// src/main.tsx
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
beforeSend(event) {
// チャンクロード失敗による自動リロード中の過渡的エラーは Sentry 送信を抑止
if (isReloadingForChunk) {
return null;
}
// ErrorBoundary等でラップされた例外も確実に検知して除外
const isChunkError = event.exception?.values?.some((val) => {
const text = `${val.type || ''} ${val.value || ''}`;
return (
text.includes('Failed to fetch dynamically imported module') ||
text.includes('Importing a module script failed') ||
text.includes('error loading dynamically imported module')
);
});
if (isChunkError) {
return null;
}
return event;
},
});
フラグ isReloadingForChunk と例外メッセージの両方をチェックすることで、Sentry に1件も不要なエラーを流すことなく、本当のバグだけに集中できるようになります。
5. 導入前後の比較
| 項目 | 対策前(素の React.lazy) | 対策後(本パイプライン) |
|---|---|---|
| デプロイ跨ぎの体験 | 画面が真っ白にクラッシュし、手動リロードが必要 | 一瞬のチラつきもなく自動で最新画面に復帰 |
| オフライン・障害時 | 無限リロードでタブがフリーズするリスク | 10秒のセーフティガードで安全に停止 |
| Sentry エラー通知 | デプロイごとに ChunkLoadError のアラートが大量発生 | 過渡的エラーが完全にゼロ になりノイズ撲滅 |
| 初期表示速度(LCP) | バンドルサイズ 1.8MB のまま分割できない | 初期バンドルを極小化 しつつ安全にコード分割 |
まとめ
SPA のコード分割(Code Splitting)はページの初期表示速度を劇的に改善する強力な武器ですが、「デプロイ跨ぎ」という運用上の壁が必ず立ちはだかります。
今回実装したパイプラインの肝は以下の4点です:
-
safeLazy: エラーを握りつぶしてダミーコンポーネントを返し、白い画面を防止する -
vite:preloadError: 公式イベントで最短でリロードをキックする -
sessionStorage: 10秒以内の連続リロードを遮断し、無限ループを防ぐ -
beforeSend: リロード中の過渡的エラーを Sentry から除外してクォータを守る
Vite で SPA を運用している方は、ぜひ取り入れてみてください。
プロダクトのご紹介
この記事で解説した「safeLazy」と自動復旧パイプラインは、筆者が開発しているプロジェクト・タスク管理ツール 「Taski」 で実際に稼働しています。
ガントチャートやカンバン、詳細モーダルをすべて遅延ロードしながら、高速性とデプロイ無停止を両立しています。会員登録不要・1秒でデモ画面を体験できます ので、SPAのサクサク感をぜひ触ってみてください!