From b55b96e6831700f39ca8a7470972ea949983ff76 Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Thu, 9 Apr 2026 14:03:04 +0300 Subject: [PATCH 1/2] improve docs and cli --- README.md | 173 ++++-- docs/getting-started-react-router.md | 263 +++++++++ docs/getting-started-tanstack-router.md | 297 ++++++++++ docs/shell-patterns-react-router.md | 204 +++++++ docs/shell-patterns-tanstack-router.md | 209 +++++++ docs/shell-patterns.md | 442 +++++++++++++++ docs/workspace-patterns.md | 518 ++++++++++++++++++ .../src/commands/create-module.ts | 11 +- .../react-router-cli/src/commands/init.ts | 23 +- .../src/templates/app-shared.ts | 11 +- .../react-router-cli/src/templates/module.ts | 51 +- .../react-router-cli/src/templates/shell.ts | 150 +++-- packages/react-router-cli/test/cli.test.ts | 6 +- .../src/commands/create-module.ts | 11 +- .../tanstack-router-cli/src/commands/init.ts | 23 +- .../src/templates/app-shared.ts | 23 +- .../src/templates/module.ts | 52 +- .../src/templates/shell.ts | 149 +++-- packages/tanstack-router-cli/test/cli.test.ts | 6 +- 19 files changed, 2487 insertions(+), 135 deletions(-) create mode 100644 docs/getting-started-react-router.md create mode 100644 docs/getting-started-tanstack-router.md create mode 100644 docs/shell-patterns-react-router.md create mode 100644 docs/shell-patterns-tanstack-router.md create mode 100644 docs/shell-patterns.md create mode 100644 docs/workspace-patterns.md diff --git a/README.md b/README.md index 2296818..c0528fe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,124 @@ # modular-react -A framework for building modular React applications with pluggable routing. Define self-contained modules that declare their routes, navigation, slot contributions, and dependencies — then assemble them into a running app via a registry. +You already use React Router or TanStack Router. **modular-react** lets you split your app into self-contained modules that each declare their own routes, navigation items, slot contributions, and dependencies — then composes them at startup through a typed registry. + +The two router integrations are peers — pick the one that matches the router you already ship. + +## The problem this solves + +In a router-only setup, every new feature adds entries in `App.tsx`, the sidebar config, the command palette registry, the auth guard list, and wherever else cross-cutting state lives. Four teams editing those same files means constant merge conflicts and no clear ownership. Deleting a feature means hunting its fragments across a dozen places. + +modular-react lets each feature own a single `modules//` directory that fully declares its routes, nav items, commands, zone contributions, and dependencies. The shell never has to know about any specific module — it just registers them and the runtime wires everything together. Adding a feature is `create module`; deleting one is removing a directory and one `registry.register(...)` call. + +Good for: plugin-style apps, apps where many teams contribute features, and apps that have grown past the point where one `App.tsx` is still comfortable to edit. + +## What a running app looks like + +``` +┌──────────┬────────────────────────────────────────────────┐ +│ │ [Refresh Billing] [Export Invoices] [user] │ ← header slot: slots.commands +│ Sidebar ├──────────────────────────────┬─────────────────┤ +│ │ │ │ +│ Dashboard│ │ │ +│ Billing │ Main outlet (active │ Detail panel │ +│ Users │ module's route component) │ (AppZones. │ +│ │ │ detailPanel, │ +│ (items │ │ filled by │ +│ from │ │ active route) │ +│ every │ │ │ +│ module)│ │ │ +└──────────┴──────────────────────────────┴─────────────────┘ + ↑ ↑ + navigation: [...] handle / staticData: + from every module { detailPanel: ... } +``` + +- The **sidebar** is built from every module's `navigation` array. +- The **header commands** are collected from every module's `slots.commands`. +- The **detail panel** (and any other zones you define) is filled by whichever module owns the active route. Navigate away, and a different module's contribution takes over — or the panel hides entirely. + +## Project status + +- `@react-router-modules/*` — **v2.x**, considered stable for the APIs documented in the guides below. +- `@tanstack-react-modules/*` — **v1.x**, considered stable for the APIs documented in the guides below. +- `@modular-react/{core,react,testing}` — the shared foundation, versioned independently at `0.x`. Breaking changes, when they happen, flow through the router-integration majors. + +All packages target **React 19**, **Node 22+**, and **pnpm** workspaces. See each getting-started guide for the full pinned version set. + +## Quickstart + +```bash +# React Router +npx @react-router-modules/cli init my-app --scope @myorg --module dashboard + +# TanStack Router +npx @tanstack-react-modules/cli init my-app --scope @myorg --module dashboard + +cd my-app && pnpm install && pnpm dev +``` + +For the walkthrough of what the scaffold produces and how to extend it, see the getting-started guide for your router: + +- [Getting started — React Router](docs/getting-started-react-router.md) +- [Getting started — TanStack Router](docs/getting-started-tanstack-router.md) + +> **Package manager:** the scaffold produces a **pnpm workspace**. Yarn Berry and Bun will work after scaffolding with minor script edits; **npm is not supported** because it doesn't implement the `workspace:*` protocol. Turborepo is orthogonal — run it on top of pnpm. See the getting-started guides for details. + +## Guides + +Conceptual documentation for building apps with the framework. Start with a getting-started guide, then dig into the shell patterns once you want to go beyond the defaults. + +| Guide | What it covers | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| [Getting started — React Router](docs/getting-started-react-router.md) | Scaffold, tour the generated workspace, add modules and stores, turn on the auth guard. | +| [Getting started — TanStack Router](docs/getting-started-tanstack-router.md) | Same walkthrough for the TSR integration, including the `staticData` type augmentation and `beforeLoad` auth guard. | +| [Shell Patterns (Fundamentals)](docs/shell-patterns.md) | Multi-zone layouts, command palette, module-to-shell communication, headless modules, optional deps, cross-store coordination. | +| [Shell Patterns — React Router](docs/shell-patterns-react-router.md) | Module route shape, route zones via `handle`, `authenticatedRoute` with `loader`, public `shellRoutes`. | +| [Shell Patterns — TanStack Router](docs/shell-patterns-tanstack-router.md) | Module route shape with `createRoute`/`getParentRoute`, route zones via `staticData`, `authenticatedRoute` with `beforeLoad`. | +| [Workspace Patterns](docs/workspace-patterns.md) | Tabbed workspaces, component-only modules, `useActiveZones`, per-session state via `createScopedStore`. | + +## What the code looks like + +Modules are plain objects describing everything a feature contributes: + +```typescript +import { defineModule } from "@react-router-modules/core"; // or @tanstack-react-modules/core + +export default defineModule({ + id: "billing", + version: "1.0.0", + createRoutes: () => [{ path: "billing", Component: BillingPage }], + navigation: [{ label: "Billing", to: "/billing", group: "finance" }], + slots: { commands: [{ id: "export", label: "Export Invoices", onSelect: exportInvoices }] }, + dynamicSlots: (deps) => ({ + commands: deps.auth.user?.isAdmin ? [{ id: "void", label: "Void Invoice", onSelect: voidInvoice }] : [], + }), +}); +``` + +The shell assembles modules into a running app via a registry: + +```typescript +import { createRegistry } from "@react-router-modules/runtime"; + +const registry = createRegistry({ + stores: { auth: authStore }, + services: { httpClient }, +}); + +registry.register(billingModule); +registry.register(usersModule); + +const { App, recalculateSlots } = registry.resolve({ + rootComponent: RootLayout, + indexComponent: HomePage, + authenticatedRoute: { loader: requireAuth, Component: ShellLayout }, +}); + +// When a store that `dynamicSlots` depends on changes, call recalculateSlots() +// to re-run the factories and update the visible slot contributions. +authStore.subscribe(recalculateSlots); +``` ## Packages @@ -50,57 +168,23 @@ Router-specific layers: cli (scaffolding) cli (scaffolding) ``` -Modules define their contributions declaratively: - -```typescript -import { defineModule } from "@react-router-modules/core"; // or @tanstack-react-modules/core - -export default defineModule({ - id: "billing", - version: "1.0.0", - createRoutes: () => [{ path: "billing", Component: BillingPage }], - navigation: [{ label: "Billing", to: "/billing", group: "finance" }], - slots: { commands: [{ id: "export", label: "Export Invoices" }] }, - dynamicSlots: (deps) => ({ - commands: deps.auth.user?.isAdmin ? [{ id: "void", label: "Void Invoice" }] : [], - }), -}); -``` - -The registry assembles modules into a running app: - -```typescript -import { createRegistry } from "@react-router-modules/runtime"; +## CLI command reference -const registry = createRegistry({ - stores: { auth: authStore }, - services: { httpClient }, -}); - -registry.register(billingModule); -registry.register(usersModule); - -const { App, recalculateSlots } = registry.resolve({ - rootComponent: Layout, - indexComponent: HomePage, -}); -``` - -## CLI reference - -Both router integrations ship a scaffolding CLI: +Both router integrations ship a `reactive` CLI binary with the same command surface. The getting-started guides cover the common case; this section lists every command. ```bash -# Initialize a new project +# Initialize a new project (see getting-started guides for the full walkthrough) reactive init my-app --scope @myorg --module dashboard # Add a module with routes -reactive create module billing --route billing +reactive create module billing --route billing [--nav-group finance] -# Add a headless store module +# Add a headless store wired into AppDependencies reactive create store notifications ``` +Run any command with `--help` for its full flag set. To invoke without installing the CLI, use `npx @react-router-modules/cli ` or `npx @tanstack-react-modules/cli `. + ## Development ```bash @@ -108,3 +192,8 @@ pnpm install pnpm build # Build all packages pnpm test # Run all tests ``` + +## Help & contributing + +- **Questions or bugs:** open an issue at [kibertoad/modular-react](https://github.com/kibertoad/modular-react/issues). +- **Pull requests** are welcome — start with an issue for anything beyond a typo fix so we can agree on the direction. diff --git a/docs/getting-started-react-router.md b/docs/getting-started-react-router.md new file mode 100644 index 0000000..68cc13e --- /dev/null +++ b/docs/getting-started-react-router.md @@ -0,0 +1,263 @@ +# Getting started — React Router + +This guide walks you from zero to a running modular React Router app. It assumes you already use (or are comfortable with) React Router v7 and want to split your app into self-contained feature modules. + +For router-agnostic fundamentals, see [Shell Patterns](./shell-patterns.md). For the React Router–specific mechanics (zones via `handle`, `authenticatedRoute`, public `shellRoutes`), see [Shell Patterns — React Router](./shell-patterns-react-router.md). + +## Prerequisites + +- **Node 22+** and **pnpm** +- **React 19**, **React Router v7**, **zustand 5** — the versions the scaffold pins +- Familiarity with React Router v7 route objects + +You don't need an existing project — the CLI scaffolds one for you. Already have a React Router app? Scaffold a throwaway first to see the structure, then follow the [migration sketch](#migrating-an-existing-app) at the bottom of this guide. + +### About the package manager + +The scaffold produces a **pnpm workspace** — `pnpm-workspace.yaml`, `workspace:*` dependencies, and scripts that use `pnpm --filter` / `pnpm -r`. This is the supported setup. + +- **Yarn Berry (v2+)** and **Bun** both understand the `workspace:*` protocol and can be used after scaffolding if you rename `pnpm-workspace.yaml` to a `workspaces` field in the root `package.json` and rewrite the scripts. Nothing in the runtime or CLI is pnpm-specific beyond the scaffold output. +- **npm is not supported.** npm doesn't implement the `workspace:*` protocol, so `npm install` in a scaffolded project will fail to resolve the workspace packages. +- **Turborepo** is orthogonal — it runs on top of any package manager. If you use Turborepo, keep pnpm underneath and add `turbo.json` afterwards. + +## Mental model + +Three roles, one contract: + +- **Shell** (`shell/`) — the host app. Owns stores, services, layouts, the registry, and `main.tsx`. The shell is where you run the app from. +- **Modules** (`modules//`) — self-contained feature packages. Each module describes everything it contributes: routes, navigation items, commands, zone fills, and the dependencies it needs from the shell. A module never imports from the shell. +- **app-shared** (`app-shared/`) — the typed contract between the two. It declares three interfaces every module is generic over: + - **`AppDependencies`** — the stores and services the shell provides (auth store, config store, http client, …). Modules read from these via typed hooks. + - **`AppSlots`** — the static contributions the shell collects across all modules (e.g. a `commands` bar). + - **`AppZones`** — per-route layout regions a module can fill (e.g. a detail panel on the right). The active route's contributions are what the shell renders. + +Every module signature looks like `defineModule({ … })`. That's how TypeScript catches a module asking for a store the shell doesn't provide — at compile time. + +## 1. Scaffold a project + +```bash +npx @react-router-modules/cli init my-app --scope @myorg --module dashboard +cd my-app +pnpm install +pnpm dev +``` + +- `my-app` is the project (and root package) name. +- `--scope @myorg` is the npm scope used for workspace package names (`@myorg/app-shared`, `@myorg/dashboard-module`, …). Pick something unique to your org; it never has to be published. +- `--module dashboard` seeds the first feature module. Omit the flag to be prompted interactively. + +The CLI creates a pnpm workspace with three sub-packages and wires them together. After `pnpm dev` the shell boots on Vite's default port (5173). + +### What you see on first run + +Open [`http://localhost:5173`](http://localhost:5173). You'll land on the **Home** page with a "Login as Demo User" button in the header. The sidebar already shows the dashboard module's navigation entries — the scaffold ships with a no-op auth guard, so module routes are reachable even before you log in, but pages that read the auth store will show "Please log in …" until you click the login button. + +Click **Login as Demo User**, then navigate to **Dashboard List** in the sidebar. You'll see the list page in the main area and a detail panel on the right. Navigate back to **Dashboard** and the detail panel disappears — it's contributed by the list route, not the module. + +## 2. What you got + +``` +my-app/ +├── app-shared/ # Shared contract: AppDependencies, AppSlots, AppZones, typed hooks +│ └── src/ +│ ├── index.ts # The contract — the single place all modules depend on +│ └── types.ts # Domain types (User, LoginCredentials, …) +├── shell/ # The host app — owns stores, services, layouts, main.tsx +│ └── src/ +│ ├── main.tsx # Registry wiring + app bootstrap +│ ├── components/ +│ │ ├── RootLayout.tsx # Runs for every route (public + protected) +│ │ ├── ShellLayout.tsx # Authenticated chrome (sidebar, header, detail panel) +│ │ ├── Sidebar.tsx # Navigation built from module contributions +│ │ └── Home.tsx # Index route +│ ├── stores/ +│ │ ├── auth.ts # Zustand store — login / logout / user +│ │ └── config.ts # Zustand store — apiBaseUrl, env, appName +│ └── services/ +│ └── http-client.ts # Wretch instance, auth-aware via defer() +└── modules/ + └── dashboard/ # The first feature module + └── src/ + ├── index.ts # defineModule(...) — the module descriptor + ├── pages/ + │ ├── DashboardDashboard.tsx # "Dashboard" — the index page convention + │ └── DashboardList.tsx + └── panels/ + └── DetailPanel.tsx # Contributed into the shell's detail panel zone +``` + +### The `Dashboard.tsx` convention + +The scaffold names every module's index page `Dashboard.tsx`. A module called `dashboard` yields `DashboardDashboard.tsx`; a module called `billing` would yield `BillingDashboard.tsx`. It's just a naming convention — rename the file and update the `lazy()` import in the descriptor if you'd rather have something else. + +### The `-module` suffix + +Packages under `modules/` are published as `@myorg/-module`. The suffix is there so module packages never collide with the non-module packages you already have in your workspace (or plan to add). When the shell imports from them, it uses the full package name: `import dashboard from '@myorg/dashboard-module'`. + +### Choice of HTTP client + +The scaffold bakes in [`wretch`](https://github.com/elbywan/wretch) + [`@lokalise/frontend-http-client`](https://github.com/lokalise/frontend-http-client) as the `httpClient` type in `AppDependencies`. Swap it for `fetch`, `axios`, `ky`, or anything else — just change the type in `app-shared/src/index.ts` and the implementation in `shell/src/services/http-client.ts`. The framework doesn't care. + +## 3. Tour the first module + +Open `modules/dashboard/src/index.ts`. This is the entire module definition: + +```typescript +import { defineModule } from '@react-router-modules/core' +import type { RouteObject } from 'react-router' +import type { AppDependencies, AppSlots, AppZones } from '@myorg/app-shared' +import { DashboardDetailPanel } from './panels/DetailPanel.js' + +export default defineModule({ + id: 'dashboard', + version: '0.1.0', + + meta: { + name: 'Dashboard', + description: 'Dashboard module', + category: 'general', + }, + + createRoutes: (): RouteObject => ({ + path: 'dashboard', + children: [ + { index: true, lazy: () => import('./pages/DashboardDashboard.js').then((m) => ({ Component: m.default })) }, + { + path: 'list', + lazy: () => import('./pages/DashboardList.js').then((m) => ({ Component: m.default })), + handle: { detailPanel: DashboardDetailPanel } satisfies AppZones, + }, + ], + }), + + navigation: [ + { label: 'Dashboard', to: '/dashboard', order: 10 }, + { label: 'Dashboard List', to: '/dashboard/list', order: 11 }, + ], + + slots: { + commands: [ + { + id: 'dashboard:refresh', + label: 'Refresh Dashboard', + group: 'actions', + onSelect: () => window.location.reload(), + }, + ], + }, + + requires: ['auth'], +}) +``` + +A single object describes everything the module contributes: + +- **`meta`** — catalog info the shell can read via `useModules()` and `getModuleMeta()`. +- **`createRoutes`** — a standard React Router `RouteObject` subtree. The runtime mounts it under `authenticatedRoute` so the whole module sits behind whatever auth guard the shell decides. `createRoutes` is **optional** — headless modules (stores, commands, zones only, no routes) simply omit it. +- **`navigation`** — items the `` in the shell picks up via `useNavigation()`. No manual registration. +- **`slots.commands`** — commands the `` header renders as buttons. The demo command reloads the page; replace it with anything callable. +- **`handle: { detailPanel: ... } satisfies AppZones`** — a **route zone**. When `/dashboard/list` is active, the shell reads `useZones().detailPanel` and renders `DashboardDetailPanel` in its right-hand panel. Navigate away and the panel disappears. See [Shell Patterns — React Router § Route Zones](./shell-patterns-react-router.md#route-zones). +- **`requires: ['auth']`** — the registry fails fast at resolve time if the `auth` store isn't provided to it. This is how modules declare their dependencies on shell-provided state. + +Visit `/dashboard/list` in the running app. You'll see the list page in the main area and the detail panel on the right. Navigate back to `/dashboard` and the panel goes away. + +## 4. Add a second module + +From the project root: + +```bash +npx @react-router-modules/cli create module billing --route billing +pnpm install +``` + +(If you prefer not to retype the package name, add `@react-router-modules/cli` to the root `devDependencies` and use `pnpm exec reactive create module billing --route billing`.) + +The `create module` command generates `modules/billing/` with the same structure as `dashboard` (plus a starter vitest test under `src/__tests__/`), adds `@myorg/billing-module` to `shell/package.json`, and wires `registry.register(billing)` into `shell/src/main.tsx`. **The `pnpm install` is not optional** — without it, the new workspace package isn't linked and `pnpm dev` will fail to resolve `@myorg/billing-module`. + +Restart `pnpm dev`. You'll see: + +- A new **Billing** group in the sidebar (or merged into an existing group if you pass `--nav-group`) +- A **Refresh Billing** button in the header's command bar +- A detail panel on `/billing/list` + +No edits to the shell required — the registry discovers everything from the module descriptor. + +## 5. Add a store + +Stores are the reactive state surface shared across modules. Add one with: + +```bash +npx @react-router-modules/cli create store notifications +``` + +This: + +1. Writes `shell/src/stores/notifications.ts` with a Zustand vanilla store. +2. Adds a `NotificationsStore` interface and `notifications: NotificationsStore` field to `AppDependencies` in `app-shared/src/index.ts`. +3. Registers the store with the registry in `shell/src/main.tsx`. + +After the CLI finishes, open the generated store file and fill in the state shape and actions. Any module can consume it with `useStore('notifications', (s) => s.unreadCount)` — the `useStore` hook is the typed one exported from `@myorg/app-shared`, so it knows the store exists and what shape it has. + +If a module `requires: ['notifications']` and you remove the store, the registry will throw at resolve time, before the app ever boots. + +## 6. Turn on the auth guard + +The scaffold ships with a no-op auth guard so the app runs immediately. Open `shell/src/main.tsx` — you'll find: + +```typescript +authenticatedRoute: { + loader: () => { + // TODO: replace with real auth check. Example: + // const { isAuthenticated } = authStore.getState() + // if (!isAuthenticated) throw redirect('/login') + return null + }, + Component: ShellLayout, +}, + +// shellRoutes: () => [ +// { path: '/login', Component: LoginPage }, +// ], +``` + +To make the guard real: + +1. Replace the `loader` body with an actual check that throws `redirect('/login')` (or whichever unauthenticated destination you want) when the user isn't signed in. +2. Uncomment `shellRoutes` and return one or more public routes. `shellRoutes` sits **outside** `authenticatedRoute`, so routes returned from it never hit the loader. +3. Build a real login page that calls `authStore.getState().login(...)` and navigates back to `/`. + +For the full pattern — layout route as auth boundary, loader vs. useEffect, per-module role guards — see [Shell Patterns — React Router § Auth Guard Pattern](./shell-patterns-react-router.md#auth-guard-pattern). + +## Migrating an existing app + +If you already have a React Router v7 app, you don't throw it away — you refactor it in place. There's no automated migration command; the work is mechanical but has to be done module-by-module. Here's the sketch: + +1. **Scaffold a throwaway project first.** Run the `init` command in a sibling directory so you have a working reference for the three-package layout, the `main.tsx` wiring, and the scaffolded `app-shared` contract. Keep it open while you migrate. +2. **Carve out `app-shared` in your repo.** Create a new workspace package (`app-shared/` or `@yourorg/app-shared`) and copy the scaffolded `index.ts` + `types.ts` as a starting point. Replace the sample store interfaces (`AuthStore`, `ConfigStore`) with the actual shapes of the stores you already have. The goal: `app-shared` becomes the single place your existing code and future modules both import types from. +3. **Move your existing stores and services under `shell/`.** They probably live in `src/stores/` and `src/services/` today — move them into `shell/src/stores/` and `shell/src/services/` with minimal changes. Wire them into `createRegistry({ stores, services })`. +4. **Pick the smallest feature and extract it as the first module.** A single feature folder with two or three routes is ideal. Create `modules//`, copy the routes out of your current `App.tsx` into `createRoutes()`, move any nav entries into `navigation: [...]`, and register the module with `registry.register(feature)`. Delete the old code paths. +5. **Repeat.** Each subsequent feature extraction is smaller than the last because the shell and `app-shared` stop growing. +6. **Flip the auth guard.** Once two or three modules are extracted and you trust the layout, replace the no-op `loader` in `authenticatedRoute` with whatever guard your old `App.tsx` ran. + +The goal isn't "all features are modules on day one." It's "every new feature goes in as a module, and old features get extracted opportunistically." That's usually one or two weeks of part-time work for an app with ~20 routes, not a rewrite. + +## Troubleshooting + +**"Cannot find module `@myorg/-module`" after `create module`.** You forgot `pnpm install`. The CLI adds the package to `shell/package.json` but doesn't run install for you. Run it from the project root. + +**TypeScript says `notifications` doesn't exist on `AppDependencies` after `create store`.** Your editor's TS server is caching the pre-edit `app-shared` types. Restart the TS server (in VS Code: "TypeScript: Restart TS Server"). If that doesn't help, verify `app-shared/src/index.ts` actually contains the new interface and the new `AppDependencies` field. + +**`pnpm typecheck` at the project root fails with `TS18002: The 'files' list in config file 'tsconfig.json' is empty`.** Known scaffold bug — the root `tsconfig.json` ships with empty `files` and `references` arrays. Workaround: typecheck each sub-package individually (`cd shell && npx tsc --noEmit`, etc.) or let Vite's dev server handle it. `pnpm dev` and `pnpm build` are unaffected. + +**Two modules with the same `id` in their descriptors.** The registry throws `Duplicate module ID ""` at resolve time. Rename one. + +**A module declares `requires: ['notifications']` but the store isn't registered.** The registry throws `Module "" requires dependencies not provided by the registry: notifications` at resolve time — before the app ever renders. Either add the store or remove the `requires` entry. + +**HMR doesn't pick up changes to a new module's descriptor.** Vite usually handles this, but descriptor-level changes (new routes, new nav items) sometimes need a full `pnpm dev` restart because the module is imported eagerly in `main.tsx`. + +## Where to go next + +- **[Shell Patterns](./shell-patterns.md)** — router-agnostic fundamentals: slots, zones, commands, stores, cross-store coordination. Read this first if you haven't. +- **[Shell Patterns — React Router](./shell-patterns-react-router.md)** — everything specific to the RR integration: `handle`-based zones, `authenticatedRoute` with `loader`, public `shellRoutes`. +- **[Workspace Patterns](./workspace-patterns.md)** — tab-based workspace apps where modules are opened as dynamic tabs rather than mounted at fixed routes. +- **[`@react-router-modules/runtime` README](../packages/react-router-runtime/README.md)** — the runtime API surface: `createRegistry`, `resolve`, hooks. diff --git a/docs/getting-started-tanstack-router.md b/docs/getting-started-tanstack-router.md new file mode 100644 index 0000000..7ef3f9a --- /dev/null +++ b/docs/getting-started-tanstack-router.md @@ -0,0 +1,297 @@ +# Getting started — TanStack Router + +This guide walks you from zero to a running modular TanStack Router app. It assumes you already use (or are comfortable with) TanStack Router v1 and want to split your app into self-contained feature modules. + +For router-agnostic fundamentals, see [Shell Patterns](./shell-patterns.md). For the TanStack Router–specific mechanics (zones via `staticData`, `authenticatedRoute` with `beforeLoad`, public `shellRoutes`), see [Shell Patterns — TanStack Router](./shell-patterns-tanstack-router.md). + +## Prerequisites + +- **Node 22+** and **pnpm** +- **React 19**, **TanStack Router v1**, **zustand 5** — the versions the scaffold pins +- Familiarity with TanStack Router's code-based route tree (`createRoute`, `getParentRoute`, `addChildren`) + +You don't need an existing project — the CLI scaffolds one for you. Already have a TanStack Router app? Scaffold a throwaway first to see the structure, then follow the [migration sketch](#migrating-an-existing-app) at the bottom of this guide. + +### About the package manager + +The scaffold produces a **pnpm workspace** — `pnpm-workspace.yaml`, `workspace:*` dependencies, and scripts that use `pnpm --filter` / `pnpm -r`. This is the supported setup. + +- **Yarn Berry (v2+)** and **Bun** both understand the `workspace:*` protocol and can be used after scaffolding if you rename `pnpm-workspace.yaml` to a `workspaces` field in the root `package.json` and rewrite the scripts. Nothing in the runtime or CLI is pnpm-specific beyond the scaffold output. +- **npm is not supported.** npm doesn't implement the `workspace:*` protocol, so `npm install` in a scaffolded project will fail to resolve the workspace packages. +- **Turborepo** is orthogonal — it runs on top of any package manager. If you use Turborepo, keep pnpm underneath and add `turbo.json` afterwards. + +## Mental model + +Three roles, one contract: + +- **Shell** (`shell/`) — the host app. Owns stores, services, layouts, the registry, and `main.tsx`. The shell is where you run the app from. +- **Modules** (`modules//`) — self-contained feature packages. Each module describes everything it contributes: routes, navigation items, commands, zone fills, and the dependencies it needs from the shell. A module never imports from the shell. +- **app-shared** (`app-shared/`) — the typed contract between the two. It declares three interfaces every module is generic over: + - **`AppDependencies`** — the stores and services the shell provides (auth store, config store, http client, …). Modules read from these via typed hooks. + - **`AppSlots`** — the static contributions the shell collects across all modules (e.g. a `commands` bar). + - **`AppZones`** — per-route layout regions a module can fill (e.g. a detail panel on the right). The active route's contributions are what the shell renders. On TanStack Router, zones ride on the route's `staticData` field, which `app-shared` tightens via a `declare module` augmentation so the types line up. + +Every module signature looks like `defineModule({ … })`. That's how TypeScript catches a module asking for a store the shell doesn't provide — at compile time. + +## 1. Scaffold a project + +```bash +npx @tanstack-react-modules/cli init my-app --scope @myorg --module dashboard +cd my-app +pnpm install +pnpm dev +``` + +- `my-app` is the project (and root package) name. +- `--scope @myorg` is the npm scope used for workspace package names (`@myorg/app-shared`, `@myorg/dashboard-module`, …). Pick something unique to your org; it never has to be published. +- `--module dashboard` seeds the first feature module. Omit the flag to be prompted interactively. + +The CLI creates a pnpm workspace with three sub-packages and wires them together. After `pnpm dev` the shell boots on Vite's default port (5173). + +### What you see on first run + +Open [`http://localhost:5173`](http://localhost:5173). You'll land on the **Home** page with a "Login as Demo User" button in the header. The sidebar already shows the dashboard module's navigation entries — the scaffold ships with a no-op auth guard, so module routes are reachable even before you log in, but pages that read the auth store will show "Please log in …" until you click the login button. + +Click **Login as Demo User**, then navigate to **Dashboard List** in the sidebar. You'll see the list page in the main area and a detail panel on the right. Navigate back to **Dashboard** and the detail panel disappears — it's contributed by the list route, not the module. + +## 2. What you got + +``` +my-app/ +├── app-shared/ # Shared contract: AppDependencies, AppSlots, AppZones, typed hooks +│ └── src/ +│ ├── index.ts # The contract — the single place all modules depend on +│ └── types.ts # Domain types (User, LoginCredentials, …) +├── shell/ # The host app — owns stores, services, layouts, main.tsx +│ └── src/ +│ ├── main.tsx # Registry wiring + app bootstrap +│ ├── components/ +│ │ ├── RootLayout.tsx # Runs for every route (public + protected) +│ │ ├── ShellLayout.tsx # Authenticated chrome (sidebar, header, detail panel) +│ │ ├── Sidebar.tsx # Navigation built from module contributions +│ │ └── Home.tsx # Index route +│ ├── stores/ +│ │ ├── auth.ts # Zustand store — login / logout / user +│ │ └── config.ts # Zustand store — apiBaseUrl, env, appName +│ └── services/ +│ └── http-client.ts # Wretch instance, auth-aware via defer() +└── modules/ + └── dashboard/ # The first feature module + └── src/ + ├── index.ts # defineModule(...) — the module descriptor + ├── pages/ + │ ├── DashboardDashboard.tsx # "Dashboard" — the index page convention + │ └── DashboardList.tsx + └── panels/ + └── DetailPanel.tsx # Contributed into the shell's detail panel zone +``` + +### The `Dashboard.tsx` convention + +The scaffold names every module's index page `Dashboard.tsx`. A module called `dashboard` yields `DashboardDashboard.tsx`; a module called `billing` would yield `BillingDashboard.tsx`. It's just a naming convention — rename the file and update the `lazyRouteComponent()` import in the descriptor if you'd rather have something else. + +### The `-module` suffix + +Packages under `modules/` are published as `@myorg/-module`. The suffix is there so module packages never collide with the non-module packages you already have in your workspace (or plan to add). When the shell imports from them, it uses the full package name: `import dashboard from '@myorg/dashboard-module'`. + +### Choice of HTTP client + +The scaffold bakes in [`wretch`](https://github.com/elbywan/wretch) + [`@lokalise/frontend-http-client`](https://github.com/lokalise/frontend-http-client) as the `httpClient` type in `AppDependencies`. Swap it for `fetch`, `axios`, `ky`, or anything else — just change the type in `app-shared/src/index.ts` and the implementation in `shell/src/services/http-client.ts`. The framework doesn't care. + +### The `staticData` type augmentation + +TanStack Router's `staticData` field is intentionally loosely typed so apps can put whatever they want on it. The scaffold tightens it to `AppZones` for you, so `staticData: { detailPanel: ... }` type-checks without casts. Open `app-shared/src/index.ts` and you'll see: + +```typescript +// Type-safe staticData: tells TanStack Router that createRoute({ staticData: { ... } }) +// should accept `AppZones` keys with compile-time checking. +// The empty import ensures TypeScript loads the target module before we augment it. +import type {} from '@tanstack/router-core' +declare module '@tanstack/router-core' { + interface StaticDataRouteOption extends AppZones {} +} +``` + +Two things to know: + +1. **The `import type {}` line is load-bearing.** Without it, TypeScript throws `TS2664: Invalid module name in augmentation`. Don't delete it. +2. **`@tanstack/router-core` is both a peerDep and a devDep of `app-shared`.** This is required for the augmentation to resolve. Don't remove either. + +## 3. Tour the first module + +Open `modules/dashboard/src/index.ts`. This is the entire module definition: + +```typescript +import { defineModule } from '@tanstack-react-modules/core' +import { createRoute, lazyRouteComponent } from '@tanstack/react-router' +import type { AppDependencies, AppSlots } from '@myorg/app-shared' +import { DashboardDetailPanel } from './panels/DetailPanel.js' + +export default defineModule({ + id: 'dashboard', + version: '0.1.0', + + meta: { + name: 'Dashboard', + description: 'Dashboard module', + category: 'general', + }, + + createRoutes: (parentRoute) => { + const root = createRoute({ + getParentRoute: () => parentRoute, + path: 'dashboard', + }) + + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component: lazyRouteComponent(() => import('./pages/DashboardDashboard.js')), + }) + + const list = createRoute({ + getParentRoute: () => root, + path: 'list', + component: lazyRouteComponent(() => import('./pages/DashboardList.js')), + staticData: { + detailPanel: DashboardDetailPanel, + }, + }) + + return root.addChildren([index, list]) + }, + + navigation: [ + { label: 'Dashboard', to: '/dashboard', order: 10 }, + { label: 'Dashboard List', to: '/dashboard/list', order: 11 }, + ], + + slots: { + commands: [ + { + id: 'dashboard:refresh', + label: 'Refresh Dashboard', + group: 'actions', + onSelect: () => window.location.reload(), + }, + ], + }, + + requires: ['auth'], +}) +``` + +A single object describes everything the module contributes: + +- **`meta`** — catalog info the shell can read via `useModules()` and `getModuleMeta()`. +- **`createRoutes(parentRoute)`** — receives the authenticated parent route from the runtime and returns a route subtree built with `createRoute({ getParentRoute: () => ... })` plus `root.addChildren([...])`. The runtime splices that subtree under `authenticatedRoute`, so the whole module sits behind whatever auth guard the shell decides. `createRoutes` is **optional** — headless modules (stores, commands, zones only, no routes) simply omit it. +- **`navigation`** — items the `` in the shell picks up via `useNavigation()`. No manual registration. +- **`slots.commands`** — commands the `` header renders as buttons. The demo command reloads the page; replace it with anything callable. +- **`staticData: { detailPanel: ... }`** — a **route zone**. When `/dashboard/list` is active, the shell reads `useZones().detailPanel` and renders `DashboardDetailPanel` in its right-hand panel. Navigate away and the panel disappears. Typing comes from the `StaticDataRouteOption` augmentation in `app-shared`. See [Shell Patterns — TanStack Router § Route Zones](./shell-patterns-tanstack-router.md#route-zones). +- **`requires: ['auth']`** — the registry fails fast at resolve time if the `auth` store isn't provided to it. This is how modules declare their dependencies on shell-provided state. + +Visit `/dashboard/list` in the running app. You'll see the list page in the main area and the detail panel on the right. Navigate back to `/dashboard` and the panel goes away. + +## 4. Add a second module + +From the project root: + +```bash +npx @tanstack-react-modules/cli create module billing --route billing +pnpm install +``` + +(If you prefer not to retype the package name, add `@tanstack-react-modules/cli` to the root `devDependencies` and use `pnpm exec reactive create module billing --route billing`.) + +The `create module` command generates `modules/billing/` with the same structure as `dashboard` (plus a starter vitest test under `src/__tests__/`), adds `@myorg/billing-module` to `shell/package.json`, and wires `registry.register(billing)` into `shell/src/main.tsx`. **The `pnpm install` is not optional** — without it, the new workspace package isn't linked and `pnpm dev` will fail to resolve `@myorg/billing-module`. + +Restart `pnpm dev`. You'll see: + +- A new **Billing** group in the sidebar (or merged into an existing group if you pass `--nav-group`) +- A **Refresh Billing** button in the header's command bar +- A detail panel on `/billing/list` + +No edits to the shell required — the registry discovers everything from the module descriptor. + +## 5. Add a store + +Stores are the reactive state surface shared across modules. Add one with: + +```bash +npx @tanstack-react-modules/cli create store notifications +``` + +This: + +1. Writes `shell/src/stores/notifications.ts` with a Zustand vanilla store. +2. Adds a `NotificationsStore` interface and `notifications: NotificationsStore` field to `AppDependencies` in `app-shared/src/index.ts`. +3. Registers the store with the registry in `shell/src/main.tsx`. + +After the CLI finishes, open the generated store file and fill in the state shape and actions. Any module can consume it with `useStore('notifications', (s) => s.unreadCount)` — the `useStore` hook is the typed one exported from `@myorg/app-shared`, so it knows the store exists and what shape it has. + +If a module `requires: ['notifications']` and you remove the store, the registry will throw at resolve time, before the app ever boots. + +## 6. Turn on the auth guard + +The scaffold ships with a no-op auth guard so the app runs immediately. Open `shell/src/main.tsx` — you'll find: + +```typescript +authenticatedRoute: { + beforeLoad: () => { + // TODO: replace with real auth check. Example: + // const { isAuthenticated } = authStore.getState() + // if (!isAuthenticated) throw redirect({ to: '/login' }) + }, + component: ShellLayout, +}, + +// shellRoutes: (root) => [ +// createRoute({ getParentRoute: () => root, path: '/login', component: LoginPage }), +// ], +``` + +To make the guard real: + +1. Replace the `beforeLoad` body with an actual check that throws `redirect({ to: '/login' })` (or whichever unauthenticated destination you want) when the user isn't signed in. Use `throw redirect({ to: '/login' })` — **not** `throw redirect('/login')`. TanStack Router's `redirect` takes an options object. +2. Uncomment `shellRoutes` and return one or more public routes built with `createRoute({ getParentRoute: () => root, ... })`. `shellRoutes` sits **outside** `authenticatedRoute`, so routes returned from it never hit `beforeLoad`. +3. Build a real login page that calls `authStore.getState().login(...)` and navigates back to `/`. + +Note the casing: `component` (lowercase) in `authenticatedRoute`, not `Component`. TanStack Router uses lowercase `component` on `createRoute({ ... })` options, and the runtime's `authenticatedRoute` follows that convention. + +For the full pattern — layout route as auth boundary, `beforeLoad` vs. loaders, per-module role guards — see [Shell Patterns — TanStack Router § Auth Guard Pattern](./shell-patterns-tanstack-router.md#auth-guard-pattern). + +## Migrating an existing app + +If you already have a TanStack Router v1 app, you don't throw it away — you refactor it in place. There's no automated migration command; the work is mechanical but has to be done module-by-module. Here's the sketch: + +1. **Scaffold a throwaway project first.** Run the `init` command in a sibling directory so you have a working reference for the three-package layout, the `main.tsx` wiring, and the scaffolded `app-shared` contract (including the `StaticDataRouteOption` augmentation). Keep it open while you migrate. +2. **Carve out `app-shared` in your repo.** Create a new workspace package (`app-shared/` or `@yourorg/app-shared`) and copy the scaffolded `index.ts` + `types.ts` as a starting point. Replace the sample store interfaces (`AuthStore`, `ConfigStore`) with the actual shapes of the stores you already have. Keep the `declare module '@tanstack/router-core'` block — that's what makes `staticData` type-check for every module you extract. The goal: `app-shared` becomes the single place your existing code and future modules both import types from. +3. **Move your existing stores and services under `shell/`.** They probably live in `src/stores/` and `src/services/` today — move them into `shell/src/stores/` and `shell/src/services/` with minimal changes. Wire them into `createRegistry({ stores, services })`. +4. **Pick the smallest feature and extract it as the first module.** A single feature folder with two or three routes is ideal. Create `modules//`, convert the routes out of your current route tree into `createRoutes(parentRoute)` using `createRoute({ getParentRoute: () => … })` + `root.addChildren([…])`, move any nav entries into `navigation: [...]`, and register the module with `registry.register(feature)`. Delete the old code paths. +5. **Repeat.** Each subsequent feature extraction is smaller than the last because the shell and `app-shared` stop growing. +6. **Flip the auth guard.** Once two or three modules are extracted and you trust the layout, replace the no-op `beforeLoad` in `authenticatedRoute` with whatever guard your old root route ran. + +The goal isn't "all features are modules on day one." It's "every new feature goes in as a module, and old features get extracted opportunistically." That's usually one or two weeks of part-time work for an app with ~20 routes, not a rewrite. + +## Troubleshooting + +**"Cannot find module `@myorg/-module`" after `create module`.** You forgot `pnpm install`. The CLI adds the package to `shell/package.json` but doesn't run install for you. Run it from the project root. + +**TypeScript says `notifications` doesn't exist on `AppDependencies` after `create store`.** Your editor's TS server is caching the pre-edit `app-shared` types. Restart the TS server (in VS Code: "TypeScript: Restart TS Server"). If that doesn't help, verify `app-shared/src/index.ts` actually contains the new interface and the new `AppDependencies` field. + +**TypeScript says `TS2664: Invalid module name in augmentation` in `app-shared/src/index.ts`.** The `import type {} from '@tanstack/router-core'` line at the top of the file is missing or got deleted. That import is what makes the `declare module '@tanstack/router-core'` block type-check. Restore it and confirm `@tanstack/router-core` is in `app-shared`'s `peerDependencies` **and** `devDependencies`. + +**`pnpm typecheck` at the project root fails with `TS18002: The 'files' list in config file 'tsconfig.json' is empty`.** Known scaffold bug — the root `tsconfig.json` ships with empty `files` and `references` arrays. Workaround: typecheck each sub-package individually (`cd shell && npx tsc --noEmit`, etc.) or let Vite's dev server handle it. `pnpm dev` and `pnpm build` are unaffected. + +**`staticData: { detailPanel: … }` type-checks in one module but not another.** The zone type augmentation lives in `app-shared/src/index.ts`. If a module imports types from `@myorg/app-shared` but TypeScript can't see the augmentation, make sure the module's `package.json` depends on `@myorg/app-shared` and that `pnpm install` has been run. + +**Two modules with the same `id` in their descriptors.** The registry throws `Duplicate module ID ""` at resolve time. Rename one. + +**A module declares `requires: ['notifications']` but the store isn't registered.** The registry throws `Module "" requires dependencies not provided by the registry: notifications` at resolve time — before the app ever renders. Either add the store or remove the `requires` entry. + +## Where to go next + +- **[Shell Patterns](./shell-patterns.md)** — router-agnostic fundamentals: slots, zones, commands, stores, cross-store coordination. Read this first if you haven't. +- **[Shell Patterns — TanStack Router](./shell-patterns-tanstack-router.md)** — everything specific to the TSR integration: `staticData`-based zones, `authenticatedRoute` with `beforeLoad`, public `shellRoutes`. +- **[Workspace Patterns](./workspace-patterns.md)** — tab-based workspace apps where modules are opened as dynamic tabs rather than mounted at fixed routes. +- **[`@tanstack-react-modules/runtime` README](../packages/tanstack-router-runtime/README.md)** — the runtime API surface: `createRegistry`, `resolve`, hooks. diff --git a/docs/shell-patterns-react-router.md b/docs/shell-patterns-react-router.md new file mode 100644 index 0000000..d5b3b3a --- /dev/null +++ b/docs/shell-patterns-react-router.md @@ -0,0 +1,204 @@ +# Shell Patterns — React Router + +Router-specific additions to [Shell Patterns (Fundamentals)](shell-patterns.md) for apps built with `@react-router-modules/*`. Read the fundamentals guide first — this document only covers the parts that depend on React Router. + +## Module routes + +A module's `createRoutes` returns `RouteObject[]` (or a single `RouteObject`). There is no parent argument — React Router route objects are plain data, and the runtime nests them under the root automatically. + +```typescript +// modules/billing/src/index.ts +import { defineModule } from "@react-router-modules/core"; +import type { RouteObject } from "react-router"; +import type { AppDependencies, AppSlots } from "@myorg/app-shared"; + +export default defineModule({ + id: "billing", + version: "1.0.0", + requires: ["auth", "httpClient"], + + createRoutes: (): RouteObject[] => [ + { + path: "billing", + lazy: () => import("./pages/BillingRoot.js").then((m) => ({ Component: m.default })), + children: [ + { + index: true, + lazy: () => import("./pages/Dashboard.js").then((m) => ({ Component: m.default })), + }, + { + path: "invoices/:invoiceId", + lazy: () => import("./pages/InvoiceDetail.js").then((m) => ({ Component: m.default })), + }, + ], + }, + ], +}); +``` + +Use `lazy: () => import(...)` for per-route code splitting — React Router handles the promise and resolves `Component`, `loader`, `action`, and `handle` from the imported module. + +## Route Zones + +React Router exposes arbitrary per-route metadata via the `handle` field on `RouteObject`. The modular-react runtime reads zones from there — any component placed on a matched route's `handle` is surfaced by `useZones()`. + +### Declaring zones on a route + +```typescript +import type { RouteObject } from "react-router"; +import { UserDetailPage } from "./pages/UserDetailPage.js"; +import { UserDetailSidebar } from "./components/UserDetailSidebar.js"; +import { UserDetailActions } from "./components/UserDetailActions.js"; + +const userDetail: RouteObject = { + path: "users/:userId", + Component: UserDetailPage, + handle: { + detailPanel: UserDetailSidebar, + headerActions: UserDetailActions, + }, +}; +``` + +### Type-safe handle + +`RouteObject.handle` is typed as `unknown` by default. Constrain it by declaring your zone shape once in `app-shared` and narrowing at the call site: + +```typescript +// app-shared/src/index.ts +import type { ComponentType } from "react"; + +export interface AppZones { + detailPanel?: ComponentType; + headerActions?: ComponentType; +} +``` + +The shell reads them via the generic on `useZones`: + +```typescript +import { useZones } from "@react-router-modules/runtime"; +import type { AppZones } from "@myorg/app-shared"; + +function Layout() { + const zones = useZones(); + const DetailPanel = zones.detailPanel; + // ... +} +``` + +Deeper routes override shallower ones: if the billing section root sets `handle.detailPanel = BillingSidebar` and the invoice detail page sets `handle.detailPanel = InvoiceSidebar`, the detail page wins while it is active. + +## Auth Guard Pattern + +The runtime follows React Router's recommended layout-route approach for auth boundaries. Use `authenticatedRoute` on `registry.resolve()` to create a pathless layout that guards protected routes, and use `shellRoutes` for anything public that must sit outside that boundary (login, signup, marketing pages). + +### Layout route as auth boundary (recommended) + +```typescript +import { createRegistry } from "@react-router-modules/runtime"; +import { redirect } from "react-router"; +import billing from "./modules/billing"; +import users from "./modules/users"; +import RootLayout from "./components/RootLayout"; +import ShellLayout from "./components/ShellLayout"; +import DashboardPage from "./pages/Dashboard"; +import LoginPage from "./pages/Login"; +import SignupPage from "./pages/Signup"; + +const registry = createRegistry({ + stores: { auth: authStore }, + services: { httpClient }, +}); + +registry.register(billing); +registry.register(users); + +const { App } = registry.resolve({ + rootComponent: RootLayout, + indexComponent: DashboardPage, + + // Runs for ALL routes (including /login) — observability, not auth + loader: async ({ request }) => { + analytics.trackPageView(new URL(request.url).pathname); + return null; + }, + + // Auth boundary — guards module routes and the index + authenticatedRoute: { + loader: async () => { + const res = await fetch("/api/auth/session"); + if (!res.ok) throw redirect("/login"); + return null; + }, + Component: ShellLayout, // optional — defaults to + }, + + // Public routes — outside the auth boundary + shellRoutes: () => [ + { path: "/login", Component: LoginPage }, + { path: "/signup", Component: SignupPage }, + ], +}); +``` + +This produces the route tree: + +``` +Root (loader: observability - runs for all routes) +├── /login (public - no auth guard) +├── /signup (public - no auth guard) +└── _authenticated (layout - auth guard protects children) + ├── / (DashboardPage) + └── /billing, /users, … (module routes) +``` + +The separation is structural: the root `loader` runs everywhere (observability, feature flags) while `authenticatedRoute.loader` is strictly for auth. + +> Note the casing: `authenticatedRoute.Component` is capitalized (matching React Router's `RouteObject.Component`). + +### Per-module or role-based guards + +For per-module auth or role-based access, put a `loader` directly on the module's route: + +```typescript +import { redirect } from "react-router"; +import { authStore } from "@myorg/app-shared/stores"; + +export default defineModule({ + id: "admin", + createRoutes: () => [ + { + path: "admin", + loader: () => { + // Access auth store directly — loaders run outside React + const { role } = authStore.getState(); + if (role !== "admin") throw redirect("/"); + return null; + }, + children: [ + // ... admin pages + ], + }, + ], +}); +``` + +`loader` runs outside the React tree, so you access stores via `store.getState()` rather than hooks. + +## createRoutes signature summary + +| Aspect | React Router | +| ---------------------- | ---------------------------------------------------------------- | +| Return type | `RouteObject \| RouteObject[]` | +| Parent argument | None — the runtime grafts your routes onto the auth boundary | +| Code splitting | `lazy: () => import('./Page.js').then((m) => ({ Component: m.default }))` | +| Zone declaration | `handle: { ... }` on the route object | +| Route-level auth guard | `loader: () => { throw redirect('/') }` | + +## See also + +- [Shell Patterns (Fundamentals)](shell-patterns.md) — the router-agnostic foundation. +- [Shell Patterns — TanStack Router](shell-patterns-tanstack-router.md) — the same patterns, expressed against TanStack Router's API. +- [Workspace Patterns](workspace-patterns.md) — tabbed workspaces and descriptor-level zones. +- `@react-router-modules/runtime` package README — `dynamicSlots`, `recalculateSlots`, `slotFilter`. diff --git a/docs/shell-patterns-tanstack-router.md b/docs/shell-patterns-tanstack-router.md new file mode 100644 index 0000000..9f1d5b6 --- /dev/null +++ b/docs/shell-patterns-tanstack-router.md @@ -0,0 +1,209 @@ +# Shell Patterns — TanStack Router + +Router-specific additions to [Shell Patterns (Fundamentals)](shell-patterns.md) for apps built with `@tanstack-react-modules/*`. Read the fundamentals guide first — this document only covers the parts that depend on TanStack Router. + +## Module routes + +A module's `createRoutes` receives a `parentRoute` and returns a route built via TanStack Router's `createRoute`. You use `getParentRoute: () => parentRoute` to graft your subtree onto whatever the runtime passes in (the auth boundary for protected modules, or the root for public ones). + +```typescript +// modules/billing/src/index.ts +import { defineModule } from "@tanstack-react-modules/core"; +import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; +import type { AppDependencies, AppSlots } from "@myorg/app-shared"; + +export default defineModule({ + id: "billing", + version: "1.0.0", + requires: ["auth", "httpClient"], + + createRoutes: (parentRoute) => { + const billingRoot = createRoute({ + getParentRoute: () => parentRoute, + path: "billing", + component: lazyRouteComponent(() => import("./pages/BillingRoot.js")), + }); + + const dashboard = createRoute({ + getParentRoute: () => billingRoot, + path: "/", + component: lazyRouteComponent(() => import("./pages/Dashboard.js")), + }); + + const invoiceDetail = createRoute({ + getParentRoute: () => billingRoot, + path: "invoices/$invoiceId", + component: lazyRouteComponent(() => import("./pages/InvoiceDetail.js")), + }); + + return billingRoot.addChildren([dashboard, invoiceDetail]); + }, +}); +``` + +## Route Zones + +TanStack Router exposes per-route metadata through `staticData`. The modular-react runtime reads zones from there — any component placed on a matched route's `staticData` is surfaced by `useZones()`. + +### Declaring zones on a route + +```typescript +import { createRoute } from "@tanstack/react-router"; +import { UserDetailPage } from "./pages/UserDetailPage.js"; +import { UserDetailSidebar } from "./components/UserDetailSidebar.js"; +import { UserDetailActions } from "./components/UserDetailActions.js"; + +const userDetail = createRoute({ + getParentRoute: () => usersRoot, + path: "$userId", + component: UserDetailPage, + staticData: { + detailPanel: UserDetailSidebar, + headerActions: UserDetailActions, + }, +}); +``` + +### Type-safe staticData + +TanStack Router ships a module-augmentation slot for `staticData`. Declare your zones once in `app-shared` and augment `StaticDataRouteOption` so `createRoute({ staticData: { ... } })` is checked at compile time: + +```typescript +// app-shared/src/index.ts +import type { ComponentType } from "react"; + +export interface AppZones { + detailPanel?: ComponentType; + headerActions?: ComponentType; +} + +declare module "@tanstack/router-core" { + interface StaticDataRouteOption extends AppZones {} +} +``` + +The shell reads them via the generic on `useZones`: + +```typescript +import { useZones } from "@tanstack-react-modules/runtime"; +import type { AppZones } from "@myorg/app-shared"; + +function Layout() { + const zones = useZones(); + const DetailPanel = zones.detailPanel; + // ... +} +``` + +Deeper routes override shallower ones: if a billing section root sets `staticData.detailPanel = BillingSidebar` and the invoice detail page sets `staticData.detailPanel = InvoiceSidebar`, the detail page wins while it is active. + +## Auth Guard Pattern + +The runtime follows TanStack Router's recommended `_authenticated` layout route pattern. Use `authenticatedRoute` on `registry.resolve()` to create a pathless layout that guards protected routes, and use `shellRoutes` for anything public that must sit outside that boundary (login, signup, marketing pages). + +### Layout route as auth boundary (recommended) + +```typescript +import { createRegistry } from "@tanstack-react-modules/runtime"; +import { createRoute, redirect } from "@tanstack/react-router"; +import billing from "./modules/billing"; +import users from "./modules/users"; +import RootLayout from "./components/RootLayout"; +import ShellLayout from "./components/ShellLayout"; +import DashboardPage from "./pages/Dashboard"; +import LoginPage from "./pages/Login"; +import SignupPage from "./pages/Signup"; + +const registry = createRegistry({ + stores: { auth: authStore }, + services: { httpClient }, +}); + +registry.register(billing); +registry.register(users); + +const { App } = registry.resolve({ + rootComponent: RootLayout, + indexComponent: DashboardPage, + + // Runs for ALL routes (including /login) — observability, not auth + beforeLoad: ({ location }) => { + analytics.trackPageView(location.pathname); + }, + + // Auth boundary — guards module routes and the index + authenticatedRoute: { + beforeLoad: async () => { + const res = await fetch("/api/auth/session"); + if (!res.ok) throw redirect({ to: "/login" }); + }, + component: ShellLayout, // optional — defaults to + }, + + // Public routes — outside the auth boundary + shellRoutes: (root) => [ + createRoute({ getParentRoute: () => root, path: "/login", component: LoginPage }), + createRoute({ getParentRoute: () => root, path: "/signup", component: SignupPage }), + ], +}); +``` + +This produces the route tree: + +``` +Root (beforeLoad: observability - runs for all routes) +├── /login (public - no auth guard) +├── /signup (public - no auth guard) +└── _authenticated (layout - auth guard protects children) + ├── / (DashboardPage) + └── /billing, /users, … (module routes) +``` + +The separation is structural: the root `beforeLoad` runs everywhere (observability, feature flags) while `authenticatedRoute.beforeLoad` is strictly for auth. + +> Note the casing: `authenticatedRoute.component` is lowercase (matching TanStack Router's `createRoute({ component: ... })`). This differs from the React Router equivalent, which uses `Component`. + +### Per-module or role-based guards + +For per-module auth or role-based access, put `beforeLoad` directly on a module-owned route: + +```typescript +import { createRoute, redirect } from "@tanstack/react-router"; +import { authStore } from "@myorg/app-shared/stores"; + +export default defineModule({ + id: "admin", + createRoutes: (parentRoute) => { + const root = createRoute({ + getParentRoute: () => parentRoute, + path: "admin", + beforeLoad: () => { + // Access auth store directly — beforeLoad runs outside React + const { role } = authStore.getState(); + if (role !== "admin") throw redirect({ to: "/" }); + }, + }); + // ... child routes + return root.addChildren([/* ... */]); + }, +}); +``` + +`beforeLoad` runs outside the React tree, so you access stores via `store.getState()` rather than hooks. + +## createRoutes signature summary + +| Aspect | TanStack Router | +| ---------------------- | ---------------------------------------------------------------- | +| Return type | `AnyRoute` (built via `createRoute` + `addChildren`) | +| Parent argument | `parentRoute: AnyRoute` — use `getParentRoute: () => parentRoute` | +| Code splitting | `component: lazyRouteComponent(() => import('./Page.js'))` | +| Zone declaration | `staticData: { ... }` on `createRoute` options | +| Route-level auth guard | `beforeLoad: () => { throw redirect({ to: '/' }) }` | + +## See also + +- [Shell Patterns (Fundamentals)](shell-patterns.md) — the router-agnostic foundation. +- [Shell Patterns — React Router](shell-patterns-react-router.md) — the same patterns, expressed against React Router's API. +- [Workspace Patterns](workspace-patterns.md) — tabbed workspaces and descriptor-level zones. +- `@tanstack-react-modules/runtime` package README — `dynamicSlots`, `recalculateSlots`, `slotFilter`. diff --git a/docs/shell-patterns.md b/docs/shell-patterns.md new file mode 100644 index 0000000..92e0b65 --- /dev/null +++ b/docs/shell-patterns.md @@ -0,0 +1,442 @@ +# Shell Patterns (Fundamentals) + +This guide covers patterns for building shell applications with the modular-react framework. A "shell" is the host app that composes modules into a unified UI — from simple sidebar-and-content layouts to multi-zone dashboards. + +> **Router-specific additions:** The framework ships two router integrations on top of a shared foundation. Topics that depend on the router (declaring per-route zones, auth guards, module route trees) live in companion docs: +> +> - [Shell Patterns — React Router](shell-patterns-react-router.md) +> - [Shell Patterns — TanStack Router](shell-patterns-tanstack-router.md) +> +> Everything in this document is router-agnostic. Import hooks from either `@react-router-modules/runtime` or `@tanstack-react-modules/runtime` — both re-export the shared primitives from `@modular-react/react`. + +> **Building a workspace-style app** (tabbed workspaces, component-only modules, per-session state)? See [Workspace Patterns](workspace-patterns.md) after reading this guide — it builds on the foundation covered here. + +## Package layout + +The framework is organized as three layers: + +| Layer | Packages | Purpose | +| ------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Shared foundation | `@modular-react/core`, `@modular-react/react`, `@modular-react/testing` | Router-agnostic: types, slot/navigation builders, React contexts and hooks, validation, stores. | +| React Router integration | `@react-router-modules/core`, `@react-router-modules/runtime`, … | `defineModule` returning `RouteObject[]`, registry that builds a React Router instance. | +| TanStack Router | `@tanstack-react-modules/core`, `@tanstack-react-modules/runtime`, … | `defineModule` using `createRoute` / `getParentRoute`, registry that builds a TanStack Router. | + +A shell app imports from exactly one of the two router integrations. The pattern code throughout this guide is identical across both — only the route construction and the auth guard wiring differ (see the companion docs). You can also write router-neutral code (shared stores, cross-module contracts, typed hooks) once and reuse it. + +## Multi-Zone Shell Layout + +A basic shell has a sidebar and a content area. A complex shell has multiple zones — a mode rail, a customer banner, a main content area, a contextual panel. + +### Defining layout zones + +The shell's `rootComponent` owns the entire layout. Use CSS Grid to define zones, and populate them from navigation, slots, zones, and shared stores: + +```typescript +// shell/src/components/Layout.tsx +import { Outlet } from 'react-router' // or '@tanstack/react-router' +import { useNavigation, useSlots, useZones } from '@react-router-modules/runtime' +// ^ or '@tanstack-react-modules/runtime' — both re-export the same hook names +import type { AppSlots, AppZones } from '@myorg/app-shared' + +export function Layout() { + const navigation = useNavigation() + const slots = useSlots() + const zones = useZones() + const DetailPanel = zones.detailPanel + + return ( +
+ {/* Mode rail - populated from navigation groups */} + + + {/* Main content - routes render here */} +
+ +
+ + {/* Contextual panel - populated from route zones */} + {DetailPanel && ( + + )} +
+ ) +} +``` + +### Which mechanism for which zone + +| Zone content | Source | +| ------------------------------------------------------------------- | ------------------------------------------------------------- | +| Navigation links and mode switches | `useNavigation()` — modules declare `navigation` items | +| Commands, badges, aggregated contributions | `useSlots()` — modules declare `slots` contributions | +| Route-specific UI for layout regions (detail panel, header actions) | `useZones()` — the active route declares route-level zones | +| Active selection, panel visibility | Shared Zustand store — runtime state | +| Route-based page content | `` — the router renders the active module's routes | + +> **How a route declares zones differs by router.** React Router reads them from the route's `handle` field; TanStack Router reads them from `staticData`. See the companion docs. + +## Command Palette Pattern + +A command palette aggregates entries from multiple framework sources into a single searchable overlay. Each source serves a distinct purpose: + +| Source | What it provides | Example | +| ---------------------------------- | ----------------------------- | ------------------------- | +| `useSlots().systems` | Iframe-based external systems | "Open Salesforce" | +| `useModules()` + `getModuleMeta()` | Journey/component modules | "Set up Direct Debit" | +| `useSlots().commands` | Module-specific actions | "Create New Invoice" | +| `useNavigation()` | Route-based navigation | "Go to Billing Dashboard" | + +### Define the command slot + +Commands are always self-executing — the module provides `onSelect` and the shell calls it: + +```typescript +// app-shared/src/index.ts +export interface CommandDefinition { + readonly id: string; + readonly label: string; + readonly group?: string; + readonly icon?: string; + readonly shortcut?: string; + readonly onSelect: () => void; +} + +export interface AppSlots { + commands: CommandDefinition[]; +} +``` + +### When to use commands vs other mechanisms + +`slots.commands` is for actions the module can execute itself. Don't use it for: + +- **Workflow launching** — use `meta` instead, the shell discovers workflows via `useModules()` +- **Navigation** — use `navigation` on the module descriptor +- **System launching** — use a domain-specific slot (e.g. `slots.systems`) + +```typescript +import { defineModule } from '@react-router-modules/core' // or '@tanstack-react-modules/core' + +export default defineModule({ + id: "billing", + slots: { + commands: [ + // Module owns the action - it knows what to do + { + id: "billing:new-invoice", + label: "Create New Invoice", + group: "actions", + onSelect: () => { + /* open modal, navigate, etc. */ + }, + }, + ], + }, + // Sidebar link - framework builds NavigationManifest + navigation: [{ label: "Billing", to: "/billing", group: "finance" }], + // Discovery in directory/command palette - shell reads via useModules() + meta: { name: "Billing", category: "finance", icon: "CreditCard" }, +}); +``` + +### Shell renders the palette + +The shell aggregates all sources. Journey modules appear via `useModules()`, not `slots.commands`: + +```typescript +import { useSlots, useModules, getModuleMeta, useNavigation } from '@react-router-modules/runtime' +import type { AppSlots, WorkflowMeta } from '@myorg/app-shared' + +function CommandPalette({ search }: { search: string }) { + const { systems, commands } = useSlots() + const modules = useModules() + const navigation = useNavigation() + + // Journey modules from catalog + const workflows = modules + .filter((m) => m.component && getModuleMeta(m)?.category) + .map((m) => ({ entry: m, meta: getModuleMeta(m)! })) + + // Module-contributed commands (self-executing actions) + const grouped = Map.groupBy(commands, (cmd) => cmd.group ?? 'other') + + return ( +
+ {/* Systems from slots */} + {systems.map((sys) => ( + + ))} + + {/* Journey modules from catalog */} + {workflows.map(({ entry, meta }) => ( + + ))} + + {/* Module-contributed actions */} + {[...grouped.entries()].map(([group, items]) => ( +
+

{group}

+ {items.map((cmd) => ( + + ))} +
+ ))} + + {/* Navigation from module descriptors */} + {navigation.items.map((item) => ( + + ))} +
+ ) +} +``` + +This code is identical across both routers — the hooks are re-exported from each runtime package and come from the shared `@modular-react/react` layer. + +### Decision guide for module-to-shell actions + +| "I want to..." | Use | +| --------------------------------------- | --------------------------------------------------------------------------- | +| Appear in the directory/command palette | `meta` — shell discovers via `useModules()` | +| Add a sidebar link | `navigation` on module descriptor | +| Contribute a self-contained action | `slots.commands` with `onSelect` | +| Trigger an imperative shell action | `useService('workspace')` — see [Workspace Patterns](workspace-patterns.md) | + +## Auth Guard Pattern (concept) + +Both runtimes expose an `authenticatedRoute` option on `registry.resolve()`. It creates a pathless layout route that sits between the root and the module routes. Protected routes (index + all module routes) nest inside it; public routes (login, signup) go in `shellRoutes`, which sits outside the boundary. + +``` +Root (runs for all routes — observability, feature flags) +├── /login (public — in shellRoutes, outside the auth boundary) +├── /signup (public — in shellRoutes) +└── _authenticated layout (the authenticatedRoute — auth guard runs here) + ├── / (indexComponent) + └── /billing, /users, … (module routes) +``` + +The separation is structural: + +- The `rootComponent`'s before-route hook (`loader` or `beforeLoad`, depending on router) runs for **every** route, including `/login`. Use it for observability, analytics, feature flags — not auth. +- `authenticatedRoute`'s guard runs **only** for protected routes. Throw a redirect from it to send unauthenticated users elsewhere. + +The hook name (`loader` vs `beforeLoad`), the argument shape, and the `Component` vs `component` casing differ between routers. See the router-specific companion docs for working examples: + +- [React Router — Auth Guard](shell-patterns-react-router.md#auth-guard-pattern) +- [TanStack Router — Auth Guard](shell-patterns-tanstack-router.md#auth-guard-pattern) + +## Module-to-Shell Communication + +There are five communication channels. Choose based on what kind of data you're passing. + +### Slots: static declarations at registration time + +Use for things that don't change at runtime — what commands are available, what badge types a module supports. + +```typescript +// Module declares once at registration +slots: { + commands: [{ id: 'billing:export', label: 'Export Report', onSelect: () => downloadReport() }], +} +``` + +The shell reads these via `useSlots()`. They're collected at `resolve()` time. For slot contributions that depend on runtime state (role, feature flags), use `dynamicSlots` with `recalculateSlots()` — see each runtime's README. + +### Shared stores: runtime state + +Use for things that change during the app's lifetime — which panel is expanded, what notifications are pending, whether the sidebar is collapsed. + +```typescript +const toggleSidebar = useStore("ui", (s) => s.toggleSidebar); +toggleSidebar(); +``` + +Both the module triggering the change and the shell rendering it subscribe to the same Zustand store. The `useStore` hook comes from `createSharedHooks()`, which you typically wrap into an `app-shared` package. + +### Reactive services: external sources + +Use for external sources you subscribe to but don't control — call adapters, presence systems, websocket connections. These are registered in the `reactiveServices` bucket and implement `ReactiveService` (`subscribe` + `getSnapshot`, matching React's `useSyncExternalStore` API). + +```typescript +const callState = useReactiveService("call", (s) => s.status); +// Re-renders when the call adapter's state changes +``` + +Unlike stores (state you own), reactive services wrap external subscriptions. Unlike plain services (static utilities), reactive services trigger re-renders. + +### React Query: server data + +Use for data fetched from APIs. React Query handles caching, deduplication, and background refetching. + +```typescript +// Module A invalidates, Module B auto-refetches +queryClient.invalidateQueries({ queryKey: ["invoices"] }); +``` + +### Route zones: per-route UI contributions + +Use for UI components that the currently active route wants rendered in shell layout regions. Unlike slots (static, from all modules), zones change on every navigation and come from the active route hierarchy. The shell reads them via `useZones()`: + +```typescript +import { useZones } from '@react-router-modules/runtime' // or '@tanstack-react-modules/runtime' +import type { AppZones } from '@myorg/app-shared' + +function Layout() { + const zones = useZones() + const DetailPanel = zones.detailPanel + + return ( +
+
+ {DetailPanel && } +
+ ) +} +``` + +Deeper routes override shallower ones. A billing section root can set a default sidebar, and the invoice detail page can replace it. Routes that don't declare zones contribute nothing. + +**Declaring zones on a route is router-specific:** + +- React Router reads zones from `route.handle` — see [React Router — Route Zones](shell-patterns-react-router.md#route-zones). +- TanStack Router reads zones from `route.staticData` with type augmentation — see [TanStack Router — Route Zones](shell-patterns-tanstack-router.md#route-zones). + +> **Workspace apps:** If your modules render in tabs (not routes), use `useActiveZones()` instead — it merges route zones with the active module's descriptor zones. See [Workspace Patterns — Descriptor Zones](workspace-patterns.md#step-4-descriptor-zones-and-useactivezones). + +### Decision guide + +| Question | Answer | +| -------------------------------------------------- | ------------------------------------------------------------------------ | +| Is it known at module registration time? | Slots | +| Does it vary per route within a module? | Route zones | +| Does it change at runtime? | Shared store | +| Is it an external source you subscribe to? | Reactive service (`useReactiveService`) | +| Does it come from an API? | React Query | +| Does it need to trigger re-renders across modules? | Shared store (Zustand subscriptions) or React Query (cache invalidation) | + +## Headless Modules with defineSlots + +For modules that only contribute slot data (no component, no routes), use `defineSlots` instead of `defineModule` to reduce boilerplate: + +```typescript +import { defineSlots } from "@react-router-modules/core"; // or "@tanstack-react-modules/core" +import type { AppDependencies, AppSlots } from "@myorg/app-shared"; + +export default defineSlots("external-systems", { + systems: [ + { id: "salesforce", name: "Salesforce", iframeUrl: "...", icon: "Building2", category: "crm" }, + ], +}); +``` + +This is syntactic sugar — the registry sees a normal `ModuleDescriptor` with `version: '0.0.0'` and no component, routes, or lifecycle. Use `defineModule` when the module has any of: `component`, `createRoutes`, `meta`, `zones`, `requires`, or `lifecycle`. + +## Optional Dependencies + +Modules can declare dependencies they can function without using `optionalRequires`. Missing optional deps log a warning at resolve time instead of throwing: + +```typescript +export default defineModule({ + id: "billing", + version: "0.1.0", + requires: ["httpClient"], // hard requirement - throws if missing + optionalRequires: ["analytics"], // soft requirement - warns if missing + // ... +}); +``` + +In components, use `useOptional` to safely access deps that may not be registered: + +```typescript +import { useOptional } from "@myorg/app-shared"; + +function BillingDashboard() { + const analytics = useOptional("analytics"); + analytics?.track("billing_viewed"); // no-op if analytics not registered + // ... +} +``` + +`useOptional` checks all three buckets (stores, then reactive services, then services). Returns `null` if the key isn't registered in any bucket. + +## Cross-Store Coordination + +When you split a monolith Zustand store into focused stores, you'll often need one store to react to changes in another. Use Zustand's built-in `subscribe` API — it's the idiomatic pattern and requires no framework involvement. + +### The pattern + +```typescript +// stores/workspace-tabs.ts +import { interactionsStore } from "./interactions-store"; +import { workspaceTabsStore } from "./workspace-tabs-store"; + +// React to interaction changes - initialize tab state for new interactions +interactionsStore.subscribe((state, prev) => { + if (state.activeInteractionId === prev.activeInteractionId) return; + const id = state.activeInteractionId; + if (!id) return; + + const tabs = workspaceTabsStore.getState(); + if (!tabs.tabStateByInteraction[id]) { + workspaceTabsStore.setState({ + tabStateByInteraction: { + ...tabs.tabStateByInteraction, + [id]: createDefaultTabState(), + }, + }); + } +}); +``` + +Key points: + +- `subscribe` receives `(currentState, previousState)` — compare to avoid redundant work. +- Place the subscription in the file of the store that **reacts**, not the one that **triggers**. This keeps the triggering store unaware of its dependents. +- Top-level subscriptions (outside React) live for the app's lifetime. That's fine for shell stores. +- For cleanup, `subscribe` returns an unsubscribe function: `const unsub = store.subscribe(...); unsub()`. + +### When to use subscribe vs useEffect + +| Situation | Use | +| ------------------------------------------------------------- | ------------------------------------------------------ | +| Store A reacts to Store B, both are app-level singletons | `store.subscribe()` at module top level | +| Component needs to react to a store change with a side effect | `useEffect` + `useStore` selector inside the component | +| Module lifecycle setup that reads store state once | `onRegister(deps)` — receives a state snapshot | + +### Module-scoped subscriptions + +If a module sets up a subscription during its lifecycle, clean it up on unmount: + +```typescript +defineModule({ + id: "billing", + lifecycle: { + onMount(deps) { + // Subscribe to auth changes + this._unsub = authStore.subscribe((state) => { + if (!state.isAuthenticated) cleanup(); + }); + }, + onUnmount() { + this._unsub?.(); + }, + }, +}); +``` + +### What NOT to build + +Don't add event buses, custom pub/sub, or `connectStores()` helpers. Zustand's `subscribe` already provides exactly the right primitive. Adding an abstraction on top would hide what's happening and make debugging harder. If you find yourself wanting an event bus, that's a signal that the cross-cutting concern should be modeled as a shared store instead. + +## Where to go next + +- [Shell Patterns — React Router](shell-patterns-react-router.md) — route-level zones via `handle`, `authenticatedRoute` with `loader`, `shellRoutes`, module route shape. +- [Shell Patterns — TanStack Router](shell-patterns-tanstack-router.md) — route-level zones via `staticData`, `authenticatedRoute` with `beforeLoad`, `createRoute` / `getParentRoute`. +- [Workspace Patterns](workspace-patterns.md) — tabbed workspaces, component-only modules, `useActiveZones`, per-session scoped stores. diff --git a/docs/workspace-patterns.md b/docs/workspace-patterns.md new file mode 100644 index 0000000..d48b78f --- /dev/null +++ b/docs/workspace-patterns.md @@ -0,0 +1,518 @@ +# Workspace Patterns + +This guide covers patterns for building **workspace-style applications** with the modular-react framework — apps where the shell renders modules in tabs, panels, and drawers rather than via URL routes. Contact center agent desktops, trading platforms, and admin consoles are typical examples. + +> **Prerequisite:** This guide builds on [Shell Patterns (Fundamentals)](shell-patterns.md), which covers the shared foundation: layout grids, slots, command palettes, cross-store coordination, and module-to-shell communication. Read that first. +> +> Workspace patterns are router-agnostic. Every hook and descriptor field shown here works identically with `@react-router-modules/*` and `@tanstack-react-modules/*` — only the imports differ. Use whichever runtime your shell already uses. Where route fallback behavior matters, the [React Router](shell-patterns-react-router.md) and [TanStack Router](shell-patterns-tanstack-router.md) companion docs show the route-declaration syntax. + +## When to use workspace patterns + +Use these patterns when your app has: + +- **Tabbed workspaces** — users open and close content tabs within a persistent shell +- **Component-only modules** — modules render via the shell (not via URL routes) +- **Per-session state** — each customer/ticket/case has its own tab state, notes, etc. +- **Contextual panels that change per tab** — the active tab determines what shows in a sidebar + +If your app is a traditional page-navigated SPA where modules own routes, the core framework + [Shell Patterns](shell-patterns.md) are sufficient. + +## Architecture overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Shell Layout (rootComponent) │ +│ │ +│ ┌──────┐ ┌──────────────────────────────────┐ ┌─────────┐ │ +│ │ Mode │ │ Workspace │ │ Detail │ │ +│ │ Rail │ │ ┌──────────────────────────────┐ │ │ Panel │ │ +│ │ │ │ │ Tab Strip │ │ │ │ │ +│ │ nav │ │ ├──────────────────────────────┤ │ │ zones. │ │ +│ │ items│ │ │ │ │ │ detail │ │ +│ │ │ │ │ Active Tab Content │ │ │ Panel │ │ +│ │ │ │ │ (module component) │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ └──────────────────────────────┘ │ │ │ │ +│ └──────┘ └──────────────────────────────────┘ └─────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ Notes / Drawer ││ +│ └─────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +The shell owns the layout. In workspace apps, the **shell controls which module is currently rendered** — not the URL. The most common pattern is a tab strip where each tab renders one module's content, but the same architecture works for any shell-managed content switching: a single content area that swaps between modules, a drawer, a modal, or a split view. Modules don't know how the shell presents them; the shell decides when and where to render each module's `component`. + +Modules contribute content through five channels: + +| What | Mechanism | Example | +| --------------------- | --------------------------------- | ----------------------------------------------- | +| Navigation items | `navigation` on module descriptor | Mode rail links, sidebar items | +| Global contributions | `slots` on module descriptor | Command palette entries, tab type registrations | +| Route-specific panels | Route-level zones | Detail panel for a route-based page | +| Tab-active panels | `zones` on module descriptor | Contextual panel when a module tab is active | +| Runtime state | Shared Zustand stores | Active tab, session state, panel visibility | + +> The fourth row — descriptor-level `zones` — is a shared `ModuleDescriptor` field and works identically across both routers. Row three (route-specific panels) is the one that varies: [React Router uses `handle`](shell-patterns-react-router.md#route-zones), [TanStack Router uses `staticData`](shell-patterns-tanstack-router.md#route-zones). + +## Step 1: Define the contracts in app-shared + +```typescript +// app-shared/src/index.ts +import { createSharedHooks } from "@react-router-modules/core"; +// ^ or "@tanstack-react-modules/core" — createSharedHooks is identical in both +import type { ComponentType } from "react"; + +// ---- Zones (layout regions that change per active content) ---- + +export interface AppZones { + contextualPanel?: ComponentType; + headerActions?: ComponentType; +} + +// For TanStack Router only — augment staticData so it type-checks at createRoute. +// React Router does not need augmentation; handle is unknown and narrowed by useZones(). +declare module "@tanstack/router-core" { + interface StaticDataRouteOption extends AppZones {} +} + +// ---- Slots (global contributions from all modules) ---- + +export interface CommandDefinition { + readonly id: string; + readonly label: string; + readonly group?: string; + readonly onSelect: () => void; +} + +export interface AppSlots { + commands: CommandDefinition[]; + systems: SystemRegistration[]; + subNavSections: SubNavSection[]; +} + +// ---- Services ---- + +export interface WorkspaceActions { + openModuleTab: (moduleId: string) => void; + openSectionTab: (sectionId: string) => void; +} + +// ---- Shared dependencies ---- + +export interface AppDependencies { + auth: AuthStore; + sessions: SessionsStore; + ui: UIStore; + httpClient: { get: (url: string) => Promise }; + workspace: WorkspaceActions; +} + +// ---- Module metadata for catalog discovery ---- +// Define your own metadata shape - the framework passes it through via TMeta generic. + +export interface WorkflowMeta { + readonly name: string; + readonly description: string; + readonly icon: string; + readonly category: string; + readonly estimatedTime?: string; + readonly keepOpenOnComplete?: boolean; + readonly addNoteOnComplete?: boolean; +} + +// ---- Typed hooks ---- + +export const { useStore, useService, useReactiveService, useOptional } = + createSharedHooks(); +``` + +The `declare module "@tanstack/router-core"` augmentation is only needed if you're using the TanStack Router integration. Skip it for React Router apps. + +## Step 2: Workspace tab state + +The tab/workspace system is **shell-owned state**, not a framework concern. Use a plain Zustand store: + +```typescript +// shell/src/stores/workspace.ts +import { createStore } from "zustand/vanilla"; + +export interface WorkspaceTab { + id: string; + type: "directory" | "iframe" | "native-workflow"; + title: string; + workflowId?: string; // for native-workflow tabs + iframeUrl?: string; // for iframe tabs + closeable: boolean; + lastAccessedAt: number; +} + +export interface TabStateBySession { + tabs: WorkspaceTab[]; + activeTabId: string; +} + +export const workspaceTabsStore = createStore((set, get) => ({ + tabStateBySession: {}, + + getCurrentTabs: (sessionId) => { + return get().tabStateBySession[sessionId]?.tabs ?? [createDirectoryTab()]; + }, + + getActiveTab: (sessionId) => { + const tabState = get().tabStateBySession[sessionId]; + if (!tabState) return null; + return tabState.tabs.find((t) => t.id === tabState.activeTabId) ?? null; + }, + + openTabForSession: (sessionId, tab) => + set((state) => { + // Activate existing tab or append new one, with LRU eviction + // ... + }), + + // closeTab, switchTab, etc. +})); +``` + +### Cross-store coordination for tab initialization + +When a new session is selected, initialize its tab state. Use Zustand's `subscribe` API (see [Cross-Store Coordination](shell-patterns.md#cross-store-coordination)): + +```typescript +sessionsStore.subscribe((state, prev) => { + if (state.activeSessionId === prev.activeSessionId) return; + const id = state.activeSessionId; + if (!id) return; + + const tabs = workspaceTabsStore.getState(); + if (!tabs.tabStateBySession[id]) { + workspaceTabsStore.setState({ + tabStateBySession: { + ...tabs.tabStateBySession, + [id]: { tabs: [createDirectoryTab()], activeTabId: "directory" }, + }, + }); + } +}); +``` + +## Step 3: Modules as workspace components + +Workspace modules use `component` instead of `createRoutes`. The shell renders them in tabs. They declare `meta` for catalog discovery and `zones` for contextual panels: + +```typescript +// modules/onboarding-flow/src/index.ts +import { defineModule } from "@react-router-modules/core"; // or "@tanstack-react-modules/core" +import { lazy } from "react"; +import { OnboardingPanel } from "./OnboardingPanel.js"; + +export default defineModule({ + id: "onboarding-flow", + version: "0.1.0", + + // The shell renders this in a workspace tab + component: lazy(() => import("./OnboardingFlow.js")), + + // Catalog metadata - shell reads via useModules() + getModuleMeta() + meta: { + name: "Customer Onboarding", + description: "Walk through the new customer setup process", + icon: "UserPlus", + category: "setup", + estimatedTime: "5-10 mins", + }, + + // Zones - shell reads via useActiveZones() when this module's tab is active + zones: { + contextualPanel: OnboardingPanel, + }, + + requires: ["auth", "httpClient"], +}); +``` + +Both `component` and `zones` live on the shared `ModuleDescriptor`, so this module definition is valid against either integration's `defineModule`. + +### Module components receive standard props + +The shell defines a standard props interface for workspace components: + +```typescript +export interface WorkflowProps { + customerId: string; + accountNumber: string; + onComplete: (result?: unknown) => void; + onCancel: () => void; + initialState?: unknown; +} +``` + +## Step 4: Descriptor zones and useActiveZones + +Tab-based modules can't declare zones on a route because they aren't rendered via routes. Instead, they declare `zones` on the module descriptor: + +```typescript +zones: { + contextualPanel: OnboardingPanel, + headerActions: OnboardingHeaderActions, +} +``` + +The shell reads zones from both routes and the active module using `useActiveZones`: + +```typescript +import { useActiveZones } from '@react-router-modules/runtime' +// ^ or '@tanstack-react-modules/runtime' — identical API +import type { AppZones } from '@myorg/app-shared' + +function ShellLayout() { + // Derive the active module ID from workspace tab state + const activeTab = getActiveTabForCurrentSession() + const activeModuleId = + activeTab?.type === 'native-workflow' ? activeTab.workflowId : null + + const zones = useActiveZones(activeModuleId) + const ContextualPanel = zones.contextualPanel + + return ( +
+ {/* ... other zones ... */} + +
+ ) +} +``` + +**How `useActiveZones` works:** + +1. Collects route zones via `useZones()` (from `handle` on React Router matched routes, or `staticData` on TanStack matched routes). +2. If `activeModuleId` is provided, looks up the module's `zones` field from `useModules()`. +3. Merges both — **module wins** for the same key. +4. When `activeModuleId` is `null`, returns route zones only. + +This gives the shell one code path regardless of whether the active content is route-based or tab-based. + +### How tab switches update zones + +When the user clicks a different tab, the zone layout updates through a reactive chain — no imperative wiring needed: + +``` +User clicks tab "Billing" + → workspaceTabsStore updates activeTabId + → ShellLayout re-renders (subscribed to the store) + → derives activeModuleId = "billing" from the new active tab + → useActiveZones("billing") returns billing module's zones + → layout renders BillingContextPanel in the aside +``` + +Each step is a standard React/Zustand subscription. The shell layout subscribes to the tab store, derives `activeModuleId` from the active tab, and passes it to `useActiveZones`. When the tab changes, React re-renders the layout and `useActiveZones` returns the new module's zones automatically. + +When switching to a tab that has no module (e.g. a directory tab or an iframe tab), `activeModuleId` resolves to `null`, and `useActiveZones(null)` falls back to route zones only. If no route contributes zones either, every zone key is `undefined` and the shell renders its fallback content. + +## Step 5: Directory page from module catalog + +The shell builds a browsable directory of available modules using `useModules()` and `getModuleMeta()`: + +```typescript +import { useModules, getModuleMeta } from '@react-router-modules/runtime' +import type { WorkflowMeta } from '@myorg/app-shared' + +function DirectoryPage() { + const modules = useModules() + + // Only show modules that have catalog metadata + const discoverable = modules.filter((m) => getModuleMeta(m)?.category) + + // Group by category + const byCategory = Map.groupBy(discoverable, (m) => + getModuleMeta(m)!.category + ) + + return ( +
+ {[...byCategory.entries()].map(([category, mods]) => ( +
+

{capitalize(category)}

+
+ {mods.map((mod) => { + const meta = getModuleMeta(mod)! + return ( + +

{meta.name}

+

{meta.description}

+ +
+ ) + })} +
+
+ ))} +
+ ) +} +``` + +Category labels fall back to `capitalize(category)` — no hardcoded label map needed. + +## Step 6: Tab rendering from module catalog + +When a tab is active, the shell looks up the module and renders its `component`: + +```typescript +import { useModules } from '@react-router-modules/runtime' + +function WorkspaceContent({ activeTab, customerId, accountNumber, sessionId }) { + const modules = useModules() + + if (activeTab.type === 'directory') return + + if (activeTab.type === 'iframe') { + return + } + + // native-workflow - look up the module + const mod = modules.find((m) => m.id === activeTab.workflowId) + if (!mod?.component) return

Module "{activeTab.workflowId}" not found

+ + return ( + + ) +} +``` + +### Workflow wrapper + +The wrapper handles completion behavior and error boundaries: + +```typescript +function WorkflowWrapper({ workflowId, customerId, accountNumber, sessionId, tabId }) { + const modules = useModules() + const mod = modules.find((m) => m.id === workflowId) + const meta = getModuleMeta(mod!) + + const handleComplete = (result?: unknown) => { + saveWorkflowState(workflowId, result) + + // Respect module's completion preferences + if (meta?.addNoteOnComplete !== false) { + addNote(sessionId, `Workflow completed: ${meta?.name ?? workflowId}`) + } + if (!meta?.keepOpenOnComplete) { + closeTab(sessionId, tabId) + } + } + + const Component = mod!.component! + return ( + + }> + closeTab(sessionId, tabId)} + initialState={loadWorkflowState(workflowId)} + /> + + + ) +} +``` + +Modules control their own completion behavior via `WorkflowMeta`: + +- `keepOpenOnComplete: true` — tab stays open, module shows its own post-completion UI +- `addNoteOnComplete: false` — no automatic note on completion + +## Step 7: Per-session state with scoped stores + +For apps where each session has independent state, use `createScopedStore`: + +```typescript +import { createScopedStore } from "@react-router-modules/core"; +// ^ or "@tanstack-react-modules/core" — identical API + +const sessionTabs = createScopedStore(() => ({ + tabs: [{ id: "directory", type: "directory", title: "Directory", closeable: false }], + activeTabId: "directory", +})); + +// In a component - subscribe to this session's tab state +function Workspace({ sessionId }: { sessionId: string }) { + const { tabs, activeTabId } = sessionTabs.useScoped(sessionId); + // ... +} + +// Cleanup when session ends +sessionTabs.remove(sessionId); +``` + +## How modules trigger workspace actions + +Modules should never import store instances directly. Expose a workspace actions service via `AppDependencies`: + +```typescript +// app-shared/src/index.ts +export interface WorkspaceActions { + openModuleTab: (moduleId: string) => void; + openSectionTab: (sectionId: string) => void; +} +``` + +The shell provides the implementation. Modules only know the interface: + +```typescript +import { useService } from '@myorg/app-shared' + +function InvoiceActions({ invoiceId }: { invoiceId: string }) { + const workspace = useService('workspace') + + return ( + + ) +} +``` + +## Zone initial state and tab navigation + +Zones are reactive — they re-derive on every route change and tab switch. There is no implicit default and no "sticky" carry-over from a previous page or tab. + +**Initial render:** When no route or module contributes a zone, every key is `undefined`. The shell layout should render fallback content: + +```typescript +{zones.contextualPanel ? : } +``` + +**Tab switch:** When the user switches from a tab whose module declares `zones: { contextualPanel: BillingPanel }` to a tab whose module declares no zones, `contextualPanel` reverts to whatever the route hierarchy provides — or `undefined` if no route sets it either. This is intentional: the shell always reflects the currently active content, not the previously active content. + +**Persistent zones across tabs:** If a zone should always be present regardless of the active tab, set it on a parent layout route (via `handle` on React Router, `staticData` on TanStack Router). Module descriptor zones override route zones for the same key, so the route value acts as a fallback when the active module doesn't contribute that zone. + +## Summary: what goes where + +| Concern | Owned by | Mechanism | +| ------------------------------------ | -------- | ------------------------------------------------------------- | +| Layout grid, zone placement | Shell | `rootComponent` with CSS Grid | +| Module identity and catalog metadata | Modules | `meta` on descriptor → `useModules()` | +| Module renderable component | Modules | `component` on descriptor → `useModules()` | +| Tab-active contextual panels | Modules | `zones` on descriptor → `useActiveZones()` | +| Route-specific panels/actions | Modules | `handle` (RR) or `staticData` (TSR) → `useActiveZones()` | +| Navigation items | Modules | `navigation` on descriptor | +| Command palette entries | Modules | `slots.commands` | +| Directory page | Shell | Reads `useModules()`, filters by `meta` | +| Tab state, active tab | Shell | Zustand store | +| Per-session state | Shell | `createScopedStore` | +| Tab rendering | Shell | Looks up module by id via `useModules()`, renders `component` | + +The framework provides the composition primitives. The shell owns the workspace architecture. Modules stay standalone and testable — they declare what they contribute, the shell decides where it goes. diff --git a/packages/react-router-cli/src/commands/create-module.ts b/packages/react-router-cli/src/commands/create-module.ts index 7b6a94f..40d76fd 100644 --- a/packages/react-router-cli/src/commands/create-module.ts +++ b/packages/react-router-cli/src/commands/create-module.ts @@ -11,6 +11,7 @@ import { moduleDescriptor, modulePage, moduleListPage, + moduleDetailPanel, moduleTest, } from "../templates/module.js"; @@ -103,7 +104,9 @@ export default defineCommand({ const importName = toCamelCase(name); // Scaffold module directory + const moduleLabel = toPascalCase(name); mkdirSync(resolve(moduleDir, "src", "pages"), { recursive: true }); + mkdirSync(resolve(moduleDir, "src", "panels"), { recursive: true }); mkdirSync(resolve(moduleDir, "src", "__tests__"), { recursive: true }); writeFileSync(resolve(moduleDir, "package.json"), modulePackageJson({ scope, name })); writeFileSync(resolve(moduleDir, "tsconfig.json"), moduleTsconfig()); @@ -113,11 +116,15 @@ export default defineCommand({ ); writeFileSync( resolve(moduleDir, "src", "pages", `${pageName}.tsx`), - modulePage({ scope, pageName, moduleLabel: toPascalCase(name), moduleName: name }), + modulePage({ scope, pageName, moduleLabel, moduleName: name }), ); writeFileSync( resolve(moduleDir, "src", "pages", `${listPageName}.tsx`), - moduleListPage({ scope, pageName: listPageName, moduleLabel: toPascalCase(name) }), + moduleListPage({ scope, pageName: listPageName, moduleLabel }), + ); + writeFileSync( + resolve(moduleDir, "src", "panels", "DetailPanel.tsx"), + moduleDetailPanel({ moduleLabel }), ); writeFileSync( resolve(moduleDir, "src", "__tests__", `${name}.test.ts`), diff --git a/packages/react-router-cli/src/commands/init.ts b/packages/react-router-cli/src/commands/init.ts index e25e830..152817f 100644 --- a/packages/react-router-cli/src/commands/init.ts +++ b/packages/react-router-cli/src/commands/init.ts @@ -24,7 +24,8 @@ import { shellAuthStore, shellConfigStore, shellHttpClient, - shellLayout, + shellRootLayout, + shellShellLayout, shellSidebar, shellHome, } from "../templates/shell.js"; @@ -34,6 +35,7 @@ import { moduleDescriptor, modulePage, moduleListPage, + moduleDetailPanel, } from "../templates/module.js"; export default defineCommand({ @@ -167,7 +169,14 @@ function scaffold( shellConfigStore({ scope, appName: projectName }), ); writeFileSync(resolve(root, "shell", "src", "services", "http-client.ts"), shellHttpClient()); - writeFileSync(resolve(root, "shell", "src", "components", "Layout.tsx"), shellLayout({ scope })); + writeFileSync( + resolve(root, "shell", "src", "components", "RootLayout.tsx"), + shellRootLayout(), + ); + writeFileSync( + resolve(root, "shell", "src", "components", "ShellLayout.tsx"), + shellShellLayout({ scope }), + ); writeFileSync( resolve(root, "shell", "src", "components", "Sidebar.tsx"), shellSidebar({ projectName }), @@ -176,7 +185,9 @@ function scaffold( // First module (with two routes for testable routing) const moduleDir = resolve(root, "modules", moduleName); + const moduleLabel = toPascalCase(moduleName); mkdirSync(resolve(moduleDir, "src", "pages"), { recursive: true }); + mkdirSync(resolve(moduleDir, "src", "panels"), { recursive: true }); writeFileSync(resolve(moduleDir, "package.json"), modulePackageJson({ scope, name: moduleName })); writeFileSync(resolve(moduleDir, "tsconfig.json"), moduleTsconfig()); writeFileSync( @@ -185,11 +196,15 @@ function scaffold( ); writeFileSync( resolve(moduleDir, "src", "pages", `${pageName}.tsx`), - modulePage({ scope, pageName, moduleLabel: toPascalCase(moduleName), moduleName }), + modulePage({ scope, pageName, moduleLabel, moduleName }), ); writeFileSync( resolve(moduleDir, "src", "pages", `${listPageName}.tsx`), - moduleListPage({ scope, pageName: listPageName, moduleLabel: toPascalCase(moduleName) }), + moduleListPage({ scope, pageName: listPageName, moduleLabel }), + ); + writeFileSync( + resolve(moduleDir, "src", "panels", "DetailPanel.tsx"), + moduleDetailPanel({ moduleLabel }), ); } diff --git a/packages/react-router-cli/src/templates/app-shared.ts b/packages/react-router-cli/src/templates/app-shared.ts index 75390a5..7ee03e5 100644 --- a/packages/react-router-cli/src/templates/app-shared.ts +++ b/packages/react-router-cli/src/templates/app-shared.ts @@ -48,6 +48,7 @@ export function appSharedTsconfig(): string { export function appSharedIndex(_params: { scope: string }): string { return `import { createSharedHooks } from '@react-router-modules/core' +import type { ComponentType } from 'react' import type { LoginCredentials, User } from './types.js' import type { Wretch } from 'wretch' @@ -79,7 +80,7 @@ export interface AppDependencies { httpClient: Wretch } -// ---- Slots ---- +// ---- Slots (static contributions from every module) ---- export interface CommandDefinition { readonly id: string @@ -93,6 +94,14 @@ export interface AppSlots { commands: CommandDefinition[] } +// ---- Zones (per-route layout regions a module can fill) ---- +// Declared on a route's \`handle\` and read by the shell via \`useZones()\`. + +export interface AppZones { + detailPanel?: ComponentType + headerActions?: ComponentType +} + // ---- Typed hooks (use these in all modules) ---- export const { useStore, useService, useReactiveService, useOptional } = createSharedHooks() diff --git a/packages/react-router-cli/src/templates/module.ts b/packages/react-router-cli/src/templates/module.ts index b5c1abf..981c4bb 100644 --- a/packages/react-router-cli/src/templates/module.ts +++ b/packages/react-router-cli/src/templates/module.ts @@ -56,24 +56,33 @@ export function moduleDescriptor(params: { listPageName: string; navGroup?: string; }): string { + const label = capitalize(params.name); const navItems = params.navGroup ? [ - `{ label: '${capitalize(params.name)}', to: '/${params.route}', group: '${params.navGroup}', order: 10 }`, - `{ label: '${capitalize(params.name)} List', to: '/${params.route}/list', group: '${params.navGroup}', order: 11 }`, + `{ label: '${label}', to: '/${params.route}', group: '${params.navGroup}', order: 10 }`, + `{ label: '${label} List', to: '/${params.route}/list', group: '${params.navGroup}', order: 11 }`, ] : [ - `{ label: '${capitalize(params.name)}', to: '/${params.route}', order: 10 }`, - `{ label: '${capitalize(params.name)} List', to: '/${params.route}/list', order: 11 }`, + `{ label: '${label}', to: '/${params.route}', order: 10 }`, + `{ label: '${label} List', to: '/${params.route}/list', order: 11 }`, ]; return `import { defineModule } from '@react-router-modules/core' import type { RouteObject } from 'react-router' -import type { AppDependencies, AppSlots } from '${params.scope}/app-shared' +import type { AppDependencies, AppSlots, AppZones } from '${params.scope}/app-shared' +import { ${label}DetailPanel } from './panels/DetailPanel.js' export default defineModule({ id: '${params.name}', version: '0.1.0', + // Catalog metadata — the shell discovers modules via useModules() + getModuleMeta() + meta: { + name: '${label}', + description: '${label} module', + category: 'general', + }, + createRoutes: (): RouteObject => ({ path: '${params.route}', children: [ @@ -84,6 +93,8 @@ export default defineModule({ { path: 'list', lazy: () => import('./pages/${params.listPageName}.js').then((m) => ({ Component: m.default })), + // Route zone — the shell renders this in its detail panel slot while this route is active. + handle: { detailPanel: ${label}DetailPanel } satisfies AppZones, }, ], }), @@ -92,11 +103,41 @@ export default defineModule({ ${navItems.join(",\n ")}, ], + // Commands aggregated into the shell's command palette / action bar + slots: { + commands: [ + { + id: '${params.name}:refresh', + label: 'Refresh ${label}', + group: 'actions', + onSelect: () => window.location.reload(), + }, + ], + }, + requires: ['auth'], }) `; } +export function moduleDetailPanel(params: { moduleLabel: string }): string { + return `// Rendered by the shell in its detail-panel zone when the list route is active. +// See the module descriptor's \`handle: { detailPanel: ... }\` for the wiring. +export function ${params.moduleLabel}DetailPanel() { + return ( +
+

+ ${params.moduleLabel} details +

+

+ This panel is contributed by the ${params.moduleLabel.toLowerCase()} module via a route zone. +

+
+ ) +} +`; +} + export function modulePage(params: { scope: string; pageName: string; diff --git a/packages/react-router-cli/src/templates/shell.ts b/packages/react-router-cli/src/templates/shell.ts index 9734c30..67e81ff 100644 --- a/packages/react-router-cli/src/templates/shell.ts +++ b/packages/react-router-cli/src/templates/shell.ts @@ -101,7 +101,8 @@ import ${params.importName} from '${params.scope}/${params.moduleName}-module' import { authStore } from './stores/auth.js' import { configStore } from './stores/config.js' import { httpClient } from './services/http-client.js' -import { Layout } from './components/Layout.js' +import { RootLayout } from './components/RootLayout.js' +import { ShellLayout } from './components/ShellLayout.js' import { Home } from './components/Home.js' // Create the registry with shared dependencies @@ -115,10 +116,32 @@ const registry = createRegistry({ // Register modules registry.register(${params.importName}) -// Resolve — validates everything and produces the app +// Resolve — validates everything and produces the app. +// +// Layout split: +// RootLayout — runs for every route (public + protected). Use for observability. +// ShellLayout — renders the authenticated chrome (sidebar, header, detail panel). +// +// Auth guard is a no-op TODO. Replace \`loader\` with a real check and uncomment +// \`shellRoutes\` to expose public pages like /login. See: +// https://github.com/kibertoad/modular-react/blob/main/docs/shell-patterns-react-router.md const { App } = registry.resolve({ - rootComponent: Layout, + rootComponent: RootLayout, indexComponent: Home, + + authenticatedRoute: { + loader: () => { + // TODO: replace with real auth check. Example: + // const { isAuthenticated } = authStore.getState() + // if (!isAuthenticated) throw redirect('/login') + return null + }, + Component: ShellLayout, + }, + + // shellRoutes: () => [ + // { path: '/login', Component: LoginPage }, + // ], }) createRoot(document.getElementById('root')!).render() @@ -186,16 +209,46 @@ export const httpClient = wretch() `; } -export function shellLayout(params: { scope: string }): string { +export function shellRootLayout(): string { + return `import { Outlet } from 'react-router' + +// The root layout renders for every route — public or protected. +// It's the place for app-wide concerns that must run even on public pages: +// analytics, feature flags, error reporting, global providers that don't +// depend on authentication. +// +// The authenticated chrome (sidebar, header, detail panel) lives in +// ShellLayout, which is mounted via \`authenticatedRoute.Component\`. +export function RootLayout() { + // TODO: Add observability / analytics here. For example: + // useLocation() + track page view + return +} +`; +} + +export function shellShellLayout(params: { scope: string }): string { return `import { Outlet } from 'react-router' +import { useSlots, useZones } from '@react-router-modules/runtime' import { useStore } from '${params.scope}/app-shared' +import type { AppSlots, AppZones } from '${params.scope}/app-shared' import { Sidebar } from './Sidebar.js' -export function Layout() { +// The authenticated shell chrome. Rendered under the \`authenticatedRoute\` +// layout in main.tsx — everything below this layout has (conceptually) +// cleared the auth guard. +// +// This is also where cross-cutting module contributions get rendered: +// - \`useSlots().commands\` — the action bar in the header +// - \`useZones().detailPanel\` — the right-hand detail panel +export function ShellLayout() { const user = useStore('auth', (s) => s.user) const isAuthenticated = useStore('auth', (s) => s.isAuthenticated) const login = useStore('auth', (s) => s.login) const logout = useStore('auth', (s) => s.logout) + const { commands } = useSlots() + const zones = useZones() + const DetailPanel = zones.detailPanel return (
@@ -205,45 +258,76 @@ export function Layout() { padding: '0.75rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', - justifyContent: 'flex-end', alignItems: 'center', - gap: '1rem', + gap: '0.5rem', }}> - {isAuthenticated ? ( - <> - {user?.name} - - - ) : ( + {/* Commands contributed by modules via slots.commands */} + {commands.map((cmd) => ( - )} + ))} + +
+ {isAuthenticated ? ( + <> + {user?.name} + + + ) : ( + + )} +
-
- -
+
+
+ +
+ {DetailPanel && ( + + )} +
) diff --git a/packages/react-router-cli/test/cli.test.ts b/packages/react-router-cli/test/cli.test.ts index 0b52f85..137e4dc 100644 --- a/packages/react-router-cli/test/cli.test.ts +++ b/packages/react-router-cli/test/cli.test.ts @@ -45,15 +45,17 @@ describe("reactive init", { sequential: true }, () => { files.fileExists("my-app/shell/src/stores/auth.ts"); files.fileExists("my-app/shell/src/stores/config.ts"); files.fileExists("my-app/shell/src/services/http-client.ts"); - files.fileExists("my-app/shell/src/components/Layout.tsx"); + files.fileExists("my-app/shell/src/components/RootLayout.tsx"); + files.fileExists("my-app/shell/src/components/ShellLayout.tsx"); files.fileExists("my-app/shell/src/components/Sidebar.tsx"); files.fileExists("my-app/shell/src/components/Home.tsx"); - // Verify module with two pages + // Verify module with two pages and a route-zone detail panel files.fileExists("my-app/modules/dashboard/package.json"); files.fileExists("my-app/modules/dashboard/src/index.ts"); files.fileExists("my-app/modules/dashboard/src/pages/DashboardDashboard.tsx"); files.fileExists("my-app/modules/dashboard/src/pages/DashboardList.tsx"); + files.fileExists("my-app/modules/dashboard/src/panels/DetailPanel.tsx"); }); it("uses scope in generated package names", async () => { diff --git a/packages/tanstack-router-cli/src/commands/create-module.ts b/packages/tanstack-router-cli/src/commands/create-module.ts index 7b6a94f..40d76fd 100644 --- a/packages/tanstack-router-cli/src/commands/create-module.ts +++ b/packages/tanstack-router-cli/src/commands/create-module.ts @@ -11,6 +11,7 @@ import { moduleDescriptor, modulePage, moduleListPage, + moduleDetailPanel, moduleTest, } from "../templates/module.js"; @@ -103,7 +104,9 @@ export default defineCommand({ const importName = toCamelCase(name); // Scaffold module directory + const moduleLabel = toPascalCase(name); mkdirSync(resolve(moduleDir, "src", "pages"), { recursive: true }); + mkdirSync(resolve(moduleDir, "src", "panels"), { recursive: true }); mkdirSync(resolve(moduleDir, "src", "__tests__"), { recursive: true }); writeFileSync(resolve(moduleDir, "package.json"), modulePackageJson({ scope, name })); writeFileSync(resolve(moduleDir, "tsconfig.json"), moduleTsconfig()); @@ -113,11 +116,15 @@ export default defineCommand({ ); writeFileSync( resolve(moduleDir, "src", "pages", `${pageName}.tsx`), - modulePage({ scope, pageName, moduleLabel: toPascalCase(name), moduleName: name }), + modulePage({ scope, pageName, moduleLabel, moduleName: name }), ); writeFileSync( resolve(moduleDir, "src", "pages", `${listPageName}.tsx`), - moduleListPage({ scope, pageName: listPageName, moduleLabel: toPascalCase(name) }), + moduleListPage({ scope, pageName: listPageName, moduleLabel }), + ); + writeFileSync( + resolve(moduleDir, "src", "panels", "DetailPanel.tsx"), + moduleDetailPanel({ moduleLabel }), ); writeFileSync( resolve(moduleDir, "src", "__tests__", `${name}.test.ts`), diff --git a/packages/tanstack-router-cli/src/commands/init.ts b/packages/tanstack-router-cli/src/commands/init.ts index e25e830..152817f 100644 --- a/packages/tanstack-router-cli/src/commands/init.ts +++ b/packages/tanstack-router-cli/src/commands/init.ts @@ -24,7 +24,8 @@ import { shellAuthStore, shellConfigStore, shellHttpClient, - shellLayout, + shellRootLayout, + shellShellLayout, shellSidebar, shellHome, } from "../templates/shell.js"; @@ -34,6 +35,7 @@ import { moduleDescriptor, modulePage, moduleListPage, + moduleDetailPanel, } from "../templates/module.js"; export default defineCommand({ @@ -167,7 +169,14 @@ function scaffold( shellConfigStore({ scope, appName: projectName }), ); writeFileSync(resolve(root, "shell", "src", "services", "http-client.ts"), shellHttpClient()); - writeFileSync(resolve(root, "shell", "src", "components", "Layout.tsx"), shellLayout({ scope })); + writeFileSync( + resolve(root, "shell", "src", "components", "RootLayout.tsx"), + shellRootLayout(), + ); + writeFileSync( + resolve(root, "shell", "src", "components", "ShellLayout.tsx"), + shellShellLayout({ scope }), + ); writeFileSync( resolve(root, "shell", "src", "components", "Sidebar.tsx"), shellSidebar({ projectName }), @@ -176,7 +185,9 @@ function scaffold( // First module (with two routes for testable routing) const moduleDir = resolve(root, "modules", moduleName); + const moduleLabel = toPascalCase(moduleName); mkdirSync(resolve(moduleDir, "src", "pages"), { recursive: true }); + mkdirSync(resolve(moduleDir, "src", "panels"), { recursive: true }); writeFileSync(resolve(moduleDir, "package.json"), modulePackageJson({ scope, name: moduleName })); writeFileSync(resolve(moduleDir, "tsconfig.json"), moduleTsconfig()); writeFileSync( @@ -185,11 +196,15 @@ function scaffold( ); writeFileSync( resolve(moduleDir, "src", "pages", `${pageName}.tsx`), - modulePage({ scope, pageName, moduleLabel: toPascalCase(moduleName), moduleName }), + modulePage({ scope, pageName, moduleLabel, moduleName }), ); writeFileSync( resolve(moduleDir, "src", "pages", `${listPageName}.tsx`), - moduleListPage({ scope, pageName: listPageName, moduleLabel: toPascalCase(moduleName) }), + moduleListPage({ scope, pageName: listPageName, moduleLabel }), + ); + writeFileSync( + resolve(moduleDir, "src", "panels", "DetailPanel.tsx"), + moduleDetailPanel({ moduleLabel }), ); } diff --git a/packages/tanstack-router-cli/src/templates/app-shared.ts b/packages/tanstack-router-cli/src/templates/app-shared.ts index 53dc4fc..4c685aa 100644 --- a/packages/tanstack-router-cli/src/templates/app-shared.ts +++ b/packages/tanstack-router-cli/src/templates/app-shared.ts @@ -20,10 +20,14 @@ export function appSharedPackageJson(params: { scope: string }): string { zod: "^3.25.0", }, peerDependencies: { + // Required so app-shared can augment `@tanstack/router-core`'s + // `StaticDataRouteOption` interface with AppZones. + "@tanstack/router-core": "^1.120.0", react: "^19.0.0", zustand: "^5.0.0", }, devDependencies: { + "@tanstack/router-core": "^1.120.0", react: "^19.0.0", zustand: "^5.0.0", "@types/react": "^19.0.0", @@ -48,6 +52,7 @@ export function appSharedTsconfig(): string { export function appSharedIndex(_params: { scope: string }): string { return `import { createSharedHooks } from '@tanstack-react-modules/core' +import type { ComponentType } from 'react' import type { LoginCredentials, User } from './types.js' import type { Wretch } from 'wretch' @@ -79,7 +84,7 @@ export interface AppDependencies { httpClient: Wretch } -// ---- Slots ---- +// ---- Slots (static contributions from every module) ---- export interface CommandDefinition { readonly id: string @@ -93,6 +98,22 @@ export interface AppSlots { commands: CommandDefinition[] } +// ---- Zones (per-route layout regions a module can fill) ---- +// Declared on a route's \`staticData\` and read by the shell via \`useZones()\`. + +export interface AppZones { + detailPanel?: ComponentType + headerActions?: ComponentType +} + +// Type-safe staticData: tells TanStack Router that createRoute({ staticData: { ... } }) +// should accept \`AppZones\` keys with compile-time checking. +// The empty import ensures TypeScript loads the target module before we augment it. +import type {} from '@tanstack/router-core' +declare module '@tanstack/router-core' { + interface StaticDataRouteOption extends AppZones {} +} + // ---- Typed hooks (use these in all modules) ---- export const { useStore, useService, useReactiveService, useOptional } = createSharedHooks() diff --git a/packages/tanstack-router-cli/src/templates/module.ts b/packages/tanstack-router-cli/src/templates/module.ts index 2fcc1c3..5ceacf0 100644 --- a/packages/tanstack-router-cli/src/templates/module.ts +++ b/packages/tanstack-router-cli/src/templates/module.ts @@ -56,24 +56,33 @@ export function moduleDescriptor(params: { listPageName: string; navGroup?: string; }): string { + const label = capitalize(params.name); const navItems = params.navGroup ? [ - `{ label: '${capitalize(params.name)}', to: '/${params.route}', group: '${params.navGroup}', order: 10 }`, - `{ label: '${capitalize(params.name)} List', to: '/${params.route}/list', group: '${params.navGroup}', order: 11 }`, + `{ label: '${label}', to: '/${params.route}', group: '${params.navGroup}', order: 10 }`, + `{ label: '${label} List', to: '/${params.route}/list', group: '${params.navGroup}', order: 11 }`, ] : [ - `{ label: '${capitalize(params.name)}', to: '/${params.route}', order: 10 }`, - `{ label: '${capitalize(params.name)} List', to: '/${params.route}/list', order: 11 }`, + `{ label: '${label}', to: '/${params.route}', order: 10 }`, + `{ label: '${label} List', to: '/${params.route}/list', order: 11 }`, ]; return `import { defineModule } from '@tanstack-react-modules/core' import { createRoute, lazyRouteComponent } from '@tanstack/react-router' import type { AppDependencies, AppSlots } from '${params.scope}/app-shared' +import { ${label}DetailPanel } from './panels/DetailPanel.js' export default defineModule({ id: '${params.name}', version: '0.1.0', + // Catalog metadata — the shell discovers modules via useModules() + getModuleMeta() + meta: { + name: '${label}', + description: '${label} module', + category: 'general', + }, + createRoutes: (parentRoute) => { const root = createRoute({ getParentRoute: () => parentRoute, @@ -90,6 +99,11 @@ export default defineModule({ getParentRoute: () => root, path: 'list', component: lazyRouteComponent(() => import('./pages/${params.listPageName}.js')), + // Route zone — the shell renders this in its detail panel slot while this route is active. + // Typed via the AppZones augmentation in app-shared. + staticData: { + detailPanel: ${label}DetailPanel, + }, }) return root.addChildren([index, list]) @@ -99,11 +113,41 @@ export default defineModule({ ${navItems.join(",\n ")}, ], + // Commands aggregated into the shell's command palette / action bar + slots: { + commands: [ + { + id: '${params.name}:refresh', + label: 'Refresh ${label}', + group: 'actions', + onSelect: () => window.location.reload(), + }, + ], + }, + requires: ['auth'], }) `; } +export function moduleDetailPanel(params: { moduleLabel: string }): string { + return `// Rendered by the shell in its detail-panel zone when the list route is active. +// See the module descriptor's \`staticData: { detailPanel: ... }\` for the wiring. +export function ${params.moduleLabel}DetailPanel() { + return ( +
+

+ ${params.moduleLabel} details +

+

+ This panel is contributed by the ${params.moduleLabel.toLowerCase()} module via a route zone. +

+
+ ) +} +`; +} + export function modulePage(params: { scope: string; pageName: string; diff --git a/packages/tanstack-router-cli/src/templates/shell.ts b/packages/tanstack-router-cli/src/templates/shell.ts index 67f2a7f..e380c7f 100644 --- a/packages/tanstack-router-cli/src/templates/shell.ts +++ b/packages/tanstack-router-cli/src/templates/shell.ts @@ -101,7 +101,8 @@ import ${params.importName} from '${params.scope}/${params.moduleName}-module' import { authStore } from './stores/auth.js' import { configStore } from './stores/config.js' import { httpClient } from './services/http-client.js' -import { Layout } from './components/Layout.js' +import { RootLayout } from './components/RootLayout.js' +import { ShellLayout } from './components/ShellLayout.js' import { Home } from './components/Home.js' // Create the registry with shared dependencies @@ -115,10 +116,31 @@ const registry = createRegistry({ // Register modules registry.register(${params.importName}) -// Resolve — validates everything and produces the app +// Resolve — validates everything and produces the app. +// +// Layout split: +// RootLayout — runs for every route (public + protected). Use for observability. +// ShellLayout — renders the authenticated chrome (sidebar, header, detail panel). +// +// Auth guard is a no-op TODO. Replace \`beforeLoad\` with a real check and uncomment +// \`shellRoutes\` to expose public pages like /login. See: +// https://github.com/kibertoad/modular-react/blob/main/docs/shell-patterns-tanstack-router.md const { App } = registry.resolve({ - rootComponent: Layout, + rootComponent: RootLayout, indexComponent: Home, + + authenticatedRoute: { + beforeLoad: () => { + // TODO: replace with real auth check. Example: + // const { isAuthenticated } = authStore.getState() + // if (!isAuthenticated) throw redirect({ to: '/login' }) + }, + component: ShellLayout, + }, + + // shellRoutes: (root) => [ + // createRoute({ getParentRoute: () => root, path: '/login', component: LoginPage }), + // ], }) createRoot(document.getElementById('root')!).render() @@ -186,16 +208,46 @@ export const httpClient = wretch() `; } -export function shellLayout(params: { scope: string }): string { +export function shellRootLayout(): string { + return `import { Outlet } from '@tanstack/react-router' + +// The root layout renders for every route — public or protected. +// It's the place for app-wide concerns that must run even on public pages: +// analytics, feature flags, error reporting, global providers that don't +// depend on authentication. +// +// The authenticated chrome (sidebar, header, detail panel) lives in +// ShellLayout, which is mounted via \`authenticatedRoute.component\`. +export function RootLayout() { + // TODO: Add observability / analytics here. For example: + // useRouterState() + track page view + return +} +`; +} + +export function shellShellLayout(params: { scope: string }): string { return `import { Outlet } from '@tanstack/react-router' +import { useSlots, useZones } from '@tanstack-react-modules/runtime' import { useStore } from '${params.scope}/app-shared' +import type { AppSlots, AppZones } from '${params.scope}/app-shared' import { Sidebar } from './Sidebar.js' -export function Layout() { +// The authenticated shell chrome. Rendered under the \`authenticatedRoute\` +// layout in main.tsx — everything below this layout has (conceptually) +// cleared the auth guard. +// +// This is also where cross-cutting module contributions get rendered: +// - \`useSlots().commands\` — the action bar in the header +// - \`useZones().detailPanel\` — the right-hand detail panel +export function ShellLayout() { const user = useStore('auth', (s) => s.user) const isAuthenticated = useStore('auth', (s) => s.isAuthenticated) const login = useStore('auth', (s) => s.login) const logout = useStore('auth', (s) => s.logout) + const { commands } = useSlots() + const zones = useZones() + const DetailPanel = zones.detailPanel return (
@@ -205,45 +257,76 @@ export function Layout() { padding: '0.75rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', - justifyContent: 'flex-end', alignItems: 'center', - gap: '1rem', + gap: '0.5rem', }}> - {isAuthenticated ? ( - <> - {user?.name} - - - ) : ( + {/* Commands contributed by modules via slots.commands */} + {commands.map((cmd) => ( - )} + ))} + +
+ {isAuthenticated ? ( + <> + {user?.name} + + + ) : ( + + )} +
-
- -
+
+
+ +
+ {DetailPanel && ( + + )} +
) diff --git a/packages/tanstack-router-cli/test/cli.test.ts b/packages/tanstack-router-cli/test/cli.test.ts index 0b52f85..137e4dc 100644 --- a/packages/tanstack-router-cli/test/cli.test.ts +++ b/packages/tanstack-router-cli/test/cli.test.ts @@ -45,15 +45,17 @@ describe("reactive init", { sequential: true }, () => { files.fileExists("my-app/shell/src/stores/auth.ts"); files.fileExists("my-app/shell/src/stores/config.ts"); files.fileExists("my-app/shell/src/services/http-client.ts"); - files.fileExists("my-app/shell/src/components/Layout.tsx"); + files.fileExists("my-app/shell/src/components/RootLayout.tsx"); + files.fileExists("my-app/shell/src/components/ShellLayout.tsx"); files.fileExists("my-app/shell/src/components/Sidebar.tsx"); files.fileExists("my-app/shell/src/components/Home.tsx"); - // Verify module with two pages + // Verify module with two pages and a route-zone detail panel files.fileExists("my-app/modules/dashboard/package.json"); files.fileExists("my-app/modules/dashboard/src/index.ts"); files.fileExists("my-app/modules/dashboard/src/pages/DashboardDashboard.tsx"); files.fileExists("my-app/modules/dashboard/src/pages/DashboardList.tsx"); + files.fileExists("my-app/modules/dashboard/src/panels/DetailPanel.tsx"); }); it("uses scope in generated package names", async () => { From 50134920653a28dabc5980c446ec911f353617c1 Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Thu, 9 Apr 2026 14:06:58 +0300 Subject: [PATCH 2/2] fix linting --- README.md | 20 +++---- docs/getting-started-react-router.md | 43 ++++++++------- docs/getting-started-tanstack-router.md | 54 +++++++++---------- docs/shell-patterns-react-router.md | 12 ++--- docs/shell-patterns-tanstack-router.md | 16 +++--- docs/shell-patterns.md | 26 ++++----- .../react-router-cli/src/commands/init.ts | 5 +- .../tanstack-router-cli/src/commands/init.ts | 5 +- 8 files changed, 91 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index c0528fe..b294fd0 100644 --- a/README.md +++ b/README.md @@ -68,14 +68,14 @@ For the walkthrough of what the scaffold produces and how to extend it, see the Conceptual documentation for building apps with the framework. Start with a getting-started guide, then dig into the shell patterns once you want to go beyond the defaults. -| Guide | What it covers | -| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| [Getting started — React Router](docs/getting-started-react-router.md) | Scaffold, tour the generated workspace, add modules and stores, turn on the auth guard. | -| [Getting started — TanStack Router](docs/getting-started-tanstack-router.md) | Same walkthrough for the TSR integration, including the `staticData` type augmentation and `beforeLoad` auth guard. | -| [Shell Patterns (Fundamentals)](docs/shell-patterns.md) | Multi-zone layouts, command palette, module-to-shell communication, headless modules, optional deps, cross-store coordination. | -| [Shell Patterns — React Router](docs/shell-patterns-react-router.md) | Module route shape, route zones via `handle`, `authenticatedRoute` with `loader`, public `shellRoutes`. | -| [Shell Patterns — TanStack Router](docs/shell-patterns-tanstack-router.md) | Module route shape with `createRoute`/`getParentRoute`, route zones via `staticData`, `authenticatedRoute` with `beforeLoad`. | -| [Workspace Patterns](docs/workspace-patterns.md) | Tabbed workspaces, component-only modules, `useActiveZones`, per-session state via `createScopedStore`. | +| Guide | What it covers | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| [Getting started — React Router](docs/getting-started-react-router.md) | Scaffold, tour the generated workspace, add modules and stores, turn on the auth guard. | +| [Getting started — TanStack Router](docs/getting-started-tanstack-router.md) | Same walkthrough for the TSR integration, including the `staticData` type augmentation and `beforeLoad` auth guard. | +| [Shell Patterns (Fundamentals)](docs/shell-patterns.md) | Multi-zone layouts, command palette, module-to-shell communication, headless modules, optional deps, cross-store coordination. | +| [Shell Patterns — React Router](docs/shell-patterns-react-router.md) | Module route shape, route zones via `handle`, `authenticatedRoute` with `loader`, public `shellRoutes`. | +| [Shell Patterns — TanStack Router](docs/shell-patterns-tanstack-router.md) | Module route shape with `createRoute`/`getParentRoute`, route zones via `staticData`, `authenticatedRoute` with `beforeLoad`. | +| [Workspace Patterns](docs/workspace-patterns.md) | Tabbed workspaces, component-only modules, `useActiveZones`, per-session state via `createScopedStore`. | ## What the code looks like @@ -91,7 +91,9 @@ export default defineModule({ navigation: [{ label: "Billing", to: "/billing", group: "finance" }], slots: { commands: [{ id: "export", label: "Export Invoices", onSelect: exportInvoices }] }, dynamicSlots: (deps) => ({ - commands: deps.auth.user?.isAdmin ? [{ id: "void", label: "Void Invoice", onSelect: voidInvoice }] : [], + commands: deps.auth.user?.isAdmin + ? [{ id: "void", label: "Void Invoice", onSelect: voidInvoice }] + : [], }), }); ``` diff --git a/docs/getting-started-react-router.md b/docs/getting-started-react-router.md index 68cc13e..3216edf 100644 --- a/docs/getting-started-react-router.md +++ b/docs/getting-started-react-router.md @@ -103,51 +103,54 @@ The scaffold bakes in [`wretch`](https://github.com/elbywan/wretch) + [`@lokalis Open `modules/dashboard/src/index.ts`. This is the entire module definition: ```typescript -import { defineModule } from '@react-router-modules/core' -import type { RouteObject } from 'react-router' -import type { AppDependencies, AppSlots, AppZones } from '@myorg/app-shared' -import { DashboardDetailPanel } from './panels/DetailPanel.js' +import { defineModule } from "@react-router-modules/core"; +import type { RouteObject } from "react-router"; +import type { AppDependencies, AppSlots, AppZones } from "@myorg/app-shared"; +import { DashboardDetailPanel } from "./panels/DetailPanel.js"; export default defineModule({ - id: 'dashboard', - version: '0.1.0', + id: "dashboard", + version: "0.1.0", meta: { - name: 'Dashboard', - description: 'Dashboard module', - category: 'general', + name: "Dashboard", + description: "Dashboard module", + category: "general", }, createRoutes: (): RouteObject => ({ - path: 'dashboard', + path: "dashboard", children: [ - { index: true, lazy: () => import('./pages/DashboardDashboard.js').then((m) => ({ Component: m.default })) }, { - path: 'list', - lazy: () => import('./pages/DashboardList.js').then((m) => ({ Component: m.default })), + index: true, + lazy: () => import("./pages/DashboardDashboard.js").then((m) => ({ Component: m.default })), + }, + { + path: "list", + lazy: () => import("./pages/DashboardList.js").then((m) => ({ Component: m.default })), handle: { detailPanel: DashboardDetailPanel } satisfies AppZones, }, ], }), navigation: [ - { label: 'Dashboard', to: '/dashboard', order: 10 }, - { label: 'Dashboard List', to: '/dashboard/list', order: 11 }, + { label: "Dashboard", to: "/dashboard", order: 10 }, + { label: "Dashboard List", to: "/dashboard/list", order: 11 }, ], slots: { commands: [ { - id: 'dashboard:refresh', - label: 'Refresh Dashboard', - group: 'actions', + id: "dashboard:refresh", + label: "Refresh Dashboard", + group: "actions", onSelect: () => window.location.reload(), }, ], }, - requires: ['auth'], -}) + requires: ["auth"], +}); ``` A single object describes everything the module contributes: diff --git a/docs/getting-started-tanstack-router.md b/docs/getting-started-tanstack-router.md index 7ef3f9a..b27e071 100644 --- a/docs/getting-started-tanstack-router.md +++ b/docs/getting-started-tanstack-router.md @@ -106,8 +106,8 @@ TanStack Router's `staticData` field is intentionally loosely typed so apps can // Type-safe staticData: tells TanStack Router that createRoute({ staticData: { ... } }) // should accept `AppZones` keys with compile-time checking. // The empty import ensures TypeScript loads the target module before we augment it. -import type {} from '@tanstack/router-core' -declare module '@tanstack/router-core' { +import type {} from "@tanstack/router-core"; +declare module "@tanstack/router-core" { interface StaticDataRouteOption extends AppZones {} } ``` @@ -122,63 +122,63 @@ Two things to know: Open `modules/dashboard/src/index.ts`. This is the entire module definition: ```typescript -import { defineModule } from '@tanstack-react-modules/core' -import { createRoute, lazyRouteComponent } from '@tanstack/react-router' -import type { AppDependencies, AppSlots } from '@myorg/app-shared' -import { DashboardDetailPanel } from './panels/DetailPanel.js' +import { defineModule } from "@tanstack-react-modules/core"; +import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; +import type { AppDependencies, AppSlots } from "@myorg/app-shared"; +import { DashboardDetailPanel } from "./panels/DetailPanel.js"; export default defineModule({ - id: 'dashboard', - version: '0.1.0', + id: "dashboard", + version: "0.1.0", meta: { - name: 'Dashboard', - description: 'Dashboard module', - category: 'general', + name: "Dashboard", + description: "Dashboard module", + category: "general", }, createRoutes: (parentRoute) => { const root = createRoute({ getParentRoute: () => parentRoute, - path: 'dashboard', - }) + path: "dashboard", + }); const index = createRoute({ getParentRoute: () => root, - path: '/', - component: lazyRouteComponent(() => import('./pages/DashboardDashboard.js')), - }) + path: "/", + component: lazyRouteComponent(() => import("./pages/DashboardDashboard.js")), + }); const list = createRoute({ getParentRoute: () => root, - path: 'list', - component: lazyRouteComponent(() => import('./pages/DashboardList.js')), + path: "list", + component: lazyRouteComponent(() => import("./pages/DashboardList.js")), staticData: { detailPanel: DashboardDetailPanel, }, - }) + }); - return root.addChildren([index, list]) + return root.addChildren([index, list]); }, navigation: [ - { label: 'Dashboard', to: '/dashboard', order: 10 }, - { label: 'Dashboard List', to: '/dashboard/list', order: 11 }, + { label: "Dashboard", to: "/dashboard", order: 10 }, + { label: "Dashboard List", to: "/dashboard/list", order: 11 }, ], slots: { commands: [ { - id: 'dashboard:refresh', - label: 'Refresh Dashboard', - group: 'actions', + id: "dashboard:refresh", + label: "Refresh Dashboard", + group: "actions", onSelect: () => window.location.reload(), }, ], }, - requires: ['auth'], -}) + requires: ["auth"], +}); ``` A single object describes everything the module contributes: diff --git a/docs/shell-patterns-react-router.md b/docs/shell-patterns-react-router.md index d5b3b3a..ea7cd5e 100644 --- a/docs/shell-patterns-react-router.md +++ b/docs/shell-patterns-react-router.md @@ -188,13 +188,13 @@ export default defineModule({ ## createRoutes signature summary -| Aspect | React Router | -| ---------------------- | ---------------------------------------------------------------- | -| Return type | `RouteObject \| RouteObject[]` | -| Parent argument | None — the runtime grafts your routes onto the auth boundary | +| Aspect | React Router | +| ---------------------- | ------------------------------------------------------------------------- | +| Return type | `RouteObject \| RouteObject[]` | +| Parent argument | None — the runtime grafts your routes onto the auth boundary | | Code splitting | `lazy: () => import('./Page.js').then((m) => ({ Component: m.default }))` | -| Zone declaration | `handle: { ... }` on the route object | -| Route-level auth guard | `loader: () => { throw redirect('/') }` | +| Zone declaration | `handle: { ... }` on the route object | +| Route-level auth guard | `loader: () => { throw redirect('/') }` | ## See also diff --git a/docs/shell-patterns-tanstack-router.md b/docs/shell-patterns-tanstack-router.md index 9f1d5b6..fc6aa52 100644 --- a/docs/shell-patterns-tanstack-router.md +++ b/docs/shell-patterns-tanstack-router.md @@ -184,7 +184,9 @@ export default defineModule({ }, }); // ... child routes - return root.addChildren([/* ... */]); + return root.addChildren([ + /* ... */ + ]); }, }); ``` @@ -193,13 +195,13 @@ export default defineModule({ ## createRoutes signature summary -| Aspect | TanStack Router | -| ---------------------- | ---------------------------------------------------------------- | -| Return type | `AnyRoute` (built via `createRoute` + `addChildren`) | +| Aspect | TanStack Router | +| ---------------------- | ----------------------------------------------------------------- | +| Return type | `AnyRoute` (built via `createRoute` + `addChildren`) | | Parent argument | `parentRoute: AnyRoute` — use `getParentRoute: () => parentRoute` | -| Code splitting | `component: lazyRouteComponent(() => import('./Page.js'))` | -| Zone declaration | `staticData: { ... }` on `createRoute` options | -| Route-level auth guard | `beforeLoad: () => { throw redirect({ to: '/' }) }` | +| Code splitting | `component: lazyRouteComponent(() => import('./Page.js'))` | +| Zone declaration | `staticData: { ... }` on `createRoute` options | +| Route-level auth guard | `beforeLoad: () => { throw redirect({ to: '/' }) }` | ## See also diff --git a/docs/shell-patterns.md b/docs/shell-patterns.md index 92e0b65..b10fa0c 100644 --- a/docs/shell-patterns.md +++ b/docs/shell-patterns.md @@ -15,11 +15,11 @@ This guide covers patterns for building shell applications with the modular-reac The framework is organized as three layers: -| Layer | Packages | Purpose | -| ------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| Shared foundation | `@modular-react/core`, `@modular-react/react`, `@modular-react/testing` | Router-agnostic: types, slot/navigation builders, React contexts and hooks, validation, stores. | -| React Router integration | `@react-router-modules/core`, `@react-router-modules/runtime`, … | `defineModule` returning `RouteObject[]`, registry that builds a React Router instance. | -| TanStack Router | `@tanstack-react-modules/core`, `@tanstack-react-modules/runtime`, … | `defineModule` using `createRoute` / `getParentRoute`, registry that builds a TanStack Router. | +| Layer | Packages | Purpose | +| ------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Shared foundation | `@modular-react/core`, `@modular-react/react`, `@modular-react/testing` | Router-agnostic: types, slot/navigation builders, React contexts and hooks, validation, stores. | +| React Router integration | `@react-router-modules/core`, `@react-router-modules/runtime`, … | `defineModule` returning `RouteObject[]`, registry that builds a React Router instance. | +| TanStack Router | `@tanstack-react-modules/core`, `@tanstack-react-modules/runtime`, … | `defineModule` using `createRoute` / `getParentRoute`, registry that builds a TanStack Router. | A shell app imports from exactly one of the two router integrations. The pattern code throughout this guide is identical across both — only the route construction and the auth guard wiring differ (see the companion docs). You can also write router-neutral code (shared stores, cross-module contracts, typed hooks) once and reuse it. @@ -72,13 +72,13 @@ export function Layout() { ### Which mechanism for which zone -| Zone content | Source | -| ------------------------------------------------------------------- | ------------------------------------------------------------- | -| Navigation links and mode switches | `useNavigation()` — modules declare `navigation` items | -| Commands, badges, aggregated contributions | `useSlots()` — modules declare `slots` contributions | -| Route-specific UI for layout regions (detail panel, header actions) | `useZones()` — the active route declares route-level zones | -| Active selection, panel visibility | Shared Zustand store — runtime state | -| Route-based page content | `` — the router renders the active module's routes | +| Zone content | Source | +| ------------------------------------------------------------------- | ------------------------------------------------------------ | +| Navigation links and mode switches | `useNavigation()` — modules declare `navigation` items | +| Commands, badges, aggregated contributions | `useSlots()` — modules declare `slots` contributions | +| Route-specific UI for layout regions (detail panel, header actions) | `useZones()` — the active route declares route-level zones | +| Active selection, panel visibility | Shared Zustand store — runtime state | +| Route-based page content | `` — the router renders the active module's routes | > **How a route declares zones differs by router.** React Router reads them from the route's `handle` field; TanStack Router reads them from `staticData`. See the companion docs. @@ -122,7 +122,7 @@ export interface AppSlots { - **System launching** — use a domain-specific slot (e.g. `slots.systems`) ```typescript -import { defineModule } from '@react-router-modules/core' // or '@tanstack-react-modules/core' +import { defineModule } from "@react-router-modules/core"; // or '@tanstack-react-modules/core' export default defineModule({ id: "billing", diff --git a/packages/react-router-cli/src/commands/init.ts b/packages/react-router-cli/src/commands/init.ts index 152817f..bb66455 100644 --- a/packages/react-router-cli/src/commands/init.ts +++ b/packages/react-router-cli/src/commands/init.ts @@ -169,10 +169,7 @@ function scaffold( shellConfigStore({ scope, appName: projectName }), ); writeFileSync(resolve(root, "shell", "src", "services", "http-client.ts"), shellHttpClient()); - writeFileSync( - resolve(root, "shell", "src", "components", "RootLayout.tsx"), - shellRootLayout(), - ); + writeFileSync(resolve(root, "shell", "src", "components", "RootLayout.tsx"), shellRootLayout()); writeFileSync( resolve(root, "shell", "src", "components", "ShellLayout.tsx"), shellShellLayout({ scope }), diff --git a/packages/tanstack-router-cli/src/commands/init.ts b/packages/tanstack-router-cli/src/commands/init.ts index 152817f..bb66455 100644 --- a/packages/tanstack-router-cli/src/commands/init.ts +++ b/packages/tanstack-router-cli/src/commands/init.ts @@ -169,10 +169,7 @@ function scaffold( shellConfigStore({ scope, appName: projectName }), ); writeFileSync(resolve(root, "shell", "src", "services", "http-client.ts"), shellHttpClient()); - writeFileSync( - resolve(root, "shell", "src", "components", "RootLayout.tsx"), - shellRootLayout(), - ); + writeFileSync(resolve(root, "shell", "src", "components", "RootLayout.tsx"), shellRootLayout()); writeFileSync( resolve(root, "shell", "src", "components", "ShellLayout.tsx"), shellShellLayout({ scope }),