@k8ordo/router

ルート表

ルート表は、アプリが答える pathname を 1 か所に並べたものです。このページでは表の文法、照合の順序、定義した時点で拒まれる書き方、表に書くエラー境界、そして表から導かれる型を扱います。

leaf と branch

表の値は 2 種類です。leaf は描画するコンポーネントそのもので、branch は { layout?, error?, children } です。branch の children は同じ形の表で、キーは親のパターンに続けて読みます。

// src/routes.ts
import { defineRoutes } from '@k8ordo/router';
import { lazy } from 'react';

import { Home } from './pages/home';
import { ProductList } from './pages/product-list';
import { ProductPage } from './pages/product-page';
import { ProductsLayout } from './products-layout';
import { RootLayout } from './root-layout';

const Settings = lazy(() => import('./pages/settings'));

export const routes = defineRoutes({
  '/': {
    layout: RootLayout,
    children: {
      '/': Home,
      '/products': {
        layout: ProductsLayout,
        children: {
          '/': ProductList,
          '/:id': ProductPage,
        },
      },
      '/settings': Settings,
    },
  },
});

子のキー / は branch 自身の index ページです。ルートに置いた / の branch は URL に何も足さないので、全ページを包むレイアウトになります。

照合の結果は、外側のレイアウトから順に並べ、最後に leaf を置いたスタックです。レイアウトは次の要素を <Outlet /> で描きます。/products/42 なら RootLayoutProductsLayoutProductPage の順に入れ子になります。

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

export function RootLayout() {
  return (
    <>
      <header>Shop</header>
      <Suspense fallback={<p>Loading…</p>}>
        <Outlet />
      </Suspense>
    </>
  );
}
// src/products-layout.tsx
import { Outlet } from '@k8ordo/router';

export function ProductsLayout() {
  return (
    <section>
      <h1>Products</h1>
      <Outlet />
    </section>
  );
}

<Router> は leaf にもレイアウトにも props を渡しません。params は useParams で読みます。表が受け付けるコンポーネントの型 RouteComponentComponentType<never> なので、フレームワークが props を渡すコンポーネントも同じ表に入ります。

React.lazy の戻り値も leaf として置けます。branch は children キーの有無で見分けるので、関数ではない lazy コンポーネントを branch と取り違えません。chunk が届くまでの間に fallback を出す場所として、上のレイアウトに <Suspense> を置きます。fallback が出るのは、最初の描画と、その <Suspense> を新しくマウントするナビゲーションのときです。すでに画面にある <Suspense> の下でページが切り替わるときは、背景での描画が前のページを残します。

パターンの文法

パターンは URLPattern の pathname として照合します。表で使う記法は、固定の区間、:name、末尾の /*、そして URL に現れないグループ /(name) です。

パターンpathnameparams備考
/products/products/{}末尾のスラッシュは同じ pathname
/products/:id/products/42{ id: '42' }:name は 1 区間を捕まえる
/products/:id/products/a%2Fb{ id: 'a/b' }値はデコードされる
/products/:id/products/a/bnull (一致しない)/ をまたがない
/products/:id/products/null (一致しない)空の区間には合わない
/:locale/*/ja/no/such/page{ locale: 'ja' }ワイルドカードの中身は params に入らない
/:locale/*/janull (一致しない)/* より前の部分そのものには合わない
'/(docs)' › '/guide'/guide{}グループは URL に区間を足さない

:param

:name は空でない 1 区間を捕まえ、decodeURIComponent でデコードした文字列を返します。デコードできない綴りは書かれたまま残します。型はパターン文字列から推論され、/:locale/products/:id の params は { locale: string; id: string } です。

ワイルドカード /*

/* はそれより前の何にも合わなかった pathname を受けます。1 区間ではなく、下に続く任意の区間に合います。照合には使えますがリンク先にはならないので、hrefnavigateTo は実行時に TypeError で拒みます。Register を宣言していれば、型の段階でも拒みます。

ルートの /*/ 自身にも合います。表の最後に置くのはこのためでもあります。

グループ /(name)

