diff --git a/docs/src/pages/en/(pages)/router/define-routes.mdx b/docs/src/pages/en/(pages)/router/define-routes.mdx index 98025ca4..e3764897 100644 --- a/docs/src/pages/en/(pages)/router/define-routes.mdx +++ b/docs/src/pages/en/(pages)/router/define-routes.mdx @@ -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. + ### Route naming @@ -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`. + +### Typed outlet components + + +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"; + + + +``` + +Each namespace exposes a single `Outlet` component (PascalCase, JSX-callable) bound to its outlet name. Compared to the underlying `` 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 ( +
+ +
+ {/* `defer` opts into client-side fetch after hydration */} + +
+
+ ); +} +``` + +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 `` form. + ### Typed loading and error components diff --git a/docs/src/pages/en/(pages)/router/outlets.mdx b/docs/src/pages/en/(pages)/router/outlets.mdx index 74d8a119..15d44c21 100644 --- a/docs/src/pages/en/(pages)/router/outlets.mdx +++ b/docs/src/pages/en/(pages)/router/outlets.mdx @@ -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"; + + +``` + To check out an example using outlets, see the [Photos tutorial](/tutorials/photos#outlet). \ No newline at end of file diff --git a/docs/src/pages/ja/(pages)/router/define-routes.mdx b/docs/src/pages/ja/(pages)/router/define-routes.mdx index 09461523..5e18e9a0 100644 --- a/docs/src/pages/ja/(pages)/router/define-routes.mdx +++ b/docs/src/pages/ja/(pages)/router/define-routes.mdx @@ -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)を参照してください。 + ### ルート命名 @@ -412,6 +414,53 @@ export default dashboard.createLayout(({ children, sidebar, content }) => { 上の例では、`content`にはデフォルト(`@content.default.tsx`)があるため常に`ReactElement`です。`sidebar`にデフォルトがない場合、`ReactElement | null`として型付けされます。 + +### 型付きアウトレットコンポーネント + + +前のセクションでは*受け取り側* — 隣接する`@outletName/`ディレクトリからファイルルーターが埋めるアウトレットスロットを消費するレイアウト — を示しました。*送り出し側*にも独自の型付きエントリーポイントがあります:ファイルルーターで宣言されたアウトレットごとに1つの名前空間をエクスポートする、仮想的な`@lazarv/react-server/outlets`モジュールです。 + +```tsx +import { sidebar, content } from "@lazarv/react-server/outlets"; + + + +``` + +各名前空間は、そのアウトレット名にバインドされた1つの`Outlet`コンポーネント(PascalCase、JSX呼び出し可能)を公開します。背後にある``形式と比較して、これは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 ( +
+ +
+ {/* `defer` でハイドレーション後のクライアントフェッチに切り替えます */} + +
+
+ ); +} +``` + +ランタイムは`.react-server`ディレクトリの`react-server-routes.d.ts`の隣に`react-server-outlets.d.ts`を書き出します。このファイルは`@lazarv/react-server/outlets`モジュールを宣言し、マニフェストで検出された各アウトレット名に対して1つの名前空間を持ちます。有効なJavaScript識別子ではないアウトレット名(例:ハイフン付きのディレクトリ名)はこのモジュールから除外されます — そのようなアウトレットは引き続き文字列指定の``形式で到達可能です。 + ### 型付きローディングとエラーコンポーネント diff --git a/docs/src/pages/ja/(pages)/router/outlets.mdx b/docs/src/pages/ja/(pages)/router/outlets.mdx index d653408d..ec5eba3e 100644 --- a/docs/src/pages/ja/(pages)/router/outlets.mdx +++ b/docs/src/pages/ja/(pages)/router/outlets.mdx @@ -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"; + + +``` + アウトレットを使用した例を確認するには、[チュートリアルのPhotoセクション](/tutorials/photos#outlet)をご覧ください。 \ No newline at end of file diff --git a/examples/typed-file-router/pages/(root).layout.tsx b/examples/typed-file-router/pages/(root).layout.tsx index 0227a102..f389d63c 100644 --- a/examples/typed-file-router/pages/(root).layout.tsx +++ b/examples/typed-file-router/pages/(root).layout.tsx @@ -10,6 +10,7 @@ import { productSkuUppercase, docs, docsSlugNested, + panels, } from "@lazarv/react-server/routes"; export default index.createLayout(({ children }) => { @@ -32,6 +33,7 @@ export default index.createLayout(({ children }) => { About User 42 Dashboard + Panels Counter Clock Todos diff --git a/examples/typed-file-router/pages/dashboard/@content/@content.default.tsx b/examples/typed-file-router/pages/dashboard/@content/[[...slug]].tsx similarity index 100% rename from examples/typed-file-router/pages/dashboard/@content/@content.default.tsx rename to examples/typed-file-router/pages/dashboard/@content/[[...slug]].tsx diff --git a/examples/typed-file-router/pages/dashboard/@sidebar/index.tsx b/examples/typed-file-router/pages/dashboard/@sidebar/index.tsx new file mode 100644 index 00000000..50c2cd16 --- /dev/null +++ b/examples/typed-file-router/pages/dashboard/@sidebar/index.tsx @@ -0,0 +1,7 @@ +export default function SidebarDefault() { + return ( +
+

Default sidebar.

+
+ ); +} diff --git a/examples/typed-file-router/pages/panels.page.tsx b/examples/typed-file-router/pages/panels.page.tsx new file mode 100644 index 00000000..50e9574a --- /dev/null +++ b/examples/typed-file-router/pages/panels.page.tsx @@ -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. +// +// +// +// 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 ( +
+

