仕組み
@k8ordo/routerがナビゲーションをどう扱っているかを説明します。使い方を覚えるのに必要な内容ではありませんが、なぜそう動くのかが分かると、思ったとおりに動かないときに原因を探しやすくなります。
このページの内容
ルーターが引き受けるナビゲーション
リンクのクリックもnavigateToも、ブラウザの戻ると進むも、GETのフォームの送信も、ブラウザは同じnavigateイベントで知らせます。ルーターはこのイベントを受け取り、行き先のパスにルート表が答えるものだけをインターセプトします。
次の4つは、ルート表に何が書いてあっても引き受けず、ブラウザに任せます。
- 再読み込み:新しいページの取得を求める操作だからです
- 本文を持つフォームの送信(POST):本文を扱えるのはサーバーだけだからです
- ダウンロード:ファイルを保存する操作だからです
- フラグメント(URLの
#より後ろ)だけの変更:同じページの中の移動だからです
これらを引き受けると、ブラウザなら当然そうする動作の代わりに、何も起きなくなってしまいます。別のオリジンへの移動のように、ブラウザがインターセプトを許さないナビゲーションも引き受けません。
GETのフォームは本文を持たないので引き受けます。@k8ordo/stateが組み立てる、クエリ文字列を書き換えるだけの送信も、ここを通ります。
finishedはページが画面に出たときに解決する
ルーターは、インターセプトしたナビゲーションを、新しいページが画面に出るまで終わらせません。そのため、navigateToが返すfinishedは、URLが書き換わったときではなく、ページが描かれたときに解決します。
正確には、Reactが新しいページを反映したあと、ブラウザがそれを描く前に解決します。描く前なので、新しいページが前のスクロール位置で1フレームだけ見えることもありません。
待つのは新しいページの最初の反映です。ナビゲーションで新しく現れた<Suspense>の中でReact.lazyのページがサスペンドしたときは、fallbackが描かれた時点で解決し、コードが届くのは待ちません。
状態の更新はページの切り替えではない
クエリ文字列や履歴エントリの状態だけが変わったナビゲーションでは、パスは画面に出ているページと同じです。ルーターはこれをページの切り替えとは扱わず、何も読み込まずにインターセプトします。
ページは作り直されず、スクロールの位置もフォーカスもそのままです。検索の条件を変えてもページの先頭に戻らないのは、このためです。待つ描画が無いので、finishedはURLが書き換わった時点で解決します。@k8ordo/stateのupdate()が返すfinishedも同じです。
比べる相手は、アドレスバーのパスではなく、画面に出ているページのパスです。別のページを読み込んでいる最中に、そのページのURLへ状態の更新が来たときは、ページの切り替えとして扱います。読み込み中のページはそのまま届き、そのfinishedもページの描画を待ちます。
次のページは背景で描く
新しいページは、useDeferredValueの優先度で描かれます。そのため、次のページの準備ができるまで前のページが画面に残り、操作もできます。
transitionにしないのは、非同期のアクションが保留中の間、Reactがすべてのtransitionをそのアクションが終わるまで止めるからです。transitionにすると、アクションの中でfinishedを待ったときに互いを待ち合って止まります。関係のないアクションが保留中のときも、ページの切り替えが遅れます。
この描画には、navigationとnavigation-pushなどの種類を付けます。<ViewTransition>がページの切り替えだけをアニメーションできるのは、この種類があるからです。
新しいページはどこから始まるか
新しいページが画面に出ると、ルーターはスクロールの位置を、ページを読み込んだときと同じ場所に動かします。URLにフラグメントがあればその要素へ、無ければページの先頭へ移ります。
フラグメントの要素は、idかname属性が一致するもので探します。デコードしてから探すので、#%E5%B0%8E%E5%85%A5はid="導入"の要素を指します。見つからなければ先頭へ移ります。
ブラウザの戻ると進むでは、ルーターはスクロールに触れず、ブラウザが覚えていた位置に任せます。
フォーカスも、ページを読み込んだときと同じく<body>に戻ります。状態の更新では、スクロールと同じくフォーカスも動きません。
追い越されたナビゲーションは中断される
読み込みの途中で次のナビゲーションが始まると、前のナビゲーションは中断されます。中断には、ブラウザ自身のAbortSignalを使います。
追い越された側のfinishedは、中断の理由でrejectします。読み込みがすでに終わっていても、そのページは画面に出ません。
React.lazyのコードの読み込みは、動的importがAbortSignalを受け取らないので取り消せません。読み込みはそのまま終わり、次に開いたときのために残りますが、追い越されたページは表示されません。フレームワークの下では、次のページのデータの取得ごと取り消されます。
useInterceptedNavigationで自分の仕組みを作る
ここまでの動きは、すべてuseInterceptedNavigationというフックが受け持っています。<Router>はこのフックにルート表をつないだもので、フレームワークのランタイムは、同じフックにサーバーから届くページをつないでいます。
フックには、次の関数をまとめたオブジェクトを渡します。
claim(url):このナビゲーションを引き受けるかどうか。インターセプトできるのはイベントの間だけなので、同期的に答えます。load(url, signal):そのURLで描くものを作ります。値かPromiseを返し、追い越されるとsignalが中断されます。apply(value):作ったものを反映します。transitionの外で、ふつうの更新として呼ばれます。refresh(url):省略できます。パスが変わらないナビゲーションでも、読み込み直すかどうかを答えます。
src/article-host.tsx'use client';
import {
NavigationGeneration,
PathnameProvider,
useInterceptedNavigation,
} from '@k8ordo/router';
import { useDeferredValue, useState } from 'react';
import type { ReactNode } from 'react';
type Props = { initial: ReactNode; pathname: string };
export function ArticleHost({ initial, pathname }: Props) {
const [latest, setLatest] = useState(initial);
const { generation } = useInterceptedNavigation<ReactNode>({
claim: (url) => url.pathname.startsWith('/articles/'),
load: (url, signal) => loadArticle(url, signal),
apply: setLatest,
});
const shown = useDeferredValue(latest);
return (
<PathnameProvider pathname={pathname}>
<NavigationGeneration value={generation}>
{shown}
</NavigationGeneration>
</PathnameProvider>
);
}applyで入れた値は、フックを呼んだのと同じコンポーネントの中で、useDeferredValueを通して描きます。こうすると新しいページが背景で描かれ、generationとfinishedも同じ反映で進みます。
generationは、新しいページが画面に出たときにだけ変わる番号です。<NavigationGeneration>で配ると、ルート表のerrorがエラーの表示を消す時を知ります。<PathnameProvider>は、サーバーでの描画とハイドレーションの間に、usePathnameが返すパスを渡します。
refreshがtrueを返すと、パスが同じでもページの切り替えと同じように読み込んで反映します。ただし、スクロールとフォーカスは動かさず、ナビゲーションの種類も付けません。フレームワークは、searchをexportしたページでクエリ文字列が変わったときにtrueを返します。
メモ
どちらも<Router>とフレームワークのランタイムが自分で置くので、アプリが書くのは、自分でこのフックを使うときだけです。