Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/src/pages/en/(pages)/router/define-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ Plus context-aware helper functions for typing your page components:

These helpers are identity functions at runtime — they return the component unchanged. They exist purely for TypeScript to infer the correct prop types from your route path, `validate` export, and outlet structure.

A companion virtual module — `@lazarv/react-server/outlets` — is generated alongside `@lazarv/react-server/routes` and exposes a typed, server-preloading bound `ReactServerComponent` for every outlet in your file-router. See [typed outlet components](#routes-module-bound-outlets) below.

<Link name="routes-module-naming">
### Route naming
</Link>
Expand Down Expand Up @@ -412,6 +414,53 @@ export default dashboard.createLayout(({ children, sidebar, content }) => {

In the example above, `content` has a default (`@content.default.tsx`) so it's always a `ReactElement`. If `sidebar` had no default, it would be typed as `ReactElement | null`.

<Link name="routes-module-bound-outlets">
### Typed outlet components
</Link>

The previous section showed the *receiving* side — a layout consuming outlet slots that are populated by the file-router from neighbouring `@outletName/` directories. The *producing* side has its own typed entry point: a virtual `@lazarv/react-server/outlets` module that exports one namespace per outlet declared in the file-router.

```tsx
import { sidebar, content } from "@lazarv/react-server/outlets";

<sidebar.Outlet url="/dashboard/nav" />
<content.Outlet url="/dashboard/feed" />
```

Each namespace exposes a single `Outlet` component (PascalCase, JSX-callable) bound to its outlet name. Compared to the underlying `<ReactServerComponent outlet="sidebar" url="…" />` form, this gives you three things:

- **No stringly-typed outlet name.** The outlet identifier is bound at the import site; you can't typo it, and unknown outlet names fail at compile time.
- **Typed `url`.** The `url` prop accepts the same union as `Link.to` — every static and dynamic route in the file-router. Typos are rejected, and dynamic segments must already be concrete (e.g. `/user/42`, not `/user/[id]`).
- **Branded return type.** The component returns `Outlet<"sidebar">` (or whichever name), so it satisfies a `createLayout` slot of the same name without casts.

By default the outlet preloads on the server: when the bound component is rendered in a server component, the runtime resolves the `url` against the file-router manifest, finds the matching `@outletName/...page.tsx` (or the `@outletName.default.tsx` fallback), renders it, and passes the result as `children` to `ReactServerComponent`. The SSR HTML therefore contains the outlet content on first paint — no client round-trip.

Two opt-outs:

- **`defer={true}`** — skip preload and let the client fetch the URL after hydration. Useful when the outlet content depends on browser-only state, or when you want to avoid blocking the parent page's render on the outlet resolution.
- **Explicit `children`** — when you pass your own children, preload is bypassed entirely and your tree is used as-is.

```tsx filename="pages/panels.page.tsx"
import { sidebar, content } from "@lazarv/react-server/outlets";

export default function Panels() {
return (
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr" }}>
<aside>
{/* Server-resolves /dashboard/nav into the sidebar slot */}
<sidebar.Outlet url="/dashboard/nav" />
</aside>
<main>
{/* `defer` opts into client-side fetch after hydration */}
<content.Outlet url="/dashboard/feed" defer />
</main>
</div>
);
}
```

The runtime writes a `react-server-outlets.d.ts` file alongside `react-server-routes.d.ts` in the `.react-server` directory. It declares the `@lazarv/react-server/outlets` module with one namespace per outlet name discovered in your manifest. Outlet names that aren't valid JavaScript identifiers (e.g. hyphenated directory names) are skipped from this module — those outlets remain reachable via the stringly-typed `<ReactServerComponent outlet="..." />` form.

<Link name="routes-module-loading">
### Typed loading and error components
</Link>
Expand Down
8 changes: 8 additions & 0 deletions docs/src/pages/en/(pages)/router/outlets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,12 @@ export default function RootLayout({ sidebar, children }) {
}
```

When you're using the file-system based router, the runtime also generates a virtual `@lazarv/react-server/outlets` module that exposes a typed, server-preloading bound `ReactServerComponent` per declared outlet — typed `url`, no stringly-typed outlet name, and SSR content out of the box. See [typed outlet components](/router/define-routes#routes-module-bound-outlets) for the full shape.

```tsx
import { sidebar } from "@lazarv/react-server/outlets";

<sidebar.Outlet url="/dashboard/nav" />
```

To check out an example using outlets, see the [Photos tutorial](/tutorials/photos#outlet).
49 changes: 49 additions & 0 deletions docs/src/pages/ja/(pages)/router/define-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ import { index, about, user, dashboard } from "@lazarv/react-server/routes";

これらのヘルパーはランタイムではアイデンティティ関数です — コンポーネントをそのまま返します。ルートパス、`validate`エクスポート、アウトレット構造から正しいプロップ型を推論するためにTypeScript専用で存在します。

`@lazarv/react-server/routes`と並んで、ファイルルーター内のすべてのアウトレットに対して、型付きでサーバープリロードされるバインド済みの`ReactServerComponent`を公開する仮想モジュール`@lazarv/react-server/outlets`も生成されます。詳細は下の[型付きアウトレットコンポーネント](#routes-module-bound-outlets)を参照してください。

<Link name="routes-module-naming">
### ルート命名
</Link>
Expand Down Expand Up @@ -412,6 +414,53 @@ export default dashboard.createLayout(({ children, sidebar, content }) => {

上の例では、`content`にはデフォルト(`@content.default.tsx`)があるため常に`ReactElement`です。`sidebar`にデフォルトがない場合、`ReactElement | null`として型付けされます。

<Link name="routes-module-bound-outlets">
### 型付きアウトレットコンポーネント
</Link>

前のセクションでは*受け取り側* — 隣接する`@outletName/`ディレクトリからファイルルーターが埋めるアウトレットスロットを消費するレイアウト — を示しました。*送り出し側*にも独自の型付きエントリーポイントがあります:ファイルルーターで宣言されたアウトレットごとに1つの名前空間をエクスポートする、仮想的な`@lazarv/react-server/outlets`モジュールです。

```tsx
import { sidebar, content } from "@lazarv/react-server/outlets";

<sidebar.Outlet url="/dashboard/nav" />
<content.Outlet url="/dashboard/feed" />
```

各名前空間は、そのアウトレット名にバインドされた1つの`Outlet`コンポーネント(PascalCase、JSX呼び出し可能)を公開します。背後にある`<ReactServerComponent outlet="sidebar" url="…" />`形式と比較して、これは3つの利点をもたらします:

- **文字列指定のアウトレット名が不要。** アウトレット識別子はインポート時にバインドされます。タイポは起こり得ず、未知のアウトレット名はコンパイル時に失敗します。
- **型付きの`url`。** `url`プロップは`Link.to`と同じユニオン — ファイルルーター内のすべての静的・動的ルート — を受け入れます。タイポは拒否され、動的セグメントは既に具体化されている必要があります(例:`/user/42`であって`/user/[id]`ではない)。
- **ブランド付きの戻り型。** コンポーネントは`Outlet<"sidebar">`(またはその名前)を返すため、キャストなしで同名の`createLayout`スロットを満たせます。

デフォルトではアウトレットはサーバー側でプリロードされます:バインドされたコンポーネントがサーバーコンポーネント内でレンダリングされると、ランタイムは`url`をファイルルーターのマニフェストに対して解決し、対応する`@outletName/...page.tsx`(または`@outletName.default.tsx`フォールバック)を見つけ、それをレンダリングして結果を`children`として`ReactServerComponent`に渡します。そのため、SSRのHTMLには初回描画の時点でアウトレットコンテンツが含まれており、クライアントのラウンドトリップは不要です。

2つのオプトアウト:

- **`defer={true}`** — プリロードをスキップし、ハイドレーション後にクライアントがURLをフェッチするようにします。アウトレットの内容がブラウザ依存の状態に依存する場合や、親ページのレンダリングをアウトレット解決でブロックしたくない場合に有用です。
- **明示的な`children`** — 自分でchildrenを渡すと、プリロードは完全にバイパスされ、あなたのツリーがそのまま使われます。

```tsx filename="pages/panels.page.tsx"
import { sidebar, content } from "@lazarv/react-server/outlets";

export default function Panels() {
return (
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr" }}>
<aside>
{/* /dashboard/nav をサーバー側でサイドバースロットに解決します */}
<sidebar.Outlet url="/dashboard/nav" />
</aside>
<main>
{/* `defer` でハイドレーション後のクライアントフェッチに切り替えます */}
<content.Outlet url="/dashboard/feed" defer />
</main>
</div>
);
}
```

ランタイムは`.react-server`ディレクトリの`react-server-routes.d.ts`の隣に`react-server-outlets.d.ts`を書き出します。このファイルは`@lazarv/react-server/outlets`モジュールを宣言し、マニフェストで検出された各アウトレット名に対して1つの名前空間を持ちます。有効なJavaScript識別子ではないアウトレット名(例:ハイフン付きのディレクトリ名)はこのモジュールから除外されます — そのようなアウトレットは引き続き文字列指定の`<ReactServerComponent outlet="..." />`形式で到達可能です。

<Link name="routes-module-loading">
### 型付きローディングとエラーコンポーネント
</Link>
Expand Down
8 changes: 8 additions & 0 deletions docs/src/pages/ja/(pages)/router/outlets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,12 @@ export default function RootLayout({ sidebar, children }) {
}
```

ファイルシステムベースのルーターを使用している場合、ランタイムは仮想的な`@lazarv/react-server/outlets`モジュールも生成します。このモジュールは、宣言された各アウトレットに対して、型付き`url`・文字列指定不要のアウトレット名・初期描画でのSSRコンテンツを備えた、型付きでサーバープリロードされるバインド済みの`ReactServerComponent`を公開します。詳細は[型付きアウトレットコンポーネント](/router/define-routes#routes-module-bound-outlets)を参照してください。

```tsx
import { sidebar } from "@lazarv/react-server/outlets";

<sidebar.Outlet url="/dashboard/nav" />
```

アウトレットを使用した例を確認するには、[チュートリアルのPhotoセクション](/tutorials/photos#outlet)をご覧ください。
2 changes: 2 additions & 0 deletions examples/typed-file-router/pages/(root).layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
productSkuUppercase,
docs,
docsSlugNested,
panels,
} from "@lazarv/react-server/routes";

export default index.createLayout(({ children }) => {
Expand All @@ -32,6 +33,7 @@ export default index.createLayout(({ children }) => {
<about.Link>About</about.Link>
<user.Link params={{ id: "42" }}>User 42</user.Link>
<dashboard.Link>Dashboard</dashboard.Link>
<panels.Link>Panels</panels.Link>
<counter.Link>Counter</counter.Link>
<clock.Link>Clock</clock.Link>
<todos.Link>Todos</todos.Link>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function SidebarDefault() {
return (
<div style={{ color: "#999" }}>
<p>Default sidebar.</p>
</div>
);
}
57 changes: 57 additions & 0 deletions examples/typed-file-router/pages/panels.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { sidebar, content } from "@lazarv/react-server/outlets";

// Demonstrates the per-outlet bound `ReactServerComponent` exposed by
// `@lazarv/react-server/outlets`. Each export is a namespace per outlet
// declared in the file-router; `.Outlet` is the bound component.
//
// <sidebar.Outlet url="/dashboard/nav" />
//
// The component closes over its outlet name, so call sites only need to
// pass `url` — typed against the route table, just like `Link.to`. The
// return value is branded `Outlet<"sidebar">` / `Outlet<"content">`, so
// it can satisfy a `createLayout` slot of the same name without losing
// the brand.
export default function Panels() {
return (
<div>
<h1>Panels</h1>
<p>
This page mounts named outlets directly via the typed{" "}
<code>@lazarv/react-server/outlets</code> module — no layout wiring, no
stringly-typed <code>outlet</code> prop.
</p>
<div
data-testid="panels-grid"
style={{
display: "grid",
gridTemplateColumns: "200px 1fr",
gap: "20px",
minHeight: "200px",
}}
>
<aside
data-testid="panels-sidebar"
style={{
background: "#f5f5f5",
padding: "16px",
borderRadius: "8px",
}}
>
<h3>Sidebar (bound outlet)</h3>
<sidebar.Outlet url="/dashboard/nav" />
</aside>
<main
data-testid="panels-content"
style={{
background: "#fafafa",
padding: "16px",
borderRadius: "8px",
}}
>
<h3>Content (bound outlet)</h3>
<content.Outlet url="/dashboard/feed" />
</main>
</div>
</div>
);
}
24 changes: 24 additions & 0 deletions packages/react-server/client/ClientProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,30 @@ const navigateOutlet = async (
};

const navigate = (to, options = {}) => {
// When the caller didn't specify an outlet, distinguish between two intents:
// - same-pathname change (e.g. `?filter=active`) → broadcast to every
// active non-root outlet so dashboards / multi-pane filters stay in sync.
// - pathname change (e.g. <Link to="/dashboard">) → it's a top-level page
// navigation; target PAGE_ROOT and skip the broadcast. Otherwise any
// route that mounts named outlets via `<*.Outlet />` would get stuck:
// the broadcast skips PAGE_ROOT and fans the new URL out to stale
// outlets, where the server has no matching outlet page and renders
// null — leaving the previous page on screen with empty outlet content.
if (options.outlet === undefined) {
try {
const targetPathname = decodeURIComponent(
new URL(to, location.origin).pathname
);
const currentPathname = decodeURIComponent(location.pathname);
if (targetPathname !== currentPathname) {
options = { ...options, outlet: PAGE_ROOT };
}
} catch {
// Malformed URL — fall through; navigateOutlet's PAGE_ROOT default
// will still fire if we end up in the single-outlet branch.
}
}

const isRoot = options.outlet === PAGE_ROOT;
if (!isRoot && outlets.size > 1) {
const activeOutlets = new Set(outlets.keys());
Expand Down
Loading
Loading