diff --git a/examples/remix/basic/README.md b/examples/remix/basic/README.md new file mode 100644 index 00000000000..7f6daf03e44 --- /dev/null +++ b/examples/remix/basic/README.md @@ -0,0 +1,151 @@ +# `@tanstack/remix-router` + `@tanstack/remix-start` example + +End-to-end demo of TanStack Router on top of Remix 3 (`@remix-run/ui`). +The whole route tree hydrates as one Remix UI mount; TanStack Router +drives navigation and data; server functions keep heavy server-only +work (markdown rendering, DB clients, anything you don't want shipped to +the browser) out of the client bundle. + +## Architecture + +``` +Browser +├─ src/client.tsx +│ await hydrateStart() ← deserialize router state +│ run({ loadModule }) ← Remix UI runtime (for clientEntry islands) +│ createRoot(document.body) +│ .render() ← full-tree mount +│ +├─ Server function calls ──────┐ +│ GET/POST /_serverFn/ │ +└─ Page navigation / data fetch │ + ↓ │ +Server (vite dev → node prod) │ +└─ default-entry server │ + createStartHandler(defaultStreamHandler) + ↓ + renderRouterToStream → @remix-run/ui/server.renderToStream + - provides shell + - splices seroval dehydration before +``` + +One reconciler everywhere: `@remix-run/ui`. TanStack Router subscribes +to its own reactive stores; on store change it calls `handle.update()` +and Remix UI re-runs the render function. + +## The five primitives + +| Primitive | When to write it | What it costs the client | +|---|---|---| +| **Route component** `function Foo(handle) { return () => }` | every page, layout, nav | full hydration cost | +| **Loader** `loader: () => fetchData()` | data the route needs | the loader code ships unless wrapped — use server fns | +| **`createServerFn({ method }).inputValidator().handler()`** | DB queries, markdown renders, anything with heavy deps or server-only secrets | nothing — body is stripped, replaced with RPC fetcher | +| **`clientEntry(id, fn)`** | per-instance interactive bits that don't need router context (counter, dropdown, video player) | only the island module + its props ship as a separate hydration root | +| **``** | streaming a server-rendered fragment from a URL — opaque to the parent client tree | the URL string + ~10kB Frame runtime | + +Default = universal: components run on both sides, no directives. Wrap +to opt out of the client (`createServerFn`, ``); wrap to opt in +to standalone hydration (`clientEntry`). + +## Routes + +| Path | Demonstrates | +|---|---| +| `/` | Static welcome page with a route guide | +| `/users` | Loader-driven list, `` to nested detail | +| `/users/$id` | `useLoaderData` + `useParams`; server-fn-rendered HTML mounted via `innerHTML` | +| `/posts` | Same shape with markdown content | +| `/posts/$slug` | Heavy markdown + syntax highlighting (server-only deps stay out of client bundle) | +| `/admin/users/$userId/sessions/$sessionId` | 4-deep nested layout via file path; exercises ``/`` reactivity at every level | +| `/catalog` | `validateSearch`, `loaderDeps`, ``, form-driven `useNavigate` | +| `/slow` | Async loader (800ms), `pendingComponent` UI | +| `/lab/error` | Loader throws → `errorComponent` | +| `/lab/missing` | Loader calls `notFound()` → `notFoundComponent` | +| `/lab/render-error` | Render-time throw caught by enclosing `` | +| `/guestbook` | `createServerFn({ method: 'POST' })` with `inputValidator`; form submit calls server fn from event handler | +| `/counter` | `clientEntry()`-marked island that hydrates standalone (counter `+`/`reset` buttons) | + +## Bundle savings + +The Vite plugin (`@tanstack/remix-start/plugin/vite` → +`@tanstack/start-plugin-core` under the hood) extracts `createServerFn` +handler bodies from the client bundle, replacing them with RPC +fetchers that hit `${TSS_SERVER_FN_BASE}/`. Heavy server-only +modules (markdown renderers, ORM clients, image pipelines) reachable +only through those handlers fall out of the client bundle entirely. + +In this example: `marked`, `highlight.js`, language grammars, and +`heavyDep` (a deliberately-fat user-bio renderer) are all server-only +— the client bundle (~190 KB minified) contains none of them. + +## Hydration model + +The whole route tree hydrates. There is no selective per-component +hydration — ``, `useLoaderData`, `useSearch` all work because the +ambient `` mount runs the route's render function on the +client. If you want a *per-instance* interactive piece that doesn't +depend on the router context (a standalone counter, a video player, a +dropdown that's reused across pages), reach for `clientEntry()`. That +pattern is rare — the route tree already gives you reactivity. + +This is fundamentally different from RSC. There are no `"use client"` / +`"use server"` directives. The boundary is the wrapped *export* +(`createServerFn(...)`, `clientEntry(...)`), not a file-level marker. A +consequence: there's no built-in way to author "this region is +server-only HTML with client islands inside" inline in the JSX tree — +that pattern needs `` (URL-driven, see below) or a +separate primitive that doesn't yet exist. + +## A note on `defer()` / `` + +The binding ships `defer()` and `` and they SSR correctly with +the fallback inline. Two follow-up fixes are needed before the slow +chunk streams in via seroval: + +1. **`awaited.tsx` server-side guard** *(done — landed in this branch)*: + `onSettle → handle.update()` must skip on the server, since the SSR + scheduler doesn't implement `scheduleUpdate` and the post-stream + update was crashing the dev server. +2. **Plumb seroval streaming chunks through `pipeWithDehydration`**: + the resolution chunk for a deferred promise is buffered server-side + (`scriptBuffer.enqueue`) but isn't reaching the response body. + `collectInjection()` runs once at the end of the stream after + serialization completes; the chunk SHOULD be in the buffered + scripts at that point, but empirically the response only contains + the initial dehydration. Needs investigation — likely a missing + subscribe somewhere between the seroval `onSerialize` callback and + the `injectScript` flush, or a timing issue with the script-buffer + barrier lift. + +The `/deferred` route has been left out for now to keep the demo +green. Once (2) is fixed, restore it from git history. + +## A note on `` + +`@remix-run/ui` ships a `` primitive that streams an +HTML fragment from a URL. It's powerful — multiple frames stream in +parallel out-of-order, each with its own fallback — but it solves a +narrow problem: shipping pre-rendered HTML when the renderer (e.g. a +markdown + syntax-highlighting pipeline) can't be bundled to the +client. For most apps the existing patterns cover this: + +- *"Heavy server-only render → mount as HTML"* is what `/posts/$slug` + already does: a `createServerFn` returns the HTML string, the route + loader awaits it, the component mounts via `innerHTML`. No async URL + builder, no `resolveFrame` plumbing. +- *"Stream multiple async values into a route"* is `defer()` /{' '} + `` (see `/deferred`). + +Reach for `` only if you specifically need N parallel +HTML-only renders streaming independently — rare in practice. The +binding supports it; this example just doesn't teach it because the +better-fit primitives already cover the 80% case. + +## Running + +```sh +pnpm install +pnpm dev # vite dev server with full SSR + SPA navigation +pnpm build # production build (client + server bundles) +pnpm preview # preview production build +``` diff --git a/examples/remix/basic/index.html b/examples/remix/basic/index.html new file mode 100644 index 00000000000..3be24af49ef --- /dev/null +++ b/examples/remix/basic/index.html @@ -0,0 +1,12 @@ + + + + + + remix-router demo + + +
+ + + diff --git a/examples/remix/basic/package.json b/examples/remix/basic/package.json new file mode 100644 index 00000000000..17791c6669c --- /dev/null +++ b/examples/remix/basic/package.json @@ -0,0 +1,22 @@ +{ + "name": "tanstack-remix-router-example-basic", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "End-to-end demo of @tanstack/remix-router on top of Remix 3.", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@remix-run/ui": "^0.1.1", + "@tanstack/remix-router": "workspace:*", + "@tanstack/remix-start": "workspace:*", + "highlight.js": "^11.10.0", + "marked": "^15.0.0" + }, + "devDependencies": { + "vite": "^8.0.0" + } +} diff --git a/examples/remix/basic/server.ts b/examples/remix/basic/server.ts new file mode 100644 index 00000000000..9623b2d6974 --- /dev/null +++ b/examples/remix/basic/server.ts @@ -0,0 +1,52 @@ +import { createServer } from 'node:http' +import { Readable } from 'node:stream' +import { createStartHandler } from '@tanstack/remix-start/server' +import { makeRouter } from './src/router.ts' + +/** + * The Start handler dispatches `/_serverFn/` requests to + * `handleServerAction` (the framework-agnostic RPC runtime) and falls + * through to the TSR app handler for everything else. Server functions + * declared via `createServerFn` are exposed automatically — no + * hand-rolled API endpoints to maintain. + * + * `` SSR works out of the box: the underlying router handler's + * default `resolveFrame` recurses through this same handler, so a + * `` resolves through the server-action + * runtime — exactly the same path a client-side RPC call takes. + */ +const handler = createStartHandler({ createRouter: makeRouter }) + +const port = Number(process.env.PORT ?? 3000) +createServer(async (req, res) => { + const url = `http://${req.headers.host}${req.url}` + const headers = new Headers() + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + for (const v of value) headers.append(key, v) + } else if (value !== undefined) { + headers.set(key, value) + } + } + const body = + req.method === 'GET' || req.method === 'HEAD' + ? undefined + : (Readable.toWeb(req) as ReadableStream) + const request = new Request(url, { + method: req.method, + headers, + body, + duplex: body ? 'half' : undefined, + } as RequestInit) + + const response = await handler(request) + res.statusCode = response.status + response.headers.forEach((v, k) => res.setHeader(k, v)) + if (response.body) { + Readable.fromWeb(response.body as any).pipe(res) + } else { + res.end() + } +}).listen(port, () => { + console.log(`listening on http://localhost:${port}`) +}) diff --git a/examples/remix/basic/src/client.tsx b/examples/remix/basic/src/client.tsx new file mode 100644 index 00000000000..72680f0014f --- /dev/null +++ b/examples/remix/basic/src/client.tsx @@ -0,0 +1,23 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { createRoot, run } from '@remix-run/ui' +import { StartClient, hydrateStart } from '@tanstack/remix-start/client' + +const router = await hydrateStart() + +// Initialize the Remix UI runtime so `clientEntry()` islands hydrate. +// `loadModule(url, exportName)` must return the named export directly, +// not the module namespace — the runtime expects a function. +run({ + loadModule: async (url: string, exportName: string) => { + const mod = await import(/* @vite-ignore */ url) + return mod[exportName] + }, +}) + +// Mount the router's render tree against the existing SSR'd body. The +// document shell is server-only (see ``); on the client +// we hydrate against `document.body` since the route render tree is +// scoped to body content. +const root = createRoot(document.body) +root.render() diff --git a/examples/remix/basic/src/components/IslandCounter.tsx b/examples/remix/basic/src/components/IslandCounter.tsx new file mode 100644 index 00000000000..6b3b4832274 --- /dev/null +++ b/examples/remix/basic/src/components/IslandCounter.tsx @@ -0,0 +1,57 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { clientEntry, on } from '@remix-run/ui' +import type { Handle } from '@remix-run/ui' + +/** + * Standalone client-hydrated component (a "client entry"). The first + * argument is the entry id — the SSR pipeline emits a hydration marker + * with this id, and the client runtime mounts JUST this component + * against the marker. The rest of the page can stay non-interactive + * static HTML. + * + * The id syntax `#` tells the runtime which + * module to dynamically import and which export to instantiate. With + * the Vite plugin in play the URL gets resolved to the deployed chunk + * URL automatically. + */ +export const IslandCounter = clientEntry( + '/src/components/IslandCounter.tsx#IslandCounter', + function IslandCounter(handle: Handle<{ initial?: number; label?: string }>) { + let count = handle.props.initial ?? 0 + return ({ initial: _ignored, label = 'Count' }: { initial?: number; label?: string }) => ( +
+ {label}: {count}{' '} + {' '} + +
+ ) + }, +) diff --git a/examples/remix/basic/src/components/LabErrorComponent.tsx b/examples/remix/basic/src/components/LabErrorComponent.tsx new file mode 100644 index 00000000000..a7197071db5 --- /dev/null +++ b/examples/remix/basic/src/components/LabErrorComponent.tsx @@ -0,0 +1,33 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { on } from '@remix-run/ui' +import type { Handle } from '@remix-run/ui' + +/** + * Error component used by `/lab/*` routes. The render fn receives + * `{ error, reset }` from the framework — `reset()` calls + * `router.invalidate()` which retries loaders and clears the captured + * error state. + */ +export function LabErrorComponent( + _handle: Handle<{ error: unknown; reset: () => void }>, +) { + return ({ + error, + reset, + }: { + error: unknown + reset: () => void + }) => ( +
+

Caught error

+
{error instanceof Error ? error.message : String(error)}
+ +
+ ) +} diff --git a/examples/remix/basic/src/routeTree.gen.ts b/examples/remix/basic/src/routeTree.gen.ts new file mode 100644 index 00000000000..4e5b349bda5 --- /dev/null +++ b/examples/remix/basic/src/routeTree.gen.ts @@ -0,0 +1,461 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as UsersRouteImport } from './routes/users' +import { Route as SlowRouteImport } from './routes/slow' +import { Route as PostsRouteImport } from './routes/posts' +import { Route as LabRouteImport } from './routes/lab' +import { Route as GuestbookRouteImport } from './routes/guestbook' +import { Route as CounterRouteImport } from './routes/counter' +import { Route as CatalogRouteImport } from './routes/catalog' +import { Route as AdminRouteImport } from './routes/admin' +import { Route as IndexRouteImport } from './routes/index' +import { Route as UsersIdRouteImport } from './routes/users.$id' +import { Route as PostsSlugRouteImport } from './routes/posts.$slug' +import { Route as LabRenderErrorRouteImport } from './routes/lab.render-error' +import { Route as LabMissingRouteImport } from './routes/lab.missing' +import { Route as LabErrorRouteImport } from './routes/lab.error' +import { Route as AdminUsersRouteImport } from './routes/admin.users' +import { Route as AdminUsersUserIdRouteImport } from './routes/admin.users.$userId' +import { Route as AdminUsersUserIdSessionsSessionIdRouteImport } from './routes/admin.users.$userId.sessions.$sessionId' + +const UsersRoute = UsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => rootRouteImport, +} as any) +const SlowRoute = SlowRouteImport.update({ + id: '/slow', + path: '/slow', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const LabRoute = LabRouteImport.update({ + id: '/lab', + path: '/lab', + getParentRoute: () => rootRouteImport, +} as any) +const GuestbookRoute = GuestbookRouteImport.update({ + id: '/guestbook', + path: '/guestbook', + getParentRoute: () => rootRouteImport, +} as any) +const CounterRoute = CounterRouteImport.update({ + id: '/counter', + path: '/counter', + getParentRoute: () => rootRouteImport, +} as any) +const CatalogRoute = CatalogRouteImport.update({ + id: '/catalog', + path: '/catalog', + getParentRoute: () => rootRouteImport, +} as any) +const AdminRoute = AdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const UsersIdRoute = UsersIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => UsersRoute, +} as any) +const PostsSlugRoute = PostsSlugRouteImport.update({ + id: '/$slug', + path: '/$slug', + getParentRoute: () => PostsRoute, +} as any) +const LabRenderErrorRoute = LabRenderErrorRouteImport.update({ + id: '/render-error', + path: '/render-error', + getParentRoute: () => LabRoute, +} as any) +const LabMissingRoute = LabMissingRouteImport.update({ + id: '/missing', + path: '/missing', + getParentRoute: () => LabRoute, +} as any) +const LabErrorRoute = LabErrorRouteImport.update({ + id: '/error', + path: '/error', + getParentRoute: () => LabRoute, +} as any) +const AdminUsersRoute = AdminUsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => AdminRoute, +} as any) +const AdminUsersUserIdRoute = AdminUsersUserIdRouteImport.update({ + id: '/$userId', + path: '/$userId', + getParentRoute: () => AdminUsersRoute, +} as any) +const AdminUsersUserIdSessionsSessionIdRoute = + AdminUsersUserIdSessionsSessionIdRouteImport.update({ + id: '/sessions/$sessionId', + path: '/sessions/$sessionId', + getParentRoute: () => AdminUsersUserIdRoute, + } as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/admin': typeof AdminRouteWithChildren + '/catalog': typeof CatalogRoute + '/counter': typeof CounterRoute + '/guestbook': typeof GuestbookRoute + '/lab': typeof LabRouteWithChildren + '/posts': typeof PostsRouteWithChildren + '/slow': typeof SlowRoute + '/users': typeof UsersRouteWithChildren + '/admin/users': typeof AdminUsersRouteWithChildren + '/lab/error': typeof LabErrorRoute + '/lab/missing': typeof LabMissingRoute + '/lab/render-error': typeof LabRenderErrorRoute + '/posts/$slug': typeof PostsSlugRoute + '/users/$id': typeof UsersIdRoute + '/admin/users/$userId': typeof AdminUsersUserIdRouteWithChildren + '/admin/users/$userId/sessions/$sessionId': typeof AdminUsersUserIdSessionsSessionIdRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/admin': typeof AdminRouteWithChildren + '/catalog': typeof CatalogRoute + '/counter': typeof CounterRoute + '/guestbook': typeof GuestbookRoute + '/lab': typeof LabRouteWithChildren + '/posts': typeof PostsRouteWithChildren + '/slow': typeof SlowRoute + '/users': typeof UsersRouteWithChildren + '/admin/users': typeof AdminUsersRouteWithChildren + '/lab/error': typeof LabErrorRoute + '/lab/missing': typeof LabMissingRoute + '/lab/render-error': typeof LabRenderErrorRoute + '/posts/$slug': typeof PostsSlugRoute + '/users/$id': typeof UsersIdRoute + '/admin/users/$userId': typeof AdminUsersUserIdRouteWithChildren + '/admin/users/$userId/sessions/$sessionId': typeof AdminUsersUserIdSessionsSessionIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/admin': typeof AdminRouteWithChildren + '/catalog': typeof CatalogRoute + '/counter': typeof CounterRoute + '/guestbook': typeof GuestbookRoute + '/lab': typeof LabRouteWithChildren + '/posts': typeof PostsRouteWithChildren + '/slow': typeof SlowRoute + '/users': typeof UsersRouteWithChildren + '/admin/users': typeof AdminUsersRouteWithChildren + '/lab/error': typeof LabErrorRoute + '/lab/missing': typeof LabMissingRoute + '/lab/render-error': typeof LabRenderErrorRoute + '/posts/$slug': typeof PostsSlugRoute + '/users/$id': typeof UsersIdRoute + '/admin/users/$userId': typeof AdminUsersUserIdRouteWithChildren + '/admin/users/$userId/sessions/$sessionId': typeof AdminUsersUserIdSessionsSessionIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/admin' + | '/catalog' + | '/counter' + | '/guestbook' + | '/lab' + | '/posts' + | '/slow' + | '/users' + | '/admin/users' + | '/lab/error' + | '/lab/missing' + | '/lab/render-error' + | '/posts/$slug' + | '/users/$id' + | '/admin/users/$userId' + | '/admin/users/$userId/sessions/$sessionId' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/admin' + | '/catalog' + | '/counter' + | '/guestbook' + | '/lab' + | '/posts' + | '/slow' + | '/users' + | '/admin/users' + | '/lab/error' + | '/lab/missing' + | '/lab/render-error' + | '/posts/$slug' + | '/users/$id' + | '/admin/users/$userId' + | '/admin/users/$userId/sessions/$sessionId' + id: + | '__root__' + | '/' + | '/admin' + | '/catalog' + | '/counter' + | '/guestbook' + | '/lab' + | '/posts' + | '/slow' + | '/users' + | '/admin/users' + | '/lab/error' + | '/lab/missing' + | '/lab/render-error' + | '/posts/$slug' + | '/users/$id' + | '/admin/users/$userId' + | '/admin/users/$userId/sessions/$sessionId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AdminRoute: typeof AdminRouteWithChildren + CatalogRoute: typeof CatalogRoute + CounterRoute: typeof CounterRoute + GuestbookRoute: typeof GuestbookRoute + LabRoute: typeof LabRouteWithChildren + PostsRoute: typeof PostsRouteWithChildren + SlowRoute: typeof SlowRoute + UsersRoute: typeof UsersRouteWithChildren +} + +declare module '@tanstack/remix-router' { + interface FileRoutesByPath { + '/users': { + id: '/users' + path: '/users' + fullPath: '/users' + preLoaderRoute: typeof UsersRouteImport + parentRoute: typeof rootRouteImport + } + '/slow': { + id: '/slow' + path: '/slow' + fullPath: '/slow' + preLoaderRoute: typeof SlowRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/lab': { + id: '/lab' + path: '/lab' + fullPath: '/lab' + preLoaderRoute: typeof LabRouteImport + parentRoute: typeof rootRouteImport + } + '/guestbook': { + id: '/guestbook' + path: '/guestbook' + fullPath: '/guestbook' + preLoaderRoute: typeof GuestbookRouteImport + parentRoute: typeof rootRouteImport + } + '/counter': { + id: '/counter' + path: '/counter' + fullPath: '/counter' + preLoaderRoute: typeof CounterRouteImport + parentRoute: typeof rootRouteImport + } + '/catalog': { + id: '/catalog' + path: '/catalog' + fullPath: '/catalog' + preLoaderRoute: typeof CatalogRouteImport + parentRoute: typeof rootRouteImport + } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/$id': { + id: '/users/$id' + path: '/$id' + fullPath: '/users/$id' + preLoaderRoute: typeof UsersIdRouteImport + parentRoute: typeof UsersRoute + } + '/posts/$slug': { + id: '/posts/$slug' + path: '/$slug' + fullPath: '/posts/$slug' + preLoaderRoute: typeof PostsSlugRouteImport + parentRoute: typeof PostsRoute + } + '/lab/render-error': { + id: '/lab/render-error' + path: '/render-error' + fullPath: '/lab/render-error' + preLoaderRoute: typeof LabRenderErrorRouteImport + parentRoute: typeof LabRoute + } + '/lab/missing': { + id: '/lab/missing' + path: '/missing' + fullPath: '/lab/missing' + preLoaderRoute: typeof LabMissingRouteImport + parentRoute: typeof LabRoute + } + '/lab/error': { + id: '/lab/error' + path: '/error' + fullPath: '/lab/error' + preLoaderRoute: typeof LabErrorRouteImport + parentRoute: typeof LabRoute + } + '/admin/users': { + id: '/admin/users' + path: '/users' + fullPath: '/admin/users' + preLoaderRoute: typeof AdminUsersRouteImport + parentRoute: typeof AdminRoute + } + '/admin/users/$userId': { + id: '/admin/users/$userId' + path: '/$userId' + fullPath: '/admin/users/$userId' + preLoaderRoute: typeof AdminUsersUserIdRouteImport + parentRoute: typeof AdminUsersRoute + } + '/admin/users/$userId/sessions/$sessionId': { + id: '/admin/users/$userId/sessions/$sessionId' + path: '/sessions/$sessionId' + fullPath: '/admin/users/$userId/sessions/$sessionId' + preLoaderRoute: typeof AdminUsersUserIdSessionsSessionIdRouteImport + parentRoute: typeof AdminUsersUserIdRoute + } + } +} + +interface AdminUsersUserIdRouteChildren { + AdminUsersUserIdSessionsSessionIdRoute: typeof AdminUsersUserIdSessionsSessionIdRoute +} + +const AdminUsersUserIdRouteChildren: AdminUsersUserIdRouteChildren = { + AdminUsersUserIdSessionsSessionIdRoute: + AdminUsersUserIdSessionsSessionIdRoute, +} + +const AdminUsersUserIdRouteWithChildren = + AdminUsersUserIdRoute._addFileChildren(AdminUsersUserIdRouteChildren) + +interface AdminUsersRouteChildren { + AdminUsersUserIdRoute: typeof AdminUsersUserIdRouteWithChildren +} + +const AdminUsersRouteChildren: AdminUsersRouteChildren = { + AdminUsersUserIdRoute: AdminUsersUserIdRouteWithChildren, +} + +const AdminUsersRouteWithChildren = AdminUsersRoute._addFileChildren( + AdminUsersRouteChildren, +) + +interface AdminRouteChildren { + AdminUsersRoute: typeof AdminUsersRouteWithChildren +} + +const AdminRouteChildren: AdminRouteChildren = { + AdminUsersRoute: AdminUsersRouteWithChildren, +} + +const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren) + +interface LabRouteChildren { + LabErrorRoute: typeof LabErrorRoute + LabMissingRoute: typeof LabMissingRoute + LabRenderErrorRoute: typeof LabRenderErrorRoute +} + +const LabRouteChildren: LabRouteChildren = { + LabErrorRoute: LabErrorRoute, + LabMissingRoute: LabMissingRoute, + LabRenderErrorRoute: LabRenderErrorRoute, +} + +const LabRouteWithChildren = LabRoute._addFileChildren(LabRouteChildren) + +interface PostsRouteChildren { + PostsSlugRoute: typeof PostsSlugRoute +} + +const PostsRouteChildren: PostsRouteChildren = { + PostsSlugRoute: PostsSlugRoute, +} + +const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) + +interface UsersRouteChildren { + UsersIdRoute: typeof UsersIdRoute +} + +const UsersRouteChildren: UsersRouteChildren = { + UsersIdRoute: UsersIdRoute, +} + +const UsersRouteWithChildren = UsersRoute._addFileChildren(UsersRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AdminRoute: AdminRouteWithChildren, + CatalogRoute: CatalogRoute, + CounterRoute: CounterRoute, + GuestbookRoute: GuestbookRoute, + LabRoute: LabRouteWithChildren, + PostsRoute: PostsRouteWithChildren, + SlowRoute: SlowRoute, + UsersRoute: UsersRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.ts' +import type { createStart } from '@tanstack/remix-start' +declare module '@tanstack/remix-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/remix/basic/src/routeTree.ts b/examples/remix/basic/src/routeTree.ts new file mode 100644 index 00000000000..5375d297d79 --- /dev/null +++ b/examples/remix/basic/src/routeTree.ts @@ -0,0 +1,12 @@ +import { Route as RootRoute } from './routes/__root' +import { Route as IndexRoute } from './routes/index' +import { Route as UsersRoute } from './routes/users' +import { Route as UserDetailRoute } from './routes/users.$id' +import { Route as PostsRoute } from './routes/posts' +import { Route as PostDetailRoute } from './routes/posts.$slug' + +UsersRoute.addChildren([UserDetailRoute]) +PostsRoute.addChildren([PostDetailRoute]) +RootRoute.addChildren([IndexRoute, UsersRoute, PostsRoute]) + +export const routeTree = RootRoute diff --git a/examples/remix/basic/src/router.ts b/examples/remix/basic/src/router.ts new file mode 100644 index 00000000000..17920b0a115 --- /dev/null +++ b/examples/remix/basic/src/router.ts @@ -0,0 +1,13 @@ +import { createRouter } from '@tanstack/remix-router' +import { routeTree } from './routeTree.gen' + +/** + * Factory invoked once per request server-side, and once on the client. + * The Start vite plugin imports this via the `#tanstack-router-entry` + * virtual module — exporting `getRouter` is the convention. + */ +export function getRouter() { + return createRouter({ routeTree }) +} + +export type AppRouter = ReturnType diff --git a/examples/remix/basic/src/routes/__root.tsx b/examples/remix/basic/src/routes/__root.tsx new file mode 100644 index 00000000000..7beb91ad8e0 --- /dev/null +++ b/examples/remix/basic/src/routes/__root.tsx @@ -0,0 +1,59 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { Link, Outlet, createRootRoute } from '@tanstack/remix-router' +import type { Handle } from '@remix-run/ui' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charset: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'TanStack Router on Remix 3' }, + ], + }), + component: RootComponent, +}) + +/** + * Root route component renders only the body content. The document + * shell (`//` plus `` and ``) + * is owned by `` in `@tanstack/remix-start/server` so the + * SSR tree's root is the shell but the client hydrates one level + * deeper into `document.body`. + */ +function RootComponent(_handle: Handle) { + return () => ( +
+ + +
+ ) +} diff --git a/examples/remix/basic/src/routes/admin.tsx b/examples/remix/basic/src/routes/admin.tsx new file mode 100644 index 00000000000..7bebfa45428 --- /dev/null +++ b/examples/remix/basic/src/routes/admin.tsx @@ -0,0 +1,33 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { Link, Outlet, createFileRoute } from '@tanstack/remix-router' +import { Route as RootRoute } from './__root' +import type { Handle } from '@remix-run/ui' + +/** + * Layer 1 of a four-deep layout chain. Each layer renders its own + * heading, an optional sub-nav, and `` so the resulting DOM + * makes nesting depth obvious. This route exercises: + * + * - `` resolving `parentMatchId` at four depths simultaneously + * - `` propagating the active matchId at each level + * (without the in-render fix, the inner Outlets returned null) + * - `subscribeDynamicStore` re-binding when leaf params change + */ +function AdminLayout(_handle: Handle) { + return () => ( +
+

Admin

+ + +
+ ) +} + +export const Route = createFileRoute('/admin')({ + getParentRoute: () => RootRoute, + path: '/admin', + component: AdminLayout, +}) diff --git a/examples/remix/basic/src/routes/admin.users.$userId.sessions.$sessionId.tsx b/examples/remix/basic/src/routes/admin.users.$userId.sessions.$sessionId.tsx new file mode 100644 index 00000000000..977ff699a48 --- /dev/null +++ b/examples/remix/basic/src/routes/admin.users.$userId.sessions.$sessionId.tsx @@ -0,0 +1,41 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { useParams, createFileRoute } from '@tanstack/remix-router' +import { Route as AdminUserDetailRoute } from './admin.users.$userId' +import type { Handle } from '@remix-run/ui' + +/** + * Leaf at depth 4 — `__root → admin → admin/users → admin/users/$userId + * → admin/users/$userId/sessions/$sessionId`. Reading the params object + * here exercises `useParams` against the full path-params union; the + * matchId-based reactivity sits four levels deep through reused + * `` and `` instances. + */ +function SessionDetail(handle: Handle) { + const params = useParams(handle, { + from: '/admin/users/$userId/sessions/$sessionId', + }) + return () => { + const p = params() + return ( +
+

Session detail

+

+ User: {p?.userId} +

+

+ Session: {p?.sessionId} +

+

+ (Synthetic data — params drive the entire view.) +

+
+ ) + } +} + +export const Route = createFileRoute('/admin/users/$userId/sessions/$sessionId')({ + getParentRoute: () => AdminUserDetailRoute, + path: 'sessions/$sessionId', + component: SessionDetail, +}) diff --git a/examples/remix/basic/src/routes/admin.users.$userId.tsx b/examples/remix/basic/src/routes/admin.users.$userId.tsx new file mode 100644 index 00000000000..6962641b40f --- /dev/null +++ b/examples/remix/basic/src/routes/admin.users.$userId.tsx @@ -0,0 +1,58 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { Link, Outlet, useLoaderData, useParams, createFileRoute } from '@tanstack/remix-router' +import { Route as AdminUsersRoute } from './admin.users' +import type { Handle } from '@remix-run/ui' + +interface AdminUserDetail { + id: number + name: string + sessions: Array<{ id: string; lastSeen: string }> +} + +function AdminUserDetailLayout(handle: Handle) { + const params = useParams(handle, { from: '/admin/users/$userId' }) + const readUser = useLoaderData(handle, { from: '/admin/users/$userId' }) + return () => { + const id = params()?.userId ?? '?' + const user = readUser() as AdminUserDetail | undefined + return ( +
+

+ User #{id} ({user?.name ?? '…'}) +

+

Sessions:

+
    + {(user?.sessions ?? []).map((s) => ( +
  • + + {s.id} — last seen {s.lastSeen} + +
  • + ))} +
+ +
+ ) + } +} + +export const Route = createFileRoute('/admin/users/$userId')({ + getParentRoute: () => AdminUsersRoute, + path: '$userId', + loader: ({ params }: { params: { userId: string } }) => { + const id = Number(params.userId) + return { + id, + name: `Operator #${id}`, + sessions: [ + { id: `s${id}-a`, lastSeen: '5m ago' }, + { id: `s${id}-b`, lastSeen: '2h ago' }, + ], + } satisfies AdminUserDetail + }, + component: AdminUserDetailLayout, +}) diff --git a/examples/remix/basic/src/routes/admin.users.tsx b/examples/remix/basic/src/routes/admin.users.tsx new file mode 100644 index 00000000000..02463ccac64 --- /dev/null +++ b/examples/remix/basic/src/routes/admin.users.tsx @@ -0,0 +1,47 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { Link, Outlet, useLoaderData, createFileRoute } from '@tanstack/remix-router' +import { Route as AdminRoute } from './admin' +import type { Handle } from '@remix-run/ui' + +interface AdminUserStub { + id: number + name: string +} + +const SEED: Array = [ + { id: 100, name: 'Operator Alice' }, + { id: 101, name: 'Operator Bob' }, +] + +function AdminUsersLayout(handle: Handle) { + const readUsers = useLoaderData(handle, { from: '/admin/users' }) + return () => { + const users = (readUsers() as Array) ?? [] + return ( +
+

Users (admin)

+
    + {users.map((u) => ( +
  • + + {u.name} + +
  • + ))} +
+ +
+ ) + } +} + +export const Route = createFileRoute('/admin/users')({ + getParentRoute: () => AdminRoute, + path: 'users', + loader: () => SEED, + component: AdminUsersLayout, +}) diff --git a/examples/remix/basic/src/routes/catalog.tsx b/examples/remix/basic/src/routes/catalog.tsx new file mode 100644 index 00000000000..f2faca8226d --- /dev/null +++ b/examples/remix/basic/src/routes/catalog.tsx @@ -0,0 +1,156 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { Link, useLoaderData, useNavigate, useSearch, createFileRoute } from '@tanstack/remix-router' +import { on } from '@remix-run/ui' +import { Route as RootRoute } from './__root' +import type { Handle } from '@remix-run/ui' + +interface CatalogSearch { + q: string + page: number + sort: 'asc' | 'desc' +} + +const ITEMS = [ + 'Apricot', + 'Blueberry', + 'Cherry', + 'Date', + 'Elderberry', + 'Fig', + 'Grape', + 'Huckleberry', + 'Iyokan', + 'Jackfruit', +] + +/** + * Search-params driven view. Demonstrates: + * - `validateSearch` parsing URL query string into a typed shape + * - `loaderDeps` selecting which params force a refetch (q + page + sort) + * - ` …}>` updating a single param via a + * functional updater + * - A controlled form with `on('submit', …)` calling `useNavigate` + * + * The reactivity flowing through `useSearch` exercises the same atom + * subscribe path as `` — any URL change emits, the binding's + * adapted teardown disconnects on unmount. + */ +function CatalogPage(handle: Handle) { + const readSearch = useSearch(handle, { from: '/catalog' }) + const readData = useLoaderData(handle, { from: '/catalog' }) + const navigate = useNavigate(handle) + + let pendingQ = '' + + return () => { + const search = readSearch() as CatalogSearch + const data = readData() as { items: Array; total: number } | undefined + + return ( +
+

Catalog

+

+ Current search:{' '} + q={search.q || '(none)'}{' '} + page={search.page}{' '} + sort={search.sort} +

+ +
('submit', (e: SubmitEvent) => { + e.preventDefault() + void navigate({ + to: '/catalog', + search: (prev: CatalogSearch) => ({ + ...prev, + q: pendingQ, + page: 1, + }), + }) + }), + ]} + > + ('input', (e: InputEvent) => { + pendingQ = (e.target as HTMLInputElement).value + }), + ]} + /> + +
+ +

