diff --git a/docs/rfcs/parallel-route-slots/README.md b/docs/rfcs/parallel-route-slots/README.md new file mode 100644 index 0000000000..8bc9dfcd72 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/README.md @@ -0,0 +1,555 @@ +# RFC: Parallel Route Slots + +**Status:** Draft +**Author:** Tanner Linsley +**Created:** 2026-01-04 + +--- + +## Overview + +**Parallel Route Slots** enable rendering multiple independent route trees simultaneously, with each slot's state persisted in the URL via search parameters. This provides shareable, bookmarkable, SSR-compatible parallel routing. + +--- + +## Motivation + +Complex UIs require multiple independent navigable areas: modals with internal navigation, drawers with route hierarchies, split-pane layouts, dashboard widgets loading in parallel. + +Current solutions fall short: + +| Framework | URL Persisted? | Survives Refresh? | Shareable? | +| --------- | -------------- | ----------------- | ---------- | +| Next.js | No (memory) | No (default.js) | No | +| Remix | N/A | N/A | N/A | +| Others | N/A | N/A | N/A | + +**The solution:** Persist slot state in search parameters: + +``` +/dashboard?@modal=/users/123&@modal.tab=profile&@drawer=/notifications +``` + +This gives us: shareable URLs, bookmarkable state, SSR compatibility, refresh safety, type safety, and natural browser history. + +--- + +## Design Principles + +1. **URL is source of truth** - Slot state lives in search params, not memory +2. **Slots are route trees** - Same mental model as regular routes +3. **Parallel execution** - Slot loaders run in parallel with main route +4. **Independent streaming** - Each slot suspends independently +5. **Type-safe** - Full TypeScript inference +6. **Progressive adoption** - Additive, no breaking changes + +--- + +## URL Structure + +Slots render by default when their parent matches. URL only stores deviations: + +``` +/dashboard # all slots at root +/dashboard?@activity=/recent # activity navigated away from root +/dashboard?@modal=/users/123 # modal opened +/dashboard?@metrics=false # metrics explicitly disabled +``` + +### Syntax + +``` +?@modal=/users/123 # slot at path +?@modal.tab=profile # slot search param +?@modal=false # slot disabled +``` + +Slot search params use dot notation. Inside slot components, access as `tab` (prefix stripped). + +### Configuration + +```ts +createRouter({ + slotPrefix: '@', // default, configurable +}) +``` + +--- + +## File Convention + +Slots use `@slotName` prefix. Both flat and directory styles work: + +``` +routes/ +├── __root.tsx +├── @modal.tsx # global slot (child of root) +├── @modal.users.$id.tsx # slot route +├── dashboard.tsx +├── dashboard.@activity.tsx # scoped slot (child of dashboard) +└── dashboard.@activity.index.tsx +``` + +Or directory style: + +``` +routes/ +├── @modal/ +│ ├── route.tsx +│ └── users.$id.tsx +└── dashboard/ + ├── route.tsx + └── @activity/ + └── route.tsx +``` + +The generator detects `@slotName` files, associates them with parent routes, and wires composition automatically. Parents discover their slots after composition and gain type-safe access. + +### Same-Name Slots + +Multiple slots can share a name if they can't match simultaneously at the same nesting level: + +``` +routes/ +├── dashboard.@widget.tsx # dashboard's widget slot +├── orders.@widget.tsx # orders' widget slot (different parent, OK) +├── @left.@sidebar.tsx # nested under @left (OK) +└── @right.@sidebar.tsx # nested under @right (OK) +``` + +Disallowed: two `@widget` slots on the same parent route. + +--- + +## Slot Routes + +Slot routes use `createSlotRoute` with standard route options: + +```ts +// @modal.users.$id.tsx +export const Route = createSlotRoute({ + path: '/users/$id', + validateSearch: z.object({ + tab: z.enum(['profile', 'settings']).default('profile'), + }), + loader: ({ params }) => fetchUser(params.id), + component: UserModal, + pendingComponent: UserModalSkeleton, + errorComponent: UserModalError, +}) +``` + +### Conditional Slots + +Use `enabled` to conditionally disable default rendering: + +```ts +// dashboard.@adminPanel.tsx +export const Route = createSlotRoute({ + enabled: ({ context }) => context.user.role === 'admin', + loader: () => fetchAdminStats(), + component: AdminPanel, +}) +``` + +### Slot Metadata + +Use `staticData` for filtering/grouping: + +```ts +export const Route = createSlotRoute({ + staticData: { + area: 'sidebar', + priority: 1, + }, + component: ActivityWidget, +}) +``` + +--- + +## Navigation API + +Two ways to navigate slots: + +### 1. Fully Qualified `to` + +Navigate directly to a slot route using its full path: + +```tsx +// Root-level modal +Open User + +// Dashboard-scoped activity +Recent Activity + +// Programmatic +navigate({ to: '/@modal/users/$id', params: { id: '123' } }) +``` + +This works with all existing APIs - same as navigating to any route. + +### 2. `slots` Object (Multi-Slot Navigation) + +For navigating multiple slots atomically, or combining main route + slot navigation: + +```tsx +// Navigate main route AND open modal + + +// Update multiple slots at once + + +// Close a slot + +``` + +The `to` inside `slots` is relative to that slot's route tree. + +### Slot Navigation Options + +| Action | Fully Qualified | slots Object | +| --------------- | ------------------------------- | ------------------------------------------------------------- | +| Open to path | `to: '/@modal/users/$id'` | `slots: { modal: { to: '/users/$id' } }` | +| Open to root | `to: '/@modal'` | `slots: { modal: {} }` | +| Update search | (use slots) | `slots: { modal: { search: {...} } }` | +| Close | (use slots) | `slots: { modal: null }` | +| Disable default | (use slots) | `slots: { modal: false }` | +| Nested slots | `to: '/@modal/@confirm/delete'` | `slots: { modal: { slots: { confirm: { to: '/delete' } } } }` | + +### Shallow Merge + +With `slots` object, unmentioned slots are preserved: + +```tsx +// URL: /dashboard?@modal=/users/123&@activity=/feed + + +// Result: /dashboard?@modal=/settings&@activity=/feed + + // main route only, slots preserved +// Result: /settings?@modal=/users/123&@activity=/feed + +// Fully qualified also preserves other slots + +// Result: /dashboard?@modal=/settings&@activity=/feed +``` + +--- + +## Rendering API + +Render slots using `Outlet` with a `slot` prop: + +```tsx +function RootComponent() { + return ( + <> + {/* regular children */} + + + + ) +} +``` + +### Accessing Slot State + +Slot routes have fully qualified paths that include their parent context: + +``` +Root slot: /@modal/users/$id (modal on root) +Scoped slot: /dashboard/@activity (activity scoped to dashboard) +Nested slot: /@modal/@confirm/delete (confirm nested in modal) +``` + +Use these paths with existing hooks: + +```ts +// Root-level modal +const modalMatch = useMatch({ from: '/@modal/users/$id', shouldThrow: false }) +const userData = useLoaderData({ from: '/@modal/users/$id' }) + +// Dashboard-scoped activity slot +const activityData = useLoaderData({ from: '/dashboard/@activity' }) +``` + +Inside slot components, use `Route.*` hooks normally: + +```tsx +function UserModal() { + const { user } = Route.useLoaderData() + const { tab } = Route.useSearch() + const { id } = Route.useParams() +} +``` + +`getRouteApi` works with the same fully qualified paths: + +```ts +const modalApi = getRouteApi('/@modal/users/$id') +const activityApi = getRouteApi('/dashboard/@activity') + +function SomeComponent() { + const { user } = modalApi.useLoaderData() + const feed = activityApi.useLoaderData() +} +``` + +### Iterating Slots + +Parent routes can iterate over slots dynamically: + +```tsx +function Dashboard() { + return ( + + {(slots) => ( + <> + {slots + .filter((s) => s.staticData?.area === 'sidebar') + .sort( + (a, b) => + (a.staticData?.priority ?? 0) - (b.staticData?.priority ?? 0), + ) + .map((slot) => ( + + ))} + + )} + + ) +} +``` + +Each slot provides: + +```ts +interface SlotRenderInfo { + name: string + staticData: StaticDataRouteOption + isOpen: boolean + path: string | null + matches: RouteMatch[] + Outlet: ComponentType +} +``` + +--- + +## Loader Execution + +### Two Phases + +1. **beforeLoad** - Serial down tree, parallel across branches (slots branch from parent) +2. **loader** - All run in parallel after all beforeLoads complete + +``` +__root.beforeLoad() + ↓ + ┌────┴────┐ + ↓ ↓ +dashboard @modal.beforeLoad() ← parallel after root +.beforeLoad() ↓ + ↓ @modal/users.$id.beforeLoad() +@activity.beforeLoad() + + ↓ All beforeLoads complete ↓ + +All loaders in parallel: +├── dashboard.loader() +├── @modal/users.$id.loader() +├── @activity/index.loader() +└── ... +``` + +Slots can have `beforeLoad` for auth/guards. When multiple slots throw redirects, the first to throw wins (same as nested routes): + +```ts +export const Route = createSlotRoute({ + beforeLoad: async ({ params }) => { + if (!(await checkPermission(params.id))) { + throw redirect({ slots: { modal: { to: '/unauthorized' } } }) + } + }, +}) +``` + +--- + +## Search Params + +Each slot defines its own search schema. Params are namespaced in URL (`@modal.tab=profile`) but accessed without prefix inside the slot. + +```ts +// @modal/users.$id.tsx +export const Route = createSlotRoute({ + validateSearch: z.object({ + tab: z.enum(['profile', 'settings']).default('profile'), + }), +}) + +function UserModal() { + const { tab } = Route.useSearch() // just 'tab', not '@modal.tab' + const { filter } = useSearch({ from: '/dashboard' }) // parent's params +} +``` + +### Collision Handling + +If slot and parent both define `tab`: + +- URL: `?tab=overview&@modal.tab=profile` (no conflict - prefixed) +- `Route.useSearch()` inside slot returns slot's value (shadows parent) +- Use `useSearch({ from: '/dashboard' })` for explicit parent access + +--- + +## Slot Lifecycle + +### Persistence + +Slot params automatically persist across navigations (unlike normal search params): + +```ts +// URL: /dashboard?@modal=/users/123 +navigate({ to: '/settings' }) +// Result: /settings?@modal=/users/123 (preserved) + +navigate({ to: '/settings', slots: { modal: null } }) +// Result: /settings (explicitly closed) +``` + +### Scoped vs Root Slots + +**Root slots** (on `__root`) persist everywhere. **Scoped slots** only exist when their parent is active: + +``` +// @activity scoped to /dashboard +/dashboard?@activity=/recent // exists +/settings // @activity gone +/dashboard // @activity starts fresh (but history preserved) +``` + +--- + +## Nested Slots + +Slots can contain slots: + +``` +routes/ +├── @modal.tsx +├── @modal.@confirm.tsx # nested slot +└── @modal.@confirm.delete.tsx +``` + +URL uses nested prefix: + +``` +?@modal=/settings&@modal@confirm=/delete +``` + +Navigate with fully qualified path or nested `slots` object: + +```ts +// Fully qualified - simpler for direct navigation +navigate({ to: '/@modal/@confirm/delete' }) + +// slots object - for multi-slot or combined navigation +navigate({ + slots: { + modal: { + to: '/settings', + slots: { confirm: { to: '/delete' } }, + }, + }, +}) +``` + +--- + +## Error Boundaries & Streaming + +Each slot has independent error handling and suspense. A slot error doesn't crash other slots or the main route. + +```ts +export const Route = createSlotRoute({ + component: UserModal, + pendingComponent: UserModalSkeleton, + errorComponent: UserModalError, +}) +``` + +SSR streams each slot independently as data resolves. + +--- + +## Type Safety + +Full inference throughout: + +```tsx +// ✅ Valid - fully qualified + + +navigate({ to: '/@modal/users/$id', params: { id: '123' } }) + +// ✅ Valid - slots object + + + +// ❌ Type errors + + // missing required param + // activity not on /settings +``` + +--- + +## Examples + +See [examples/](./examples/) for complete implementations: + +1. **[modal-with-navigation](./examples/modal-with-navigation/)** - Global modal with internal navigation +2. **[dashboard-widgets](./examples/dashboard-widgets/)** - Parallel-loading widgets with explicit placement +3. **[component-routes](./examples/component-routes/)** - Auto-rendering with `` iteration +4. **[split-pane-mail](./examples/split-pane-mail/)** - Independent pane navigation +5. **[nested-slots](./examples/nested-slots/)** - Modal with nested confirmation dialog + +--- + +## Migration + +Additive feature, no breaking changes: + +1. Create `@slotName` files +2. Render with `` +3. Navigate with `` or `slots: { name: {...} }` + +--- + +## Open Questions + +1. **Devtools** - How to visualize parallel slot trees? + +2. **Testing utilities** - What helpers are needed for slot navigation testing? + +## Resolved Questions + +1. **Preloading** - Slots follow the same preloading behavior as nested routes. `preload="intent"` preloads all matched routes including slots. + +2. **Revalidation** - Slots participate in revalidation the same way nested routes do today. + +3. **`.lazy()` support** - Slot routes support `.lazy()` for code splitting, same as regular routes. Critical for monorepo support where route definitions are separate from components. + +--- + +## References + +- [Next.js Parallel Routes](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes) +- [Remix Sibling Routes Proposal](https://github.com/remix-run/remix/discussions/5431) +- [Jamie Kyle's Slots Tweet](https://twitter.com/buildsghost/status/1531754246856527872) 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..ccd2d1a166 --- /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..c7b8e23270 --- /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 +- **staticData** - 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..dd176a17c1 --- /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 staticData.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..cf49791ec6 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.@userCard.tsx @@ -0,0 +1,56 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } 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 +
+
+ {/* Open modal from dashboard - Route.Link has implicit from */} + + 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..331f1ddff5 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/component-routes/routes/dashboard.tsx @@ -0,0 +1,81 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createFileRoute, Outlet } from '@tanstack/react-router' + +// Slots are NOT declared here - they're discovered from dashboard.@*.tsx files +// after composition. Route.Outlet and Route.Slots gain type-safe access automatically. +export const Route = createFileRoute('/dashboard')({ + 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..e8640a367c --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/dashboard-widgets/routes/dashboard.tsx @@ -0,0 +1,47 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createFileRoute, Outlet } from '@tanstack/react-router' + +// Slots are NOT declared here - they're discovered from dashboard.@*.tsx files +// after composition. Route.Outlet gains type-safe slot prop automatically. +export const Route = createFileRoute('/dashboard')({ + 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..ef1e55e303 --- /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 Outlet with slot prop +├── @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..e8bb4bf7da --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.index.tsx @@ -0,0 +1,27 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +// Modal index - shown when @modal=/ +export const Route = createSlotRoute({ + path: '/', + component: ModalIndex, +}) + +function ModalIndex() { + return ( +
+

