react-router-dom v6とNavigation Block の話
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>
-
BrowserRouter
のuseLayoutEffect
でlocation
を監視し、現在のhistory
を保持する。-
history
が持つlocation
は、useLocation
で使用される。
-
-
Routes
では、渡されたchildren
(Route
群)からRouteObject[]
を作成する。
-
navigate(path)
が呼ばれたときに、内部的にnavigator.push
が実行され、Routes
で登録されたRouteObject
の中で最初にマッチしたパスのコンポーネントが読み込まれて表示される。- 内部的には、
navigater
を使ってパスを書き換える(navigator == history
)。
- 内部的には、
-
RouteObject[]
からマッチするパスを探す(remainingPathname
は/about
)let matches = matchRoutes(routes, { pathname: remainingPathname });
-
matches
から、以下のAgnosticRouteMatch
というオブジェクトを作成。
- このオブジェクトを
_renderMatches
に渡して、ReactElement
を作成する。 - 最後に、パスに対応するコンポーネントを描画する。
参考
これはv5ベースだけどたぶんv6でもそんなに変わらない。
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
の一部としてリトライ関数(ブロック解除後に再度前ページに戻ること)をユーザーランドに公開しました。しかし、その制御が難しくなってしまいました。
例:
- ユーザーが C というページを見ており、A → B → C という履歴スタックを持っています。
- ユーザーが戻るボタンを押して B に戻ろうとします。
- ナビゲーションがブロックされます(この時点で内部的にユーザーは B にいる)。
- ブロックがキャンセルされたので、
retry: (history.back(-1))
が提供されます。 - その間にユーザーがさらに戻るボタンを押します(ブラウザでは B にいる状態で A に戻ろうとしています)。
- そのタイミングで
retry: (history.back(-1))
を実行すると、ユーザーは A に来てしまいます。
本来は B に戻るべきですが、A に遷移してしまいます。
具体的な実装では、window.confirm
は JS スレッド上で同期的に実行されます。しかし、その間にブラウザは戻る、進むなどの操作を受け付けてしまいます。そのため、リトライの問題が発生してしまいました。
しかし、実際に window.confirm
を利用しているユーザーはほとんどいないため、今後サポート対象外となりました(実際にアンケートを実施した話は issue 参照)。
信頼性の高い Navigation Block
信頼性の高い Navigation Block を話し合った結果、以下の 3 つの仕様が決まりました。
- ナビゲーションをブロックすべきかどうかに対する答えは同期的でなければならない。ブロッキングされて質問に答えている間にユーザーが追加のナビゲーションを実行する方法があってはならない。
- ナビゲーシションが成功したら、すぐにすべてのブロッカーをリセットしなければならない。ブロッカーの再試行関数は本質的に古く、したがって、それらを呼び出すとさらに奇妙なことが起こる。
- 画面上に存在するブロッカーは一つだけであること。
- 複数のフォームが存在するような画面の場合でも、ブロッカーが 2 つ以上存在するとブロッキング操作後にリトライ処理が失敗するため。
その後
上記の仕様で決まり、結果的にretry の提供は無くなりました。v6.4 でのリリースを目指して実装が始まり、その後、useBlocker は v6.7.0 でリリースされました。
現在では unstable_useBlocker として公開されています。unstable というのは、現在のところ、動作が不安定であることと、ユーザーに API の使い勝手を試してもらうためにそうしているとのことです。
実装
-
useBlocker
が呼び出されると、インクリメントされたIDを使ってBlockerId
を生成します。 - 渡された
BlockerFunction
とBlockerId
をblockerFunctions Map
に保持します。 -
Blocker
を返します(IDLE_BLOCKER
)。 - ナビゲート時に、
blockerFunctions Map
からBlockerFunction
を取得して実行します(存在する場合)。-
window.confirm
などのブロッキング処理は対象外です。
-
-
blockerKey
のstatus
をblocked
に更新し、proceed()
関数をセットします。 - ユーザーがナビゲーションを許可する場合、
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