+ ({ + ...prev, + sort: prev.sort === 'asc' ? 'desc' : 'asc', + })} + > + Toggle sort ({search.sort}) + + {' · '} + ({ + ...prev, + page: Math.max(1, prev.page - 1), + })} + > + ← Prev + + {' · '} + ({ ...prev, page: prev.page + 1 })} + > + Next → + +

+ +

+ + Showing page {search.page} of {data?.total ?? '?'} items + {search.q ? ` matching "${search.q}"` : ''}. + +

+
    + {(data?.items ?? []).map((item) =>
  1. {item}
  2. )} +
+
+ ) + } +} + +export const Route = createFileRoute('/catalog')({ + getParentRoute: () => RootRoute, + path: '/catalog', + validateSearch: (raw: Record): CatalogSearch => ({ + q: typeof raw.q === 'string' ? raw.q : '', + page: typeof raw.page === 'number' ? raw.page : 1, + sort: raw.sort === 'desc' ? 'desc' : 'asc', + }), + loaderDeps: ({ search }: { search: CatalogSearch }) => ({ + q: search.q, + page: search.page, + sort: search.sort, + }), + loader: ({ deps }: { deps: { q: string; page: number; sort: 'asc' | 'desc' } }) => { + const filtered = ITEMS.filter((item) => + item.toLowerCase().includes(deps.q.toLowerCase()), + ) + const sorted = [...filtered].sort((a, b) => + deps.sort === 'asc' ? a.localeCompare(b) : b.localeCompare(a), + ) + const start = (deps.page - 1) * 3 + const items = sorted.slice(start, start + 3) + return { items, total: sorted.length } + }, + component: CatalogPage, +}) diff --git a/examples/remix/basic/src/routes/counter.tsx b/examples/remix/basic/src/routes/counter.tsx new file mode 100644 index 00000000000..24833a64afb --- /dev/null +++ b/examples/remix/basic/src/routes/counter.tsx @@ -0,0 +1,37 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { createFileRoute } from '@tanstack/remix-router' +import { IslandCounter } from '../components/IslandCounter' +import { Route as RootRoute } from './__root' +import type { Handle } from '@remix-run/ui' + +function CounterPage(_handle: Handle) { + return () => ( +
+

Interactive island

+

+ The route component is server-rendered static HTML. The counter + below is a clientEntry()-marked island — only it + hydrates on the client, the rest stays static. Click "+" to + verify the island is alive without re-running the route loader + or re-mounting the surrounding tree. +

+
+ +
+

+ + Each `+` click runs purely on the client — no network round + trip, no router re-render. The island state is local to the + component. + +

+
+ ) +} + +export const Route = createFileRoute('/counter')({ + getParentRoute: () => RootRoute, + path: '/counter', + component: CounterPage, +}) diff --git a/examples/remix/basic/src/routes/guestbook.tsx b/examples/remix/basic/src/routes/guestbook.tsx new file mode 100644 index 00000000000..9f1311d8e2b --- /dev/null +++ b/examples/remix/basic/src/routes/guestbook.tsx @@ -0,0 +1,128 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @remix-run/ui */ +import { useLoaderData, createFileRoute } from '@tanstack/remix-router' +import { on } from '@remix-run/ui' +import { Route as RootRoute } from './__root' +import { + type GuestbookEntry, + addEntry, + listEntries, +} from '../server/guestbook' +import type { Handle } from '@remix-run/ui' + +function GuestbookPage(handle: Handle) { + const readEntries = useLoaderData(handle, { from: '/guestbook' }) + let pendingName = '' + let pendingMessage = '' + let localEntries: Array | undefined + let submitting = false + let submitError: string | undefined + + return () => { + const entries = (localEntries ?? (readEntries() as Array)) ?? [] + + return ( +
+

Guestbook

+

+ + Posts are server-validated via createServerFn +{' '} + .inputValidator; the form below calls the server + function directly on submit. + +

+ +
('submit', async (e: SubmitEvent) => { + e.preventDefault() + if (submitting) return + submitting = true + submitError = undefined + void handle.update() + try { + const next = await addEntry({ + data: { name: pendingName, message: pendingMessage }, + }) + localEntries = next + pendingName = '' + pendingMessage = '' + ;(e.target as HTMLFormElement).reset() + } catch (err) { + submitError = + err instanceof Error ? err.message : String(err) + } finally { + submitting = false + void handle.update() + } + }), + ]} + > +

+ ('input', (e: InputEvent) => { + pendingName = (e.target as HTMLInputElement).value + }), + ]} + /> +

+

+