Quick Actions

+ {/* Navigation within the modal - Route.Link has implicit from */} + +
+ ) +} 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..2aff05a577 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.settings.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } 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 navigate = Route.useNavigate() + + const handleClose = () => { + navigate({ slots: { modal: null } }) + } + + const handleSave = async (formData: FormData) => { + await saveSettings(formData) + handleClose() + } + + 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..382c23c7b8 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.tsx @@ -0,0 +1,30 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet } from '@tanstack/react-router' + +// This is the slot's root route - wraps all modal content +export const Route = createSlotRootRoute({ + component: ModalWrapper, +}) + +function ModalWrapper() { + const navigate = Route.useNavigate() + + const handleClose = () => { + navigate({ slots: { modal: null } }) + } + + 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..a5f6493b89 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/@modal.users.$id.tsx @@ -0,0 +1,84 @@ +// @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 - using fully qualified paths (simpler for single slot nav) */} + + + {/* 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..aedfc3cd9b --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/modal-with-navigation/routes/__root.tsx @@ -0,0 +1,27 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createRootRoute, Outlet } from '@tanstack/react-router' + +// Slots are NOT declared here - they're discovered from @modal.tsx files +// after composition. Route.Outlet gains type-safe slot prop automatically. +export const Route = createRootRoute({ + 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..86f98477c4 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.delete.tsx @@ -0,0 +1,46 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +// Confirm delete account dialog +export const Route = createSlotRoute({ + path: '/delete', + component: DeleteConfirm, +}) + +function DeleteConfirm() { + const navigate = Route.useNavigate() + + const handleDelete = async () => { + await deleteAccount() + // Close the entire modal (and its nested slots) + navigate({ slots: { modal: null } }) + // In reality you'd also redirect to a logged-out page + } + + const handleCancel = () => { + // Close just the confirm dialog, keep modal open + navigate({ slots: { modal: { slots: { confirm: null } } } }) + } + + 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..be2ddb5683 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.discard.tsx @@ -0,0 +1,38 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute } from '@tanstack/react-router' + +// Confirm discard changes dialog +export const Route = createSlotRoute({ + path: '/discard', + component: DiscardConfirm, +}) + +function DiscardConfirm() { + const navigate = Route.useNavigate() + + const handleDiscard = () => { + // Close the entire modal (and its nested slots) + navigate({ slots: { modal: null } }) + } + + const handleCancel = () => { + // Just close the confirm dialog, keep modal open + navigate({ slots: { modal: { slots: { confirm: null } } } }) + } + + 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..dec6bcee65 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.@confirm.tsx @@ -0,0 +1,26 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet } from '@tanstack/react-router' + +// Nested confirmation dialog slot +export const Route = createSlotRootRoute({ + component: ConfirmDialogWrapper, +}) + +function ConfirmDialogWrapper() { + const navigate = Route.useNavigate() + + const handleClose = () => { + // Close just the confirm dialog, keep modal open + navigate({ slots: { modal: { slots: { confirm: null } } } }) + } + + 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..597b9f828b --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.settings.tsx @@ -0,0 +1,72 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRoute, Link } from '@tanstack/react-router' +import { useState } from 'react' + +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 navigate = Route.useNavigate() + + const handleClose = () => { + if (hasChanges) { + // Open the nested confirm slot - fully qualified path + // Results in URL: ?@modal=/settings&@modal@confirm=/discard + navigate({ to: '/@modal/@confirm/discard' }) + } else { + // Close modal - need slots object for null + navigate({ slots: { modal: null } }) + } + } + + return ( +
+

