🔗

react-router(v6)のLazy Loadingでページ遷移時のdynamically importedエラーを回避する

2024/12/14に公開

この記事は株式会社ガラパゴス(有志)アドベントカレンダー2024の14日目の記事です。

react-router v7安定版がリリースされましたね!🎉
ということで、react-router v6について記事を書こうと思います。

v6からv7への移行と、この記事の注意点

v6でdata router(createBrowserRouterRouterProvider)を使った場合に、route定義でlazyオプションが使えました。この記事では、v6でdata routerでlazyを使っている場合の実装を記載しています。

v7でReact Router Viteプラグインを使った場合、lazyfileというオプションに置き換える必要があります。filestring型のみ受け付けるため、fileではこの記事で紹介する方法は使えなくなると思います。

v7.0.2時点での型は下記になっています。

remix-run/react-router/packages/react-router-dev/config/routes.ts
interface RouteConfigEntry {
    /**
     * The unique id for this route.
     */
    id?: string;
    /**
     * The path this route uses to match on the URL pathname.
     */
    path?: string;
    /**
     * Should be `true` if it is an index route. This disallows child routes.
     */
    index?: boolean;
    /**
     * Should be `true` if the `path` is case-sensitive. Defaults to `false`.
     */
    caseSensitive?: boolean;
    /**
     * The path to the entry point for this route, relative to
     * `config.appDirectory`.
     */
    file: string;
    /**
     * The child routes.
     */
    children?: RouteConfigEntry[];
}

https://github.com/remix-run/react-router/blob/821ae3fc6ae6a6ac9d752d2e5f88218547beca95/packages/react-router-dev/config/routes.ts#L68-L99

React Router Viteプラグイン(フレームワークとしてv7)を使った場合、自動でcode-splittingされるのでlazyが不要なのは理解できたのですが、今回紹介するFailed to fetch dynamically imported moduleについての課題をv7がフレームワーク(React Router Viteプラグイン)としてどのように解決しているのか、そして今回の実装をそのまま移行できるのかは未検証です。

ただ、createBrowserRouterRouterProviderを廃止しているわけではなく、『Custom Framework』という項目で使っているので、この記事が役立つことを祈っています。

https://reactrouter.com/start/framework/custom

Failed to fetch dynamically imported moduleの問題

前置きが長くなりましたが、本題に入ります。
この実装は以下の前提で行っています。

  • クライアントサイドレンダリングSPAの開発である
  • react-router v6.28.0
  • createBrowserRouterlazyを使ってルート単位でコード分割している

ユーザーがWebアプリを開いた状態で、ビルド&デプロイ実行され、ユーザーがナビゲーションを行うと、ブラウザはFailed to fetch dynamically imported moduleエラーになります。

  • ビルド前
    • chunk_a_1.jschunk_a_2.jsを参照
    • ブラウザはchunk_a_1.jsを開いている
  • ビルド&デプロイ後
    • chunk_a_1.jsは消え、chunk_b_1.jsとして生まれ変わる
    • chunk_a_2.jsは消え、chunk_b_2.jsとして生まれ変わる
    • ブラウザはchunk_a_1.jsを開いている
    • ブラウザがナビゲーションしようとしてchunk_a_2.jsをリクエストする
    • もうそこにchunk_a_2.jsはいない

下記のissueに解決方法があり、これで良いかなと思いましたが課題が残りました。

lazy: () => import("./routes/Demo/Demo").catch(() => window.location.reload()),

https://github.com/remix-run/react-router/discussions/10333

上記の実装だと、ページAからページBへのナビゲーションで失敗した場合、ページAでリロードされるため、ユーザーとしては不自然な挙動だと感じました。

期待する挙動と実装

  • エラーがない場合はソフトナビゲーション(window.history.pushState())
  • エラーの場合はハードナビゲーション(ページ全体がリロードされる)

挙動的には上記になってほしいのですが、.catch(() => window.location.reload())では実現できなかったため、下記のように実装しました。

import * as React from "react";
import { type RouteObject, createBrowserRouter } from "react-router-dom";

type ImportError = {
  path: string;
  error: unknown;
};

// リロードするだけのコンポーネントを定義
const LocationReload = () => {
  React.useEffect(() => {
    window.location.reload();
  }, []);
  return null;
};

// dynamic importのcatchに渡す関数
const handleDynamicImportError = ({ path, error }: ImportError) => {

  // ネットワークが繋がっていない場合など、chunk変更以外のエラーの場合の無限リロードを防止する
  const storageKey = `react_router_reload:${path}`;
  if (!sessionStorage.getItem(storageKey)) {
    sessionStorage.setItem(storageKey, "1");

    // エラーの場合はリロードするコンポーネントを返す
    return {
      Component: LocationReload,
    } satisfies RouteObject;
  }

  throw error;
};

const router = createBrowserRouter([
  {
    path: "/",
    ErrorBoundary: () => {
      return (
        <>
          <h1>Uh oh!</h1>
          <p>Something went wrong!</p>
          <button onClick={() => window.location.reload()}>
            Click here to reload the page
          </button>
        </>
      );
    },
    lazy: () =>
      import("./routes/Demo/Demo").catch((error) =>
        handleDynamicImportError({
          path: "/",
          error,
        })
      ),
  },
]);

無限ループ防止のためのsessionStorage実装は@tanstack/react-routerを参考にしました。

https://github.com/TanStack/router/blob/ccf79726266fe0031bf34be52c7d2e1caaf7acf2/packages/react-router/src/lazyRouteComponent.tsx#L84-L96

結び

Viteにはvite:preloadErrorイベントが用意されていて、これを使って上手く実装できないかとも思ったのですが、
やはり遷移前のページでリロードされるため、今回の実装に落ち着きました。

window.addEventListener('vite:preloadError', (event) => {
  window.location.reload() // for example, refresh the page
})

https://vite.dev/guide/build#load-error-handling

v7のReact Router Viteプラグインではこのイベントを使っていたりするのでしょうか。
願わくば、v7へのアップデートで今回の実装が不要になるといいなぁと思っています。

GitHubで編集を提案
株式会社ガラパゴス(有志)

Discussion