@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.
Files changed (155) hide show
  1. package/CLAUDE.md +7 -0
  2. package/README.md +19 -0
  3. package/dist/vite/index.js +1298 -0
  4. package/package.json +140 -0
  5. package/skills/caching/SKILL.md +319 -0
  6. package/skills/document-cache/SKILL.md +152 -0
  7. package/skills/hooks/SKILL.md +359 -0
  8. package/skills/intercept/SKILL.md +292 -0
  9. package/skills/layout/SKILL.md +216 -0
  10. package/skills/loader/SKILL.md +365 -0
  11. package/skills/middleware/SKILL.md +442 -0
  12. package/skills/parallel/SKILL.md +255 -0
  13. package/skills/route/SKILL.md +141 -0
  14. package/skills/router-setup/SKILL.md +403 -0
  15. package/skills/theme/SKILL.md +54 -0
  16. package/skills/typesafety/SKILL.md +352 -0
  17. package/src/__mocks__/version.ts +6 -0
  18. package/src/__tests__/component-utils.test.ts +76 -0
  19. package/src/__tests__/route-definition.test.ts +63 -0
  20. package/src/__tests__/urls.test.tsx +436 -0
  21. package/src/browser/event-controller.ts +876 -0
  22. package/src/browser/index.ts +18 -0
  23. package/src/browser/link-interceptor.ts +121 -0
  24. package/src/browser/lru-cache.ts +69 -0
  25. package/src/browser/merge-segment-loaders.ts +126 -0
  26. package/src/browser/navigation-bridge.ts +893 -0
  27. package/src/browser/navigation-client.ts +162 -0
  28. package/src/browser/navigation-store.ts +823 -0
  29. package/src/browser/partial-update.ts +559 -0
  30. package/src/browser/react/Link.tsx +248 -0
  31. package/src/browser/react/NavigationProvider.tsx +275 -0
  32. package/src/browser/react/ScrollRestoration.tsx +94 -0
  33. package/src/browser/react/context.ts +53 -0
  34. package/src/browser/react/index.ts +52 -0
  35. package/src/browser/react/location-state-shared.ts +120 -0
  36. package/src/browser/react/location-state.ts +62 -0
  37. package/src/browser/react/use-action.ts +240 -0
  38. package/src/browser/react/use-client-cache.ts +56 -0
  39. package/src/browser/react/use-handle.ts +178 -0
  40. package/src/browser/react/use-href.tsx +208 -0
  41. package/src/browser/react/use-link-status.ts +134 -0
  42. package/src/browser/react/use-navigation.ts +150 -0
  43. package/src/browser/react/use-segments.ts +188 -0
  44. package/src/browser/request-controller.ts +164 -0
  45. package/src/browser/rsc-router.tsx +353 -0
  46. package/src/browser/scroll-restoration.ts +324 -0
  47. package/src/browser/server-action-bridge.ts +747 -0
  48. package/src/browser/shallow.ts +35 -0
  49. package/src/browser/types.ts +464 -0
  50. package/src/cache/__tests__/document-cache.test.ts +522 -0
  51. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  52. package/src/cache/__tests__/memory-store.test.ts +484 -0
  53. package/src/cache/cache-scope.ts +565 -0
  54. package/src/cache/cf/__tests__/cf-cache-store.test.ts +428 -0
  55. package/src/cache/cf/cf-cache-store.ts +428 -0
  56. package/src/cache/cf/index.ts +19 -0
  57. package/src/cache/document-cache.ts +340 -0
  58. package/src/cache/index.ts +58 -0
  59. package/src/cache/memory-segment-store.ts +150 -0
  60. package/src/cache/memory-store.ts +253 -0
  61. package/src/cache/types.ts +387 -0
  62. package/src/client.rsc.tsx +88 -0
  63. package/src/client.tsx +621 -0
  64. package/src/component-utils.ts +76 -0
  65. package/src/components/DefaultDocument.tsx +23 -0
  66. package/src/default-error-boundary.tsx +88 -0
  67. package/src/deps/browser.ts +8 -0
  68. package/src/deps/html-stream-client.ts +2 -0
  69. package/src/deps/html-stream-server.ts +2 -0
  70. package/src/deps/rsc.ts +10 -0
  71. package/src/deps/ssr.ts +2 -0
  72. package/src/errors.ts +259 -0
  73. package/src/handle.ts +120 -0
  74. package/src/handles/MetaTags.tsx +193 -0
  75. package/src/handles/index.ts +6 -0
  76. package/src/handles/meta.ts +247 -0
  77. package/src/href-client.ts +128 -0
  78. package/src/href-context.ts +33 -0
  79. package/src/href.ts +177 -0
  80. package/src/index.rsc.ts +79 -0
  81. package/src/index.ts +87 -0
  82. package/src/loader.rsc.ts +204 -0
  83. package/src/loader.ts +47 -0
  84. package/src/network-error-thrower.tsx +21 -0
  85. package/src/outlet-context.ts +15 -0
  86. package/src/root-error-boundary.tsx +277 -0
  87. package/src/route-content-wrapper.tsx +198 -0
  88. package/src/route-definition.ts +1371 -0
  89. package/src/route-map-builder.ts +146 -0
  90. package/src/route-types.ts +198 -0
  91. package/src/route-utils.ts +89 -0
  92. package/src/router/__tests__/match-context.test.ts +104 -0
  93. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  94. package/src/router/__tests__/match-result.test.ts +566 -0
  95. package/src/router/__tests__/on-error.test.ts +935 -0
  96. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  97. package/src/router/error-handling.ts +287 -0
  98. package/src/router/handler-context.ts +158 -0
  99. package/src/router/loader-resolution.ts +326 -0
  100. package/src/router/manifest.ts +138 -0
  101. package/src/router/match-context.ts +264 -0
  102. package/src/router/match-middleware/background-revalidation.ts +236 -0
  103. package/src/router/match-middleware/cache-lookup.ts +261 -0
  104. package/src/router/match-middleware/cache-store.ts +266 -0
  105. package/src/router/match-middleware/index.ts +81 -0
  106. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  107. package/src/router/match-middleware/segment-resolution.ts +174 -0
  108. package/src/router/match-pipelines.ts +214 -0
  109. package/src/router/match-result.ts +214 -0
  110. package/src/router/metrics.ts +62 -0
  111. package/src/router/middleware.test.ts +1355 -0
  112. package/src/router/middleware.ts +748 -0
  113. package/src/router/pattern-matching.ts +272 -0
  114. package/src/router/revalidation.ts +190 -0
  115. package/src/router/router-context.ts +299 -0
  116. package/src/router/types.ts +96 -0
  117. package/src/router.ts +3876 -0
  118. package/src/rsc/__tests__/helpers.test.ts +175 -0
  119. package/src/rsc/handler.ts +1060 -0
  120. package/src/rsc/helpers.ts +64 -0
  121. package/src/rsc/index.ts +56 -0
  122. package/src/rsc/nonce.ts +18 -0
  123. package/src/rsc/types.ts +237 -0
  124. package/src/segment-system.tsx +456 -0
  125. package/src/server/__tests__/request-context.test.ts +171 -0
  126. package/src/server/context.ts +417 -0
  127. package/src/server/handle-store.ts +230 -0
  128. package/src/server/loader-registry.ts +174 -0
  129. package/src/server/request-context.ts +554 -0
  130. package/src/server/root-layout.tsx +10 -0
  131. package/src/server/tsconfig.json +14 -0
  132. package/src/server.ts +146 -0
  133. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  134. package/src/ssr/index.tsx +234 -0
  135. package/src/theme/ThemeProvider.tsx +291 -0
  136. package/src/theme/ThemeScript.tsx +61 -0
  137. package/src/theme/__tests__/theme.test.ts +120 -0
  138. package/src/theme/constants.ts +55 -0
  139. package/src/theme/index.ts +58 -0
  140. package/src/theme/theme-context.ts +70 -0
  141. package/src/theme/theme-script.ts +152 -0
  142. package/src/theme/types.ts +182 -0
  143. package/src/theme/use-theme.ts +44 -0
  144. package/src/types.ts +1561 -0
  145. package/src/urls.ts +726 -0
  146. package/src/use-loader.tsx +346 -0
  147. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  148. package/src/vite/expose-action-id.ts +344 -0
  149. package/src/vite/expose-handle-id.ts +209 -0
  150. package/src/vite/expose-loader-id.ts +357 -0
  151. package/src/vite/expose-location-state-id.ts +177 -0
  152. package/src/vite/index.ts +787 -0
  153. package/src/vite/package-resolution.ts +125 -0
  154. package/src/vite/version.d.ts +12 -0
  155. 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