@k8ordo/router

フレームワーク配下

@k8ordo/static@k8ordo/server は、このルーターの上に作られています。表は src/routes/ から生成され、ページはサーバーで描かれます。このページでは、フレームワークの下でアプリが使うルーターの部分と使わない部分、ルートファイルの props の型、そしてアプリが書くものと生成されるものの境目を扱います。

ブラウザは表を持たない

フレームワークの下では、ページはサーバーで描かれ、ブラウザは表から木を組み立てるのではなく、描かれた木を受け取ります。クライアントのバンドルにルート表は含まれず、レイアウトは <Outlet /> ではなく children で入れ子になります。

ナビゲーションは引き続きこのルーターのものです。フレームワークのランタイムは useInterceptedNavigation の上に作られていて、同一オリジンの URL を引き受け、load で次のページの RSC ペイロードを取得し、背景で描きます。finished が画面に出た時点で解決すること、状態の変更がページの切り替えにならないこと、スクロール、transition の型、追い越されたナビゲーションの abort は、そのまま引き継がれます。 ナビゲーション

API<Router> を自分でマウントするアプリ@k8ordo/static / @k8ordo/server
defineRoutes手書きするsrc/routes/ から .k8ordo/routes.gen.ts に生成
<Router> / <Outlet />自分で使う使わない(ランタイムが描き、レイアウトは children で入れ子)
href / navigateTo / bindParams自分で使う同じ。params はスキーマの型で受け取る
usePathname / useMatch / matchPath / normalizePathname自分で使う同じ
useParams / useRoute使う(params はいつも文字列)使えない。ページが params を props で受け取る
Registertypes/ に手書き.k8ordo/register.gen.ts に生成
ErrorComponent表の branch に error を書くerror.tsx が表の error になる
PathnameProvider / NavigationGeneration / useInterceptedNavigation<Router> がマウントするモードのランタイムがマウントする
PageProps / LayoutPropsルートファイルの props の型

現在地は usePathnameuseMatch で読む

usePathnameuseMatch は表ではなくプラットフォームを読むので、フレームワークの下でもそのまま動きます。useRouteuseParams はコンテキストの照合結果を読みますが、ブラウザには読むべき照合結果が無いので throw します。

// src/routes/_parts/section-nav.tsx
'use client';

import { href, useMatch, usePathname } from '@k8ordo/router';

export function SectionNav() {
  const pathname = usePathname();
  const inProducts = useMatch('/products/*', { inclusive: true }) !== null;
  return (
    <nav>
      <a aria-current={pathname === '/' ? 'page' : undefined} href={href('/')}>
        Home
      </a>
      <a
        aria-current={inProducts ? 'true' : undefined}
        href={href('/products')}
      >
        Products
      </a>
    </nav>
  );
}

フックは client component でしか使えません。Server Component のページやレイアウトは、自分の描画の pathname を props で受け取ります。

このサイトのサイドナビゲーションも、ロケール配下のシェル(client component)で useMatch('/:locale/ui/components/*') に「部品のページが開いているか」を尋ねて出し分けています。パターンは生成された表のものなので、区画の名前を変えればコンパイルで落ちます。 リンクと現在地

PagePropsLayoutProps

ルートファイルが受け取る props の型は、そのファイルを置いたディレクトリが表すパターンで決まります。src/routes/products/[id]/page.tsx のパターンは /products/:id です。

PageProps<P>{ params, pathname } です。params はそのページまでのスキーマが作った型で、スキーマが扱わない param は文字列のままです。pathname はこの描画の URL の pathname です。

// src/routes/products/[id]/page.tsx
import { href } from '@k8ordo/router';
import type { PageProps } from '@k8ordo/router';
import * as z from 'zod/mini';

export const paramsSchema = z.object({
  id: z.coerce.number().check(z.int(), z.positive()),
});

export default function ProductPage({ params }: PageProps<'/products/:id'>) {
  return (
    <article>
      <h1>Product {params.id}</h1>
      <a href={href('/products/:id', { id: params.id + 1 })}>Next product</a>
    </article>
  );
}

LayoutProps<P> はそれに children を足したものです(下の例は src/routes/products/page.tsx もあるものとします)。LayoutProps は、スキーマを宣言していても params を文字列(ParamsOf<P>)として型付けします。not-found.tsx の下では何も検証されないからです。ただし検証を通ったページの描画では、実行時にはスキーマの出力がレイアウトにも渡ります。値が文字列であることに頼らないでください。

// src/routes/products/layout.tsx
import type { LayoutProps } from '@k8ordo/router';

export default function ProductsLayout({ children }: LayoutProps<'/products'>) {
  return (
    <section>
      <h1>Products</h1>
      {children}
    </section>
  );
}

@k8ordo/server では、生成される Registerrequest も持つので、どちらの型にも request が加わります。@k8ordo/static のビルドにはリクエストが無いので、request を読むページはそこで型エラーになります。

// src/routes/page.tsx
import type { PageProps } from '@k8ordo/router';

export default function HomePage({ request }: PageProps<'/'>) {
  return <p>{request.headers.get('accept-language') ?? '-'}</p>;
}

P は生成された表にページがあるパターンでなければなりません。自分の位置にページを持たないレイアウトは、props をインラインで宣言します。インラインで宣言しても、生成された表が import の位置で同じことを検査します。

