react-router-dom の動作原理と v6 で消えた Prompt にまつわる話
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>
このコードがどのように動作するのか、順を追って見ていきましょう。
-
BrowserRouter
のuseLayoutEffect
がlocation
を監視し、現在のhistory
を保持します。なお、history
が持つlocation
は、useLocation
によって参照されます。 - 次に
Routes
では、引数として渡されたchildren
(つまりRoute
の集合)からRouteObject[]
が作成されます。
-
navigate(path)
が呼ばれた時には、内部的にnavigator.push
が実行されます。これにより、Routes
に登録されたRouteObject
の中で最初にマッチしたパスのコンポーネントが読み込まれて表示されます。この際、内部的にnavigater
がパスを書き換えます(つまり、navigator == history
)。 - 上記で作成された
routes: RouteObject[]
からマッチするパスを探します(この場合、remainingPathname
は/about
)。
let matches = matchRoutes(routes, { pathname: remainingPathname });
-
matches: AgnosticRouteMatch
というオブジェクトが生成されます。
- このオブジェクトは
_renderMatches
に渡され、element
からReactElement
が作成されます。 - 最後に、パスに対応するコンポーネントが描画されます。
この一連の流れが、react-router-dom
がどのように動作するのかを示しています。
参考資料
上記は主にreact-router
のバージョン6を基に説明しています。
以下のリンクではバージョン5で説明されています。
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を置き換えています。これにより、「戻る」ボタンは無効になります。
フックはpushState
やreplaceState
の実行時にブロッカーを処理し、ブロックが発生した場合には以降の処理をキャンセルします。これにより、URLとUIが同期して動作します。
POP
によるブロック
POP
ナビゲーション、つまりブラウザの戻るボタンを押した場合は、PUSH/REPLACE
と異なり、戻るボタンが押された後にURLが更新され、その後でpopstate
イベントが発生します。そのため、react-router
側ではすぐにその動作を検知することができません。
バージョン5では、この問題に対応するために、location.state
にインデックスを保存し、popstate
の差分が何であったかを判断できるようにしていました。そして、ナビゲーションがブロックされた場合には元に戻すように実装されていました。
RELOAD
によるブロック
バージョン6では、useBeforeUnload
フックが提供されています。
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
はJavaScriptのスレッド上で同期的に実行されます。しかし、その間にブラウザは戻るや進むなどの操作を受け付けてしまうため、リトライに関する問題が生じてしまいました。
ただし、実際にwindow.confirm
を使用しているユーザーはほとんどおらず、今後はサポート対象から外れる予定です(実際に行われたアンケートの詳細はissueで確認できます)。
高信頼性を目指したナビゲーションブロック
高信頼性を目指したナビゲーションブロックについて話し合った結果、以下の3つの仕様に落ち着きました。
- ナビゲーションをブロックするべきかどうかの回答は、同期的に提供されなければならない。ユーザーがブロッキングされている間に追加のナビゲーション操作を行うことが許されてはならない。
- ナビゲーションが成功した時点で、全てのブロッカーは直ちにリセットされるべきです。再試行関数は基本的に古い状態のものであるため、これを呼び出すと意図しない動作を引き起こす可能性があります。
- 画面上に存在するブロッカーは一つだけであるべきです。
- 複数のフォームが存在するページのような場合でも、ブロッカーが2つ以上存在すると、ブロッキング操作後の再試行処理が失敗する可能性があります。
その後の進展
上記の仕様が確定した後、retryの提供はなくなり、v6.4のリリースを目指して実装が進められました。そして、その後useBlockerはv6.7.0でリリースされました。
現在、この機能はunstable_useBlockerとして公開されています。"unstable"というプレフィックスは、現時点では動作が不安定であること、そしてユーザーにAPIの使い勝手を試してフィードバックを得ることを目指していることを示しています。
新しいuseBlockerの使い方
-
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>
);
}
以上がreact-router-dom v6 で消えた Prompt の顛末でした。
元ネタ: https://github.com/remix-run/react-router/blob/main/decisions/0001-use-blocker.md
Discussion
一番最後のコード例について、useBlockerの初期値はsetFormIsDirtyでは変えることができないと思いました!