react-router-dom の動作原理と v6 で消えた Prompt にまつわる話

2023/04/13に公開
1

react-router-domとは何か

react-routerは、一元的なリポジトリ(monorepo)として、lernaを利用して3つのパッケージを開発しています。それぞれの役割は以下のようになっています。

  • react-router
    • react-routerの中心的な実装で、主にDOMやNativeの環境で使用されます。
  • react-router-dom
    • react-routerのブラウザ向けのラッパー。ブラウザでの動作を担当し、具体的にはBrowserRouterなどが含まれます。
  • react-router-native
    • react-routerのNative向けのラッパー。ネイティブアプリケーションでの動作を担当し、具体的にはNativeRouterなどが含まれます。
  • router
    • remix-run/routerはフレームワークに依存しないルーティングパッケージで、ブラウザエミュレータとも呼ばれることがあります。react-routerとremixの中核部分として、データのロードやデータの変化に伴うルーティングに関するすべての主要な機能を提供します。これにはエラーハンドリング、レースコンディション、中断、キャンセル、データの遅延ロードなどの多岐にわたる処理が含まれます。

本記事では、これらの中から特にreact-router-domに焦点を当てて説明を進めます。

react-router-domの動作原理

例えば、以下のようなコードがあったとします。

<BrowserRouter>
  <Routes>
    <Route path="/" element={<Layout />}>
      <Route index element={<Home />} />
      <Route path="about" element={<About />} />
      <Route path="dashboard" element={<Dashboard />} />
    </Route>
  </Routes>
</BrowserRouter>

このコードがどのように動作するのか、順を追って見ていきましょう。

  1. BrowserRouteruseLayoutEffectlocationを監視し、現在のhistoryを保持します。なお、historyが持つlocationは、useLocationによって参照されます。
  2. 次にRoutesでは、引数として渡されたchildren(つまりRouteの集合)からRouteObject[]が作成されます。
    RouteObjects
  3. navigate(path)が呼ばれた時には、内部的にnavigator.pushが実行されます。これにより、Routesに登録されたRouteObjectの中で最初にマッチしたパスのコンポーネントが読み込まれて表示されます。この際、内部的にnavigaterがパスを書き換えます(つまり、navigator == history)。
  4. 上記で作成された routes: RouteObject[]からマッチするパスを探します(この場合、remainingPathname/about)。
let matches = matchRoutes(routes, { pathname: remainingPathname });
  1. matches: AgnosticRouteMatchというオブジェクトが生成されます。
    AgnosticRouteMatch
  2. このオブジェクトは_renderMatchesに渡され、element から ReactElementが作成されます。
  3. 最後に、パスに対応するコンポーネントが描画されます。

この一連の流れが、react-router-domがどのように動作するのかを示しています。

参考資料

上記は主にreact-routerのバージョン6を基に説明しています。
以下のリンクではバージョン5で説明されています。

react-routerの作成方法

useBlockerが削除された背景

バージョン5では、<Prompt>の実装を支えるためにuseBlockerが計画され、提供される予定でした。しかし、バージョン6.0.0のベータ7版リリース中にこの機能は削除されました。

その理由とは

その理由は、useBlockerの機能が完全に完成しなかったからです。これに対してコントリビュータたちは協議し、useBlockerの完成を待つよりも、現行のバージョン6をリリースすることを優先するという結論に至りました。

useBlockerの役割

useBlockerは、ナビゲーションブロックを実現するためのフックとして実装されていました。具体的には、画面遷移時にフォーム等の入力状態を破棄するかどうかを確認するダイアログを表示する機能が含まれていました。

PUSH/REPLACEによるブロック

PUSH動作では、history.pushStateを用いてブラウザの履歴エントリにURLを追加します。

const state = { 'page_id': 1, 'user_id': 5 }
const url = 'hello-world.html'
history.pushState(state, '', url)

上記の例では、履歴エントリにURLを追加しています。これにより、「戻る」ボタンが有効になります。

一方、REPLACE動作では、history.replaceStateを用いてブラウザの履歴エントリのURLを置き換えます。

const stateObj = { foo: 'bar' };
history.pushState(stateObj, '', 'bar.html');

上記の例では、履歴エントリのURLを置き換えています。これにより、「戻る」ボタンは無効になります。

フックはpushStatereplaceStateの実行時にブロッカーを処理し、ブロックが発生した場合には以降の処理をキャンセルします。これにより、URLとUIが同期して動作します。

POPによるブロック

POPナビゲーション、つまりブラウザの戻るボタンを押した場合は、PUSH/REPLACEと異なり、戻るボタンが押された後にURLが更新され、その後でpopstateイベントが発生します。そのため、react-router側ではすぐにその動作を検知することができません。

バージョン5では、この問題に対応するために、location.stateにインデックスを保存し、popstateの差分が何であったかを判断できるようにしていました。そして、ナビゲーションがブロックされた場合には元に戻すように実装されていました。

RELOADによるブロック

バージョン6では、useBeforeUnloadフックが提供されています。

それでは、useBlockerが困難だったのは何故か?

