ErrorBoundaryが捕まえたのは5つ中2つだった|実際に壊して確かめた

ErrorBoundaryが捕まえたのは5つ中2つだった|実際に壊して確かめた

ErrorBoundary を置いてあると、なんとなく安心してしまいます。実際にどこまで捕まえてくれるのか、5通りの壊し方を試して確かめました。

結果は、5つのうち2つだけでした。しかも捕まえてくれなかった3つのほうが、実務では圧倒的によく起きます。

5通りの壊し方のうちErrorBoundaryが捕捉したのはレンダリング中とuseEffectの2つだけで、残り3つはwindow.onerrorとunhandledrejectionに届いたことを示す図
2回実行して同じ結果になったもの
目次

試したこと

ErrorBoundary で包んだコンポーネントを5パターン用意し、それぞれ違う場所で例外を投げます。同時に window.onerror と unhandledrejection も購読しておいて、エラーが実際にどこへ届いたかを記録しました。

class Boundary extends React.Component {
  static getDerivedStateFromError() { return { failed: true }; }
  componentDidCatch(err) { log.boundary.push(err.message); }
  render() { return this.state.failed ? <div>fallback</div> : this.props.children; }
}

window.addEventListener('error', e => log.windowError.push(e.message));
window.addEventListener('unhandledrejection', e => log.rejection.push(e.reason.message));
壊し方ErrorBoundary実際に届いた先
レンダリング中に throw捕捉したErrorBoundary
useEffect の中で throw捕捉したErrorBoundary
イベントハンドラの中で throw素通りwindow.onerror
setTimeout の中で throw素通りwindow.onerror
async の await 後に throw素通りunhandledrejection

なぜ半分しか捕まえないのか

ErrorBoundary が見ているのは「React がコンポーネントを描画している最中」だけだからです。レンダリングと副作用の実行は React の管理下なので捕まえられます。

一方、イベントハンドラ・タイマー・非同期処理は、React の呼び出しスタックから外れたところで実行されます。ブラウザがイベントループから直接呼ぶので、Reactは介在していません。捕まえようがない、というのが正確なところです。

そして実務で起きるエラーは、圧倒的に後者に寄っています。

  • 「保存」ボタンを押したらAPIが500を返して、そのあとの処理で落ちる
  • await fetch() のレスポンスが想定と違う形で、プロパティアクセスで落ちる
  • ポーリング処理が失敗し続けている

これらは全部 ErrorBoundary を素通りします。しかも画面は壊れないので、見た目には何も起きていないように見えます。「ボタンを押しても何も起きない」という報告だけが上がってくる、というのがこの状態です。

Script error. で30分溶かさないために

今回の検証中に、実際に引っかかったことがあります。最初はイベントハンドラのエラーが window.onerror にこう届きました。

window.onerrorに届いた: ["Script error."]

メッセージも行番号もスタックもありません。「何かエラーが起きた」以上の情報がゼロの状態です。

原因は、Reactを別ドメイン(CDN)から読み込んでいたことでした。ブラウザは、別オリジンのスクリプトで起きたエラーの詳細を、セキュリティ上の理由で隠します。スクリプトタグに crossorigin を付けて、配信側が CORS ヘッダーを返していれば開示されます。

<!-- 付ける前 -->
<script src="https://cdn.example.com/react.js"></script>
   → window.onerrorに届いた: ["Script error."]

<!-- 付けた後 -->
<script crossorigin="anonymous" src="https://cdn.example.com/react.js"></script>
   → window.onerrorに届いた: ["Uncaught Error: handler"]

属性1つで情報量がまったく変わります。エラー監視を入れる前に、これを確認しておかないと、収集したエラーの大半が Script error. になります。CDNからライブラリを読んでいる構成では必ず起きるので、監視ツールを入れる日の作業に含めておきたいところです。

コンクリートの壁に取り付けられた消火器と、閉じた鉄の防火扉を正面から撮った写真
置いてあることと、届くことは別

3層で受け止める

ErrorBoundary だけでは足りないので、受け止める場所を分けます。

1. 予期できるものは、その場で処理する

