From 581406e3dd8416ec9d1df5e1698fddd3f56575c3 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sun, 4 Jan 2026 19:22:35 -0700 Subject: [PATCH 1/6] Parallel routes RFC --- docs/rfcs/parallel-route-slots/README.md | 1166 +++++++++++++++++ .../parallel-route-slots/examples/README.md | 53 + .../examples/component-routes/README.md | 47 + .../routes/dashboard.@activity.tsx | 62 + .../routes/dashboard.@adminPanel.tsx | 59 + .../routes/dashboard.@header.tsx | 43 + .../routes/dashboard.@metrics.tsx | 48 + .../routes/dashboard.@notifications.tsx | 74 ++ .../routes/dashboard.@quickActions.tsx | 40 + .../routes/dashboard.@userCard.tsx | 53 + .../component-routes/routes/dashboard.tsx | 88 ++ .../examples/dashboard-widgets/README.md | 37 + .../dashboard-widgets/routes/__root.tsx | 24 + .../routes/dashboard.@activity.index.tsx | 39 + .../routes/dashboard.@activity.tsx | 30 + .../routes/dashboard.@metrics.index.tsx | 39 + .../routes/dashboard.@metrics.tsx | 33 + .../routes/dashboard.@quickActions.tsx | 43 + .../dashboard-widgets/routes/dashboard.tsx | 51 + .../examples/modal-with-navigation/README.md | 29 + .../routes/@modal.index.tsx | 26 + .../routes/@modal.settings.tsx | 69 + .../modal-with-navigation/routes/@modal.tsx | 26 + .../routes/@modal.users.$id.tsx | 87 ++ .../modal-with-navigation/routes/__root.tsx | 29 + .../examples/nested-slots/README.md | 34 + .../routes/@modal.@confirm.delete.tsx | 42 + .../routes/@modal.@confirm.discard.tsx | 39 + .../nested-slots/routes/@modal.@confirm.tsx | 21 + .../nested-slots/routes/@modal.settings.tsx | 74 ++ .../examples/nested-slots/routes/@modal.tsx | 32 + .../examples/nested-slots/routes/__root.tsx | 22 + .../examples/split-pane-mail/README.md | 37 + .../routes/mail.@list.inbox.tsx | 48 + .../split-pane-mail/routes/mail.@list.tsx | 16 + .../routes/mail.@preview.$id.tsx | 47 + .../routes/mail.@preview.index.tsx | 18 + .../split-pane-mail/routes/mail.@preview.tsx | 23 + .../examples/split-pane-mail/routes/mail.tsx | 43 + 39 files changed, 2791 insertions(+) create mode 100644 docs/rfcs/parallel-route-slots/README.md create mode 100644 docs/rfcs/parallel-route-slots/examples/README.md create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/README.md create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@activity.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@adminPanel.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@header.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@metrics.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@notifications.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@quickActions.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/dashboard-widgets/README.md create mode 100644 docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/__root.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@activity.index.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@activity.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@metrics.index.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@metrics.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@quickActions.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/modal-with-navigation/README.md create mode 100644 docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.settings.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/__root.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/nested-slots/README.md create mode 100644 docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.delete.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.discard.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.settings.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/nested-slots/routes/__root.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/split-pane-mail/README.md create mode 100644 docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@list.inbox.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@list.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.$id.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.index.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.tsx create mode 100644 docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.tsx diff --git a/docs/rfcs/parallel-route-slots/README.md b/docs/rfcs/parallel-route-slots/README.md new file mode 100644 index 0000000000..0e465bf22c --- /dev/null +++ b/docs/rfcs/parallel-route-slots/README.md @@ -0,0 +1,1166 @@ +# RFC: Parallel Route Slots + +**Status:** Draft +**Author:** Tanner Linsley +**Created:** 2026-01-04 + +--- + +## Overview + +This RFC proposes **Parallel Route Slots** - a system for rendering multiple independent route trees simultaneously, with each slot's state persisted in the URL via search parameters. This enables shareable, bookmarkable, SSR-compatible parallel routing without relying on client-side memory or React Server Components. + +--- + +## Motivation + +### The Problem + +Complex UIs often require multiple independent navigable areas: + +- **Modals** with internal navigation (e.g., multi-step wizards, user profile modals with tabs) +- **Drawers** with their own route hierarchy (e.g., notification drawer with nested views) +- **Split-pane layouts** where each pane navigates independently (e.g., email client, IDE-like interfaces) +- **Dashboard widgets** that fetch data in parallel (e.g., activity feed, metrics panels, quick actions) + +Current solutions in other frameworks fall short: + +| Framework | Approach | URL Persisted? | Survives Refresh? | Shareable? | +| ------------------ | ----------------------------------------------- | -------------- | ------------------------------- | ---------- | +| Next.js App Router | `@folder` parallel routes | No | No (uses `default.js` fallback) | No | +| Remix | Proposed "Sibling Routes" but never implemented | N/A | N/A | N/A | +| SvelteKit | No parallel route support | N/A | N/A | N/A | +| React Router | No parallel route support | N/A | N/A | N/A | + +### Why Existing Solutions Fail + +**Next.js Parallel Routes:** + +- Slot state lives in client memory during soft navigation +- On hard refresh, falls back to `default.js` - losing the user's place +- Cannot share a URL that includes modal/drawer state +- Bookmarking captures only the main route, not slot state + +**Remix "Sibling Routes" Proposal:** + +- Discussed in 2023 (Discussion #5431) but never implemented +- Team punted to RSC as the solution +- RSC doesn't actually solve URL persistence + +### The Solution + +Persist slot state in search parameters: + +``` +/dashboard?@modal=/users/123&@modal.tab=profile&@drawer=/notifications +``` + +This gives us: + +- **Shareable** - copy/paste URL includes complete slot state +- **Bookmarkable** - save the exact UI state including all open slots +- **SSR-compatible** - server sees slot paths in search params, can render correctly +- **Refresh-safe** - no state loss on browser refresh +- **Type-safe** - full TypeScript inference for slot navigation +- **Back/forward compatible** - browser history works naturally + +--- + +## Design Principles + +1. **URL is the source of truth** - Slot state lives in search params, not memory +2. **Slots are just route trees** - Same mental model as regular routes (loaders, components, search params, etc.) +3. **Parallel by default** - All matched slot loaders run in parallel with main route loaders +4. **Independent streaming** - Each slot can suspend independently +5. **Type-safe throughout** - Slot navigation is fully typed against the slot's route tree +6. **Progressive adoption** - Additive feature, no breaking changes to existing apps + +--- + +## URL Structure + +### Default Behavior: Slots Render Automatically + +Slots render by default when their parent route matches - no URL param needed for the default state. The URL only stores _deviations_ from the default: + +``` +/dashboard # all slots render at their root path +/dashboard?@activity=/recent # activity navigated away from root +/dashboard?@modal=/users/123 # modal open (if optional) or navigated +/dashboard?@metrics=false # metrics explicitly disabled +``` + +This keeps URLs clean. A dashboard with 5 widgets doesn't need 5 params just to show the default state. + +### When URL Params Are Needed + +A slot's state appears in the URL only when: + +1. **Navigated away from root** - `@modal=/users/123` +2. **Has search params** - `@modal.tab=profile` +3. **Explicitly disabled** - `@metrics=false` (opt-out of default rendering) + +### Slot Path Syntax + +``` +?@modal=/users/123 # slot at specific path +?@modal=/settings/profile # nested path within slot +?@modal # slot at root (rarely needed, see below) +?@modal=false # slot explicitly closed/disabled +``` + +The bare `@slotName` (no value) is only needed when you want to force a slot open that has `enabled` returning false by default, or to be explicit. + +### Slot Search Params + +Slot-specific search params use dot notation: + +``` +?@modal=/users/123&@modal.tab=profile&@modal.page=2 +``` + +Inside the slot's routes, these are accessed as just `tab` and `page` - the prefix is stripped. + +### Combined Example + +``` +/dashboard?filter=active&@modal=/users/123&@modal.tab=profile + ^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ + main route modal navigated modal search params + search (not at root) +``` + +vs the clean default: + +``` +/dashboard?filter=active # all default slots render, modal closed (if optional) +``` + +### Configuration + +```ts +createRouter({ + slotPrefix: '@', // default, configurable per-router +}) +``` + +--- + +## Route Definition + +### Slots Are Defined in Isolation + +Just like child routes, **slots don't need to be declared on their parent route**. They're defined as separate `@slotName` files and automatically composed into the route tree at generation time. + +The parent route discovers its slots after composition and can reference them type-safely in its component. + +### Global Slots (Children of Root) + +Global slots are `@slotName` files at the routes root - they become children of `__root.tsx`: + +```ts +// @modal.tsx - defined in isolation, no parent reference needed +export const Route = createSlotRoute({ + component: ModalWrapper, +}) + +// @modal.users.$id.tsx +export const Route = createSlotRoute({ + path: '/users/$id', + loader: ({ params }) => fetchUser(params.id), + component: UserModal, +}) +``` + +```ts +// __root.tsx - doesn't declare slots, just renders them +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + {/* Type-safe: Route knows about @modal from composition */} + + + + + ) +} +``` + +### Route-Scoped Slots + +Scoped slots use the `parentRoute.@slotName` file pattern: + +```ts +// dashboard.@activity.tsx - scoped to dashboard route +export const Route = createSlotRoute({ + loader: () => fetchActivityFeed(), + component: ActivityWidget, +}) + +// dashboard.@metrics.tsx +export const Route = createSlotRoute({ + loader: () => fetchMetrics(), + component: MetricsWidget, +}) +``` + +```ts +// dashboard.tsx - doesn't declare slots, discovers them after composition +export const Route = createFileRoute('/dashboard')({ + component: Dashboard, +}) + +function Dashboard() { + return ( +
+ +
+ +
+ +
+ ) +} +``` + +### How Composition Works + +The route generator: + +1. Detects `@slotName` files +2. Associates them with their parent route (root for `@modal`, dashboard for `dashboard.@activity`) +3. Generates the slot route trees +4. Makes slots available on `Route` for type-safe access in components + +This mirrors how child routes work - parents don't declare children, children reference parents, and composition wires everything together. + +--- + +## File-Based Convention + +Slots use the `@slotName` prefix in file names. Both flat and directory styles work, matching existing TanStack Router conventions. + +### Flat Style + +``` +routes/ +├── __root.tsx +├── @modal.tsx # global slot root +├── @modal.index.tsx # @modal=/ +├── @modal.users.$id.tsx # @modal=/users/123 +├── @modal.settings.tsx # @modal=/settings +├── @drawer.tsx # global slot root +├── @drawer.notifications.tsx # @drawer=/notifications +├── dashboard.tsx +├── dashboard.index.tsx +├── dashboard.@activity.tsx # route-scoped slot root +├── dashboard.@activity.index.tsx +├── dashboard.@metrics.tsx +└── dashboard.@metrics.index.tsx +``` + +### Directory Style + +``` +routes/ +├── __root.tsx +├── @modal/ +│ ├── route.tsx # slot root +│ ├── index.tsx # @modal=/ +│ ├── users.$id.tsx # @modal=/users/123 +│ └── settings.tsx # @modal=/settings +├── @drawer/ +│ ├── route.tsx +│ └── notifications.tsx +└── dashboard/ + ├── route.tsx + ├── index.tsx + ├── @activity/ + │ ├── route.tsx + │ └── index.tsx + └── @metrics/ + ├── route.tsx + └── index.tsx +``` + +### Mixed (Whatever Makes Sense) + +``` +routes/ +├── __root.tsx +├── @modal.tsx # simple slot root +├── @modal/ +│ ├── users.$id.tsx # children in folder +│ └── settings.tsx +├── dashboard.tsx +├── dashboard.@activity.tsx # inline scoped slot +└── dashboard.@activity.index.tsx +``` + +### Generator Behavior + +The route generator: + +1. Detects `@slotName` prefixed files/folders +2. Associates each slot with its parent route based on file path +3. Generates typed slot route trees +4. Augments parent route types so `Route.SlotOutlet`, `Route.Slots`, etc. are type-safe + +```ts +// Generated routeTree.gen.ts (simplified) + +// Slot routes generated from @modal.tsx, @modal.users.$id.tsx, etc. +const modalSlotRoot = createSlotRoute({ ... }) +const modalUsersIdRoute = createSlotRoute({ path: '/users/$id', ... }) +const modalSlotTree = modalSlotRoot.addChildren([modalUsersIdRoute, ...]) + +// Root route - slots are attached during composition, not in user code +export const rootRoute = createRootRoute({ ... }) + ._addSlots({ modal: modalSlotTree, drawer: drawerSlotTree }) + +// Dashboard route with its scoped slots +export const dashboardRoute = createFileRoute('/dashboard')({ ... }) + ._addSlots({ activity: activitySlotTree, metrics: metricsSlotTree }) +``` + +The `_addSlots` is internal - users never call it. The generator wires everything based on file structure. + +--- + +## Navigation API + +### Navigate to a Slot + +```ts +// Open modal to a specific route +router.navigate({ + slot: 'modal', + to: '/users/$id', + params: { id: '123' }, + search: { tab: 'profile' }, // becomes @modal.tab=profile +}) + +// Open modal to its index route +router.navigate({ slot: 'modal' }) // → @modal=/ +// or explicitly +router.navigate({ slot: 'modal', to: '/' }) +``` + +### Close a Slot + +```ts +router.navigate({ slot: 'modal', to: null }) +``` + +### Navigate Main Route + Slots Atomically + +```ts +// Navigate to dashboard and open modal, close drawer +router.navigate({ + to: '/dashboard', + slots: { + modal: { to: '/users/$id', params: { id: '123' } }, + drawer: null, // close drawer + }, +}) + +// Navigate main route, preserve all slot state (default behavior) +router.navigate({ to: '/settings' }) +// Slots stay as-is since they're in search params +``` + +### Link Component + +```tsx +// Open a slot + + Open User Modal + + +// Open slot at root + + Open Modal + + +// Close a slot + + Close Modal + + +// Navigate within a slot (from inside the slot) + + Go to Settings + +``` + +### Type Safety + +Navigation is fully typed against the slot's route tree: + +```ts +// Assuming modal has routes: /, /users/$id, /settings + +// ✅ Valid + + +// ❌ Type error - route doesn't exist in modal slot + + +// ❌ Type error - missing required param + + +// ❌ Type error - slot doesn't exist + +``` + +--- + +## Rendering API + +### SlotOutlet Component + +Render a slot's content. Type-safe based on the route's `slots` definition. + +```tsx +// Basic usage + + +// With fallback when slot is closed + +} /> +``` + +### useSlot Hook + +Access slot state and helpers programmatically: + +```ts +const modal = useSlot('modal') + +// Returns: +{ + isOpen: boolean, // is the slot currently open? + path: string | null, // current path within slot, null if closed + matches: RouteMatch[], // slot's route matches + search: ModalSearchSchema, // slot's search params (typed) + + // Navigation helpers + navigate: (opts) => void, // navigate within this slot + close: () => void, // close the slot +} + +// Example usage +function ModalTrigger() { + const modal = useSlot('modal') + + if (modal.isOpen) { + return + } + + return ( + + ) +} +``` + +### useSlotLoaderData Hook + +Access a slot route's loader data from outside the slot: + +```ts +// Access modal's current route loader data +const userData = useSlotLoaderData('modal', '/users/$id') +``` + +--- + +## Component Routes (Auto-Rendering Slots) + +**Slots render by default when their parent route matches.** No URL param needed for the default state. This makes slots ideal for dashboard widgets that should "just work". + +### Default Behavior + +```ts +// dashboard.@activity.tsx +export const Route = createSlotRoute({ + path: '/', + staticData: { + area: 'sidebar', + priority: 1, + }, + loader: () => fetchActivityFeed(), + component: ActivityWidget, +}) +``` + +When you navigate to `/dashboard`, the activity slot: + +- Automatically renders (no `@activity` param needed) +- Runs its loader in parallel with the dashboard loader +- Only appears in URL if navigated away from root: `@activity=/other-view` + +### Conditional Slots (Opt-Out) + +Use `enabled` to conditionally _disable_ a slot that would otherwise render: + +```ts +// dashboard.@adminPanel.tsx +export const Route = createSlotRoute({ + path: '/', + // Only render for admin users + enabled: ({ context }) => context.user.role === 'admin', + loader: () => fetchAdminStats(), + component: AdminPanel, +}) + +// dashboard.@notifications.tsx +export const Route = createSlotRoute({ + path: '/', + // User can disable in preferences + enabled: ({ context }) => + context.user.preferences.showNotifications !== false, + component: NotificationsWidget, +}) +``` + +When `enabled` returns `false`: + +- The slot doesn't render +- The slot's loader doesn't run +- The slot doesn't appear in `` iteration + +Users can also force-disable via URL: `?@notifications=false` + +### Using staticData for Filtering/Grouping + +Use the existing `staticData` (type-safe and extensible via module declaration) to organize slots: + +```ts +// Extend staticData types for your app (optional) +declare module '@tanstack/react-router' { + interface StaticDataRouteOption { + area?: 'sidebar' | 'main' | 'header' + priority?: number + title?: string + collapsible?: boolean + } +} + +// dashboard.@activity.tsx +export const Route = createSlotRoute({ + path: '/', + staticData: { + area: 'sidebar', + priority: 1, + title: 'Activity', + collapsible: true, + }, + component: ActivityWidget, +}) +``` + +### Iterating Over Slots + +The parent route can iterate over all its slots dynamically: + +```tsx +// dashboard.tsx +function Dashboard() { + return ( +
+ + {(slots) => ( + <> + {/* Filter slots by staticData and render in specific areas */} + + +
+ +
+ + + + )} +
+
+ ) +} +``` + +### Slot Object Shape + +Each slot in the render prop provides: + +```ts +interface SlotRenderInfo { + name: string // slot name (e.g., 'activity') + staticData: StaticDataRouteOption // from slot route definition (type-safe!) + isOpen: boolean // is this slot currently active? + path: string | null // current path within slot + matches: RouteMatch[] // slot's route matches + Outlet: ComponentType // renders the slot content +} +``` + +### Mixed Approach + +You can combine explicit placement with iteration: + +```tsx +function Dashboard() { + return ( +
+ {/* Explicitly placed slot */} +
+ +
+ + {/* Dynamically rendered slots */} + + {(slots) => ( +
+ {slots + .filter((s) => s.staticData?.area === 'grid') + .map((slot) => ( + + ))} +
+ )} +
+ + {/* Explicit main content */} +
+ +
+
+ ) +} +``` + +### File Structure for Component Routes + +``` +routes/ +├── dashboard.tsx # parent route with +├── dashboard.index.tsx # main content +├── dashboard.@header.tsx # explicitly placed +├── dashboard.@activity.tsx # area: 'grid', priority: 1 +├── dashboard.@metrics.tsx # area: 'grid', priority: 2 +├── dashboard.@notifications.tsx # area: 'grid', priority: 3 +├── dashboard.@adminPanel.tsx # area: 'grid', enabled for admins only +└── dashboard.@quickActions.tsx # area: 'sidebar' +``` + +### URL Behavior + +Slots render by default - URL only captures deviations: + +``` +/dashboard # all enabled slots render at root +/dashboard?@activity=/recent # activity navigated to /recent +/dashboard?@notifications=false # user explicitly hid notifications +/dashboard?@activity=/recent&@metrics=false # mixed state +``` + +The URL stays clean for the common case while still capturing all state when needed. + +--- + +## Loader Execution + +### Parallel Execution + +All matched loaders run in parallel - main route, child routes, and all active slot routes: + +``` +Navigation to /dashboard (with @modal=/users/123 in URL, other slots at default) + +Executes in parallel: +├── dashboard.loader() +├── @modal/users.$id.loader() # modal navigated to /users/123 +├── dashboard.@activity.loader() # renders by default +├── dashboard.@metrics.loader() # renders by default +└── dashboard.@quickActions.loader() # renders by default +``` + +This eliminates the waterfall problem. Each "widget" on a dashboard can fetch its own data without blocking others. + +### beforeLoad Remains Serial + +As with current nested routes, `beforeLoad` runs serially for authentication, redirects, etc.: + +``` +Serial (beforeLoad): +1. __root.beforeLoad() +2. dashboard.beforeLoad() + +Then parallel (loaders): +├── dashboard.loader() +├── @modal/users.$id.loader() +└── dashboard.@activity/index.loader() +``` + +### Slot beforeLoad + +Slots can have their own `beforeLoad` for slot-specific auth/guards: + +```ts +// @modal/users.$id.tsx +export const Route = createSlotRoute({ + path: '/users/$id', + beforeLoad: async ({ params }) => { + const canViewUser = await checkPermission(params.id) + if (!canViewUser) { + throw redirect({ slot: 'modal', to: '/unauthorized' }) + } + }, + loader: ({ params }) => fetchUser(params.id), +}) +``` + +--- + +## Search Params + +### Slot-Specific Search Schemas + +Each slot route can define its own search schema, just like regular routes: + +```ts +// @modal/users.$id.tsx +import { z } from 'zod' + +export const Route = createSlotRoute({ + path: '/users/$id', + validateSearch: z.object({ + tab: z.enum(['profile', 'settings', 'activity']).default('profile'), + expanded: z.boolean().default(false), + }), + component: UserModal, +}) + +function UserModal() { + const { tab, expanded } = Route.useSearch() + // tab and expanded are typed and validated +} +``` + +### URL Namespacing + +Slot search params are automatically namespaced in the URL: + +``` +Main route search: ?filter=active&page=2 +Modal search: ?@modal.tab=profile&@modal.expanded=true + +Combined URL: +/dashboard?filter=active&page=2&@modal=/users/123&@modal.tab=profile&@modal.expanded=true +``` + +Inside the slot's components, you access search params without the prefix: + +```ts +// Inside @modal/users.$id.tsx +const { tab, expanded } = Route.useSearch() // not @modal.tab, just tab +``` + +### Conflict Handling + +If a slot's search schema defines a key that conflicts with a parent route, the same behavior as current nested routes applies - it's an error at the schema validation level. Use namespacing conventions in your schemas to avoid conflicts. + +--- + +## Slot Lifecycle + +### Persistence + +Slots persist to the URL by default. This means: + +- Refresh preserves slot state +- Back/forward navigation works +- Sharing URL shares complete state +- SSR renders slots correctly + +### Slot Preservation on Navigation + +When navigating the main route without mentioning slots, slots are **preserved by default** (since they're in search params): + +```ts +// Modal stays open when navigating main route +router.navigate({ to: '/settings' }) + +// Explicitly close modal when navigating +router.navigate({ + to: '/settings', + slots: { modal: null }, +}) + +// Open a different modal route when navigating +router.navigate({ + to: '/settings', + slots: { modal: { to: '/confirmation' } }, +}) +``` + +### Scoped Slot Behavior + +Route-scoped slots (defined on a specific route rather than root) are only rendered when that route is active: + +```ts +// dashboard.tsx defines @activity slot +// When navigating away from /dashboard, the @activity slot is not rendered +// But its URL state (@activity or @activity=/path) remains in the URL + +// When returning to /dashboard, the slot renders with previous state +router.navigate({ to: '/settings' }) // slot not rendered, but state preserved +router.navigate({ to: '/dashboard' }) // slot renders with previous state +``` + +--- + +## Nested Slots (Slots Within Slots) + +Slots can define their own slots for complex nested UI: + +```ts +// @modal/route.tsx +export const Route = createSlotRootRoute({ + slots: { + confirm: confirmDialogTree, // nested slot + }, + component: ModalWrapper, +}) + +function ModalWrapper() { + return ( +
+ + +
+ ) +} +``` + +### Nested Slot URL Structure + +Prefixes nest using `@`: + +``` +?@modal=/users/123&@modal@confirm=/delete + ^^^^^^^^^^^^^^^^^^^^^ + nested slot within modal +``` + +### Navigation to Nested Slots + +```ts +// From within the modal +router.navigate({ + slot: 'confirm', + to: '/delete', +}) + +// From outside (fully qualified) +router.navigate({ + slot: 'modal', + to: '/users/$id', + params: { id: '123' }, + slots: { + confirm: { to: '/delete' }, + }, +}) +``` + +--- + +## Error Boundaries + +Each slot has independent error handling. A slot error doesn't affect other slots or the main route. + +```ts +// @modal/users.$id.tsx +export const Route = createSlotRoute({ + path: '/users/$id', + loader: fetchUser, + component: UserModal, + errorComponent: UserModalError, +}) + +function UserModalError({ error }) { + return ( +
+

