@k8ordo/router

仕組み

@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>とフレームワークのランタイムが自分で置くので、アプリが書くのは、自分でこのフックを使うときだけです。

k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2