diff --git a/docs/design/route-identity-rsc.md b/docs/design/route-identity-rsc.md new file mode 100644 index 0000000..c41b89d --- /dev/null +++ b/docs/design/route-identity-rsc.md @@ -0,0 +1,668 @@ +# Design: Two-Phase Route Definition for RSC + +## Problem Statement + +When using `@funstack/router` with React Server Components (RSC), there is a +fundamental tension between **type-safe routing** and the **RSC module boundary**. + +### The Circular Dependency + +``` + Server Module (App.tsx) Client Module ("use client") + ┌──────────────────────────┐ ┌──────────────────────────┐ + │ │ │ │ + │ route({ │ │ useRouteParams(???) │ + │ id: "user", │ │ // needs route object │ + │ path: "/users/:userId"│ │ // for type safety │ + │ component: │ ──✗──► │ │ + │ loader: fetchUser │ │ useRouteData(???) │ + │ }) │ │ // needs route object │ + │ │ │ // for typed data │ + │ (can reference server │ │ │ + │ components ✓) │ │ │ + └──────────────────────────┘ └──────────────────────────┘ + Client cannot import from server module +``` + +**If routes are in a server module** — they can reference server components, but +client components cannot import them (RSC boundary prevents it). + +**If routes are in a shared module** — client components can import them, but they +cannot reference server components (server component imports make a module +server-only). + +**Result:** There is no single location where route objects can both reference +server components AND be imported by client components for type-safe hooks. + +--- + +## Key Insight + +The only part of a route definition that is inherently server-specific is the +**component reference** (because the component may be a server component). Every +other aspect of a route — path, id, loader, action, state — is client-safe: + +``` + ┌──────────────────────────────────────────────────────────┐ + │ route() definition │ + │ │ + │ ┌──────────────────────────────────┐ ┌──────────────┐ │ + │ │ id, path, loader, action, state │ │ component │ │ + │ │ (client-safe — runs in browser) │ │ (may be a │ │ + │ │ │ │ server │ │ + │ │ ✓ Importable by "use client" │ │ component) │ │ + │ └──────────────────────────────────┘ └──────────────┘ │ + │ everything else the only │ + │ server part │ + └──────────────────────────────────────────────────────────┘ +``` + +**Loaders and actions run client-side** (they execute in the browser during +navigation), so they can live in shared modules. The type information that client +components need — `Params` from path, `Data` from loader, `State` from +`routeState` — is all carried by the non-component parts. + +This means we can split a route definition at exactly one point: the component. + +--- + +## Proposed Solution: Two-Phase Route Definition + +### Overview + +``` + Phase 1: route() Phase 2: bindRoute() + (shared module — colocated (server module — assembles + with page components) route tree for ) + + ┌─────────────────────────┐ ┌──────────────────────────┐ + │ route({ │ │ │ + │ id: "user", │─────────►│ bindRoute(userRoute, { │ + │ path: "/:userId", │ │ component: │ + │ loader: fetchUser, │ │ }) │ + │ }) │ │ │ + └────────────┬────────────┘ └──────────────────────────┘ + │ + │ import (shared → client ✓) + ▼ + ┌──────────────────────────┐ + │ "use client" │ + │ │ + │ useRouteParams(userRoute│ ← Params from path ✓ + │ useRouteData(userRoute) │ ← Data from loader ✓ + │ useRouteState(userRoute)│ ← State from routeState ✓ + └──────────────────────────┘ +``` + +### Phase 1 — Route Definition Without Component (Shared Module) + +The existing `route()` function, when called **without a `component`**, produces +a partial route object. This object carries all type information (params, data, +state) and is safe to import from client modules. + +The recommended pattern is to **colocate** each route definition with the page +components that use it: + +``` +src/ + pages/ + user/ + route.ts ← Phase 1: route with loader (shared) + UserProfile.tsx ← Server component + UserActions.tsx ← "use client" — imports ./route + settings/ + route.ts ← Phase 1: route with state (shared) + Settings.tsx ← Server component + SettingsPanel.tsx ← "use client" — imports ./route + App.tsx ← Phase 2: bindRoute() for all pages +``` + +```typescript +// src/pages/user/route.ts — shared module +import { route } from "@funstack/router"; +import type { User } from "../../types"; + +export const userRoute = route({ + id: "user", + path: "/:userId", + loader: async ({ params }) => { + const res = await fetch(`/api/users/${params.userId}`); + return res.json() as Promise; + }, +}); +// All types inferred: +// Params = { userId: string } — from path +// Data = User — from loader +``` + +```tsx +// src/pages/user/UserActions.tsx — "use client" +import { userRoute } from "./route"; +import { useRouteParams, useRouteData } from "@funstack/router"; + +export function UserActions() { + const { userId } = useRouteParams(userRoute); // { userId: string } ✓ + const user = useRouteData(userRoute); // User ✓ + // ... +} +``` + +### Phase 2 — Binding Components (Server Module) + +A **new function `bindRoute()`** takes a Phase 1 route and adds the component +(and optionally children) to produce a full `RouteDefinition` for ``. + +This function lives in `@funstack/router/server` — the server-only entrypoint. + +```tsx +// src/App.tsx — server component +import { bindRoute } from "@funstack/router/server"; +import { Outlet } from "@funstack/router"; +import { homeRoute } from "./pages/home/route"; +import { Home } from "./pages/home/Home"; +import { userRoute } from "./pages/user/route"; +import { UserProfile } from "./pages/user/UserProfile"; +import { settingsRoute } from "./pages/settings/route"; +import { Settings } from "./pages/settings/Settings"; +import { Router } from "./Router"; + +const routes = [ + bindRoute(homeRoute, { + component: , + }), + bindRoute(userRoute, { + component: , + }), + bindRoute(settingsRoute, { + component: , + children: [ + // nested routes... + ], + }), +]; + +export default function App({ ssrPath }: { ssrPath: string }) { + return ; +} +``` + +--- + +## API Design + +### Phase 1: `route()` — Route Without Component + +This is the existing `route()` function. When called without a `component` +property, it returns a **partial route** — an object that carries all type +information but cannot be rendered by the Router on its own. + +```typescript +// Existing call with component → full RouteDefinition (unchanged) +route({ + id: "user", + path: "/:userId", + component: , + loader: fetchUser, +}); + +// NEW: call without component → PartialRouteDefinition +route({ + id: "user", + path: "/:userId", + loader: fetchUser, +}); +``` + +The return type when `component` is absent: + +```typescript +type PartialRouteDefinition = { + readonly id: TId; + readonly path: string; + readonly [partialRouteBrand]: { + id: TId; + params: TParams; + state: TState; + data: TData; + }; + // loader, action, etc. are stored internally +}; +``` + +This type carries the same type parameters as `TypefulOpaqueRouteDefinition` +and is accepted by all hooks. + +**`routeState()` works as-is:** + +```typescript +// src/pages/settings/route.ts +import { routeState } from "@funstack/router"; + +export const settingsRoute = routeState<{ tab: string }>()({ + id: "settings", + path: "/settings", +}); +// Params = {}, State = { tab: string } +``` + +**Entrypoint:** `route()` and `routeState()` are available from both +`@funstack/router` and `@funstack/router/server` (as today). + +### Phase 2: `bindRoute()` — Bind Component to Route + +A new function exported from `@funstack/router/server`: + +```typescript +function bindRoute( + partialRoute: PartialRouteDefinition, + binding: { + component: ReactNode | ComponentType>; + children?: RouteDefinition[]; + exact?: boolean; + requireChildren?: boolean; + }, +): TypefulOpaqueRouteDefinition; +``` + +**Parameters:** + +- `partialRoute` — A Phase 1 route object (output of `route()` without + component) +- `binding.component` — The React component to render (may be a server + component) +- `binding.children` — Child routes for nested routing (optional) + +**Returns:** A full `TypefulOpaqueRouteDefinition` — the same type that the +existing `route()` with component returns. Fully compatible with ``. + +**Entrypoint:** `@funstack/router/server` only, since the component may +reference server components. + +**Routes without `id`:** `bindRoute` also accepts `OpaqueRouteDefinition` +(routes created without `id`), returning an `OpaqueRouteDefinition`. This +supports layout routes like `` wrappers that don't need typed hooks: + +```typescript +const usersLayout = route({ path: "/users" }); +bindRoute(usersLayout, { component: , children: [...] }); +``` + +### Hook Compatibility + +All hooks accept both `PartialRouteDefinition` and +`TypefulOpaqueRouteDefinition`: + +```typescript +function useRouteParams(route: T): ExtractRouteParams; +function useRouteState(route: T): ExtractRouteState | undefined; +function useRouteData(route: T): ExtractRouteData; +``` + +At runtime, both types carry an `id` property. The hooks call +`useRouteContext(route.id)` to look up the matching context — the same +mechanism as today. + +--- + +## Collocation Pattern + +The recommended project structure colocates each route definition with its page: + +``` +src/pages/user/ + route.ts ← Phase 1 (shared): id, path, loader + UserProfile.tsx ← Server component (the page itself) + UserActions.tsx ← "use client" component (imports route.ts for hooks) +``` + +**Why collocation works well:** + +1. **Locality** — The route definition is next to the components that use it. + `import { userRoute } from "./route"` is a short, obvious import. + +2. **Encapsulation** — Each page "owns" its route. Adding a new page means + adding a folder with route + components, then one line in `App.tsx`. + +3. **Type safety is local** — The loader return type and path params are defined + once in `route.ts` and consumed by sibling client components. No need to + maintain a separate type declaration. + +4. **Server components stay in server modules** — `UserProfile.tsx` imports + server-only code freely. It's only referenced from `App.tsx` (also server). + +--- + +## Nested Routes + +Partial routes use **relative path segments**, matching the current router +behavior: + +```typescript +// src/pages/users/route.ts +export const usersRoute = route({ id: "users", path: "/users" }); + +// src/pages/users/profile/route.ts +export const userProfileRoute = route({ + id: "userProfile", + path: "/:userId", // relative to parent + loader: fetchUser, +}); + +// src/pages/users/settings/route.ts +export const userSettingsRoute = route({ + id: "userSettings", + path: "/:userId/settings", // relative to parent +}); +``` + +```typescript +// src/App.tsx +const routes = [ + bindRoute(usersRoute, { + component: , + children: [ + bindRoute(userProfileRoute, { + component: , + }), + bindRoute(userSettingsRoute, { + component: , + }), + ], + }), +]; +``` + +This requires no changes to the router's path matching — `bindRoute` produces +standard `RouteDefinition` objects with relative segments, identical to what +`route()` with `component` produces today. + +### Future Extension: `fullPath` + +A `fullPath` property on `PartialRouteDefinition` could be added in the future +for use cases that require the complete URL pattern (e.g., standalone URL +construction outside of Router context). This is out of scope for the initial +implementation. + +--- + +## TypeScript Overload Design + +### Distinguishing Partial vs. Full Routes + +The `route()` function currently has multiple overloads. Adding the "without +component" behavior requires TypeScript to distinguish between: + +1. `route({ id, path, loader })` → `PartialRouteDefinition` (new) +2. `route({ id, path, component, loader })` → `TypefulOpaqueRouteDefinition` + (existing) +3. `route({ path, children })` → `OpaqueRouteDefinition` (existing layout route) + +**Disambiguation rule:** The presence or absence of `component` is the +discriminant. When `component` is absent AND `id` is present, the return type +is `PartialRouteDefinition`. The overloads are ordered so that this new case +is checked first: + +```typescript +// NEW overload (checked first): id + no component → PartialRouteDefinition +function route( + definition: { + id: TId; + path: TPath; + loader?: (args: LoaderArgs>) => TData; + action?: ...; + // component intentionally absent + } +): PartialRouteDefinition, undefined, TData>; + +// Existing overloads (unchanged): with component → full RouteDefinition +function route( + definition: { + id?: TId; + path?: TPath; + component: ReactNode | ComponentType; + loader?: ...; + children?: RouteDefinition[]; + } +): TypefulOpaqueRouteDefinition<...>; + +// Existing: no id, no component → OpaqueRouteDefinition (layout) +function route(definition: { + path?: string; + children?: RouteDefinition[]; +}): OpaqueRouteDefinition; +``` + +### `component: undefined` + +If someone explicitly passes `component: undefined`, it matches the "no +component" overload and returns `PartialRouteDefinition`. This is correct +behavior — the caller did not provide a component, so the route is partial. + +### `routeState()` Compatibility + +The `routeState()` function returns a curried function with the same +overload structure as `route()`. When called without `component`: + +```typescript +export const settingsRoute = routeState<{ tab: string }>()({ + id: "settings", + path: "/settings", +}); +// → PartialRouteDefinition<"settings", {}, { tab: string }, undefined> +``` + +The same disambiguation rule applies — `component` absent + `id` present → +`PartialRouteDefinition` with the `TState` type parameter populated. + +### `bindRoute` Overloads + +`bindRoute` has two overloads based on whether the input carries type +information: + +```typescript +// Typed input (has id) → typed output +function bindRoute( + partialRoute: PartialRouteDefinition, + binding: { + component: ReactNode | ComponentType; + children?: RouteDefinition[]; + exact?: boolean; + requireChildren?: boolean; + }, +): TypefulOpaqueRouteDefinition; + +// Untyped input (no id) → untyped output +function bindRoute( + partialRoute: OpaqueRouteDefinition, + binding: { + component: ReactNode | ComponentType; + children?: RouteDefinition[]; + exact?: boolean; + requireChildren?: boolean; + }, +): OpaqueRouteDefinition; +``` + +### Unified Type Extraction + +A set of type utilities that work with both partial and full route definitions: + +```typescript +type ExtractRouteParams = + T extends PartialRouteDefinition + ? P + : T extends TypefulOpaqueRouteDefinition + ? P + : never; + +type ExtractRouteState = + T extends PartialRouteDefinition + ? S + : T extends TypefulOpaqueRouteDefinition + ? S + : never; + +type ExtractRouteData = + T extends PartialRouteDefinition + ? D + : T extends TypefulOpaqueRouteDefinition + ? D + : never; +``` + +--- + +## Backwards Compatibility + +The existing `route()` API is **unchanged**. The two-phase pattern is additive: + +```typescript +// Old pattern — still works, no changes needed +route({ + id: "user", + path: "/:userId", + component: , + loader: fetchUser, +}) + +// New pattern — same route(), just without component +const userRoute = route({ + id: "user", + path: "/:userId", + loader: fetchUser, +}); +// + bindRoute in server module +bindRoute(userRoute, { component: }) +``` + +Users can adopt the new pattern incrementally, one route at a time. Routes using +the old single-phase pattern and the new two-phase pattern can coexist in the +same `routes` array. + +--- + +## Migration Path + +### Step 1: Extract route definitions from App.tsx + +Move the non-component parts of each route to a colocated `route.ts`: + +```typescript +// Before (App.tsx — everything in one server module) +export const userRoute = route({ + id: "user", + path: "/:userId", + component: , + loader: fetchUser, +}); + +// After: +// pages/user/route.ts (shared) +export const userRoute = route({ + id: "user", + path: "/:userId", + loader: fetchUser, +}); + +// App.tsx (server) +import { userRoute } from "./pages/user/route"; +bindRoute(userRoute, { component: }); +``` + +### Step 2: Use route objects in client components + +```tsx +// pages/user/UserActions.tsx — "use client" +import { userRoute } from "./route"; +import { useRouteParams, useRouteData } from "@funstack/router"; + +function UserActions() { + const { userId } = useRouteParams(userRoute); // ✓ type-safe + const user = useRouteData(userRoute); // ✓ type-safe +} +``` + +### Step 3: Simplify entries.tsx (for static generation) + +```typescript +import { userRoute } from "./pages/user/route"; +import { aboutRoute } from "./pages/about/route"; + +const staticRoutes = [userRoute, aboutRoute]; + +export default function getEntries(): EntryDefinition[] { + return staticRoutes.map((r) => ({ + path: pathToEntryPath(r.path), + root: () => import("./root"), + app: , + })); +} +``` + +--- + +## Alternative Approaches Considered + +### A. Global Type Registry (Module Augmentation) + +```typescript +declare module "@funstack/router" { + interface RouteRegistry { + user: { path: "/users/:userId"; params: { userId: string } }; + } +} +const { userId } = useRouteParams("user"); // type-safe via registry +``` + +**Verdict:** Loses IDE navigation (go-to-definition), requires manual type +declarations (no inference from path/loader), and module augmentation is an +unfamiliar pattern. Rejected. + +### B. Compile-Time Transform (Vite Plugin) + +Strip `component` from route objects in client bundle automatically. + +**Verdict:** Most seamless DX but high implementation complexity, implicit +behavior, and TypeScript type mismatch between server/client views. +Could be a future optimization. Rejected for now. + +### C. `import type` + Runtime-Free Hooks + +```typescript +import type { userRoute } from "../server/routes"; +const params = useRouteParams(); // no runtime arg +``` + +**Verdict:** Breaks the runtime hook contract. Can't distinguish between nested +routes without a runtime identifier. Rejected. + +### D. Explicit Type Declarations on Route Identity + +```typescript +const userRoute = routeId("user", "/users/:userId") + .withData() + .withState<{ tab: string }>(); +``` + +**Verdict:** Unnecessary once we recognize that loaders are client-side. The +loader's return type and `routeState` already carry all needed type information +naturally. Superseded by the simpler approach. + +--- + +## Summary + +The two-phase route definition solves the RSC routing dilemma by splitting at the +only server-specific boundary — the component reference: + +| | Phase 1: `route()` | Phase 2: `bindRoute()` | +| ----------------- | ---------------------------------- | ---------------------- | +| **Module** | Shared (colocated with page) | Server (`App.tsx`) | +| **Contains** | id, path, loader, action, state | component, children | +| **Importable by** | Server + Client | Server only | +| **Type info** | Params, Data, State (all inferred) | Inherited from Phase 1 | + +All type information flows naturally — params from path pattern, data from loader +return type, state from `routeState()`. No explicit type annotations needed. +The pattern is backwards-compatible, incrementally adoptable, and encourages +a clean colocated project structure.