Failed to load user

+

{error.message}

+ +
+ ) +} +``` + +### Error Isolation + +``` +Main route: /dashboard (renders fine) +├── @activity slot (renders fine) +├── @metrics slot (loader throws error) +│ └── Shows MetricsError component +└── @modal slot (renders fine) +``` + +The metrics error doesn't crash the dashboard or other slots. + +--- + +## Streaming / Suspense + +Each slot can suspend independently, enabling granular loading states: + +```tsx +function RootComponent() { + return ( + <> + + }> + + + }> + + + + ) +} +``` + +### SSR Streaming + +On SSR, slots stream independently as their data resolves: + +1. Shell HTML sent immediately +2. Main route streams when ready +3. Each slot streams independently when its data resolves +4. Client hydrates progressively + +This enables: + +- Fast initial paint +- Progressive enhancement +- No blocking on slowest loader + +--- + +## Type Safety + +Full type inference throughout the system. + +### Slot Names + +```tsx +// ✅ Valid - modal slot exists on root + + +// ❌ Type error - slot doesn't exist + +``` + +### Slot Navigation + +```ts +// Given modal has routes: /, /users/$id, /settings + +// ✅ Valid +router.navigate({ slot: 'modal', to: '/users/$id', params: { id: '123' } }) + +// ❌ Type error - route doesn't exist +router.navigate({ slot: 'modal', to: '/invalid' }) + +// ❌ Type error - missing required param +router.navigate({ slot: 'modal', to: '/users/$id' }) + +// ❌ Type error - slot doesn't exist +router.navigate({ slot: 'invalid', to: '/' }) +``` + +### Slot Search Params + +```ts +// Given modal's /users/$id route has search schema { tab: 'profile' | 'settings' } + +// ✅ Valid +router.navigate({ + slot: 'modal', + to: '/users/$id', + params: { id: '123' }, + search: { tab: 'profile' }, +}) + +// ❌ Type error - invalid tab value +router.navigate({ + slot: 'modal', + to: '/users/$id', + params: { id: '123' }, + search: { tab: 'invalid' }, +}) +``` + +--- + +## Examples + +See the [examples/](./examples/) directory for complete file structure examples: + +1. **[modal-with-navigation](./examples/modal-with-navigation/)** - Global modal slot with internal navigation +2. **[dashboard-widgets](./examples/dashboard-widgets/)** - Route-scoped slots for parallel-loading widgets (explicit placement) +3. **[component-routes](./examples/component-routes/)** - Auto-rendering widget slots with `` iteration and conditional `enabled` +4. **[split-pane-mail](./examples/split-pane-mail/)** - Email client with independently navigable panes +5. **[nested-slots](./examples/nested-slots/)** - Modal with a nested confirmation dialog slot + +--- + +## Migration Path + +This is an additive feature with no breaking changes to existing apps. + +### Incremental Adoption + +1. **Add a slot to `__root.tsx`** + + ```ts + export const Route = createRootRoute({ + slots: { + modal: modalRouteTree, + }, + }) + ``` + +2. **Create the slot's route files** + + ``` + routes/ + @modal.tsx + @modal.users.$id.tsx + ``` + +3. **Render the SlotOutlet** + + ```tsx + + ``` + +4. **Use Link/navigate to open slots** + ```tsx + + ``` + +Existing routes, loaders, and components continue to work unchanged. + +--- + +## Comparison to Other Frameworks + +| Aspect | TanStack Router | Next.js | Remix (Proposed) | +| --------------------- | -------------------- | ------------------------ | ------------------- | +| URL persisted | Yes (search params) | No (memory) | No | +| Survives refresh | Yes | No (default.js fallback) | N/A | +| Shareable URL | Yes | No | N/A | +| SSR compatible | Yes (native) | Partial | N/A | +| Type-safe | Yes (full inference) | No | N/A | +| Parallel loaders | Yes | N/A (RSC model) | Proposed | +| Independent streaming | Yes | Yes | N/A | +| File convention | @slotName | @folder | @sibling (proposed) | +| Nested slots | Yes | Yes | No | +| Slot search params | Yes (namespaced) | No | No | + +### Key Differentiator + +TanStack Router is the only framework where parallel routes are truly URL-first. The URL contains complete application state, enabling: + +- **Sharing**: Send someone a URL with exact modal/drawer state +- **Bookmarking**: Save the complete UI state +- **SSR**: Server renders correct slot state on first request +- **History**: Back/forward works naturally +- **Testing**: Deterministic URLs for e2e tests + +--- + +## Summary of Design Decisions + +| Aspect | Decision | +| ------------------ | ------------------------------------------------------------ | +| URL prefix | `@` (configurable via `slotPrefix`) | +| File convention | `@slotName` prefix, follows existing flat/directory patterns | +| Slot definition | Isolated files - no parent declaration needed, auto-composed | +| Slot root file | `@modal.tsx` or `@modal/route.tsx` | +| Default behavior | Slots render by default when parent matches | +| URL for root path | Not needed - only deviations from default appear in URL | +| Disable syntax | `@slotName=false` to explicitly hide a slot | +| Close syntax | `to: null` | +| Conditional render | `enabled` function opts OUT of default rendering | +| Slot metadata | Use `staticData` (type-safe via module declaration) | +| Search params | Namespaced: `@modal.tab=profile` | +| Nested slots | Supported with nested prefix: `@modal@confirm` | +| Loader execution | Parallel with main route | +| beforeLoad | Serial (same as regular routes) | +| Suspense | Independent per slot | +| Error boundaries | Independent per slot | +| Slot preservation | Preserved on main route navigation by default | + +--- + +## Open Questions for Implementation + +1. **Preloading**: How should `` work? Should it preload just the slot's route, or also affect main route preloading? + +2. **shouldRevalidate**: Should slots participate in the main route's revalidation cycle, or have completely independent revalidation? + +3. **Devtools**: How should the devtools visualize parallel slots? Separate trees? Merged view? + +4. **Code splitting**: Should slot route trees be lazy-loadable independently of the routes that render them? + +5. **Testing utilities**: What test helpers are needed for testing slot navigation? + +--- + +## References + +- [Next.js Parallel Routes](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes) +- [Remix Sibling Routes Proposal (Discussion #5431)](https://github.com/remix-run/remix/discussions/5431) +- [Jamie Kyle's Slots Tweet](https://twitter.com/buildsghost/status/1531754246856527872) +- [React Router Named Outlets (historical)](https://github.com/remix-run/react-router/discussions/8023) diff --git a/docs/rfcs/parallel-route-slots/examples/README.md b/docs/rfcs/parallel-route-slots/examples/README.md new file mode 100644 index 0000000000..6959d49bc7 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/README.md @@ -0,0 +1,53 @@ +# Parallel Route Slots - Examples + +These examples illustrate file structures and component patterns for different slot use cases. + +**Note:** These are conceptual examples - they won't compile. They're meant to show what the developer experience would look like. + +## Examples + +1. **[modal-with-navigation](./modal-with-navigation/)** - Global modal slot with internal navigation (user profiles, settings, etc.) + +2. **[dashboard-widgets](./dashboard-widgets/)** - Route-scoped slots for parallel-loading dashboard widgets (explicit placement) + +3. **[component-routes](./component-routes/)** - Auto-rendering widget slots with `` iteration, conditional enabling, and staticData-based filtering + +4. **[split-pane-mail](./split-pane-mail/)** - Email client with independently navigable list and preview panes + +5. **[nested-slots](./nested-slots/)** - Modal with a nested confirmation dialog slot + +## URL Examples + +``` +# Modal open with navigation (modal only in URL because it's navigated) +/products?@modal=/users/123&@modal.tab=profile + +# Dashboard - all slots render by default, no URL params needed! +/dashboard + +# Dashboard with one slot navigated away from root +/dashboard?@activity=/recent + +# Dashboard with a slot explicitly disabled +/dashboard?@notifications=false + +# Split pane mail - both slots render by default at their roots +/mail # list and preview both at / +/mail?@list=/sent # list navigated to /sent +/mail?@list=/sent&@preview=/msg-456 # both navigated + +# Nested slots +/app?@modal=/settings # modal open, confirm closed +/app?@modal=/settings&@modal@confirm # confirm open at root +/app?@modal=/settings&@modal@confirm=/discard # confirm at specific path +``` + +## Patterns Comparison + +| Pattern | Use Case | Slot Placement | URL Behavior | +| ----------------- | ----------------- | ------------------------------ | ------------------------------------ | +| Modal | Global overlays | Explicit `` | Manual open/close | +| Dashboard Widgets | Fixed layout | Explicit `` | Manual open/close | +| Component Routes | Dynamic widgets | `` iteration | `defaultOpen: true` auto-adds to URL | +| Split Pane | Independent panes | Explicit `` | Both panes in URL | +| Nested Slots | Layered UI | Parent slot places child slots | Nested `@parent@child` prefix | diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/README.md b/docs/rfcs/parallel-route-slots/examples/component-routes/README.md new file mode 100644 index 0000000000..975aeb1f0d --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/README.md @@ -0,0 +1,47 @@ +# Component Routes (Auto-Rendering Widget Slots) + +A dashboard where widget slots automatically render when the parent route matches. Slots can be filtered by meta, conditionally enabled, and dynamically arranged. + +## URL Examples + +``` +# Default - all enabled slots render, clean URL! +/dashboard + +# Activity navigated to a different view +/dashboard?@activity=/recent + +# User explicitly disabled notifications +/dashboard?@notifications=false + +# Admin user - adminPanel auto-enabled, no URL change needed +/dashboard + +# Non-admin user won't see adminPanel (enabled returns false) +/dashboard +``` + +## File Structure + +``` +routes/ +├── __root.tsx +├── dashboard.tsx # uses to iterate +├── dashboard.index.tsx # main dashboard content +├── dashboard.@header.tsx # explicitly placed (not in iteration) +├── dashboard.@activity.tsx # area: 'main', priority: 1 +├── dashboard.@metrics.tsx # area: 'main', priority: 2 +├── dashboard.@notifications.tsx # area: 'main', priority: 3, user can disable +├── dashboard.@adminPanel.tsx # area: 'main', admin only +├── dashboard.@quickActions.tsx # area: 'sidebar' +└── dashboard.@userCard.tsx # area: 'sidebar' +``` + +## Key Concepts + +- **Slots render by default** - No URL param needed for default state +- **enabled** - Opt-out function to conditionally disable slots +- **`=false` in URL** - Users can explicitly disable slots via URL +- **meta** - Static metadata for filtering and organizing slots +- **** - Render prop to iterate over all enabled slots +- **Mixed approach** - Combine explicit `` with dynamic iteration diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@activity.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@activity.tsx new file mode 100644 index 0000000000..fdc86eeea1 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@activity.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/', + // Slots render by default - no opt-in needed! + // Static data for filtering/grouping in parent (type-safe via module declaration) + staticData: { + area: 'main', + priority: 1, + title: 'Recent Activity', + collapsible: true, + }, + loader: async () => { + const activities = await fetchRecentActivity() + return { activities } + }, + component: ActivityWidget, +}) + +function ActivityWidget() { + const { activities } = Route.useLoaderData() + + return ( +
    + {activities.map((item) => ( +
  • + +
    + {item.user.name} {item.action} + +
    +
  • + ))} +