Settings

+ +
setHasChanges(true)}> + + + +
+ +
+ + + + {/* Direct link to delete confirmation - fully qualified nested slot path */} + + 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..10e2eabea1 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/@modal.tsx @@ -0,0 +1,34 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet } from '@tanstack/react-router' + +// Nested slots are NOT declared here - they're discovered from @modal.@confirm.tsx +// after composition. Route.Outlet gains type-safe slot prop automatically. +export const Route = createSlotRootRoute({ + component: ModalWrapper, +}) + +function ModalWrapper() { + const navigate = Route.useNavigate() + + const handleClose = () => { + navigate({ slots: { modal: null } }) + } + + 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..d77c123817 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/nested-slots/routes/__root.tsx @@ -0,0 +1,21 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createRootRoute, Outlet } from '@tanstack/react-router' + +// Slots are NOT declared here - they're discovered from @modal.tsx files +// after composition. Route.Outlet gains type-safe slot prop automatically. +export const Route = createRootRoute({ + 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..f309d8f119 --- /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 - fully qualified */} + + {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..764118fa82 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.@preview.tsx @@ -0,0 +1,31 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createSlotRootRoute, Outlet, useMatch } from '@tanstack/react-router' + +export const Route = createSlotRootRoute({ + component: PreviewPane, +}) + +function PreviewPane() { + const navigate = Route.useNavigate() + + // Check if we're at something other than the preview root + const previewMatch = useMatch({ from: '/mail/@preview', shouldThrow: false }) + const isAtRoot = previewMatch?.pathname === '/' + + const handleClose = () => { + navigate({ slots: { preview: null } }) + } + + return ( +
+ {previewMatch && !isAtRoot && ( + + )} + +
+ ) +} 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..0c28fbb861 --- /dev/null +++ b/docs/rfcs/parallel-route-slots/examples/split-pane-mail/routes/mail.tsx @@ -0,0 +1,34 @@ +// @ts-nocheck +// Example only - this is a conceptual demonstration + +import { createFileRoute, Link } from '@tanstack/react-router' + +// Slots are NOT declared here - they're discovered from mail.@*.tsx files +// after composition. Route.Outlet gains type-safe slot prop automatically. +export const Route = createFileRoute('/mail')({ + component: MailLayout, +}) + +function MailLayout() { + return ( +
+ {/* Sidebar with folders - using fully qualified paths */} + + + {/* Message list pane */} +
+ +
+ + {/* Preview pane */} +
+ +
+
+ ) +}