react-router-dom v6とNavigation Block の話

2023/04/13に公開約6,500字

react-router-dom

react-router は 3つの packages を monorepo (lerna)で開発されてます。

役割としては以下です。

  • react-router
    • react-router の core 実装。基本的に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がどうやって動いているか

e.g.

<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 では、渡された childrenRoute群)から RouteObject[] を作成する。

img1

  1. navigate(path) が呼ばれたときに、内部的に navigator.push が実行され、Routes で登録された RouteObject の中で最初にマッチしたパスのコンポーネントが読み込まれて表示される。

    • 内部的には、navigater を使ってパスを書き換える(navigator == history)。
  2. RouteObject[] からマッチするパスを探す(remainingPathname/about)

    let matches = matchRoutes(routes, { pathname: remainingPathname });
    
  3. matches から、以下の AgnosticRouteMatch というオブジェクトを作成。

img2

  1. このオブジェクトを _renderMatches に渡して、ReactElement を作成する。
  2. 最後に、パスに対応するコンポーネントを描画する。

参考

これはv5ベースだけどたぶんv6でもそんなに変わらない。

react-router 作り方

useBlocker が消えた

v5 では <Prompt> の実装をカバーするために useBlocker が実装され、提供される予定でしたが、v6.0.0 beta 7 のリリースプロセス中に削除されました。

削除された理由

useBlocker の機能が完成しなかったため、コントリビュータたちが話し合った結果、完成を待つよりも現状の v6 をリリースすることを優先することになりました。

useBlocker の役割

useBlocker は、ナビゲーションブロックのために実装されたフックでした。例えば、画面遷移時にフォームなどの入力状態を破棄するかどうかを確認するダイアログを表示する機能がありました。

PUSH / REPLACE でのBlock

PUSH時はhistory.pushState を使用して、ブラウザの履歴エントリにURLを追加する。

以下、履歴エントリにURLを追加している。戻るボタンが有効になる。

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

REPLACE時はhistory.replaceState を使用してブラウザの履歴エントリのURLを書き換える。

以下、履歴エントリにURLを書き換えている。戻るボタンは有効にならない。

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

このとき、hook側でpushState, replaceState 時にブロッカーを処理してブロックされた場合にその後の処理をキャンセルすることができ、これはURLとUIが同期可能。

POP でのBlock

POP ナビゲーション、つまりブラウザの戻るボタンなどを押された場合はPUSH/REPLACE とは異なり、戻るボタンが押されたのち、URLが更新されてからpopstate イベントが呼ばれるため、react-router側ですぐに検知することができない。

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

history/index.ts at 3e9dab413f4eda8d6bce565388c5ddb7aeff9f7e · remix-run/history

RELOADでのBlock

v6ではuseBeforeUnload hookが用意されている。

結局、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 は JS スレッド上で同期的に実行されます。しかし、その間にブラウザは戻る、進むなどの操作を受け付けてしまいます。そのため、リトライの問題が発生してしまいました。

しかし、実際に window.confirm を利用しているユーザーはほとんどいないため、今後サポート対象外となりました(実際にアンケートを実施した話は issue 参照)。

信頼性の高い Navigation Block

信頼性の高い Navigation Block を話し合った結果、以下の 3 つの仕様が決まりました。

  1. ナビゲーションをブロックすべきかどうかに対する答えは同期的でなければならない。ブロッキングされて質問に答えている間にユーザーが追加のナビゲーションを実行する方法があってはならない。
  2. ナビゲーシションが成功したら、すぐにすべてのブロッカーをリセットしなければならない。ブロッカーの再試行関数は本質的に古く、したがって、それらを呼び出すとさらに奇妙なことが起こる。
    • 画面上に存在するブロッカーは一つだけであること。
  3. 複数のフォームが存在するような画面の場合でも、ブロッカーが 2 つ以上存在するとブロッキング操作後にリトライ処理が失敗するため。

その後

上記の仕様で決まり、結果的にretry の提供は無くなりました。v6.4 でのリリースを目指して実装が始まり、その後、useBlocker は v6.7.0 でリリースされました。

現在では unstable_useBlocker として公開されています。unstable というのは、現在のところ、動作が不安定であることと、ユーザーに API の使い勝手を試してもらうためにそうしているとのことです。

実装

  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>
  );
}

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

Discussion

ログインするとコメントできます