+ ) +} + +async function fetchRecentActivity() { + return [ + { + id: '1', + user: { name: 'Alice', avatar: '/a.jpg' }, + action: 'created a new project', + timestamp: '5m ago', + }, + { + id: '2', + user: { name: 'Bob', avatar: '/b.jpg' }, + action: 'completed a task', + timestamp: '12m ago', + }, + { + id: '3', + user: { name: 'Carol', avatar: '/c.jpg' }, + action: 'left a comment', + timestamp: '1h ago', + }, + ] +} diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@adminPanel.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@adminPanel.tsx new file mode 100644 index 0000000000..8635548c92 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@adminPanel.tsx @@ -0,0 +1,59 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/', + staticData: { + area: 'main', + priority: 10, // render last in main area + title: 'Admin Panel', + collapsible: false, + }, + // Opt-out: only render for admin users + enabled: ({ context }) => { + return context.user.role === 'admin' + }, + loader: async () => { + const stats = await fetchAdminStats() + return { stats } + }, + component: AdminPanelWidget, +}) + +function AdminPanelWidget() { + const { stats } = Route.useLoaderData() + + return ( +
+
+
+ {stats.pendingApprovals} + Pending Approvals +
+
+ {stats.flaggedContent} + Flagged Content +
+
+ {stats.activeUsers} + Active Users +
+
+
+ + + +
+
+ ) +} + +async function fetchAdminStats() { + return { + pendingApprovals: 12, + flaggedContent: 3, + activeUsers: 847, + } +} diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@header.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@header.tsx new file mode 100644 index 0000000000..9a8443d601 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@header.tsx @@ -0,0 +1,43 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, Link } from '@tanstack/react-router' + +// Header is explicitly placed, not part of iteration +// No meta.area needed since it's rendered via +export const Route = createSlotRoute({ + path: '/', + // No defaultOpen needed - slots render by default! + loader: async ({ context }) => { + const user = context.user + const notifications = await fetchUnreadCount(user.id) + return { user, notifications } + }, + component: DashboardHeader, +}) + +function DashboardHeader() { + const { user, notifications } = Route.useLoaderData() + + return ( +
+