APIが400を返す、通信に失敗する、といったものは例外ではなく想定内の分岐です。try/catch でその場で受けて、ユーザーに何が起きたかを表示します。

try {
  await save(form);
} catch (e) {
  setError('保存できませんでした。時間をおいて再度お試しください。');
  reportError(e, { where: 'save', formId });   // 監視にも送る
}

ここで大事なのは、catch して握りつぶさないことです。画面には優しいメッセージを出しつつ、監視には生のエラーを送ります。ユーザーには見せないが記録は残す、が原則です。

2. 画面が壊れる系は ErrorBoundary

レンダリング中の例外はここで受けます。ポイントは置く粒度です。

アプリ全体を1つで包むと、どこか1か所の不具合で画面全体が真っ白になります。「この部分が壊れても、他は使える」という単位で分けて置くほうが被害が小さくなります。サイドバーのウィジェットが壊れても、記事本文は読めるべきです。

フォールバックUIには復帰の手段を置きます。「エラーが発生しました」だけだと、ユーザーはリロードしか選べません。再試行ボタンや、前の画面に戻る導線があると違います。

3. 取りこぼしはグローバルで拾う

window.addEventListener('error', e => report(e.error));
window.addEventListener('unhandledrejection', e => report(e.reason));

unhandledrejection の購読を忘れないでください。今回の検証でも、非同期のエラーは error イベントには来ませんでした。await を使うコードが増えるほど、こちらの比率が上がります。

ここは最後の砦なので、直せるものを拾う場所ではなく、想定外を検知する場所と考えます。ここに大量に流れてくるなら、1番と2番の設計が足りていないということです。

ノイズを減らさないと監視は死ぬ

グローバルで拾い始めると、自分たちのバグではないエラーが大量に流れ込みます。

  • ブラウザ拡張が注入したスクリプトのエラー
    件数としては最多になりがち
  • ResizeObserver loop limit exceeded
    実害がないのに大量に出る有名なもの
  • ユーザーが通信を切った・タブを閉じたことによる fetch の中断
  • クローラーやボットの挙動によるもの

これらを放置すると、本物のエラーがその中に埋もれます。アラートが鳴りっぱなしになれば、そのうち誰も見なくなります。

対策は、送る前にフィルタすることです。スタックに自分たちのドメインが含まれないものを落とす、既知の無害なメッセージを除外する、といった処理を入口に置きます。「全部集める」より「対応するものだけ集める」ほうが、監視としては機能します。

この考え方はログ設計の記事で書いたことと同じです。対応する気のないものを記録しても、判断の材料にはなりません。

エラーに文脈をつける

スタックトレースだけでは、再現の手がかりになりません。送るときに情報を足します。

  • どの画面・どの操作か
    URLと、直前のユーザー操作
  • ユーザーID
    特定のユーザーだけかを判断するため。氏名やメールは送らない
  • リリースバージョン
    いつのデプロイから増えたかが分かる
  • ソースマップ
    これがないと圧縮されたコードの行番号しか出ない

ソースマップは公開ディレクトリに置かず、監視ツール側にアップロードするのが定石です。ソースマップは元のコードを復元できるので、誰でも取得できる場所に置くとソースを配布しているのと同じになります。

まとめ

  • ErrorBoundary が捕まえたのは5つ中2つ
    レンダリング中と useEffect だけ
  • イベントハンドラ・setTimeout・async は素通りする
    実務ではこちらのほうが多い
  • 素通りしたものは画面が壊れない
    だから気づかれない
  • unhandledrejection を必ず購読する
    error イベントには来ない
  • CDNのスクリプトには crossorigin を付ける
    ないと Script error. しか取れない
  • ノイズを落としてから集める
    全部集めると本物が埋もれる

「ErrorBoundary を置いたのでエラー対策済み」という認識がいちばん危ないと思います。置いてあるのに拾えていない範囲を、具体的に把握しておくことが対策の第一歩でした。

次に読む記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

わどこんのアバター わどこん

実務12年のバックエンド・インフラエンジニア。バックエンド開発からクラウド・インフラの設計・構築・運用まで担当しています。主要言語は Java・Kotlin・PHP・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次