グループは表を構造化します。自分のレイアウトと部分木を持ちますが、URL に区間を足しません。1 つのオブジェクトに / は 1 度しか書けないので、同じ深さの 2 つの区画に別々のレイアウトを持たせるにはグループが要ります。

// src/routes.ts
import { defineRoutes } from '@k8ordo/router';

import { DocsLayout } from './docs-layout';
import { MarketingLayout } from './marketing-layout';
import { Guide } from './pages/guide';
import { Home } from './pages/home';
import { Pricing } from './pages/pricing';

export const routes = defineRoutes({
  '/(marketing)': {
    layout: MarketingLayout,
    children: {
      '/': Home,
      '/pricing': Pricing,
    },
  },
  '/(docs)': {
    layout: DocsLayout,
    children: {
      '/guide': Guide,
    },
  },
});

上の表で /pricingMarketingLayout の中、/guideDocsLayout の中に描かれ、URL には marketingdocs も現れません。グループは branch の中にも置けます。

書いた順が規則

照合は表を上から順にたどり、最初に合ったパターンを採ります。優先順位は書いた順そのもので、特異度のランキングはありません。表はコードと同じように上から読めば答えが分かります。

// src/routes.ts
import { defineRoutes } from '@k8ordo/router';

import { NewProduct } from './pages/new-product';
import { ProductPage } from './pages/product-page';

export const routes = defineRoutes({
  '/products/:id': ProductPage,
  '/products/new': NewProduct,
});

この順では /products/newProductPage に合い、idnew になります。

// src/routes.ts
import { defineRoutes } from '@k8ordo/router';

import { NewProduct } from './pages/new-product';
import { ProductPage } from './pages/product-page';

export const routes = defineRoutes({
  '/products/new': NewProduct,
  '/products/:id': ProductPage,
});

固定の区間を先に書けば、/products/newNewProduct に、それ以外の /products/…ProductPage に届きます。

表を直接引く

defineRoutes が返す Routes の操作は match(pathname, accept?) の 1 つです。勝ったパターン・params・スタックを Match として返し、何にも合わなければ null を返します。ブラウザを必要としないので、テストで表の形と優先順位を直接確かめられます。

// src/routes.test.ts
import type { Match } from '@k8ordo/router';
import { expect, it } from 'vitest';

import { routes } from './routes';

it('sends /products/new to its own page, not to :id', () => {
  expect(routes.match('/products/new')?.pattern).toBe('/products/new');
  expect(routes.match('/products/42')?.params).toStrictEqual({ id: '42' });
  expect(routes.match('/nowhere')).toBeNull();
});

it('walks on when the caller declines a fit', () => {
  const onlyNumericIds = (found: Match) =>
    found.pattern !== '/products/:id' ||
    /^\d+$/u.test(found.params['id'] ?? '');

  expect(routes.match('/products/7', onlyNumericIds)?.pattern).toBe(
    '/products/:id',
  );
  expect(routes.match('/products/shoes', onlyNumericIds)).toBeNull();
});

accept は合ったものを呼び出し側が断るための関数で、false を返すと照合はそのパターンが合わなかったものとして次へ進みます。フレームワークはこれで、スキーマが拒んだ params を「そのパターンが答えない pathname」として扱っています。

定義した時点で拒まれるもの

表はモジュールが読み込まれたときに検査されます。誰かが最初にそのページへ移動したときではありません。どのエラーも TypeError です。

書いたものエラー
/ で始まらないキー{ 'products': Products }route pattern "products" must start with "/"
グループそのものではない括弧{ '/(admin)/new': NewItem }route group "/(admin)/new" must be "/(name)" and nothing else — a regular expression is not part of the grammar
children を持たないグループ{ '/(oops)': Home }route group "/(oops)" must have children
同じ完全パターンの 2 度目(入れ子の位置が違っても){ '/x': A, '/': { children: { '/x': B } } }route pattern "/x" is declared twice
URLPattern が解釈できないパターン{ '/a{b': Home }URLPattern 自身の TypeError