Dashboard

+ +
+ + {user.name} +
+
+ ) +} + +async function fetchUnreadCount(userId: string) { + return 5 +} diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@metrics.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@metrics.tsx new file mode 100644 index 0000000000..26d4bd3260 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@metrics.tsx @@ -0,0 +1,48 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/', + staticData: { + area: 'main', + priority: 2, + title: 'Key Metrics', + collapsible: true, + }, + loader: async () => { + const metrics = await fetchMetrics() + return { metrics } + }, + component: MetricsWidget, +}) + +function MetricsWidget() { + const { metrics } = Route.useLoaderData() + + return ( +
+ {metrics.map((metric) => ( +
+ {metric.value} + {metric.label} + 0 ? 'positive' : 'negative'}`} + > + {metric.change > 0 ? '↑' : '↓'} {Math.abs(metric.change)}% + +
+ ))} +
+ ) +} + +async function fetchMetrics() { + return [ + { label: 'Revenue', value: '$12,345', change: 12 }, + { label: 'Users', value: '1,234', change: 8 }, + { label: 'Orders', value: '567', change: -3 }, + { label: 'Conversion', value: '4.2%', change: 5 }, + ] +} diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@notifications.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@notifications.tsx new file mode 100644 index 0000000000..9ef3dc920e --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@notifications.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/', + staticData: { + area: 'main', + priority: 3, + title: 'Notifications', + collapsible: true, + }, + // Opt-out: disable if user turned off in preferences + enabled: ({ context }) => { + return context.user.preferences?.showNotificationsWidget !== false + }, + loader: async () => { + const notifications = await fetchNotifications() + return { notifications } + }, + component: NotificationsWidget, +}) + +function NotificationsWidget() { + const { notifications } = Route.useLoaderData() + + if (notifications.length === 0) { + return

No new notifications

+ } + + return ( +
    + {notifications.map((notif) => ( +
  • + {notif.icon} +
    +

    {notif.message}

    + +
    +
  • + ))} +
+ ) +} + +async function fetchNotifications() { + return [ + { + id: '1', + icon: '📬', + message: 'New message from Alice', + time: '2m ago', + read: false, + }, + { + id: '2', + icon: '✅', + message: 'Task "Update docs" completed', + time: '1h ago', + read: false, + }, + { + id: '3', + icon: '🎉', + message: 'You earned a new badge!', + time: '3h ago', + read: true, + }, + ] +} diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@quickActions.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@quickActions.tsx new file mode 100644 index 0000000000..6ff71f868d --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@quickActions.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, Link } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/', + staticData: { + area: 'sidebar', + priority: 1, + }, + // No loader needed - static content + component: QuickActionsWidget, +}) + +function QuickActionsWidget() { + return ( +
+

Quick Actions

+
+ + + + +
+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx new file mode 100644 index 0000000000..023c25b829 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx @@ -0,0 +1,53 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, Link } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/', + staticData: { + area: 'sidebar', + priority: 2, + }, + loader: async ({ context }) => { + const user = await fetchUserProfile(context.user.id) + return { user } + }, + component: UserCardWidget, +}) + +function UserCardWidget() { + const { user } = Route.useLoaderData() + + return ( +
+ {user.name} +

{user.name}

+

{user.role}

+
+
+ {user.projects} + Projects +
+
+ {user.tasks} + Tasks +
+
+ + View Profile + +
+ ) +} + +async function fetchUserProfile(userId: string) { + return { + id: userId, + name: 'Jane Doe', + role: 'Product Manager', + avatar: '/avatars/jane.jpg', + projects: 8, + tasks: 24, + } +} diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.tsx new file mode 100644 index 0000000000..7bd5795ca1 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.tsx @@ -0,0 +1,88 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createFileRoute, Outlet } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + slots: { + header: true, + activity: true, + metrics: true, + notifications: true, + adminPanel: true, + quickActions: true, + userCard: true, + }, + component: Dashboard, +}) + +function Dashboard() { + return ( +
+ {/* Explicitly placed header slot */} +
+ +
+ +
+ {/* Use Route.Slots to dynamically render widgets */} + + {(slots) => { + // Filter and group slots by their staticData.area + const mainSlots = slots + .filter((s) => s.staticData?.area === 'main') + .sort( + (a, b) => + (a.staticData?.priority ?? 99) - + (b.staticData?.priority ?? 99), + ) + + const sidebarSlots = slots + .filter((s) => s.staticData?.area === 'sidebar') + .sort( + (a, b) => + (a.staticData?.priority ?? 99) - + (b.staticData?.priority ?? 99), + ) + + return ( + <> + {/* Main content area with widget grid */} +
+ {/* Regular child routes */} + + + {/* Widget grid */} +
+ {mainSlots.map((slot) => ( +
+
+

{slot.staticData?.title || slot.name}

+ {slot.staticData?.collapsible && ( + + )} +
+
+ +
+
+ ))} +
+
+ + {/* Sidebar with additional widgets */} + + + ) + }} +
+
+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/README.md b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/README.md new file mode 100644 index 0000000000..bc85bbb924 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/README.md @@ -0,0 +1,37 @@ +# Dashboard Widgets + +Route-scoped slots for a dashboard with multiple independently-loading widgets. Each widget has its own loader that runs in parallel. + +## URL Examples + +``` +/dashboard # all widgets render at root (clean!) +/dashboard?@activity=/recent # activity navigated to /recent +/dashboard?@metrics=/revenue # metrics navigated to /revenue +/dashboard?@quickActions=false # quick actions explicitly hidden +``` + +## File Structure + +``` +routes/ +├── __root.tsx +├── dashboard.tsx # defines scoped slots: activity, metrics, quickActions +├── dashboard.index.tsx # main dashboard content +├── dashboard.@activity.tsx # activity widget root +├── dashboard.@activity.index.tsx +├── dashboard.@activity.recent.tsx +├── dashboard.@metrics.tsx # metrics widget root +├── dashboard.@metrics.index.tsx +├── dashboard.@metrics.revenue.tsx +├── dashboard.@metrics.users.tsx +├── dashboard.@quickActions.tsx # quick actions widget (single route) +└── index.tsx # home page +``` + +## Key Concepts + +- Slots defined on `dashboard.tsx` are only available within the dashboard +- All widget loaders run in parallel with the dashboard loader +- Each widget can have internal navigation (activity, metrics) or be a single view (quickActions) +- Widgets can independently suspend/error without affecting others diff --git a/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/__root.tsx b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/__root.tsx new file mode 100644 index 0000000000..df487c2677 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/__root.tsx @@ -0,0 +1,24 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + Dashboard App + + +
+ +
+ + + + ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@activity.index.tsx b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@activity.index.tsx new file mode 100644 index 0000000000..fcff4ce9a4 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@activity.index.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +// Activity index - shows all activity +export const Route = createSlotRoute({ + path: '/', + loader: async () => { + // This runs in PARALLEL with dashboard.loader and other widget loaders + const activities = await fetchAllActivities() + return { activities } + }, + component: AllActivities, +}) + +function AllActivities() { + const { activities } = Route.useLoaderData() + + return ( +
    + {activities.map((activity) => ( +
  • + {activity.user} + {activity.action} + +
  • + ))} +
+ ) +} + +async function fetchAllActivities() { + return [ + { id: '1', user: 'Alice', action: 'created a new project', time: '2m ago' }, + { id: '2', user: 'Bob', action: 'commented on task', time: '5m ago' }, + { id: '3', user: 'Carol', action: 'completed milestone', time: '1h ago' }, + ] +} diff --git a/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@activity.tsx b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@activity.tsx new file mode 100644 index 0000000000..796c33fe01 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@activity.tsx @@ -0,0 +1,30 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet, Link } from '@tanstack/react-router' + +// Activity widget root - wraps all activity views +export const Route = createSlotRootRoute({ + component: ActivityWidget, +}) + +function ActivityWidget() { + return ( +
+
+