Panels

+

+ This page mounts named outlets directly via the typed{" "} + @lazarv/react-server/outlets module — no layout wiring, no + stringly-typed outlet prop. +

+
+ +
+

Content (bound outlet)

+ +
+
+
+ ); +} diff --git a/packages/react-server/client/ClientProvider.jsx b/packages/react-server/client/ClientProvider.jsx index 80ca98a0..04ddd022 100644 --- a/packages/react-server/client/ClientProvider.jsx +++ b/packages/react-server/client/ClientProvider.jsx @@ -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. ) → 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()); diff --git a/packages/react-server/lib/plugins/file-router/plugin.mjs b/packages/react-server/lib/plugins/file-router/plugin.mjs index b7dd0757..c365d0bf 100644 --- a/packages/react-server/lib/plugins/file-router/plugin.mjs +++ b/packages/react-server/lib/plugins/file-router/plugin.mjs @@ -364,6 +364,7 @@ export default function viteReactServerRouter(options = {}) { // the entries exist when the SSR/client load handlers check the store. initStoreEntry("resources"); initStoreEntry("routes"); + initStoreEntry("outlets"); const outDir = options.outDir ?? ".react-server"; const entry = { @@ -669,146 +670,249 @@ export default function viteReactServerRouter(options = {}) { * Produces per-route interfaces with context-aware helper methods and branded outlet types. */ function generateRoutesDts(routeInfos) { - const lines = []; - lines.push(`// Auto-generated by @lazarv/react-server file-router plugin`); - lines.push(`// Do not edit manually\n`); - lines.push(`declare module "@lazarv/react-server/routes" {`); - lines.push( - ` import type { RouteDescriptor, ExtractParams, ValidateSchema, RouteValidate } from "@lazarv/react-server/router";\n` - ); - - // Branded outlet type - lines.push(` const __outlet__: unique symbol;`); - lines.push(` type Outlet<`); - lines.push(` Name extends string,`); - lines.push(` Nullable extends boolean = false`); - lines.push(` > = (React.ReactElement & { readonly [__outlet__]: Name })`); - lines.push(` | (Nullable extends true ? null : never);\n`); - - for (const info of routeInfos) { + // Build the per-route interface body by composing the optional sections. + // Each section returns either its block (already indented) or "" — the + // trailing `.filter(Boolean)` drops the empty ones before joining. + const buildRouteBlock = (info) => { const { name, path, types, src, hasValidate } = info; const interfaceName = name.charAt(0).toUpperCase() + name.slice(1) + "Route"; - - // Compute params type const paramsType = generateParamsType(path); - const validateParamsType = + const validatePath = hasValidate && src - ? `typeof import("${sys.normalizePath(relative(join(cwd, outDir), src))}").validate extends { params: ValidateSchema } ? T : ${paramsType}` - : paramsType; - const validateSearchType = - hasValidate && src - ? `typeof import("${sys.normalizePath(relative(join(cwd, outDir), src))}").validate extends { search: ValidateSchema } ? T : Record` - : `Record`; - - lines.push(` // ── ${path} ──`); - lines.push( - ` interface ${interfaceName} extends RouteDescriptor<"${path}", ${validateParamsType}, ${validateSearchType}> {` - ); + ? sys.normalizePath(relative(join(cwd, outDir), src)) + : null; + const validateParamsType = validatePath + ? `typeof import("${validatePath}").validate extends { params: ValidateSchema } ? T : ${paramsType}` + : paramsType; + const validateSearchType = validatePath + ? `typeof import("${validatePath}").validate extends { search: ValidateSchema } ? T : Record` + : `Record`; - // Page-level file types → methods const fileTypes = [...types.keys()]; const hasPage = fileTypes.includes("page") && types.get("page").some((e) => !e.outlet); - if (hasPage) { - lines.push(` createPage(`); - lines.push( - ` component: (props: ${validateParamsType}) => React.ReactNode` - ); - lines.push(` ): typeof component;`); - } + const pageBlock = hasPage + ? ` createPage( + component: (props: ${validateParamsType}) => React.ReactNode + ): typeof component;` + : ""; + + // Layout — one `createLayout` overload per layout entry, with typed + // outlet props inserted into the props bag. + const layoutBlock = fileTypes.includes("layout") + ? types + .get("layout") + .map((layoutEntry) => { + const outletProps = layoutEntry.outlets + ? [...layoutEntry.outlets] + .map( + ([outletName, outletInfo]) => + ` ${outletName}: Outlet<"${outletName}"${ + outletInfo.hasDefault ? "" : ", true" + }>;` + ) + .join("\n") + : ""; + return ` createLayout( + component: (props: { + children: React.ReactNode;${outletProps ? `\n${outletProps}` : ""} + }) => React.ReactNode + ): typeof component;`; + }) + .join("\n") + : ""; + + const hasMiddleware = + fileTypes.includes("middleware") || + manifest.middlewares.some(([, mp]) => mp === path); + const middlewareBlock = hasMiddleware + ? ` createMiddleware( + handler: (ctx: { + request: Request & { params: ${paramsType} }; + }) => Response | void | Promise + ): typeof handler;` + : ""; + + const errorBlock = fileTypes.includes("error") + ? ` createError( + component: (props: { error: Error }) => React.ReactNode + ): typeof component;` + : ""; + + const loadingBlock = fileTypes.includes("loading") + ? ` createLoading( + component: () => React.ReactNode + ): typeof component;` + : ""; + + const fallbackBlock = fileTypes.includes("fallback") + ? ` createFallback( + component: (props: { error: Error }) => React.ReactNode + ): typeof component;` + : ""; + + const resourceBlock = fileTypes.includes("resource") + ? ` createResourceMapping( + mapping: (ctx: { params: ${validateParamsType}; search: ${validateSearchType} }) => TKey + ): typeof mapping;` + : ""; + + // Matchers — typed per alias present in the route path. Only emitted + // when the path contains at least one `[param=alias]` form, including + // catch-alls (which widen the signature to string[]). + const matcherAliasEntries = Object.entries(extractMatcherAliases(path)); + const matchersBlock = + matcherAliasEntries.length > 0 + ? (() => { + const matcherShape = matcherAliasEntries + .map( + ([alias, arity]) => `${alias}?: (value: ${arity}) => boolean` + ) + .join("; "); + return ` createMatchers( + matchers: { ${matcherShape} } + ): typeof matchers;`; + })() + : ""; + + const body = [ + pageBlock, + layoutBlock, + middlewareBlock, + errorBlock, + loadingBlock, + fallbackBlock, + resourceBlock, + matchersBlock, + ] + .filter(Boolean) + .join("\n"); + + return ` // ── ${path} ── + interface ${interfaceName} extends RouteDescriptor<"${path}", ${validateParamsType}, ${validateSearchType}> { +${body} + } + export const ${name}: ${interfaceName};`; + }; - // Layout — with typed outlets - if (fileTypes.includes("layout")) { - const layoutEntries = types.get("layout"); - for (const layoutEntry of layoutEntries) { - const outletProps = []; - if (layoutEntry.outlets) { - for (const [outletName, outletInfo] of layoutEntry.outlets) { - const nullable = !outletInfo.hasDefault; - outletProps.push( - ` ${outletName}: Outlet<"${outletName}"${nullable ? ", true" : ""}>;` - ); - } - } - lines.push(` createLayout(`); - lines.push(` component: (props: {`); - lines.push(` children: React.ReactNode;`); - for (const prop of outletProps) { - lines.push(prop); - } - lines.push(` }) => React.ReactNode`); - lines.push(` ): typeof component;`); - } - } + const routeBlocks = routeInfos.map(buildRouteBlock).join("\n\n"); - // Middleware - if ( - fileTypes.includes("middleware") || - manifest.middlewares.some(([, mp]) => mp === path) - ) { - lines.push(` createMiddleware(`); - lines.push(` handler: (ctx: {`); - lines.push(` request: Request & { params: ${paramsType} };`); - lines.push(` }) => Response | void | Promise`); - lines.push(` ): typeof handler;`); - } + return `// Auto-generated by @lazarv/react-server file-router plugin +// Do not edit manually - // Error - if (fileTypes.includes("error")) { - lines.push(` createError(`); - lines.push( - ` component: (props: { error: Error }) => React.ReactNode` - ); - lines.push(` ): typeof component;`); - } +declare module "@lazarv/react-server/routes" { + import type { RouteDescriptor, ExtractParams, ValidateSchema, RouteValidate } from "@lazarv/react-server/router"; - // Loading - if (fileTypes.includes("loading")) { - lines.push(` createLoading(`); - lines.push(` component: () => React.ReactNode`); - lines.push(` ): typeof component;`); - } + // Branded outlet type — exported so the + // \`@lazarv/react-server/outlets\` module can produce values that satisfy + // \`createLayout\` slot types declared here. + const __outlet__: unique symbol; + export type Outlet< + Name extends string, + Nullable extends boolean = false + > = (React.ReactElement & { readonly [__outlet__]: Name }) + | (Nullable extends true ? null : never); - // Fallback - if (fileTypes.includes("fallback")) { - lines.push(` createFallback(`); - lines.push( - ` component: (props: { error: Error }) => React.ReactNode` - ); - lines.push(` ): typeof component;`); - } +${routeBlocks} +} +`; + } - // Resource — typed mapping function - if (fileTypes.includes("resource")) { - lines.push(` createResourceMapping(`); - lines.push( - ` mapping: (ctx: { params: ${validateParamsType}; search: ${validateSearchType} }) => TKey` - ); - lines.push(` ): typeof mapping;`); + /** + * Collect the unique, sorted list of outlet names declared in the + * file-router manifest. Single source of truth for both the outlets virtual + * module's runtime exports and the generated type declarations. + * + * Only outlet names that are valid JavaScript identifiers can be emitted + * as ESM exports. Names with hyphens or other non-identifier characters + * are skipped with a warning — those outlets remain reachable via the + * stringly-typed `` form. + */ + function collectOutletNames() { + const seen = new Set(); + const skipped = new Set(); + const isIdent = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + for (const [, , outlet] of manifest.pages) { + if (!outlet) continue; + if (isIdent.test(outlet)) { + seen.add(outlet); + } else if (!skipped.has(outlet)) { + skipped.add(outlet); + if (logger) { + logger.warn( + `Outlet ${colors.cyan(`@${outlet}`)} is not a valid JavaScript identifier — skipping in @lazarv/react-server/outlets virtual module. Use @lazarv/react-server/navigation's directly.` + ); + } } + } + // Alphabetical, locale-aware — matches the + // `toSorted((a, b) => a.localeCompare(b))` convention used elsewhere + // in this plugin. Default `.sort()` orders by UTF-16 code units, + // which puts uppercase before lowercase and `$`/`_` in surprising + // positions, making the generated `.d.ts` harder to scan. + return [...seen].toSorted((a, b) => a.localeCompare(b)); + } - // Matchers — typed per alias present in the route path. - // Only emitted when the path contains at least one [param=alias] form, - // including catch-alls (which widen the signature to string[]). - const matcherAliases = extractMatcherAliases(path); - const matcherAliasEntries = Object.entries(matcherAliases); - if (matcherAliasEntries.length > 0) { - const matcherShape = matcherAliasEntries - .map(([alias, arity]) => `${alias}?: (value: ${arity}) => boolean`) - .join("; "); - lines.push(` createMatchers(`); - lines.push(` matchers: { ${matcherShape} }`); - lines.push(` ): typeof matchers;`); - } + /** + * Generate TypeScript `.d.ts` for the `@lazarv/react-server/outlets` virtual + * module. Each unique outlet name in the file-router becomes a per-outlet + * namespace exposing a bound `ReactServerComponent`: + * + * import { sidebar } from "@lazarv/react-server/outlets"; + * + * + * `.Outlet` closes over `outlet=""`, types `url` against the same + * `RouteImpl` union as `Link.to`, and returns `Outlet<"">` so the + * value can satisfy a `createLayout` slot of the same name. + * + * `outletNames` is the unique, sorted list of outlet names from the manifest. + */ + function generateOutletsDts(outletNames) { + // One namespace per outlet — match the typed-router idiom + // (`route.Link`, `route.Route`). `.Outlet` is JSX-callable (PascalCase); + // additional per-outlet helpers can be hung off the same namespace later + // without breaking the API. + // + // When `outletNames` is empty we still emit the module so that + // `import { unknownOutlet } from "@lazarv/react-server/outlets"` fails + // as a "no exported member" error rather than a missing module. + const namespaces = + outletNames.length === 0 + ? ` // No outlets declared in the file-router.` + : outletNames + .map( + (name) => ` /** + * Per-outlet API for the \`@${name}\` outlet, declared in the file-router. + * + * \`Outlet\` is a bound \`ReactServerComponent\`: \`url\` is typed against + * the route table (same union as \`Link.to\`), and the return value is + * branded \`Outlet<"${name}">\` so it satisfies a \`createLayout\` slot of + * the same name. + */ + export const ${name}: { + Outlet: (props: __OutletProps) => __Outlet<"${name}">; + };` + ) + .join("\n\n"); - lines.push(` }`); - lines.push(` export const ${name}: ${interfaceName};\n`); - } + return `// Auto-generated by @lazarv/react-server file-router plugin +// Do not edit manually + +declare module "@lazarv/react-server/outlets" { + import type { ReactServerComponentProps as __OriginalProps } from "@lazarv/react-server/client/navigation.d.ts"; + import type { Outlet as __Outlet } from "@lazarv/react-server/routes"; + + // Shared props shape — like ReactServerComponent, but with \`url\` typed + // against the route table and \`outlet\` removed (bound at the call site). + type __OutletProps = Omit<__OriginalProps, "url" | "outlet"> & { + url?: __react_server_routing__.RouteImpl; + }; - lines.push(`}`); - return lines.join("\n"); +${namespaces} +} +`; } /** @@ -1023,6 +1127,12 @@ export default function viteReactServerRouter(options = {}) { if (routesModule) { viteServer.moduleGraph.invalidateModule(routesModule); } + const outletsModule = viteServer.moduleGraph.getModuleById( + `virtual:@lazarv/react-server/outlets` + ); + if (outletsModule) { + viteServer.moduleGraph.invalidateModule(outletsModule); + } // Invalidate the __resources__ virtual module so it regenerates for (const env of Object.values(viteServer.environments)) { const resourcesModule = env.moduleGraph.getModuleById( @@ -1177,6 +1287,17 @@ export default function viteReactServerRouter(options = {}) { const routesDts = generateRoutesDts(routeInfos); await writeFile(join(cwd, outDir, "react-server-routes.d.ts"), routesDts); + // Generate outlets.d.ts with one bound `ReactServerComponent` per + // unique outlet name in the manifest. Mirrors the + // `__react_server_routing_outlets__` derivation above so the + // virtual module's runtime exports and the generated types stay in lockstep. + const outletNames = collectOutletNames(); + const outletsDts = generateOutletsDts(outletNames); + await writeFile( + join(cwd, outDir, "react-server-outlets.d.ts"), + outletsDts + ); + debounceTypesGeneration = null; }; if (viteCommand !== "build") { @@ -1422,6 +1543,7 @@ export default function viteReactServerRouter(options = {}) { for (const moduleId of [ "virtual:@lazarv/react-server/file-router/manifest", "virtual:@lazarv/react-server/routes", + "virtual:@lazarv/react-server/outlets", ]) { const mod = viteServer.environments.rsc.moduleGraph.getModuleById( @@ -1549,6 +1671,15 @@ export default function viteReactServerRouter(options = {}) { routesModule ); } + const outletsModule = + viteServer.environments.rsc.moduleGraph.getModuleById( + "virtual:@lazarv/react-server/outlets" + ); + if (outletsModule) { + viteServer.environments.rsc.moduleGraph.invalidateModule( + outletsModule + ); + } Array.from( viteServer.environments.rsc.moduleGraph.urlToModuleMap.entries() @@ -1701,6 +1832,15 @@ export default function viteReactServerRouter(options = {}) { routesModule ); } + const outletsModule = + viteServer.environments.rsc.moduleGraph.getModuleById( + "virtual:@lazarv/react-server/outlets" + ); + if (outletsModule) { + viteServer.environments.rsc.moduleGraph.invalidateModule( + outletsModule + ); + } }); } } catch (e) { @@ -1856,6 +1996,30 @@ ${routeExportLines.join("\n")} ); } + // Set outlets virtual module content for the SSR / client builds. + // Same factory pattern as the dev-mode load() handler — keep them + // in sync so dev and prod produce identical exports. + const outletNames = collectOutletNames(); + const outletExportLines = outletNames.map( + (name) => `export const ${name} = __bind(${JSON.stringify(name)});` + ); + setVirtualModuleContent( + "outlets", + `import { createElement as __h } from "react"; +import { ReactServerComponent as __RSC } from "@lazarv/react-server/navigation"; + +function __bind(name) { + function Outlet(props) { + return __h(__RSC, { ...props, outlet: name }); + } + Outlet.displayName = \`Outlet(\${name})\`; + return { Outlet }; +} + +${outletExportLines.join("\n")} +` + ); + const options = getContext(BUILD_OPTIONS); if (options.export !== false) { @@ -1944,12 +2108,132 @@ ${routeExportLines.join("\n")} if ( id === "@lazarv/react-server/file-router/manifest" || id === "@lazarv/react-server/routes" || + id === "@lazarv/react-server/outlets" || id.startsWith("__react_server_router_page__") ) { return `virtual:${id}`; } }, async load(id) { + if (id === "virtual:@lazarv/react-server/outlets") { + // Emit one namespace per unique outlet name. Each namespace carries + // a `.Outlet` PascalCase property (JSX-callable) that closes over the + // outlet name. Match the existing typed-router idiom (`route.Link`, + // `route.Route`) so future per-outlet helpers (Refresh, hooks) can + // live on the same namespace without breaking the API. + // + // Two emissions, picked per Vite environment: + // + // - RSC + SSR (server-side envs): `Outlet` is async. When called + // with a `url` and no `children`/`defer`, it consults the + // file-router manifest, resolves the matching outlet page, + // 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. + // + // - Client env: `Outlet` stays sync and forwards directly. The + // server-only imports (manifest + route-match) would crash in + // a browser bundle. In normal flows the client doesn't import + // this module at all (server components serialize as client + // refs); the simple variant is a safety net for "use client" + // call sites. + // + // `defer={true}` and explicit `children` always bypass the preload + // — they are the user's opt-out for client-only fetch and custom + // inline content respectively. + // + // Build-mode SSR / client builds receive a separate non-preloading + // variant from the resources store via `setVirtualModuleContent`, + // since the file-router plugin only runs in the RSC build there. + const outletNames = collectOutletNames(); + const exportLines = outletNames.map( + (name) => `export const ${name} = __bind(${JSON.stringify(name)});` + ); + const isClientEnv = this.environment?.name === "client"; + if (!isClientEnv) { + return `import { createElement as __h } from "react"; +import { ReactServerComponent as __RSC } from "@lazarv/react-server/navigation"; +import { pages as __pages, matchersFor as __matchersFor } from "@lazarv/react-server/file-router/manifest"; +import { match as __match } from "@lazarv/react-server/server/route-match.mjs"; + +async function __resolveOutletContent(name, url) { + let pathname; + try { + // Absolute URL: parse and read pathname. + // Relative path: anchor against a dummy base so URL() accepts it. + pathname = decodeURIComponent( + new URL(url, "http://outlets.invalid/").pathname + ); + } catch { + return null; + } + // Look for a concrete outlet page whose path matches the URL. + for (const entry of __pages) { + const [path, type, outlet, lazy] = entry; + if (type !== "page" || outlet !== name) continue; + let params; + try { + params = __match(path, pathname, { + exact: true, + matchers: __matchersFor?.(path), + }); + } catch { + continue; + } + if (params) { + const mod = await lazy(); + const Component = mod && mod.default; + return Component ? __h(Component, params) : null; + } + } + // Fall back to the outlet's @.default page when no concrete match. + for (const entry of __pages) { + const [, type, outlet, lazy] = entry; + if (type !== "default" || outlet !== name) continue; + const mod = await lazy(); + const Component = mod && mod.default; + return Component ? __h(Component, {}) : null; + } + return null; +} + +function __bind(name) { + async function Outlet(props) { + const { url, children, defer } = props; + let content = children; + if (content == null && url && !defer) { + try { + content = await __resolveOutletContent(name, url); + } catch { + // Resolution failed — leave content null and let + // ReactServerComponent fall back to the client-side fetch. + content = null; + } + } + return __h(__RSC, { ...props, outlet: name, children: content }); + } + Outlet.displayName = \`Outlet(\${name})\`; + return { Outlet }; +} + +${exportLines.join("\n")} +`; + } + + return `import { createElement as __h } from "react"; +import { ReactServerComponent as __RSC } from "@lazarv/react-server/navigation"; + +function __bind(name) { + function Outlet(props) { + return __h(__RSC, { ...props, outlet: name }); + } + Outlet.displayName = \`Outlet(\${name})\`; + return { Outlet }; +} + +${exportLines.join("\n")} +`; + } if (id === "virtual:@lazarv/react-server/routes") { const routeInfos = await buildRouteInfos(); const exportLines = []; diff --git a/packages/react-server/lib/plugins/resources.mjs b/packages/react-server/lib/plugins/resources.mjs index bc03fcae..e5a27054 100644 --- a/packages/react-server/lib/plugins/resources.mjs +++ b/packages/react-server/lib/plugins/resources.mjs @@ -2,19 +2,20 @@ * Vite plugin providing virtual module fallbacks: * - @lazarv/react-server/__resources__ (resource descriptor collection) * - @lazarv/react-server/routes (typed route descriptors) + * - @lazarv/react-server/outlets (per-outlet bound ReactServerComponent) * * When the file-router is active (RSC build), its prePlugin (enforce: "pre") * resolves __resources__ first, so this plugin's resolveId is never called * for that module. The file-router's mainPlugin then handles the load. * - * In the SSR build, pass { useStore: true } so both resources and routes + * In the SSR build, pass { useStore: true } so resources, routes, and outlets * are served from the store (populated by the RSC build's configResolved). * The store uses promises so the SSR build (which runs in parallel with * RSC) can safely await content that hasn't been set yet. The SSR build * includes a resource-transform plugin that adds __rs_descriptor__ exports. * - * In the client build, pass { useStore: true } to read both resources and - * routes from the store. + * In the client build, pass { useStore: true } to read resources, routes, + * and outlets from the store. */ /** @@ -61,11 +62,12 @@ export function setVirtualModuleContent(key, content) { const RESOURCES_ID = "\0react-server:resources"; const ROUTES_ID = "\0react-server:routes"; +const OUTLETS_ID = "\0react-server:outlets"; /** * @param {object} opts - * @param {boolean} [opts.useStore] - Read both resources and routes from store (client build) - * @param {boolean} [opts.useRouteStore] - Read only routes from store (SSR build) + * @param {boolean} [opts.useStore] - Read resources, routes, and outlets from store (client build) + * @param {boolean} [opts.useRouteStore] - Read only routes and outlets from store (SSR build) */ export default function resources({ useStore = false, @@ -73,6 +75,7 @@ export default function resources({ } = {}) { const readResources = useStore; const readRoutes = useStore || useRouteStore; + const readOutlets = useStore || useRouteStore; return { name: "react-server:resources", @@ -83,6 +86,9 @@ export default function resources({ if (id === "@lazarv/react-server/routes" && readRoutes) { return ROUTES_ID; } + if (id === "@lazarv/react-server/outlets" && readOutlets) { + return OUTLETS_ID; + } }, async load(id) { if (id === RESOURCES_ID) { @@ -109,6 +115,19 @@ export default function resources({ return entry.content; } } + if (id === OUTLETS_ID && readOutlets) { + // Outlets follow the same pattern as routes — the RSC build sets + // the content during configResolved; SSR/client builds await it + // here. Without a file-router, the entry is undefined and Vite's + // normal resolution applies (which will fail — but importing + // `@lazarv/react-server/outlets` only makes sense with a + // file-router). + const entry = store.outlets; + if (entry) { + await entry.promise; + return entry.content; + } + } }, }; } diff --git a/test/__test__/apps/typed-file-router.spec.mjs b/test/__test__/apps/typed-file-router.spec.mjs index 38522070..06e221e4 100644 --- a/test/__test__/apps/typed-file-router.spec.mjs +++ b/test/__test__/apps/typed-file-router.spec.mjs @@ -130,6 +130,119 @@ describe("typed-file-router — dashboard layout and outlets", () => { }); }); +// ── Bound outlets via @lazarv/react-server/outlets ── + +describe("typed-file-router — bound outlets", () => { + // /panels mounts the @sidebar/nav and @content/feed outlets directly + // via the typed `@lazarv/react-server/outlets` module. The outlet name + // is bound at the import site (no `outlet="..."` prop needed) and + // `url` is typed against the same route table as `Link.to`. + test("renders the panels page", async () => { + await page.goto(`${hostname}/panels`); + await page.waitForLoadState("load"); + expect(await page.textContent("body")).toContain("Panels"); + }); + + test("sidebar.Outlet mounts /dashboard/nav into the sidebar slot", async () => { + await page.goto(`${hostname}/panels`); + await page.waitForLoadState("load"); + await waitForHydration(); + + const sidebar = await page.$('[data-testid="panels-sidebar"]'); + expect(sidebar).not.toBeNull(); + const sidebarText = await sidebar.textContent(); + // /dashboard/nav resolves to @sidebar/nav.page.tsx, which renders the + // SidebarNav with three internal links. + expect(sidebarText).toContain("Overview"); + expect(sidebarText).toContain("Settings"); + expect(sidebarText).toContain("Analytics"); + }); + + test("content.Outlet mounts /dashboard/feed into the content slot", async () => { + await page.goto(`${hostname}/panels`); + await page.waitForLoadState("load"); + await waitForHydration(); + + const content = await page.$('[data-testid="panels-content"]'); + expect(content).not.toBeNull(); + const contentText = await content.textContent(); + // /dashboard/feed resolves to @content/feed.page.tsx → ContentFeed. + expect(contentText).toContain("Activity Feed"); + expect(contentText).toContain("User signed up"); + expect(contentText).toContain("New order placed"); + }); + + // Regression: while the panels page is mounted, the sidebar+content + // outlets are registered in ClientProvider's `outlets` Map. A default + // click (no `target`/`local`/`root` prop) used to broadcast the + // navigation to every active non-root outlet and skip PAGE_ROOT entirely, + // so the user stayed on /panels with the sidebar/content slots showing + // null. Top-level Link clicks must navigate the page even with named + // outlets active. + test("typed Link click from panels to dashboard performs full navigation", async () => { + await page.goto(`${hostname}/panels`); + await page.waitForLoadState("load"); + await waitForHydration(); + // Wait for the bound outlets to register (their useEffect must have + // run before we click the cross-page link). + await page.waitForSelector('[data-testid="panels-sidebar"]'); + + const prevUrl = page.url(); + const dashboardLink = await page.$('nav a[href="/dashboard"]'); + expect(dashboardLink).not.toBeNull(); + await dashboardLink.click(); + await waitForChange(null, () => page.url(), prevUrl); + + expect(page.url()).toContain("/dashboard"); + const body = await page.textContent("body"); + expect(body).toContain("Welcome to the dashboard"); + expect(body).toContain("Sidebar"); + expect(body).toContain("Content"); + }); + + // The bound `Outlet` resolves the `url` against the file-router manifest + // on the server (RSC env) and renders the matching outlet page as + // `children` for `ReactServerComponent`. The result must already be in + // the initial SSR HTML — no client round-trip required. A raw fetch + // bypasses Playwright's JS execution and proves the content is server + // rendered, not hydrated. + test("server preloads outlet content into the initial SSR HTML", async () => { + const response = await fetch(`${hostname}/panels`); + expect(response.ok).toBe(true); + const html = await response.text(); + // Sidebar slot: from @sidebar/nav.page.tsx (SidebarNav) + expect(html).toContain("Overview"); + expect(html).toContain("Settings"); + expect(html).toContain("Analytics"); + // Content slot: from @content/feed.page.tsx (ContentFeed) + expect(html).toContain("Activity Feed"); + expect(html).toContain("User signed up"); + }); + + test("each bound outlet receives its own outlet identifier", async () => { + await page.goto(`${hostname}/panels`); + await page.waitForLoadState("load"); + await waitForHydration(); + + // Each `<*.Outlet />` call creates an island with the matching outlet + // identifier; in dev mode the runtime emits a marker per outlet. The + // marker proves the outlet name was bound at the call site rather than + // collapsed to a single shared scope. + const sidebarMarker = await page.$( + '[data-testid="panels-sidebar"] [data-devtools-outlet="sidebar"]' + ); + const contentMarker = await page.$( + '[data-testid="panels-content"] [data-devtools-outlet="content"]' + ); + // Markers are dev-only; assert when present, skip otherwise so the + // assertion still passes against a production build. + if (sidebarMarker || contentMarker) { + expect(sidebarMarker).not.toBeNull(); + expect(contentMarker).not.toBeNull(); + } + }); +}); + // ── Virtual routes ── describe("typed-file-router — virtual routes", () => {