@k8ordo/router

ナビゲーション

<Router> は Navigation API の navigate イベントを受け取り、表が答える移動をブラウザの中で処理します。このページでは、どの移動を引き受けるか、引き受けた移動が何を保証するか、ページ切り替えのアニメーション、<Router> の土台になっている useInterceptedNavigation、そしてテストの書き方を扱います。

引き受ける移動

同一オリジンのリンクのクリック、navigateTonavigation.navigate()、戻る・進む、GET フォームの送信は、どれも同じ navigate イベントとして届きます。ルーターはそのうち、表が行き先の pathname に答えるものを intercept します。

移動扱い
表が答える別の pathname への移動ページの切り替え。新しい木を useDeferredValue で背景に描く
今表示しているページと同じ pathname への移動(search や履歴エントリの状態だけが変わる)状態の変更。読み込みも再マウントもしない
表が答えない pathname への移動intercept しない。ブラウザの文書読み込みになり、404 もサーバーの本物の答えになる
リロード表にかかわらず intercept しない。ブラウザに任せる
本文を持つフォームの送信(POST)表にかかわらず intercept しない。ブラウザに任せる
ダウンロード(download 属性の付いたリンク)表にかかわらず intercept しない。ブラウザに任せる
fragment だけの変更(#section表にかかわらず intercept しない。ブラウザに任せる
プラットフォームが intercept できないと言うもの(別オリジンなど)表にかかわらず intercept しない。ブラウザに任せる

リロード・POST・ダウンロード・fragment だけの変更を intercept すると、プラットフォームなら当然そうする動作の代わりに、何も起きなくなります。POST の本文を扱えるのはサーバーだけですし、F5 を押しても再読み込みされなくなります。GET フォームは本文を持たないので、@k8ordo/state が組み立てる search の形の送信は引き受けます。

表が答えない pathname で <Router> をマウントすると、推測で何かを描くことはせず、何も描きません。何にでも合う /* を表の最後に置けば、その pathname も表が答えるものになります。その代わり、ホストが配るファイル(/report.pdf など)へのリンクも表が答えるものになり、ファイルは開かれずに /* のコンポーネントが描かれます。そうしたリンクには download 属性を付けます。

ページが切り替わるまで

表が答える別の pathname への移動は、次の順で進みます。

  1. navigate イベントで、ルーターは自分が扱う移動か、表が行き先に答えるかを同期的に判断します。intercept できるのはこの瞬間だけです。
  2. URL が確定します。committed が解決し、usePathname が新しい pathname を返します。画面はまだ前のページです。
  3. 新しい照合結果を通常の更新として適用し、useDeferredValue を通して背景で描きます。その commit には navigationnavigation-push などの型が付きます。
  4. React が新しい木を commit します。描画の前の layout effect で、ルーターがスクロール位置を決めます。
  5. finished が解決します。

ナビゲーションが保証すること

上の流れから、アプリが頼ってよい性質がいくつか決まります。

finished は「画面に出た」

intercept のハンドラは、React が新しい木を commit した後、ブラウザが描画する前の layout effect で解決します。finished を待つコードは URL の書き換えではなく描画を待っています。待つのは新しい木の最初の commit なので、ナビゲーションで新しくマウントされた <Suspense>React.lazy のページがサスペンドした場合は、chunk が届く前、fallback が commit された時点で解決します。

startTransition の非同期アクションの中で待っても止まりません。ページの切り替えはアクションに加わらないので、finished はページが画面に出た時点で解決し、アクションの終わりを待ちません。どこかの非同期アクションが保留中の間に始まったページの切り替えも、そのアクションを待たずに画面に出ます。 リンクと現在地

状態の変更はページの切り替えではない

search か履歴エントリの状態だけが変わった移動では、pathname は画面に出ているページのものと同じです。ルーターはハンドラを付けずに intercept し、ルートの木に触れません。何も再マウントされず、スクロール位置もフォーカスも動きません。search を更新してもページが先頭に戻らないのはこのためです。待つべき描画が無いので、その finished@k8ordo/stateupdate().finished を含む)は移動が確定した時点で解決します。

比べる相手はアドレスバーではなく、画面に出ているページです。intercept では URL が先に確定するので、別のページの読み込み中にその URL へ状態の更新が来た場合はページの切り替えとして扱います。そのページが届くのを止めず、その finished もそのページの描画を待ちます。元のページ移動の finished は abort で reject し、ページは状態の更新の側のナビゲーションで届きます。

新しいページは先頭から

新しい木が画面に出ると、文書の読み込みと同じ位置にスクロールします。URL に #fragment があればその要素(id か、古い綴りの <a name> と同じ name 属性で探す)へ、無いか見つからなければ先頭へ移ります。fragment はデコードしてから探すので、#%E5%B0%8E%E5%85%A5id="導入" の要素を指します。

戻る・進むの位置はブラウザが保存したものを復元します。ルーターは触れません。フォーカスは、ページの切り替えではプラットフォームの既定のリセットに任せ、状態の変更では動かしません。

ページの切り替えは背景で描く

新しい木は useDeferredValue の優先度で描かれるので、次のページの準備ができるまで React は前のページを操作できる状態で保てます。React.lazy の chunk を待つ間も同じです。transition にしないのは、非同期アクションが保留中の間、React がすべての transition をそのアクションの終わりまで止めるからです。

追い越されたナビゲーションは abort される

2 つ目のナビゲーションは、プラットフォーム自身の signal で 1 つ目を abort します。追い越された側の finished は abort の理由で reject し、その読み込みがすでに終わっていても、その木は画面に出ません。load はその signal を受け取るので、フレームワークの下ではペイロードの fetch ごと取り消されます。React.lazy の chunk は取り消せない(動的 import は signal を取らない)ので裏で読み込みを終え、次の訪問のために残りますが、そのページは表示されません。

push・replace・traverse

ルーターは移動の種類をプラットフォームの navigationType から受け取り、transition の型として伝えます。どのページの切り替えも 1 つ目の型は navigation です。

2 つ目の型いつ
navigation-pushリンクのクリック、navigateTo(既定)
navigation-replacenavigateTo(…, { history: 'replace' })navigation.navigate(url, { history: 'replace' })
navigation-traverse戻る・進む、navigation.back() / navigation.forward() / navigation.traverseTo()

履歴エントリに状態を載せるのは @k8ordo/state の仕事です。navigateTo のオプションは history だけです。 @k8ordo/state

ページの切り替えをアニメーションする

ページの切り替えは背景での描画なので、transition と同じく React の <ViewTransition> でアニメーションできます。ページが描かれる穴を <ViewTransition> で包み、ルーターの transition の型をキーにします。

// src/root-layout.tsx
import { href, Outlet } from '@k8ordo/router';
import { ViewTransition } from 'react';

export function RootLayout() {
  return (
    <>
      <nav>
        <a href={href('/')}>Home</a>
        <a href={href('/products')}>Products</a>
      </nav>
      <ViewTransition
        default="none"
        update={{ navigation: 'auto', default: 'none' }}
      >
        <Outlet />
      </ViewTransition>
    </>
  );
}

型で絞るのは、ページの切り替え以外にも <ViewTransition> を動かす更新があるからです。ボタンの保留中のアクションは transition で、型で絞らなければボタンを押すたびにページ全体がクロスフェードします。update を使うのは、境界そのものは残り、中身だけが入れ替わるからです。auto はブラウザ既定のクロスフェードです。

今読んでいるこのサイトが、まさにこの形です。ロケール配下のレイアウトがページを update={{ navigation: 'auto', default: 'none' }}<ViewTransition> で包んでいるので、サイト内でページを移るたびにクロスフェードします。 locale-shell.tsx

2 つ目の型を使えば、戻るボタンをリンクと逆向きにスライドさせられます。

// src/page-transition.tsx
import { Outlet } from '@k8ordo/router';
import { ViewTransition } from 'react';

export function PageTransition() {
  return (
    <ViewTransition
      default="none"
      update={{
        'navigation-push': 'slide-forward',
        'navigation-replace': 'slide-forward',
        'navigation-traverse': 'slide-back',
        default: 'none',
      }}
    >
      <Outlet />
    </ViewTransition>
  );
}

各クラスは ::view-transition-old(.slide-back)::view-transition-new(.slide-back) で装飾します。

@keyframes slide-in-from-right {
  from {
    opacity: 0;
    translate: 32px 0;
  }
}

@keyframes slide-out-to-left {
  to {
    opacity: 0;
    translate: -32px 0;
  }
}

@keyframes slide-in-from-left {
  from {
    opacity: 0;
    translate: -32px 0;
  }
}

@keyframes slide-out-to-right {
  to {
    opacity: 0;
    translate: 32px 0;
  }
}

::view-transition-old(.slide-forward) {
  animation: 200ms ease-in both slide-out-to-left;
}

::view-transition-new(.slide-forward) {
  animation: 200ms ease-out both slide-in-from-right;
}

::view-transition-old(.slide-back) {
  animation: 200ms ease-in both slide-out-to-right;
}

::view-transition-new(.slide-back) {
  animation: 200ms ease-out both slide-in-from-left;
}

状態の変更(@k8ordo/stateupdate())は木を変えないので、アニメーションしません。

@k8ordo/ui のスタイルシートは prefers-reduced-motion のときに view transition のアニメーションを止めます。使っていないアプリは同じ規則を自分で足します。

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
}

フレームワークの下では、同じ <ViewTransition> でレイアウトの children を包みます。Server Component のレイアウトがそのまま描けます。 フレームワーク配下

useInterceptedNavigation

<Router> のナビゲーションの部分は、単独のフックとして公開されています。intercept して読み込み、適用し、新しい木が画面に出てからプラットフォームのハンドラを解決します。上の保証はすべてこのフックによるものです。何を読み込むかは呼び出し側が決めます。

引数は NavigationHandler<T> です。

  • claim(url):この移動をアプリが扱うか。intercept できるのはこの瞬間だけなので同期的に答えます。false を返すと文書読み込みになります。
  • load(url, signal):この URL で描くものを作ります。値か Promise を返します。signal は追い越されたときに abort されます。
  • apply(value):それを適用します。transition の外で、通常の更新として呼ばれます。ホストは設定した値を useDeferredValue を通して描きます。そうすると新しいページが背景で描かれ、その間は前のページが画面に残り、generationfinished も同じ commit で進みます。
// src/article-host.tsx
'use client';

import {
  NavigationGeneration,
  PathnameProvider,
  useInterceptedNavigation,
} from '@k8ordo/router';
import { useDeferredValue, useState } from 'react';

type Article = { title: string; body: string };

const loadArticle = async (url: URL, signal: AbortSignal) => {
  const response = await fetch(`/api${url.pathname}.json`, { signal });
  return (await response.json()) as Article;
};

export function ArticleHost({
  initial,
  pathname,
}: {
  initial: Article;
  pathname: string;
}) {
  const [latest, setLatest] = useState(initial);
  const { generation } = useInterceptedNavigation<Article>({
    claim: (url) => url.pathname.startsWith('/articles/'),
    load: loadArticle,
    apply: setLatest,
  });
  const article = useDeferredValue(latest);
  return (
    <PathnameProvider pathname={pathname}>
      <NavigationGeneration value={generation}>
        <article>
          <h1>{article.title}</h1>
          <p>{article.body}</p>
        </article>
      </NavigationGeneration>
    </PathnameProvider>
  );
}

ハンドラは描画時ではなくイベントの時点で読まれるので、描画のたびに新しいオブジェクトを渡してもメモ化は要りません。pathname が画面のページと同じ移動(状態の変更)では、claimload も呼ばれません。

<Router> はこのフックに claim として「表が合うか」、load として照合結果、apply として state の更新を渡したものです。フレームワークのランタイムは、同一オリジンの URL をすべて引き受け、load でサーバーの RSC ペイロードを取得します。

戻り値の generation は、URL が動いたときではなく新しい木が画面に出たときにだけ変わる番号です。ホストはこれを <NavigationGeneration value> で配り、表の error 境界が失敗を手放す時を知らせます。サーバーでの描画とハイドレーションに備えて <PathnameProvider pathname> もホストがマウントします。<Router> もフレームワークのランタイムもこの 2 つを自分で置くので、アプリがこのフックを使うのは、自分で遷移の継ぎ目を作るときだけです。

テスト

このパッケージは何もモックしないので、ナビゲーションのテストには本物のブラウザ環境が要ります。パッケージ自身のテストは Vitest の browser mode を Chromium で動かしています。

表の形・params・優先順位は、ブラウザなしで routes.match() に直接問えます。matchPath も純粋関数です。

// src/routes.test.ts
import { expect, it } from 'vitest';

import { routes } from './routes';

it('matches a product page before the catch-all', () => {
  expect(routes.match('/products/42')).toMatchObject({
    pattern: '/products/:id',
    params: { id: '42' },
  });
  expect(routes.match('/no/such/page')?.pattern).toBe('/*');
});

<Router> をマウントしたテストは、表が答えるパスへの移動をすでに intercept しています。finished は新しい木が画面に出たときに解決するので、待った直後に waitFor なしで画面を確かめられます。ページの passive effect はその後に走るので、effect の結果はリトライしながら確かめます。

注意が 1 つあります。マウントした <Router> が intercept しない移動(表に無い URL への移動や、ルーターが無い状態でテストランナーの URL へ戻す処理など)は、テスト自身が intercept しなければなりません。誰も intercept しない navigation.navigate() は文書読み込みになり、テストランナーごと別のページへ移ってしまいます。下の例は、表が何に答えるかにかかわらず後片付けが効くように、ランナーの URL へ戻す移動を自分で intercept しています。

// src/app.browser.test.tsx
import { navigateTo, Router } from '@k8ordo/router';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { render } from 'vitest-browser-react';

import { routes } from './routes';

const interceptEverything = (event: NavigateEvent) => {
  if (event.canIntercept) event.intercept();
};

let runnerUrl: string;

beforeEach(() => {
  runnerUrl = location.href;
});

afterEach(async () => {
  navigation.addEventListener('navigate', interceptEverything);
  try {
    await navigation.navigate(runnerUrl, { history: 'replace' }).finished;
  } finally {
    navigation.removeEventListener('navigate', interceptEverything);
  }
});

it('shows the product page once finished resolves', async () => {
  await render(<Router routes={routes} />);

  await navigateTo('/products/:id', { id: '1' }).finished;

  expect(document.querySelector('h1')?.textContent).toBe('Product 1');
});