Activity

+ +
+
+ +
+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@metrics.index.tsx b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@metrics.index.tsx new file mode 100644 index 0000000000..7fe9a2c8f7 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@metrics.index.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +// Metrics overview +export const Route = createSlotRoute({ + path: '/', + loader: async () => { + const overview = await fetchMetricsOverview() + return { overview } + }, + component: MetricsOverview, +}) + +function MetricsOverview() { + const { overview } = Route.useLoaderData() + + return ( +
+
+ {overview.revenue} + Revenue +
+
+ {overview.users} + Users +
+
+ {overview.orders} + Orders +
+
+ ) +} + +async function fetchMetricsOverview() { + return { revenue: '$12,345', users: '1,234', orders: '567' } +} diff --git a/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@metrics.tsx b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@metrics.tsx new file mode 100644 index 0000000000..05dbf9f535 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@metrics.tsx @@ -0,0 +1,33 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet, Link } from '@tanstack/react-router' + +// Metrics widget root +export const Route = createSlotRootRoute({ + component: MetricsWidget, +}) + +function MetricsWidget() { + return ( +
+
+

Metrics

+ +
+
+ +
+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@quickActions.tsx b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@quickActions.tsx new file mode 100644 index 0000000000..fa015bfe8b --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.@quickActions.tsx @@ -0,0 +1,43 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Link } from '@tanstack/react-router' + +// Quick actions widget - single route, no internal navigation +export const Route = createSlotRootRoute({ + loader: async () => { + const actions = await fetchQuickActions() + return { actions } + }, + component: QuickActionsWidget, +}) + +function QuickActionsWidget() { + const { actions } = Route.useLoaderData() + + return ( +
+
+

Quick Actions

