フレームワーク配下
@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 で受け取る |
Register | types/ に手書き | .k8ordo/register.gen.ts に生成 |
ErrorComponent | 表の branch に error を書く | error.tsx が表の error になる |
PathnameProvider / NavigationGeneration / useInterceptedNavigation | <Router> がマウントする | モードのランタイムがマウントする |
PageProps / LayoutProps | — | ルートファイルの props の型 |
現在地は usePathname と useMatch で読む
usePathname と useMatch は表ではなくプラットフォームを読むので、フレームワークの下でもそのまま動きます。useRoute と useParams はコンテキストの照合結果を読みますが、ブラウザには読むべき照合結果が無いので 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/*') に「部品のページが開いているか」を尋ねて出し分けています。パターンは生成された表のものなので、区画の名前を変えればコンパイルで落ちます。 リンクと現在地
PageProps と LayoutProps
ルートファイルが受け取る 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 では、生成される Register が request も持つので、どちらの型にも 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 として持ちます。そのため href と navigateTo は、ページが受け取るのと同じ型で 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 } にした型。生成される Register の params |
ParamsSchemaFor<Pattern> | そのパターンのスキーマとして書けるもの。出力のキーはパターンの params の部分集合 |
StandardSchemaLike<Output> | Standard Schema の形(~standard の types.output)。zod・zod/mini など、実装するライブラリならどれでも |
SchemaOutput<Schema> | スキーマの出力の型 |
RegisteredPageParams<P> | そのパターンのページが受け取る params。PageProps の params |
これらは主に生成されるコードのための型です。アプリが直接書くのは PageProps と LayoutProps で足ります。 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.tsx・layout.tsx・error.tsx・not-found.tsx・redirect.ts)とparamsSchemahref/navigateToのリンク。共有の param があればbindParamsのモジュール- client component での
usePathname/useMatch - ページの切り替えをアニメーションするなら、レイアウトの
childrenを包む<ViewTransition>
生成される、またはランタイムが持つ
.k8ordo/routes.gen.ts:defineRoutesの表と、パターンごとのスキーマの列.k8ordo/register.gen.ts:このルーターのRegister(アプリが@k8ordo/stateに依存していればそのRegisterも)- ランタイム:
useInterceptedNavigation・<NavigationGeneration>・<PathnameProvider>
アプリが書かない
<Router>・<Outlet />・useRoute・useParams、そして手書きのRegister(生成されたものと 2 つ目の答えになる)
生成された型を効かせるには、tsconfig.json の include に .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>
);
}