Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
555 changes: 555 additions & 0 deletions docs/rfcs/parallel-route-slots/README.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions docs/rfcs/parallel-route-slots/examples/README.md
Original file line number Diff line number Diff line change
@@ -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 `<Route.Slots>` 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 `<Route.Outlet slot="x">` | Manual open/close |
| Dashboard Widgets | Fixed layout | Explicit `<Route.Outlet slot="x">` | Manual open/close |
| Component Routes | Dynamic widgets | `<Route.Slots>` iteration | `defaultOpen: true` auto-adds to URL |
| Split Pane | Independent panes | Explicit `<Route.Outlet slot="x">` | Both panes in URL |
| Nested Slots | Layered UI | Parent slot places child slots | Nested `@parent@child` prefix |
47 changes: 47 additions & 0 deletions docs/rfcs/parallel-route-slots/examples/component-routes/README.md
Original file line number Diff line number Diff line change
@@ -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 <Route.Slots> to iterate
├── dashboard.index.tsx # main dashboard content
├── [email protected] # explicitly placed (not in iteration)
├── [email protected] # area: 'main', priority: 1
├── [email protected] # area: 'main', priority: 2
├── [email protected] # area: 'main', priority: 3, user can disable
├── [email protected] # area: 'main', admin only
├── [email protected] # area: 'sidebar'
└── [email protected] # 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
- **<Route.Slots>** - Render prop to iterate over all enabled slots
- **Mixed approach** - Combine explicit `<Route.Outlet slot="x">` with dynamic iteration
Original file line number Diff line number Diff line change
@@ -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 (
<ul className="activity-feed">
{activities.map((item) => (
<li key={item.id} className="activity-item">
<img src={item.user.avatar} alt="" className="activity-avatar" />
<div className="activity-content">
<strong>{item.user.name}</strong> {item.action}
<time>{item.timestamp}</time>
</div>
</li>
))}
</ul>
)
}

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',
},
]
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="admin-panel">
<div className="admin-stats">
<div className="stat">
<span className="stat-value">{stats.pendingApprovals}</span>
<span className="stat-label">Pending Approvals</span>
</div>
<div className="stat">
<span className="stat-value">{stats.flaggedContent}</span>
<span className="stat-label">Flagged Content</span>
</div>
<div className="stat">
<span className="stat-value">{stats.activeUsers}</span>
<span className="stat-label">Active Users</span>
</div>
</div>
<div className="admin-actions">
<button>Review Queue</button>
<button>User Management</button>
<button>System Settings</button>
</div>
</div>
)
}

async function fetchAdminStats() {
return {
pendingApprovals: 12,
flaggedContent: 3,
activeUsers: 847,
}
}
Original file line number Diff line number Diff line change
@@ -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 <Route.Outlet slot="header" />
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 (
<div className="dashboard-header">
<h1>Dashboard</h1>
<nav>
<Link to="/dashboard">Overview</Link>
<Link to="/dashboard/analytics">Analytics</Link>
<Link to="/dashboard/reports">Reports</Link>
</nav>
<div className="header-actions">
<button className="notifications-btn">
🔔{' '}
{notifications > 0 && <span className="badge">{notifications}</span>}
</button>
<span className="user-name">{user.name}</span>
</div>
</div>
)
}

async function fetchUnreadCount(userId: string) {
return 5
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="metrics-grid">
{metrics.map((metric) => (
<div key={metric.label} className="metric-card">
<span className="metric-value">{metric.value}</span>
<span className="metric-label">{metric.label}</span>
<span
className={`metric-change ${metric.change > 0 ? 'positive' : 'negative'}`}
>
{metric.change > 0 ? '↑' : '↓'} {Math.abs(metric.change)}%
</span>
</div>
))}
</div>
)
}

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 },
]
}
Original file line number Diff line number Diff line change
@@ -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 <p className="empty-state">No new notifications</p>
}

return (
<ul className="notifications-list">
{notifications.map((notif) => (
<li
key={notif.id}
className={`notification ${notif.read ? 'read' : 'unread'}`}
>
<span className="notification-icon">{notif.icon}</span>
<div className="notification-content">
<p>{notif.message}</p>
<time>{notif.time}</time>
</div>
</li>
))}
</ul>
)
}

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,
},
]
}
Loading
Loading