この問題はブロックの方法そのものではなく、POPナビゲーションのリトライタイミングにありました。

remixは、useBlockerの一部としてリトライ関数(ブロック解除後に再度前のページに戻る機能)をユーザーランドに公開しました。しかし、その制御が難しくなってしまったのです。

例えば、以下のようなシチュエーションを考えてみましょう。

  1. ユーザーは現在Cというページを閲覧しており、その履歴スタックはA→B→Cという順番になっています。
  2. ユーザーは戻るボタンをクリックしてBへ戻ろうとします。
  3. しかしこの時点で、ナビゲーションはブロックされます(内部的にはユーザーはすでにBにいる状態になっています)。
  4. ブロックがキャンセルされた場合に備えて、retry: (history.back(-1))が提供されます。
  5. しかし、その間にユーザーがさらに戻るボタンをクリックします(ブラウザ上ではユーザーはBにいる状態でAに戻ろうとしています)。
  6. その時点でretry: (history.back(-1))を実行すると、ユーザーはAへと遷移してしまいます。

結果的には、ユーザーはBに戻るべきところを、Aに遷移してしまうという問題が起きます。

具体的な実装について説明しますと、window.confirmはJavaScriptのスレッド上で同期的に実行されます。しかし、その間にブラウザは戻るや進むなどの操作を受け付けてしまうため、リトライに関する問題が生じてしまいました。

ただし、実際にwindow.confirmを使用しているユーザーはほとんどおらず、今後はサポート対象から外れる予定です(実際に行われたアンケートの詳細はissueで確認できます)。

高信頼性を目指したナビゲーションブロック

高信頼性を目指したナビゲーションブロックについて話し合った結果、以下の3つの仕様に落ち着きました。

  1. ナビゲーションをブロックするべきかどうかの回答は、同期的に提供されなければならない。ユーザーがブロッキングされている間に追加のナビゲーション操作を行うことが許されてはならない。
  2. ナビゲーションが成功した時点で、全てのブロッカーは直ちにリセットされるべきです。再試行関数は基本的に古い状態のものであるため、これを呼び出すと意図しない動作を引き起こす可能性があります。
    • 画面上に存在するブロッカーは一つだけであるべきです。
  3. 複数のフォームが存在するページのような場合でも、ブロッカーが2つ以上存在すると、ブロッキング操作後の再試行処理が失敗する可能性があります。

その後の進展

上記の仕様が確定した後、retryの提供はなくなり、v6.4のリリースを目指して実装が進められました。そして、その後useBlockerはv6.7.0でリリースされました。

現在、この機能はunstable_useBlockerとして公開されています。"unstable"というプレフィックスは、現時点では動作が不安定であること、そしてユーザーにAPIの使い勝手を試してフィードバックを得ることを目指していることを示しています。

新しいuseBlockerの使い方

  1. useBlockerが呼び出された際に、増分されたIDを用いて BlockerIdを生成します。
  2. 引数として受け取った BlockerFunctionBlockerIdblockerFunctions Map に保管します。
  3. Blockerを返却します(この時点ではIDLE_BLOCKER)。
  4. ナビゲーションが実行されると、blockerFunctions Mapから BlockerFunctionを取得し、それを実行します(存在する場合)。
    1. window.confirmなどのブロッキング処理は除外されます。
  5. blockerKeystatusblockedに更新し、proceed() 関数を設定します。
  6. ユーザーがナビゲーションを許可する場合、Blocker.proceed()を呼び出し、次のページへの遷移を行います。

Usage

type Blocker =
  | {
      state: "unblocked";
      reset: undefined;
      proceed: undefined;
    }
  | {
      state: "blocked";
      reset(): void;
      proceed(): void;
    }
  | {
      state: "proceeding";
      reset: undefined;
      proceed: undefined;
    };

declare function useBlocker(shouldBlock: boolean | () => boolean): Blocker;

function MyFormComponent() {
  let [formIsDirty, setFormIsDirty] = React.useState(false);
  let blocker = useBlocker(formIsDirty);

  return (
    <Form method="post" onChange={(e) => setFormIsDirty(true)}>
      <label>
        First name:
        <input name="firstname" required />
      </label>
      <label>
        Last name:
        <input name="lastname" required />
      </label>
      <button type="submit">Submit</button>

      {blocker.state === "blocked" ? (
        <div>
          <p>You have unsaved changes!<p>
          <button onClick={() => blocker.reset()}>
            Oh shoot - I need them keep me here!
          </button>
          <button onClick={() => blocker.proceed()}>
            I know! They don't matter - let me out of here!
          </button>
        </div>
      ) : blocker.state === "proceeding" ? (
        <p>Navigating away with unsaved changes...</p>
      ) : null}
    </Form>
  );
}

以上がreact-router-dom v6 で消えた Prompt の顛末でした。

元ネタ: https://github.com/remix-run/react-router/blob/main/decisions/0001-use-blocker.md

Discussion

melodycluemelodyclue

一番最後のコード例について、useBlockerの初期値はsetFormIsDirtyでは変えることができないと思いました!