+
+
+
+ {actions.map((action) => ( + + ))} +
+
+
+ ) +} + +async function fetchQuickActions() { + return [ + { id: '1', icon: '➕', label: 'New Project' }, + { id: '2', icon: '👤', label: 'Invite User' }, + { id: '3', icon: '📊', label: 'Generate Report' }, + ] +} diff --git a/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.tsx b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.tsx new file mode 100644 index 0000000000..f6bdd20de1 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.tsx @@ -0,0 +1,51 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createFileRoute, Outlet } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + // Define route-scoped slots for dashboard widgets + slots: { + activity: true, // auto-wired from dashboard.@activity.tsx + metrics: true, // auto-wired from dashboard.@metrics.tsx + quickActions: true, // auto-wired from dashboard.@quickActions.tsx + }, + loader: async () => { + // Dashboard-level data (user info, permissions, etc.) + const user = await fetchCurrentUser() + return { user } + }, + component: Dashboard, +}) + +function Dashboard() { + const { user } = Route.useLoaderData() + + return ( +
+

Welcome back, {user.name}

+ +
+ {/* Left column - Activity feed */} + + + {/* Main content area */} +
+ +
+ + {/* Right column - Metrics and Quick Actions */} + +
+
+ ) +} + +async function fetchCurrentUser() { + return { id: '1', name: 'Jane' } +} diff --git a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/README.md b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/README.md new file mode 100644 index 0000000000..1bc38639f9 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/README.md @@ -0,0 +1,29 @@ +# Modal with Navigation + +A global modal slot that can be opened from anywhere in the app. The modal has its own internal navigation (user profiles with tabs, settings pages, etc.). + +## URL Examples + +``` +/products # modal closed +/products?@modal=/ # modal open at index +/products?@modal=/users/123 # viewing user 123 +/products?@modal=/users/123&@modal.tab=activity # user 123, activity tab +/products?@modal=/settings # settings view in modal +/checkout?@modal=/users/123 # modal persists across main navigation +``` + +## File Structure + +``` +routes/ +├── __root.tsx # defines modal slot, renders SlotOutlet +├── @modal.tsx # modal wrapper (backdrop, close button, animation) +├── @modal.index.tsx # modal landing/index view +├── @modal.users.$id.tsx # user profile view +├── @modal.settings.tsx # settings view +├── index.tsx # home page +├── products.tsx # products layout +├── products.index.tsx # products list +└── products.$id.tsx # product detail +``` diff --git a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx new file mode 100644 index 0000000000..2f5745abfb --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx @@ -0,0 +1,26 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, Link } from '@tanstack/react-router' + +// Modal index - shown when @modal=/ +export const Route = createSlotRoute({ + path: '/', + component: ModalIndex, +}) + +function ModalIndex() { + return ( +
+

Quick Actions

+ +
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.settings.tsx b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.settings.tsx new file mode 100644 index 0000000000..875e5f374b --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.settings.tsx @@ -0,0 +1,69 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, useSlot } from '@tanstack/react-router' + +// Settings modal - shown when @modal=/settings +export const Route = createSlotRoute({ + path: '/settings', + loader: async () => { + const settings = await fetchSettings() + return { settings } + }, + component: SettingsModal, +}) + +function SettingsModal() { + const { settings } = Route.useLoaderData() + const modal = useSlot('modal') + + const handleSave = async (formData: FormData) => { + await saveSettings(formData) + modal.close() + } + + return ( +
+

Settings

+
{ + e.preventDefault() + handleSave(new FormData(e.currentTarget)) + }} + > + + + + +
+ + +
+
+
+ ) +} + +// Placeholder functions +async function fetchSettings() { + return { theme: 'system', notifications: true } +} +async function saveSettings(data: FormData) { + console.log('Saving settings', Object.fromEntries(data)) +} diff --git a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.tsx b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.tsx new file mode 100644 index 0000000000..97ece3788d --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.tsx @@ -0,0 +1,26 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet, useSlot } from '@tanstack/react-router' + +// This is the slot's root route - wraps all modal content +export const Route = createSlotRootRoute({ + component: ModalWrapper, +}) + +function ModalWrapper() { + const modal = useSlot('modal') + + return ( +
+
e.stopPropagation()}> + + + {/* Render the matched modal route */} + +
+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx new file mode 100644 index 0000000000..a00c8752b6 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx @@ -0,0 +1,87 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, Link } from '@tanstack/react-router' +import { z } from 'zod' + +// User profile modal - shown when @modal=/users/123 +export const Route = createSlotRoute({ + path: '/users/$id', + validateSearch: z.object({ + tab: z.enum(['profile', 'activity', 'settings']).default('profile'), + }), + loader: async ({ params }) => { + const user = await fetchUser(params.id) + return { user } + }, + component: UserModal, +}) + +function UserModal() { + const { user } = Route.useLoaderData() + const { tab } = Route.useSearch() + const params = Route.useParams() + + return ( +
+
+ {user.name} +

{user.name}

+
+ + {/* Tab navigation within the modal */} + + + {/* Tab content */} +
+ {tab === 'profile' && } + {tab === 'activity' && } + {tab === 'settings' && } +
+
+ ) +} + +// Placeholder components +function UserProfile({ user }) { + return
Profile for {user.name}
+} +function UserActivity({ user }) { + return
Activity for {user.name}
+} +function UserSettings({ user }) { + return
Settings for {user.name}
+} + +// Placeholder fetch +async function fetchUser(id: string) { + return { id, name: 'John Doe', avatar: '/avatars/john.jpg' } +} diff --git a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/__root.tsx b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/__root.tsx new file mode 100644 index 0000000000..cfa8789db8 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/__root.tsx @@ -0,0 +1,29 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + // Declare the modal slot - references the @modal route tree + slots: { + modal: true, // auto-wired from @modal.tsx + }, + component: RootComponent, +}) + +function RootComponent() { + return ( + + + My App + + + {/* Main content */} + + + {/* Modal slot - rendered on top of everything */} + + + + ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/nested-slots/README.md b/docs/rfcs/parallel-route-slots/examples/nested-slots/README.md new file mode 100644 index 0000000000..2d14d30f06 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/README.md @@ -0,0 +1,34 @@ +# Nested Slots + +A modal that contains its own nested confirmation dialog slot. Demonstrates slots within slots. + +## URL Examples + +``` +/app # no modal +/app?@modal=/settings # settings modal open +/app?@modal=/settings&@modal@confirm # confirm dialog open at root +/app?@modal=/settings&@modal@confirm=/discard # specific confirm action +``` + +## File Structure + +``` +routes/ +├── __root.tsx # defines global modal slot +├── @modal.tsx # modal wrapper, defines nested confirm slot +├── @modal.index.tsx +├── @modal.settings.tsx +├── @modal.@confirm.tsx # nested slot root (confirm dialog) +├── @modal.@confirm.index.tsx +├── @modal.@confirm.discard.tsx +├── @modal.@confirm.delete.tsx +└── index.tsx +``` + +## Key Concepts + +- `@modal.@confirm` is a slot within the modal slot +- URL uses nested prefix: `@modal@confirm=/discard` +- The modal can open confirmation dialogs without closing itself +- Confirmation dialogs have their own routes for different actions diff --git a/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.delete.tsx b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.delete.tsx new file mode 100644 index 0000000000..b4df7722f2 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.delete.tsx @@ -0,0 +1,42 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, useSlot } from '@tanstack/react-router' + +// Confirm delete account dialog +export const Route = createSlotRoute({ + path: '/delete', + component: DeleteConfirm, +}) + +function DeleteConfirm() { + const modal = useSlot('modal') + const confirm = useSlot('confirm') + + const handleDelete = async () => { + await deleteAccount() + // Close everything and redirect + modal.close() + // In reality you'd also redirect to a logged-out page + } + + return ( +
+

Delete Account?

+

+ This action cannot be undone. All your data will be permanently deleted. +

+ +
+ + +
+
+ ) +} + +async function deleteAccount() { + console.log('Account deleted') +} diff --git a/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.discard.tsx b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.discard.tsx new file mode 100644 index 0000000000..21115f47dd --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.discard.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, useSlot } from '@tanstack/react-router' + +// Confirm discard changes dialog +export const Route = createSlotRoute({ + path: '/discard', + component: DiscardConfirm, +}) + +function DiscardConfirm() { + const modal = useSlot('modal') + const confirm = useSlot('confirm') + + const handleDiscard = () => { + // Close both the confirm dialog and the modal + modal.close() + } + + const handleCancel = () => { + // Just close the confirm dialog, keep modal open + confirm.close() + } + + return ( +
+

Discard changes?

+

You have unsaved changes. Are you sure you want to discard them?

+ +
+ + +
+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.tsx b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.tsx new file mode 100644 index 0000000000..dc4e971428 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.tsx @@ -0,0 +1,21 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet, useSlot } from '@tanstack/react-router' + +// Nested confirmation dialog slot +export const Route = createSlotRootRoute({ + component: ConfirmDialogWrapper, +}) + +function ConfirmDialogWrapper() { + const confirm = useSlot('confirm') + + return ( +
+
e.stopPropagation()}> + +
+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.settings.tsx b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.settings.tsx new file mode 100644 index 0000000000..3bc2101b92 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.settings.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, Link, useSlot } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/settings', + loader: async () => { + const settings = await fetchSettings() + return { settings } + }, + component: SettingsModal, +}) + +function SettingsModal() { + const { settings } = Route.useLoaderData() + const [hasChanges, setHasChanges] = useState(false) + const modal = useSlot('modal') + + const handleClose = () => { + if (hasChanges) { + // Open the nested confirm slot instead of closing directly + // This navigates to @modal@confirm=/discard in the URL + modal.navigate({ + slots: { + confirm: { to: '/discard' }, + }, + }) + } else { + modal.close() + } + } + + return ( +
+

Settings

