@rangojs/router 0.0.0-experimental.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +7 -0
- package/README.md +19 -0
- package/dist/vite/index.js +1298 -0
- package/package.json +140 -0
- package/skills/caching/SKILL.md +319 -0
- package/skills/document-cache/SKILL.md +152 -0
- package/skills/hooks/SKILL.md +359 -0
- package/skills/intercept/SKILL.md +292 -0
- package/skills/layout/SKILL.md +216 -0
- package/skills/loader/SKILL.md +365 -0
- package/skills/middleware/SKILL.md +442 -0
- package/skills/parallel/SKILL.md +255 -0
- package/skills/route/SKILL.md +141 -0
- package/skills/router-setup/SKILL.md +403 -0
- package/skills/theme/SKILL.md +54 -0
- package/skills/typesafety/SKILL.md +352 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/component-utils.test.ts +76 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/__tests__/urls.test.tsx +436 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +893 -0
- package/src/browser/navigation-client.ts +162 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +559 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +275 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-href.tsx +208 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +164 -0
- package/src/browser/rsc-router.tsx +353 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +464 -0
- package/src/cache/__tests__/document-cache.test.ts +522 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +428 -0
- package/src/cache/cf/cf-cache-store.ts +428 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/document-cache.ts +340 -0
- package/src/cache/index.ts +58 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +387 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +621 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +23 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +193 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href-context.ts +33 -0
- package/src/href.ts +177 -0
- package/src/index.rsc.ts +79 -0
- package/src/index.ts +87 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1371 -0
- package/src/route-map-builder.ts +146 -0
- package/src/route-types.ts +198 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +158 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +138 -0
- package/src/router/match-context.ts +264 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +266 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +214 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +272 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3876 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +1060 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +237 -0
- package/src/segment-system.tsx +456 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +417 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +146 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +234 -0
- package/src/theme/ThemeProvider.tsx +291 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/__tests__/theme.test.ts +120 -0
- package/src/theme/constants.ts +55 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/theme-context.ts +70 -0
- package/src/theme/theme-script.ts +152 -0
- package/src/theme/types.ts +182 -0
- package/src/theme/use-theme.ts +44 -0
- package/src/types.ts +1561 -0
- package/src/urls.ts +726 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +787 -0
- package/src/vite/package-resolution.ts +125 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hooks
|
|
3
|
+
description: Client-side React hooks for navigation, loaders, and state in rsc-router
|
|
4
|
+
argument-hint: [hook-name]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Client-Side React Hooks
|
|
8
|
+
|
|
9
|
+
All hooks are imported from `rsc-router` or `rsc-router/client`.
|
|
10
|
+
|
|
11
|
+
## Navigation Hooks
|
|
12
|
+
|
|
13
|
+
### useNavigation()
|
|
14
|
+
|
|
15
|
+
Track navigation state and control navigation:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
"use client";
|
|
19
|
+
import { useNavigation } from "rsc-router";
|
|
20
|
+
|
|
21
|
+
function NavIndicator() {
|
|
22
|
+
const nav = useNavigation();
|
|
23
|
+
|
|
24
|
+
// Full state
|
|
25
|
+
nav.state; // 'idle' | 'loading' | 'streaming'
|
|
26
|
+
nav.isStreaming; // boolean
|
|
27
|
+
nav.location; // Current URL
|
|
28
|
+
nav.pendingUrl; // Target URL during navigation (or null)
|
|
29
|
+
|
|
30
|
+
// Methods
|
|
31
|
+
nav.navigate("/products"); // Navigate programmatically
|
|
32
|
+
nav.navigate("/products", { replace: true }); // Replace history
|
|
33
|
+
nav.refresh(); // Refresh current route
|
|
34
|
+
|
|
35
|
+
return nav.state === 'loading' ? <Spinner /> : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// With selector for performance
|
|
39
|
+
function IsLoading() {
|
|
40
|
+
const isLoading = useNavigation(nav => nav.state === 'loading');
|
|
41
|
+
return isLoading ? <Spinner /> : null;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### useSegments()
|
|
46
|
+
|
|
47
|
+
Access current URL path and matched route segments:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
"use client";
|
|
51
|
+
import { useSegments } from "rsc-router";
|
|
52
|
+
|
|
53
|
+
function Breadcrumbs() {
|
|
54
|
+
const { path, segmentIds, location } = useSegments();
|
|
55
|
+
|
|
56
|
+
// path: ["/shop", "products", "123"]
|
|
57
|
+
// segmentIds: ["shop-layout", "products-route"]
|
|
58
|
+
// location: URL object
|
|
59
|
+
|
|
60
|
+
return <nav>{path.join(" > ")}</nav>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// With selector
|
|
64
|
+
const isShopRoute = useSegments(s => s.path[0] === "shop");
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### useLinkStatus()
|
|
68
|
+
|
|
69
|
+
Track pending state inside a Link component:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
"use client";
|
|
73
|
+
import { Link, useLinkStatus } from "rsc-router/client";
|
|
74
|
+
|
|
75
|
+
function LoadingIndicator() {
|
|
76
|
+
const { pending } = useLinkStatus();
|
|
77
|
+
return pending ? <Spinner /> : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Must be inside Link
|
|
81
|
+
<Link to="/dashboard">
|
|
82
|
+
Dashboard
|
|
83
|
+
<LoadingIndicator />
|
|
84
|
+
</Link>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Data Hooks
|
|
88
|
+
|
|
89
|
+
### useLoader()
|
|
90
|
+
|
|
91
|
+
Access loader data (strict - data guaranteed):
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
"use client";
|
|
95
|
+
import { useLoader } from "rsc-router";
|
|
96
|
+
import { ProductLoader } from "../loaders/product";
|
|
97
|
+
|
|
98
|
+
function ProductPrice() {
|
|
99
|
+
const { data, isLoading, error } = useLoader(ProductLoader);
|
|
100
|
+
|
|
101
|
+
// data: T (guaranteed - throws if not in context)
|
|
102
|
+
// isLoading: boolean
|
|
103
|
+
// error: Error | null
|
|
104
|
+
|
|
105
|
+
return <span>${data.price}</span>;
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Precondition**: Loader must be registered on route via `loader()` helper.
|
|
110
|
+
|
|
111
|
+
### useFetchLoader()
|
|
112
|
+
|
|
113
|
+
Access loader with on-demand fetching (flexible):
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
"use client";
|
|
117
|
+
import { useFetchLoader } from "rsc-router";
|
|
118
|
+
import { SearchLoader } from "../loaders/search";
|
|
119
|
+
|
|
120
|
+
function SearchResults() {
|
|
121
|
+
const { data, load, isLoading, error } = useFetchLoader(SearchLoader);
|
|
122
|
+
|
|
123
|
+
// data: T | undefined (may be undefined if not fetched)
|
|
124
|
+
// load: (options?) => Promise<T>
|
|
125
|
+
// refetch: alias for load
|
|
126
|
+
|
|
127
|
+
const handleSearch = async (query: string) => {
|
|
128
|
+
await load({ params: { query } });
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div>
|
|
133
|
+
<input onChange={(e) => handleSearch(e.target.value)} />
|
|
134
|
+
{isLoading && <Spinner />}
|
|
135
|
+
{data?.results.map(r => <Result key={r.id} {...r} />)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Load options**:
|
|
142
|
+
```tsx
|
|
143
|
+
await load({
|
|
144
|
+
method: 'POST', // GET, POST, PUT, PATCH, DELETE
|
|
145
|
+
params: { query: 'test' }, // Query string (GET) or body (others)
|
|
146
|
+
body: { data: 'value' }, // For POST/PUT/PATCH/DELETE
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### useLoaderData()
|
|
151
|
+
|
|
152
|
+
Get all loader data in current context:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
"use client";
|
|
156
|
+
import { useLoaderData } from "rsc-router";
|
|
157
|
+
|
|
158
|
+
function DebugPanel() {
|
|
159
|
+
const allData = useLoaderData();
|
|
160
|
+
// Record<string, any> - Map of loader ID to data
|
|
161
|
+
|
|
162
|
+
return <pre>{JSON.stringify(allData, null, 2)}</pre>;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Handle Hooks
|
|
167
|
+
|
|
168
|
+
### useHandle()
|
|
169
|
+
|
|
170
|
+
Access accumulated handle data from route segments:
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
"use client";
|
|
174
|
+
import { useHandle } from "rsc-router";
|
|
175
|
+
import { Breadcrumbs } from "../handles/breadcrumbs";
|
|
176
|
+
|
|
177
|
+
function BreadcrumbNav() {
|
|
178
|
+
const crumbs = useHandle(Breadcrumbs);
|
|
179
|
+
// Array of { label, href } accumulated from layouts/routes
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<nav>
|
|
183
|
+
{crumbs.map((c, i) => (
|
|
184
|
+
<span key={i}>
|
|
185
|
+
<a href={c.href}>{c.label}</a>
|
|
186
|
+
{i < crumbs.length - 1 && " > "}
|
|
187
|
+
</span>
|
|
188
|
+
))}
|
|
189
|
+
</nav>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// With selector
|
|
194
|
+
const lastCrumb = useHandle(Breadcrumbs, data => data.at(-1));
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Action Hooks
|
|
198
|
+
|
|
199
|
+
### useAction()
|
|
200
|
+
|
|
201
|
+
Track state of server action invocations:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
"use client";
|
|
205
|
+
import { useAction } from "rsc-router";
|
|
206
|
+
import { addToCart } from "../actions/cart";
|
|
207
|
+
|
|
208
|
+
function AddToCartButton({ productId }: { productId: string }) {
|
|
209
|
+
const { state, error, result } = useAction(addToCart);
|
|
210
|
+
|
|
211
|
+
// state: 'idle' | 'loading' | 'streaming'
|
|
212
|
+
// actionId: string | null
|
|
213
|
+
// payload: unknown | null (input data)
|
|
214
|
+
// error: Error | null
|
|
215
|
+
// result: unknown | null (return value)
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<form action={addToCart}>
|
|
219
|
+
<input type="hidden" name="productId" value={productId} />
|
|
220
|
+
<button disabled={state === 'loading'}>
|
|
221
|
+
{state === 'loading' ? 'Adding...' : 'Add to Cart'}
|
|
222
|
+
</button>
|
|
223
|
+
{error && <p className="error">{error.message}</p>}
|
|
224
|
+
</form>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Match by string suffix (convenient but may be ambiguous)
|
|
229
|
+
const isLoading = useAction('addToCart', s => s.state === 'loading');
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## State Hooks
|
|
233
|
+
|
|
234
|
+
### useLocationState()
|
|
235
|
+
|
|
236
|
+
Read type-safe state from history:
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
"use client";
|
|
240
|
+
import { useLocationState, createLocationState } from "rsc-router";
|
|
241
|
+
|
|
242
|
+
// Define typed state
|
|
243
|
+
export const ProductState = createLocationState<{
|
|
244
|
+
name: string;
|
|
245
|
+
price: number;
|
|
246
|
+
}>();
|
|
247
|
+
|
|
248
|
+
function ProductHeader() {
|
|
249
|
+
const state = useLocationState(ProductState);
|
|
250
|
+
// { name: string; price: number } | undefined
|
|
251
|
+
|
|
252
|
+
if (state) {
|
|
253
|
+
return <h1>{state.name} - ${state.price}</h1>;
|
|
254
|
+
}
|
|
255
|
+
return <h1>Loading...</h1>;
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Pass state through Link:
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import { Link } from "rsc-router/client";
|
|
263
|
+
import { ProductState } from "./state";
|
|
264
|
+
|
|
265
|
+
<Link
|
|
266
|
+
to="/product/123"
|
|
267
|
+
state={[ProductState({ name: "Widget", price: 99 })]}
|
|
268
|
+
>
|
|
269
|
+
View Product
|
|
270
|
+
</Link>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Cache Hooks
|
|
274
|
+
|
|
275
|
+
### useClientCache()
|
|
276
|
+
|
|
277
|
+
Manually control client-side navigation cache:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
"use client";
|
|
281
|
+
import { useClientCache } from "rsc-router";
|
|
282
|
+
|
|
283
|
+
function SaveButton() {
|
|
284
|
+
const { clear } = useClientCache();
|
|
285
|
+
|
|
286
|
+
const handleSave = async () => {
|
|
287
|
+
await fetch('/api/data', {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
body: JSON.stringify(data)
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Invalidate cache after mutation
|
|
293
|
+
clear();
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return <button onClick={handleSave}>Save</button>;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Use cases**: REST API mutations, WebSocket updates, non-RSC data changes.
|
|
301
|
+
|
|
302
|
+
## Outlet Components
|
|
303
|
+
|
|
304
|
+
### Outlet / ParallelOutlet
|
|
305
|
+
|
|
306
|
+
Render child content in layouts:
|
|
307
|
+
|
|
308
|
+
```tsx
|
|
309
|
+
import { Outlet, ParallelOutlet } from "rsc-router";
|
|
310
|
+
|
|
311
|
+
function DashboardLayout({ children }: { children?: React.ReactNode }) {
|
|
312
|
+
return (
|
|
313
|
+
<div className="dashboard">
|
|
314
|
+
<aside>
|
|
315
|
+
<ParallelOutlet name="@sidebar" />
|
|
316
|
+
</aside>
|
|
317
|
+
<main>
|
|
318
|
+
{children ?? <Outlet />}
|
|
319
|
+
</main>
|
|
320
|
+
<ParallelOutlet name="@notifications" />
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### useOutlet()
|
|
327
|
+
|
|
328
|
+
Access outlet content programmatically:
|
|
329
|
+
|
|
330
|
+
```tsx
|
|
331
|
+
"use client";
|
|
332
|
+
import { useOutlet } from "rsc-router";
|
|
333
|
+
|
|
334
|
+
function ConditionalLayout() {
|
|
335
|
+
const outlet = useOutlet();
|
|
336
|
+
// ReactNode | null
|
|
337
|
+
|
|
338
|
+
return outlet ? (
|
|
339
|
+
<div className="with-content">{outlet}</div>
|
|
340
|
+
) : (
|
|
341
|
+
<div className="empty">No content</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Hook Summary
|
|
347
|
+
|
|
348
|
+
| Hook | Purpose | Returns |
|
|
349
|
+
|------|---------|---------|
|
|
350
|
+
| `useNavigation()` | Navigation state & control | state, navigate, refresh |
|
|
351
|
+
| `useSegments()` | URL path & segment IDs | path, segmentIds, location |
|
|
352
|
+
| `useLinkStatus()` | Link pending state | { pending } |
|
|
353
|
+
| `useLoader()` | Loader data (strict) | data, isLoading, error |
|
|
354
|
+
| `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
|
|
355
|
+
| `useLoaderData()` | All loader data | Record<string, any> |
|
|
356
|
+
| `useHandle()` | Accumulated handle data | T (handle type) |
|
|
357
|
+
| `useAction()` | Server action state | state, error, result |
|
|
358
|
+
| `useLocationState()` | History state | T \| undefined |
|
|
359
|
+
| `useClientCache()` | Cache control | { clear } |
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: intercept
|
|
3
|
+
description: Define intercept routes for modals, slide-overs, and soft navigation patterns in rsc-router
|
|
4
|
+
argument-hint: [@slot-name] [route-to-intercept]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Intercept Routes
|
|
8
|
+
|
|
9
|
+
Intercept routes render a different component during soft navigation (client-side) while preserving the background route. Hard navigation (direct URL) shows the full page.
|
|
10
|
+
|
|
11
|
+
## Basic Intercept
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { map } from "rsc-router/server";
|
|
15
|
+
import { ParallelOutlet } from "rsc-router";
|
|
16
|
+
|
|
17
|
+
export default map<typeof routes>(({ route, layout, parallel, intercept, loader }) => [
|
|
18
|
+
layout(
|
|
19
|
+
() => (
|
|
20
|
+
<ShopLayout>
|
|
21
|
+
<Outlet />
|
|
22
|
+
<ParallelOutlet name="@modal" />
|
|
23
|
+
</ShopLayout>
|
|
24
|
+
),
|
|
25
|
+
() => [
|
|
26
|
+
// Define empty modal slot
|
|
27
|
+
parallel({
|
|
28
|
+
"@modal": () => null,
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
// Intercept product detail into modal
|
|
32
|
+
intercept(
|
|
33
|
+
"@modal", // Slot name
|
|
34
|
+
"products.detail", // Route to intercept
|
|
35
|
+
(ctx) => <ProductModal id={ctx.params.id} />, // Modal component
|
|
36
|
+
),
|
|
37
|
+
|
|
38
|
+
route("products.index", ProductList),
|
|
39
|
+
route("products.detail", ProductDetail), // Full page version
|
|
40
|
+
]
|
|
41
|
+
),
|
|
42
|
+
]);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Navigation Behavior
|
|
46
|
+
|
|
47
|
+
| Navigation Type | What Renders |
|
|
48
|
+
|-----------------|--------------|
|
|
49
|
+
| Click link `/product/abc` | `<ProductModal />` in `@modal`, background preserved |
|
|
50
|
+
| Direct URL `/product/abc` | Full `<ProductDetail />` page |
|
|
51
|
+
| Back button from modal | Modal closes, background restored |
|
|
52
|
+
| Refresh in modal | Full page loads |
|
|
53
|
+
|
|
54
|
+
## Intercept with Configuration
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
intercept(
|
|
58
|
+
"@modal",
|
|
59
|
+
"products.detail",
|
|
60
|
+
async (ctx) => {
|
|
61
|
+
const product = await ctx.use(ProductLoader);
|
|
62
|
+
return <ProductModal product={product} />;
|
|
63
|
+
},
|
|
64
|
+
() => [
|
|
65
|
+
loader(ProductLoader),
|
|
66
|
+
loading(<ProductModalSkeleton />),
|
|
67
|
+
revalidate(({ actionId }) => actionId?.includes("cart") ?? false),
|
|
68
|
+
errorBoundary(<ModalErrorFallback />),
|
|
69
|
+
]
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Conditional Intercept with `when()`
|
|
74
|
+
|
|
75
|
+
Only intercept under certain conditions:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
intercept(
|
|
79
|
+
"@modal",
|
|
80
|
+
"products.detail",
|
|
81
|
+
(ctx) => <ProductModal id={ctx.params.id} />,
|
|
82
|
+
() => [
|
|
83
|
+
// Only intercept when NOT coming from product pages
|
|
84
|
+
when(({ from }) => !from.pathname.startsWith("/products/")),
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The `when()` callback receives:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
when(({ from, to }) => {
|
|
93
|
+
from.pathname // Where user is coming from
|
|
94
|
+
from.params // Params of source route
|
|
95
|
+
to.pathname // Where user is going
|
|
96
|
+
to.params // Params of target route
|
|
97
|
+
|
|
98
|
+
return true; // true = intercept, false = full navigation
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Multiple Intercepts
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
layout(<ShopLayout />, () => [
|
|
106
|
+
parallel({
|
|
107
|
+
"@modal": () => null,
|
|
108
|
+
"@slideOver": () => null,
|
|
109
|
+
}),
|
|
110
|
+
|
|
111
|
+
// Product detail opens as modal
|
|
112
|
+
intercept(
|
|
113
|
+
"@modal",
|
|
114
|
+
"products.detail",
|
|
115
|
+
(ctx) => <ProductModal id={ctx.params.id} />,
|
|
116
|
+
),
|
|
117
|
+
|
|
118
|
+
// Quick view opens as slide-over
|
|
119
|
+
intercept(
|
|
120
|
+
"@slideOver",
|
|
121
|
+
"products.quickView",
|
|
122
|
+
(ctx) => <QuickViewPanel id={ctx.params.id} />,
|
|
123
|
+
),
|
|
124
|
+
|
|
125
|
+
// Cart opens as slide-over
|
|
126
|
+
intercept(
|
|
127
|
+
"@slideOver",
|
|
128
|
+
"cart",
|
|
129
|
+
() => <CartSlideOver />,
|
|
130
|
+
),
|
|
131
|
+
|
|
132
|
+
route("products.index", ProductList),
|
|
133
|
+
route("products.detail", ProductDetail),
|
|
134
|
+
route("products.quickView", ProductQuickView),
|
|
135
|
+
route("cart", CartPage),
|
|
136
|
+
])
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Intercept with Layout
|
|
140
|
+
|
|
141
|
+
Wrap the intercepted content:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
intercept(
|
|
145
|
+
"@modal",
|
|
146
|
+
"products.detail",
|
|
147
|
+
(ctx) => <ProductModalContent id={ctx.params.id} />,
|
|
148
|
+
() => [
|
|
149
|
+
layout(<ModalWrapper />, () => [
|
|
150
|
+
// Configuration applies to modal
|
|
151
|
+
loader(ProductLoader),
|
|
152
|
+
loading(<ProductModalSkeleton />),
|
|
153
|
+
]),
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// ModalWrapper provides chrome around content
|
|
158
|
+
function ModalWrapper({ children }: { children: React.ReactNode }) {
|
|
159
|
+
return (
|
|
160
|
+
<div className="modal-backdrop">
|
|
161
|
+
<div className="modal-container">
|
|
162
|
+
<CloseButton />
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Intercept with Middleware
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
intercept(
|
|
174
|
+
"@modal",
|
|
175
|
+
"account.settings",
|
|
176
|
+
(ctx) => <SettingsModal user={ctx.get("user")} />,
|
|
177
|
+
() => [
|
|
178
|
+
middleware(async (ctx, next) => {
|
|
179
|
+
// Verify user is logged in for modal
|
|
180
|
+
const user = ctx.get("user");
|
|
181
|
+
if (!user) {
|
|
182
|
+
throw redirect("/login");
|
|
183
|
+
}
|
|
184
|
+
await next();
|
|
185
|
+
}),
|
|
186
|
+
]
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Photo Gallery Example
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
// Instagram-style photo gallery with modal
|
|
194
|
+
export default map<typeof routes>(({ route, layout, parallel, intercept }) => [
|
|
195
|
+
layout(
|
|
196
|
+
() => (
|
|
197
|
+
<GalleryLayout>
|
|
198
|
+
<Outlet />
|
|
199
|
+
<ParallelOutlet name="@lightbox" />
|
|
200
|
+
</GalleryLayout>
|
|
201
|
+
),
|
|
202
|
+
() => [
|
|
203
|
+
parallel({
|
|
204
|
+
"@lightbox": () => null,
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
// Photo opens in lightbox on soft nav
|
|
208
|
+
intercept(
|
|
209
|
+
"@lightbox",
|
|
210
|
+
"photos.detail",
|
|
211
|
+
(ctx) => (
|
|
212
|
+
<Lightbox>
|
|
213
|
+
<PhotoViewer id={ctx.params.id} />
|
|
214
|
+
</Lightbox>
|
|
215
|
+
),
|
|
216
|
+
() => [
|
|
217
|
+
loader(PhotoLoader),
|
|
218
|
+
loading(<PhotoSkeleton />),
|
|
219
|
+
// Only intercept from gallery pages
|
|
220
|
+
when(({ from }) => from.pathname.startsWith("/gallery")),
|
|
221
|
+
]
|
|
222
|
+
),
|
|
223
|
+
|
|
224
|
+
route("photos.index", PhotoGrid),
|
|
225
|
+
route("photos.detail", PhotoPage), // Full page with comments, etc.
|
|
226
|
+
]
|
|
227
|
+
),
|
|
228
|
+
]);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Form Modal Example
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// Edit form in modal, full page fallback
|
|
235
|
+
intercept(
|
|
236
|
+
"@modal",
|
|
237
|
+
"posts.edit",
|
|
238
|
+
async (ctx) => {
|
|
239
|
+
const post = await ctx.use(PostLoader);
|
|
240
|
+
return (
|
|
241
|
+
<EditModal>
|
|
242
|
+
<PostForm post={post} />
|
|
243
|
+
</EditModal>
|
|
244
|
+
);
|
|
245
|
+
},
|
|
246
|
+
() => [
|
|
247
|
+
loader(PostLoader),
|
|
248
|
+
middleware(authMiddleware),
|
|
249
|
+
// Revalidate after save action
|
|
250
|
+
revalidate(({ actionId }) => actionId === "savePost"),
|
|
251
|
+
]
|
|
252
|
+
)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Closing the Modal
|
|
256
|
+
|
|
257
|
+
From within the modal, use navigation:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
"use client";
|
|
261
|
+
|
|
262
|
+
// Go back (closes modal, restores background)
|
|
263
|
+
function CloseButton() {
|
|
264
|
+
return (
|
|
265
|
+
<button onClick={() => window.history.back()}>
|
|
266
|
+
Close
|
|
267
|
+
</button>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Or navigate to a specific route
|
|
272
|
+
import { useNavigation } from "rsc-router";
|
|
273
|
+
|
|
274
|
+
function CloseButton() {
|
|
275
|
+
const { navigate } = useNavigation();
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<button onClick={() => navigate("/products")}>
|
|
279
|
+
Close
|
|
280
|
+
</button>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Key Points
|
|
286
|
+
|
|
287
|
+
1. **Intercept requires a parallel slot** - Define with `parallel({ "@modal": () => null })`
|
|
288
|
+
2. **Soft nav only** - Only works for client-side navigation, not direct URLs
|
|
289
|
+
3. **Preserves background** - The route behind the modal stays rendered
|
|
290
|
+
4. **Full page fallback** - Direct URL always shows the full route handler
|
|
291
|
+
5. **Back button works** - Browser back closes modal naturally
|
|
292
|
+
6. **SEO friendly** - Search engines see the full page, users get the modal UX
|