paramsSchema と params の型

ページやレイアウトは paramsSchema を export して、params をどう読むかを宣言できます。ルーター自身はスキーマを走らせません。走らせるのはフレームワークで、このパッケージが受け持つのは出てくる値の型です。

生成される Register は、スキーマのかかるパターンごとにスキーマの出力型を params として持ちます。そのため hrefnavigateTo は、ページが受け取るのと同じ型で param を受け取り、スキーマが読み戻す綴りに変換します。スキーマが number にした :id には { id: 42 } を渡し、文字列を渡すと型エラーになります。スキーマのかかるパターンで、どのスキーマも扱わない param は、ページと同じく文字列で受け取ります。生成より前、あるいはどのスキーマもかからないパターンでは、綴りが 1 つに決まる値ならどれでも渡せます。

import { href } from '@k8ordo/router';

href('/products/:id', { id: 42 });

// @ts-expect-error
href('/products/:id', { id: '42' });

生成されるファイルは次のような形です(このサイトのものから抜粋)。

// .k8ordo/register.gen.ts
import type { ParsedParamsMap } from '@k8ordo/router';
import type { paramSchemas, routes } from './routes.gen';

declare module '@k8ordo/router' {
  interface Register {
    routes: typeof routes;
    params: ParsedParamsMap<typeof paramSchemas>;
  }
}

declare module '@k8ordo/state' {
  interface Register {
    routes: typeof routes;
  }
}
意味
ParsedParams<Pattern, Schemas>パターンの params に、スキーマの列(外側のレイアウトから順、ページが最後)の出力を順に重ねた型
ParsedParamsMap<Schemas>{ パターン: スキーマの列 }{ パターン: ParsedParams } にした型。生成される Registerparams
ParamsSchemaFor<Pattern>そのパターンのスキーマとして書けるもの。出力のキーはパターンの params の部分集合
StandardSchemaLike<Output>Standard Schema の形(~standardtypes.output)。zod・zod/mini など、実装するライブラリならどれでも
SchemaOutput<Schema>スキーマの出力の型
RegisteredPageParams<P>そのパターンのページが受け取る paramsPagePropsparams

これらは主に生成されるコードのための型です。アプリが直接書くのは PagePropsLayoutProps で足ります。 paramsSchema の詳細(@k8ordo/static

PathnameProvider はアプリが書かない

client component の最初の描画は、Navigation API の無い場所(サーバーと、ハイドレーション)で起きます。そのため usePathname の値は、それを知っている描画側から <PathnameProvider pathname> で届ける必要があります。<Router> は自分でマウントし、両モードのランタイムも自分でマウントします。アプリが書くことはありません。書くのは useInterceptedNavigation で自前の継ぎ目を作るホストだけです。

どれの下にも無いままサーバーやハイドレーションで usePathname を呼ぶと、次のエラーになります。

usePathname needs <Router> above it, or a page rendered by @k8ordo/static or @k8ordo/server

ハイドレーションではサーバーの pathname で描き、その後ブラウザの pathname に切り替わります。2 つが違えば 1 度描き直されるだけで、不一致のエラーにはなりません。このサイトの 404.html は 1 枚で全ロケールに答えるので、表示中の URL のロケールへこの描き直しで切り替わります。

アプリが書くもの、生成されるもの

フレームワークの下では、ルーターに関わるものの多くが生成されるか、ランタイムに含まれています。

アプリが書く

  • src/routes/ のルートファイル(page.tsxlayout.tsxerror.tsxnot-found.tsxredirect.ts)と paramsSchema
  • href / navigateTo のリンク。共有の param があれば bindParams のモジュール
  • client component での usePathname / useMatch
  • ページの切り替えをアニメーションするなら、レイアウトの children を包む <ViewTransition>

生成される、またはランタイムが持つ

  • .k8ordo/routes.gen.tsdefineRoutes の表と、パターンごとのスキーマの列
  • .k8ordo/register.gen.ts:このルーターの Register(アプリが @k8ordo/state に依存していればその Register も)
  • ランタイム:useInterceptedNavigation<NavigationGeneration><PathnameProvider>

アプリが書かない

  • <Router><Outlet />useRouteuseParams、そして手書きの Register(生成されたものと 2 つ目の答えになる)

生成された型を効かせるには、tsconfig.jsoninclude.k8ordo/**/*.ts のグロブを書きます。.k8ordo はドットで始まるので、ディレクトリ名だけを書くと黙って読み飛ばされ、href が表と照合されなくなります。

{
  "include": ["src/**/*.ts", "src/**/*.tsx", ".k8ordo/**/*.ts"]
}

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

レイアウトで children<ViewTransition> で包みます。<ViewTransition> は RSC ペイロードでそのまま送られるので、Server Component のレイアウトが直接描けます。このサイトが client component の中に置いているのは、シェルがフックを使うからです。

// src/routes/layout.tsx
import type { LayoutProps } from '@k8ordo/router';
import { ViewTransition } from 'react';

import { SectionNav } from './_parts/section-nav';

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="en">
      <body>
        <SectionNav />
        <ViewTransition
          default="none"
          update={{ navigation: 'auto', default: 'none' }}
        >
          {children}
        </ViewTransition>
      </body>
    </html>
  );
}

モードごとのルーティング