+ +
setHasChanges(true)}> + + + +
+ +
+ + + + {/* Direct link to delete confirmation */} + + Delete Account + +
+
+ ) +} + +async function fetchSettings() { + return { theme: 'light', notifications: true } +} diff --git a/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.tsx b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.tsx new file mode 100644 index 0000000000..75dab7ffd8 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.tsx @@ -0,0 +1,32 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet, useSlot } from '@tanstack/react-router' + +export const Route = createSlotRootRoute({ + // Modal has its own nested slot for confirmation dialogs + slots: { + confirm: true, // auto-wired from @modal.@confirm.tsx + }, + component: ModalWrapper, +}) + +function ModalWrapper() { + const modal = useSlot('modal') + + return ( +
+
e.stopPropagation()}> + + + {/* Modal content */} + + + {/* Nested confirmation dialog slot */} + +
+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/__root.tsx b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/__root.tsx new file mode 100644 index 0000000000..71a7552a73 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/__root.tsx @@ -0,0 +1,22 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + slots: { + modal: true, // auto-wired from @modal.tsx + }, + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/split-pane-mail/README.md b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/README.md new file mode 100644 index 0000000000..520220ce88 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/README.md @@ -0,0 +1,37 @@ +# Split-Pane Mail Client + +An email client with two independently navigable panes: a message list and a message preview. Each pane has its own route state. + +## URL Examples + +``` +/mail # both panes at default +/mail?@list=/inbox # inbox selected, no preview +/mail?@list=/inbox&@preview=/msg-123 # inbox with message 123 preview +/mail?@list=/sent&@preview=/msg-456 # sent folder with message 456 preview +/mail?@list=/drafts # drafts, preview closed +``` + +## File Structure + +``` +routes/ +├── __root.tsx +├── mail.tsx # defines slots: list, preview +├── mail.@list.tsx # message list wrapper +├── mail.@list.index.tsx # default (all mail) +├── mail.@list.inbox.tsx # inbox folder +├── mail.@list.sent.tsx # sent folder +├── mail.@list.drafts.tsx # drafts folder +├── mail.@preview.tsx # preview pane wrapper +├── mail.@preview.index.tsx # empty state +├── mail.@preview.$id.tsx # message preview +└── index.tsx +``` + +## Key Concepts + +- Two slots that navigate completely independently +- Selecting a folder doesn't affect which message is previewed +- Deep linking works: `/mail?@list=/sent&@preview=/msg-789` +- Each pane loads its own data in parallel diff --git a/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@list.inbox.tsx b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@list.inbox.tsx new file mode 100644 index 0000000000..4760720212 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@list.inbox.tsx @@ -0,0 +1,48 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, Link } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/inbox', + loader: async () => { + const messages = await fetchInboxMessages() + return { messages } + }, + component: InboxList, +}) + +function InboxList() { + const { messages } = Route.useLoaderData() + + return ( +
+

Inbox

+
    + {messages.map((msg) => ( +
  • + {/* Clicking a message opens it in the preview slot */} + + {msg.from} + {msg.subject} + + +
  • + ))} +
+
+ ) +} + +async function fetchInboxMessages() { + return [ + { id: 'msg-1', from: 'Alice', subject: 'Project update', date: '10:30 AM' }, + { id: 'msg-2', from: 'Bob', subject: 'Re: Meeting notes', date: '9:15 AM' }, + { + id: 'msg-3', + from: 'Carol', + subject: 'Quick question', + date: 'Yesterday', + }, + ] +} diff --git a/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@list.tsx b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@list.tsx new file mode 100644 index 0000000000..3f0bb818bf --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@list.tsx @@ -0,0 +1,16 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createSlotRootRoute({ + component: ListPane, +}) + +function ListPane() { + return ( +
+ +
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.$id.tsx b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.$id.tsx new file mode 100644 index 0000000000..46b442da5e --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.$id.tsx @@ -0,0 +1,47 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +export const Route = createSlotRoute({ + path: '/$id', + loader: async ({ params }) => { + const message = await fetchMessage(params.id) + return { message } + }, + component: MessagePreview, +}) + +function MessagePreview() { + const { message } = Route.useLoaderData() + + return ( +
+
+

{message.subject}

+
+ From: {message.from} + To: {message.to} + +
+
+
{message.body}
+
+ + + +
+
+ ) +} + +async function fetchMessage(id: string) { + return { + id, + from: 'alice@example.com', + to: 'me@example.com', + subject: 'Project update', + date: 'January 4, 2026 at 10:30 AM', + body: 'Hey! Just wanted to give you a quick update on the project...', + } +} diff --git a/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.index.tsx b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.index.tsx new file mode 100644 index 0000000000..097186c10f --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.index.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +// Empty state when no message is selected +export const Route = createSlotRoute({ + path: '/', + component: PreviewEmpty, +}) + +function PreviewEmpty() { + return ( +
+

Select a message to preview

+
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.tsx b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.tsx new file mode 100644 index 0000000000..9b84b87bc1 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.tsx @@ -0,0 +1,23 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet, useSlot } from '@tanstack/react-router' + +export const Route = createSlotRootRoute({ + component: PreviewPane, +}) + +function PreviewPane() { + const preview = useSlot('preview') + + return ( +
+ {preview.isOpen && preview.path !== '/' && ( + + )} + +
+ ) +} diff --git a/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.tsx b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.tsx new file mode 100644 index 0000000000..84fd002363 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.tsx @@ -0,0 +1,43 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/mail')({ + slots: { + list: true, // auto-wired from mail.@list.tsx + preview: true, // auto-wired from mail.@preview.tsx + }, + component: MailLayout, +}) + +function MailLayout() { + return ( +
+ {/* Sidebar with folders */} + + + {/* Message list pane */} +
+ +
+ + {/* Preview pane */} +
+ +
+
+ ) +} From 5bbaf7d7e241e28700f643bd396b8c836698b11a Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sun, 4 Jan 2026 19:46:32 -0700 Subject: [PATCH 2/6] slot navigation --- docs/rfcs/parallel-route-slots/README.md | 355 +++++++++++++++--- .../routes/dashboard.@userCard.tsx | 8 +- .../routes/@modal.index.tsx | 7 +- .../routes/@modal.users.$id.tsx | 5 +- 4 files changed, 308 insertions(+), 67 deletions(-) diff --git a/docs/rfcs/parallel-route-slots/README.md b/docs/rfcs/parallel-route-slots/README.md index 0e465bf22c..ffaf05c871 100644 --- a/docs/rfcs/parallel-route-slots/README.md +++ b/docs/rfcs/parallel-route-slots/README.md @@ -341,90 +341,223 @@ The `_addSlots` is internal - users never call it. The generator wires everythin ## Navigation API -### Navigate to a Slot +### Core Principle: Slots Are Scoped to Routes + +Slots are defined on specific routes (root or otherwise). You can only navigate a slot if you're also navigating to (or already at) a route that renders that slot. This ensures type safety and prevents navigating to slots that won't be rendered. + +### Simple API: SlotRoute.navigate() and SlotRoute.Link + +The simplest way to navigate a slot is through the slot route's own navigation methods. These are type-safe and ensure the parent route hierarchy is valid: ```ts -// Open modal to a specific route -router.navigate({ - slot: 'modal', +// Import the slot route (generated) +import { modalRoute } from './routeTree.gen' + +// Navigate the modal slot +modalRoute.navigate({ to: '/users/$id', params: { id: '123' }, - search: { tab: 'profile' }, // becomes @modal.tab=profile + search: { tab: 'profile' }, }) -// Open modal to its index route -router.navigate({ slot: 'modal' }) // → @modal=/ -// or explicitly -router.navigate({ slot: 'modal', to: '/' }) +// Navigate to slot root +modalRoute.navigate({ to: '/' }) +// or just +modalRoute.navigate({}) + +// Close the slot +modalRoute.navigate({ to: null }) + +// Update just search params (preserve current path) +modalRoute.navigate({ search: { tab: 'settings' } }) ``` -### Close a Slot +### SlotRoute.Link Component -```ts -router.navigate({ slot: 'modal', to: null }) +```tsx +import { modalRoute } from './routeTree.gen' + +// Open modal to specific route + + View User + + +// Open modal to root + + Open Modal + + +// Close modal + + Close + + +// Update search only + + Profile Tab + ``` -### Navigate Main Route + Slots Atomically +### Within-Slot Navigation + +When you're inside a slot component, regular `Link` and `navigate` work within that slot's context: + +```tsx +// Inside @modal/users.$id.tsx +function UserModal() { + return ( +
+ {/* These navigate within the modal slot */} + Settings + + Other User + + + {/* Close the modal */} + Close +
+ ) +} +``` + +### Advanced: Atomic Multi-Slot Navigation + +For navigating multiple slots atomically (or combining with main route navigation), use the `slots` property: ```ts -// Navigate to dashboard and open modal, close drawer +// Navigate to dashboard and update multiple slots atomically router.navigate({ to: '/dashboard', slots: { - modal: { to: '/users/$id', params: { id: '123' } }, - drawer: null, // close drawer + activity: { to: '/recent' }, + metrics: { search: { range: '7d' } }, + modal: null, // close modal }, }) +``` -// Navigate main route, preserve all slot state (default behavior) -router.navigate({ to: '/settings' }) -// Slots stay as-is since they're in search params +This is useful when you need to: + +- Navigate main route + slots in one atomic update +- Update multiple slots at once +- Ensure consistency between route and slot state + +### Shallow Updates (Default Behavior) + +Like params, slots use **shallow merging** by default - unmentioned slots are preserved: + +```ts +// Current URL: /dashboard?@activity=/feed&@metrics.range=30d&@modal=/settings + +// Update just activity - others preserved +activityRoute.navigate({ to: '/recent' }) +// Result: /dashboard?@activity=/recent&@metrics.range=30d&@modal=/settings + +// Or via slots object +router.navigate({ + slots: { activity: { to: '/recent' } }, +}) ``` -### Link Component +### Close vs Disable -```tsx -// Open a slot - - Open User Modal - +```ts +// Close - removes slot from URL entirely +modalRoute.navigate({ to: null }) +// or +router.navigate({ slots: { modal: null } }) -// Open slot at root - - Open Modal - +// Disable - for slots that render by default, explicitly hide +router.navigate({ slots: { notifications: false } }) +// Adds @notifications=false to URL +``` + +### Route-Scoped Slot Type Safety + +Slots scoped to specific routes enforce that you're on (or navigating to) that route: + +```ts +// dashboard.@activity is scoped to /dashboard +import { activityRoute } from './routeTree.gen' + +// ✅ Works when on /dashboard +activityRoute.navigate({ to: '/recent' }) + +// ✅ Works when navigating to /dashboard +router.navigate({ + to: '/dashboard', + slots: { activity: { to: '/recent' } }, +}) + +// ❌ Type error - activity not available when navigating to /settings +router.navigate({ + to: '/settings', + slots: { activity: { to: '/recent' } }, +}) +``` + +### Nested Slot Navigation + +For slots within slots: + +```ts +// Modal has a nested @confirm slot +import { modalRoute, modalConfirmRoute } from './routeTree.gen' -// Close a slot - - Close Modal - +// Simple: use the nested slot's route directly +modalConfirmRoute.navigate({ to: '/delete' }) -// Navigate within a slot (from inside the slot) - - Go to Settings - +// Advanced: atomic navigation +router.navigate({ + slots: { + modal: { + to: '/users/$id', + params: { id: '123' }, + slots: { + confirm: { to: '/delete' }, + }, + }, + }, +}) +// URL: ?@modal=/users/123&@modal@confirm=/delete ``` -### Type Safety +### Type Safety Summary -Navigation is fully typed against the slot's route tree: +All navigation is fully typed: ```ts -// Assuming modal has routes: /, /users/$id, /settings +import { modalRoute } from './routeTree.gen' // ✅ Valid - +modalRoute.navigate({ to: '/users/$id', params: { id: '123' } }) -// ❌ Type error - route doesn't exist in modal slot - +// ❌ Type error - route doesn't exist +modalRoute.navigate({ to: '/nonexistent' }) // ❌ Type error - missing required param - +modalRoute.navigate({ to: '/users/$id' }) -// ❌ Type error - slot doesn't exist - +// ❌ Type error - invalid search param +modalRoute.navigate({ + to: '/users/$id', + params: { id: '123' }, + search: { invalid: true }, +}) ``` +### API Summary + +| Task | Simple API | Advanced API | +| ------------------ | ---------------------------------- | -------------------------------------------------- | +| Navigate slot | `slotRoute.navigate({ to })` | `router.navigate({ slots: { name: { to } } })` | +| Open slot root | `slotRoute.navigate({})` | `router.navigate({ slots: { name: {} } })` | +| Update search only | `slotRoute.navigate({ search })` | `router.navigate({ slots: { name: { search } } })` | +| Close slot | `slotRoute.navigate({ to: null })` | `router.navigate({ slots: { name: null } })` | +| Disable default | - | `router.navigate({ slots: { name: false } })` | +| Multi-slot atomic | - | `router.navigate({ to, slots: {...} })` | +| Link | `` | `` | + --- ## Rendering API @@ -793,16 +926,119 @@ Combined URL: /dashboard?filter=active&page=2&@modal=/users/123&@modal.tab=profile&@modal.expanded=true ``` -Inside the slot's components, you access search params without the prefix: +### Open Question: Search Param Access Within Slots + +**How should slot components access their search params?** + +There are several options, each with tradeoffs: + +#### Option A: Strip Prefix (Transparent Access) + +Inside the slot, access params without the prefix - the router handles namespacing transparently: ```ts // Inside @modal/users.$id.tsx const { tab, expanded } = Route.useSearch() // not @modal.tab, just tab + +// Schema defines unprefixed keys +validateSearch: z.object({ + tab: z.enum(['profile', 'settings']), +}) ``` +**Pros:** + +- Clean, ergonomic API - feels like regular routes +- Slot code is portable (no hardcoded slot name) +- Schema matches what you access in code + +**Cons:** + +- "Magic" transformation happening under the hood +- Debugging confusion: URL shows `@modal.tab` but code uses `tab` +- Implementation complexity in router internals + +#### Option B: Always Prefixed (Explicit Access) + +Slot components always use the full prefixed key: + +```ts +// Inside @modal/users.$id.tsx +const { '@modal.tab': tab, '@modal.expanded': expanded } = Route.useSearch() + +// Or with a helper +const { tab, expanded } = Route.useSlotSearch() +``` + +**Pros:** + +- What you see in URL is what you use in code +- No magic transformation +- Easier to debug + +**Cons:** + +- Verbose, especially with destructuring +- Slot code is coupled to its name +- Renaming a slot requires updating component code + +#### Option C: Slot-Scoped Hook + +Provide a dedicated hook that handles the namespacing: + +```ts +// Inside @modal/users.$id.tsx +const { tab, expanded } = Route.useSlotSearch() // knows it's in @modal context + +// Regular useSearch still works for accessing parent route search +const { filter } = useSearch({ from: '/dashboard' }) +``` + +**Pros:** + +- Clear separation between slot search and parent search +- Explicit about what you're accessing +- Slot code remains portable + +**Cons:** + +- New API to learn +- Two different hooks for search params + +#### Option D: Nested Search Object + +Structure the search object to reflect the nesting: + +```ts +// Inside @modal/users.$id.tsx +const search = Route.useSearch() +// search = { tab: 'profile', expanded: true } within slot context +// But from parent: search = { filter: 'active', '@modal': { tab: 'profile', ... } } +``` + +**Pros:** + +- Clear structure +- Works well with TypeScript + +**Cons:** + +- Different shapes depending on where you're reading from +- Complex to type correctly + +--- + +**Current leaning:** Option A (strip prefix) or Option C (dedicated hook) seem most ergonomic, but this needs more research and community input before deciding. + ### Conflict Handling -If a slot's search schema defines a key that conflicts with a parent route, the same behavior as current nested routes applies - it's an error at the schema validation level. Use namespacing conventions in your schemas to avoid conflicts. +Regardless of which option is chosen, conflicts between slot search keys and parent route search keys need handling. Options: + +1. **Error at schema validation** - Conflict is a build/runtime error +2. **Slot always wins** - Slot params shadow parent params within slot context +3. **Explicit namespacing required** - Force users to namespace in their schemas + +This is related to the access pattern decision above and should be resolved together. --- @@ -1084,9 +1320,14 @@ This is an additive feature with no breaking changes to existing apps. ``` -4. **Use Link/navigate to open slots** +4. **Use the slot route to navigate** + ```tsx - + import { modalRoute } from './routeTree.gen' + + ; + Open User + ``` Existing routes, loaders, and components continue to work unchanged. @@ -1134,7 +1375,7 @@ TanStack Router is the only framework where parallel routes are truly URL-first. | Close syntax | `to: null` | | Conditional render | `enabled` function opts OUT of default rendering | | Slot metadata | Use `staticData` (type-safe via module declaration) | -| Search params | Namespaced: `@modal.tab=profile` | +| Search params | Namespaced in URL: `@modal.tab=profile` (access pattern TBD) | | Nested slots | Supported with nested prefix: `@modal@confirm` | | Loader execution | Parallel with main route | | beforeLoad | Serial (same as regular routes) | @@ -1146,15 +1387,17 @@ TanStack Router is the only framework where parallel routes are truly URL-first. ## Open Questions for Implementation -1. **Preloading**: How should `` work? Should it preload just the slot's route, or also affect main route preloading? +1. **Search param access within slots**: How should slot components access their namespaced search params? See detailed options in the [Search Params](#open-question-search-param-access-within-slots) section. This affects DX significantly and needs community input. + +2. **Preloading**: How should `` work? Should it preload just the slot's route, or also affect main route preloading? -2. **shouldRevalidate**: Should slots participate in the main route's revalidation cycle, or have completely independent revalidation? +3. **shouldRevalidate**: Should slots participate in the main route's revalidation cycle, or have completely independent revalidation? -3. **Devtools**: How should the devtools visualize parallel slots? Separate trees? Merged view? +4. **Devtools**: How should the devtools visualize parallel slots? Separate trees? Merged view? -4. **Code splitting**: Should slot route trees be lazy-loadable independently of the routes that render them? +5. **Code splitting**: Should slot route trees be lazy-loadable independently of the routes that render them? -5. **Testing utilities**: What test helpers are needed for testing slot navigation? +6. **Testing utilities**: What test helpers are needed for testing slot navigation? --- diff --git a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx index 023c25b829..b55a91c903 100644 --- a/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx @@ -1,7 +1,8 @@ // @ts-nocheck // Example only - this is a conceptual demonstration -import { createSlotRoute, Link } from '@tanstack/react-router' +import { createSlotRoute } from '@tanstack/react-router' +import { modalRoute } from '../routeTree.gen' export const Route = createSlotRoute({ path: '/', @@ -34,9 +35,10 @@ function UserCardWidget() { Tasks - + {/* Open modal from dashboard using slot route's Link */} + View Profile - + ) } diff --git a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx index 2f5745abfb..439ef6d564 100644 --- a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx @@ -13,13 +13,12 @@ function ModalIndex() { return (

Quick Actions

+ {/* Navigation within the modal - context knows we're in @modal */}
) diff --git a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx index a00c8752b6..3863e089af 100644 --- a/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx @@ -29,10 +29,9 @@ function UserModal() {

{user.name}

- {/* Tab navigation within the modal */} + {/* Tab navigation within the modal - context knows we're in @modal */}