括弧を拒むのは、URLPattern が (…) を正規表現のグループとして読むからです。/(admin)/new を通すと、名前の無い param を捕まえながら /admin/new に黙って合ってしまいます。文法での括弧の意味はグループの 1 つだけです。グループに children を求めるのは、区間を足さない leaf が親の index の 2 度目の宣言になってしまうからです。

エラー境界

branch には layout と並べて error を書けます。その下のどこかが描画中に throw すると、レイアウトの穴に error のコンポーネントが代わりに描かれ、レイアウトという枠はそのまま残ります。

// src/products-error.tsx
import type { ErrorProps } from '@k8ordo/router';
import { href } from '@k8ordo/router';

export function ProductsError({ error, reset }: ErrorProps) {
  return (
    <div role="alert">
      <p>{error instanceof Error ? error.message : 'Something went wrong'}</p>
      <button onClick={reset} type="button">
        Try again
      </button>
      <a href={href('/products')}>Back to the list</a>
    </div>
  );
}
// src/routes.ts
import { defineRoutes } from '@k8ordo/router';

import { ProductList } from './pages/product-list';
import { ProductPage } from './pages/product-page';
import { ProductsError } from './products-error';
import { ProductsLayout } from './products-layout';

export const routes = defineRoutes({
  '/products': {
    layout: ProductsLayout,
    error: ProductsError,
    children: {
      '/': ProductList,
      '/:id': ProductPage,
    },
  },
});

error のコンポーネントは ErrorProps{ error, reset })を受け取ります。型は ErrorComponent です。error は throw された値そのもので unknown 型です。reset() はその場で部分木をもう一度描画し、再び throw すればまた error が出ます。

失敗したページを離れると、失敗は消えます。境界は NavigationGeneration(新しい木が画面に適用されるたびに変わる番号)をキーにしているので、別のページへ移ると作り直されます。pathname をキーにしないのは、URL が木より先に確定するからです。search だけが変わる状態の更新では木が変わらないので、失敗もそのまま残ります。

境界はレイアウトの内側にあるので、レイアウト自身の throw は同じ branch の error では受け止められず、さらに外側の branch の error に届きます。どこにも境界が無ければ、エラーは <Router> の外へ出ます。

境界は下の部分木を fallbacknull<Suspense> でも包みます(サーバーでの描画で throw した部分木をブラウザに任せるため)。そのため error を持つ branch の下にある React.lazy のページは、最初の描画やその branch に入るナビゲーションで chunk を待つ間、上のレイアウトの <Suspense> ではなくこの境界の中で何も描きません。fallback を見せたいなら、<Suspense> をその branch より下のレイアウトに置くか、lazy コンポーネントを直接包みます。

@k8ordo/static@k8ordo/server では、error.tsx が生成された表のこの error になります。 フレームワーク配下

表から導かれる型

表の型はパターン文字列からの推論だけで決まり、コード生成を使いません。routes を Get Started で作った表(//products/products/:id/*)とすると、3 つの型は 2 つ目のコードのとおりに解決されます。

import type {
  NavigablePatternOf,
  PatternOf,
  RouteOf,
} from '@k8ordo/router';

import type { routes } from './routes';

type Pattern = PatternOf<typeof routes.record>;
type Linkable = NavigablePatternOf<typeof routes.record>;
type Path = RouteOf<typeof routes>;
type Pattern = '/' | '/products' | '/products/:id' | '/*';
type Linkable = '/' | '/products' | '/products/:id';
type Path = '/' | '/products' | `/products/${string}`;
意味
PatternOf<R>表のすべての leaf パターン(書いたとおりの綴り)
NavigablePatternOf<R>リンク先にできるパターン。ワイルドカードを除いたもの
RouteOf<typeof routes>表の pathname 空間。リンク可能なパターンの :param を任意の文字列にした union
Routes<R>defineRoutes の戻り値。kindrecord(渡した表)・match を持つ
RoutesRecord表そのものの型。キーは / で始まる文字列
RouteNode表の値。RouteComponent か branch
RouteComponentleaf とレイアウトの型。ComponentType<never>
Matchmatch の戻り値。patternparamsstack(外側から順、leaf が最後)

RouteOf@k8ordo/stateRegister が型付きのパスに使う型です。 リンクと現在地