TL;DR
- あるリソースの fetch 中にページ遷移すると、一部ブラウザでは fetch が中断される
- 中断されると、TypeError が throw される
- ページ遷移時は、ブラウザによって遷移前のページの実行が"停止"され、"捨てられる"ので、通常 throw された後のことは考えなくて良い
- しかし、そのページが Back/Forward Cache から復元されうるなら、話は別
- ブラウザバックすると、エラーが throw された後からページが再開される!!!
- そして発生する、奇妙な現象の数々...
はじまりは、あるサービスの不具合報告
ある日、「Webサービスから外部サービスにページ遷移した後、ブラウザバックで戻ると、エラー画面が表示される」という不具合が報告された。どうも Webサービスの ErrorBoundary で何かしらのエラーが catch され、それによってエラー画面に切り替わっているようだった。しかもページ遷移するタイミングによって、再現する時としない時があるという。
よく調べてみると、以下の手順で再現することが分かった。
- Webサービスのページ (ページA) を開く
- 上記ページ上でアクセス直後に、外部サービスへと遷移する
- 外部サービスのページ (ページB) が開かれる
- ブラウザバックでページAへと戻る
- Webサービスのエラー画面が表示される
何が起きているのか
重要なこととして、ページAは Back/Forward Cache (bfcache) に対応したページだった。bfcache に明るくない人にも補足しておくと、bfcache はページ遷移時に遷移元のページの状態をメモリにキャッシュしておき、ブラウザバックで戻ってきた時にその状態からページを再開する機能である。これにより、ユーザは高速にページ遷移でき、input 要素に入力していた内容も残った状態から再開できる。より詳しくは web.dev の記事を参照してほしい。
また、我々が開発している Web サービスは SSR 無しで動く SPA だった。ブラウザ側で fetch API でバックエンドにリクエストし、その結果をもとにコンテンツを表示している。よってページAにアクセスした直後は、fetch API で様々なデータの取得が行われている最中だった。
話を戻すと、つまりはこういうことが起きていた。
- Webサービスのページ (ページA) を開く
- バックエンドに対して fetch でリクエストを投げる
- 上記ページ上でアクセス直後に、外部サービスへと遷移する
- ページ遷移により、fetch が中断された
- 中断されると、 TypeError が発生する
- それを ErrorBoundary が catch し、エラー画面に切り替わった
- そして、そのエラー画面に切り替わった状態で bfcache に保存された
- 外部サービスのページ (ページB) が開かれる
- ブラウザバックでページAへと戻る
- ページAは bfcache に対応しているので、bfcache から復元された
- Webサービスのエラー画面が表示される
ページ遷移によって fetch が中断されてエラーが throw され、ブラウザバックした際にその状態からページが再開される ことで、こういった奇妙な現象が起きていたようだった。再現が確率的なのも、ページ遷移が fetch 中かそうでないかによって再現するかどうかが決まるためだった。
修正
妥当な修正方法から、やんちゃなものまで、色々考えられる。
- 案1: bfcache をやめる
- unload イベントのリスナーを設定しておくと bfcache されなくなり、エラー画面から再開されなくなる
- けど bfcache はユーザ体験向上のためにあるので、悪手だと思う
- 案2: bfcache から復元された時にエラー画面が出ていたら、ページをリロードする
- https://web.dev/articles/bfcache?hl=ja#observe_when_a_page_is_restored_from_bfcache で bfcache からの復元を検知可能
- けどリロードするまでの間にエラー画面が一瞬見えて微妙
- 案3: fetch がエラーになっても何度か retry する
- retry 中はエラーを ErrorBoundary に伝えない。最後まで retry して駄目だったら伝える。
- これであればページ遷移時に 1 度エラーになってもエラー画面は出ない
- ブラウザバックで戻ってきた時に、retry される
- 案4: fetch でエラーになってもエラー画面に切り替えない
- 画面全体を「エラーが発生しました」に切り替えて操作不能にしてしまうのではなく、レンダリングできるところまでやる戦略
- これなら仮にエラーになっても、ユーザへの影響を最小限にできる
- 丁寧ではあるけど、中途半端にレンダリングが行われて、表示上の不整合が出たりと、面倒なことが多い
- 我々の Web サービスではそこまで丁寧にやらないポリシーだったので、これは避けたかった
- 画面全体を「エラーが発生しました」に切り替えて操作不能にしてしまうのではなく、レンダリングできるところまでやる戦略
今回は 案3 の方法で修正した。多分真っ当な修正方法だと思う。そもそも日常生活の中で一瞬オフラインになって fetch がネットワークエラーを投げることはままあるし、そういう意味でも retry する機構はあったほうが良いだろう。
もし他に良い修正方法があれば教えてほしい!
ページ遷移時に fetch を中断するかは、ブラウザ次第
どうも、この挙動はブラウザによって統一されてないらしい。Chrome はページ遷移しても fetch を中断せず、裏で継続してくれるようだった。そのため、Chrome では上記不具合は再現しなかった。
Chrome とそれ以外のブラウザ、どちらが仕様に準拠した挙動なのか気になって調べてみたけど、そこまで細かい挙動は仕様で定められておらず、どちらも仕様に準拠した挙動らしい。
なんとなく統一したほうがユーザの混乱を生まない気はするけど、どうなんでしょうね。まあ Chrome の挙動も、それ以外のブラウザの挙動も、どちらも妥当ではありそう。
上記の Issue を読んだ限りでは、挙動の統一に関する議論まではされてなさそうな雰囲気だった。
throw されるエラーの特徴
https://github.com/mizdra/bfcache-and-page-transitions-during-fetching-test で観察してみたところ...
- エラーオブジェクトの種類
- Firefox, Safari ともに TypeError
error.message
- Firefox:
NetworkError when attempting to fetch resource.
- Safari:
Load failed
- Firefox:
- その他特徴
- スタックトレースが一切ない
「スタックトレースが一切ない」というのが大きな特徴。例えば、ネットワークがオフラインになったことにより fetch が中断された場合は、以下のようにスタックトレース付きのエラーが throw される *1。
TypeError: Failed to fetch at fetchResource (http://localhost:3000/1-basic:14:13) at HTMLButtonElement.<anonymous> (http://localhost:3000/1-basic:32:7)
しかし、ページ遷移で fetch が中断されて throw されたエラーには、スタックトレースが含まれない。
TypeError: NetworkError when attempting to fetch resource.
詳細は不明だが、多分ページをまたぐことで、なんやかんやあってスタックトレースが吹き飛んでいるのだと思う。これがブラウザのバグなのか、仕様なのかはよく分かってない。
意外と色々なところで起きている
多分似たようなことが様々なプロダクトで起きてると思う。社内でも、TypeError: Load failed
, TypeError: NetworkError when attempting to fetch resource.
というキーワードで Sentry の Issue を検索すると、それらしい Issue がいくつか見つかった。
折角なので、この機会に自分のプロダクトを見直してみると良いと思う。昔からエラーが報告されていたものの、スタックトレースが空で何が起きているかの原因が掴めなかった...というのが実はこれかもしれない。
以下に Sentry に届いたエラーが本現象に起因するものかどうかを見極めるポイントを書いておく。
- エラーの name/messsage が
TypeError: Load failed
かTypeError: NetworkError when attempting to fetch resource.
になってる - スタックトレースが空になっている
- エラーの発生ページが bfcache に対応している
- Chrome なら、devtools の
Appliaction > Background services > Back/forward Cache
から bfcache 対応か確認できる - あとは
pageshow
イベントで得られるevent.persisted
を見るとか - bfcache される条件はブラウザによって微妙に違うので、後者で確認するのがより正確だが、手軽に確認するなら前者がオススメ
- Chrome なら、devtools の
- fetch 中にページ遷移したという行動履歴が Breadcrumbs に残っている
- 何かしらのリソースを fetch 中にリンクをクリックしている、みたいな行動履歴とか
もし本現象が起きていたら、fetch を retry する仕組みを入れたり、あるいは retry 機構が組み込まれた fetch ライブラリを使ってみるなどを検討してみると良いかもしれない。
まとめ
- fetch 中にページ遷移すると、(Firefox と Safari では) fetch が中断され、エラーが throw される
- bfcache 対応ページだと、ブラウザバック時にエラーが throw された状態からページが再開されうる
- fetch は retry しよう
追記 (2024-11-06)
Firefox と Safari にそれぞれ不具合として報告しておいた。
- 1928385 - Unstable behavior when a page that transited during fetch is restored from bfcache
- 282506 – The page is resumed from the point at which fetch is aborted
*1:Firefox のエラー出力を例に取り上げている