@ivogt/rsc-router 0.0.0-experimental.1

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 (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,1333 @@
1
+ import type { ReactNode } from "react";
2
+ import type {
3
+ PartialCacheOptions,
4
+ DefaultEnv,
5
+ ErrorBoundaryHandler,
6
+ ExtractRouteParams,
7
+ Handler,
8
+ HandlersForRouteMap,
9
+ LoaderDefinition,
10
+ LoaderFn,
11
+ MiddlewareFn,
12
+ NotFoundBoundaryHandler,
13
+ ResolvedRouteMap,
14
+ RouteConfig,
15
+ RouteDefinition,
16
+ RouteDefinitionOptions,
17
+ ShouldRevalidateFn,
18
+ TrailingSlashMode,
19
+ } from "./types.js";
20
+ import {
21
+ getContext,
22
+ type EntryData,
23
+ type InterceptEntry,
24
+ type InterceptWhenFn,
25
+ type InterceptSelectorContext,
26
+ } from "./server/context";
27
+ import { invariant } from "./errors";
28
+ import RootLayout from "./server/root-layout";
29
+ import type {
30
+ AllUseItems,
31
+ LayoutItem,
32
+ RouteItem,
33
+ ParallelItem,
34
+ InterceptItem,
35
+ MiddlewareItem,
36
+ RevalidateItem,
37
+ LoaderItem,
38
+ LoadingItem,
39
+ ErrorBoundaryItem,
40
+ NotFoundBoundaryItem,
41
+ LayoutUseItem,
42
+ RouteUseItem,
43
+ ParallelUseItem,
44
+ InterceptUseItem,
45
+ LoaderUseItem,
46
+ WhenItem,
47
+ CacheItem,
48
+ } from "./route-types.js";
49
+ // const __DEV__ = import.meta.MODE === "development";
50
+
51
+ /**
52
+ * Result of route() function with paths and trailing slash config
53
+ */
54
+ export interface RouteDefinitionResult<T extends RouteDefinition> {
55
+ routes: ResolvedRouteMap<T>;
56
+ trailingSlash: Record<string, TrailingSlashMode>;
57
+ }
58
+
59
+ /**
60
+ * Check if a value is a RouteConfig object
61
+ */
62
+ function isRouteConfig(value: unknown): value is RouteConfig {
63
+ return (
64
+ typeof value === "object" &&
65
+ value !== null &&
66
+ "path" in value &&
67
+ typeof (value as RouteConfig).path === "string"
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Define routes with optional trailing slash configuration
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * // Simple string paths
77
+ * const routes = route({
78
+ * blog: "/blog",
79
+ * post: "/blog/:id",
80
+ * });
81
+ *
82
+ * // With trailing slash config
83
+ * const routes = route({
84
+ * blog: "/blog",
85
+ * api: { path: "/api", trailingSlash: "ignore" },
86
+ * }, { trailingSlash: "never" }); // global default
87
+ * ```
88
+ */
89
+ export function route<const T extends RouteDefinition>(
90
+ input: T,
91
+ options?: RouteDefinitionOptions
92
+ ): ResolvedRouteMap<T> & {
93
+ __trailingSlash?: Record<string, TrailingSlashMode>;
94
+ } {
95
+ const trailingSlash: Record<string, TrailingSlashMode> = {};
96
+ const routes = flattenRoutes(
97
+ input as RouteDefinition,
98
+ "",
99
+ trailingSlash,
100
+ options?.trailingSlash
101
+ );
102
+
103
+ // Attach trailing slash config as a non-enumerable property
104
+ // This keeps backwards compatibility while passing the config through
105
+ const result = routes as ResolvedRouteMap<T> & {
106
+ __trailingSlash?: Record<string, TrailingSlashMode>;
107
+ };
108
+ if (Object.keys(trailingSlash).length > 0) {
109
+ Object.defineProperty(result, "__trailingSlash", {
110
+ value: trailingSlash,
111
+ enumerable: false,
112
+ writable: false,
113
+ });
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Flatten nested route definitions
121
+ */
122
+ function flattenRoutes(
123
+ routes: RouteDefinition,
124
+ prefix: string,
125
+ trailingSlashConfig: Record<string, TrailingSlashMode>,
126
+ defaultTrailingSlash?: TrailingSlashMode
127
+ ): Record<string, string> {
128
+ const flattened: Record<string, string> = {};
129
+
130
+ for (const [key, value] of Object.entries(routes)) {
131
+ const fullKey = prefix + key;
132
+
133
+ if (typeof value === "string") {
134
+ // Direct route pattern - include prefix
135
+ flattened[fullKey] = value;
136
+ // Apply default trailing slash if set
137
+ if (defaultTrailingSlash) {
138
+ trailingSlashConfig[fullKey] = defaultTrailingSlash;
139
+ }
140
+ } else if (isRouteConfig(value)) {
141
+ // Route config object with path and optional trailingSlash
142
+ flattened[fullKey] = value.path;
143
+ // Use route-specific config or fall back to default
144
+ const mode = value.trailingSlash ?? defaultTrailingSlash;
145
+ if (mode) {
146
+ trailingSlashConfig[fullKey] = mode;
147
+ }
148
+ } else {
149
+ // Nested routes - flatten recursively
150
+ const nested = flattenRoutes(
151
+ value,
152
+ `${fullKey}.`,
153
+ trailingSlashConfig,
154
+ defaultTrailingSlash
155
+ );
156
+ Object.assign(flattened, nested);
157
+ }
158
+ }
159
+
160
+ return flattened;
161
+ }
162
+
163
+ // Type definitions moved to route-types.ts to avoid bundling in client code
164
+ // Re-export for backward compatibility within this module
165
+ export type {
166
+ AllUseItems,
167
+ LayoutItem,
168
+ RouteItem,
169
+ ParallelItem,
170
+ InterceptItem,
171
+ MiddlewareItem,
172
+ RevalidateItem,
173
+ LoaderItem,
174
+ ErrorBoundaryItem,
175
+ NotFoundBoundaryItem,
176
+ LayoutUseItem,
177
+ RouteUseItem,
178
+ ParallelUseItem,
179
+ InterceptUseItem,
180
+ WhenItem,
181
+ CacheItem,
182
+ } from "./route-types.js";
183
+
184
+ // Re-export intercept selector types for use in handlers
185
+ export type {
186
+ InterceptSelectorContext,
187
+ InterceptSegmentsState,
188
+ InterceptWhenFn,
189
+ } from "./server/context";
190
+
191
+ /**
192
+ * Route helpers provided by map()
193
+ * These are the only typed helpers users interact with
194
+ */
195
+ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
196
+ /**
197
+ * Define a route handler for a specific route pattern
198
+ * ```typescript
199
+ * route("products.detail", async (ctx) => {
200
+ * const product = await getProduct(ctx.params.slug);
201
+ * return <ProductPage product={product} />;
202
+ * })
203
+ *
204
+ * // With nested use() for middleware, loaders, etc.
205
+ * route("products.detail", ProductHandler, () => [
206
+ * loader(ProductLoader),
207
+ * loading(<ProductSkeleton />),
208
+ * ])
209
+ * ```
210
+ * @param name - Route name matching a key from route definitions
211
+ * @param handler - Async function that returns JSX for the route
212
+ * @param use - Optional callback returning middleware, loaders, loading, etc.
213
+ */
214
+ route: <K extends keyof ResolvedRouteMap<T> & string>(
215
+ name: K,
216
+ handler: Handler<ExtractRouteParams<T, K & string>, TEnv>,
217
+ use?: () => RouteUseItem[]
218
+ ) => RouteItem;
219
+ /**
220
+ * Define a layout that wraps child routes
221
+ * ```typescript
222
+ * layout(<AppShell />, () => [
223
+ * route("home", HomePage),
224
+ * route("about", AboutPage),
225
+ * ])
226
+ *
227
+ * // With dynamic layout handler
228
+ * layout(async (ctx) => {
229
+ * const user = ctx.get("user");
230
+ * return <DashboardShell user={user} />;
231
+ * }, () => [
232
+ * middleware(authMiddleware),
233
+ * route("dashboard", DashboardPage),
234
+ * ])
235
+ * ```
236
+ * @param component - Static JSX or async handler for the layout
237
+ * @param use - Callback returning child routes, middleware, loaders, etc.
238
+ */
239
+ layout: (
240
+ component: ReactNode | Handler<any, TEnv>,
241
+ use?: () => LayoutUseItem[]
242
+ ) => LayoutItem;
243
+ /**
244
+ * Define parallel routes that render simultaneously in named slots
245
+ * ```typescript
246
+ * parallel({
247
+ * "@sidebar": <Sidebar />,
248
+ * "@main": async (ctx) => <MainContent data={ctx.use(DataLoader)} />,
249
+ * })
250
+ *
251
+ * // With loaders and loading states
252
+ * parallel({
253
+ * "@analytics": AnalyticsPanel,
254
+ * "@metrics": MetricsPanel,
255
+ * }, () => [
256
+ * loader(DashboardLoader),
257
+ * loading(<DashboardSkeleton />),
258
+ * ])
259
+ * ```
260
+ * @param slots - Object with slot names (prefixed with @) mapped to handlers
261
+ * @param use - Optional callback for loaders, loading, revalidate, etc.
262
+ */
263
+ parallel: <
264
+ TSlots extends Record<`@${string}`, Handler<any, TEnv> | ReactNode>,
265
+ >(
266
+ slots: TSlots,
267
+ use?: () => ParallelUseItem[]
268
+ ) => ParallelItem;
269
+ /**
270
+ * Define an intercepting route for soft navigation
271
+ *
272
+ * When soft-navigating to the target route from within the current layout,
273
+ * the intercept handler renders in the named slot instead of the route's
274
+ * default handler. Direct navigation uses the route's handler.
275
+ *
276
+ * ```typescript
277
+ * // In a layout - intercept "card" route as modal
278
+ * layout(<KanbanLayout />, () => [
279
+ * intercept("@modal", "card", () => <CardModal />),
280
+ * ])
281
+ *
282
+ * // With loaders and revalidation
283
+ * intercept("@modal", "card", () => <CardModal />, () => [
284
+ * loader(CardModalLoader),
285
+ * revalidate(() => false),
286
+ * ])
287
+ * ```
288
+ * @param slotName - Named slot (prefixed with @) where intercept renders
289
+ * @param routeName - Route name to intercept
290
+ * @param handler - Component or handler for intercepted render
291
+ * @param use - Optional callback for loaders, middleware, revalidate, etc.
292
+ */
293
+ intercept: <K extends keyof ResolvedRouteMap<T> & string>(
294
+ slotName: `@${string}`,
295
+ routeName: K,
296
+ handler: ReactNode | Handler<ExtractRouteParams<T, K>, TEnv>,
297
+ use?: () => InterceptUseItem[]
298
+ ) => InterceptItem;
299
+ /**
300
+ * Attach middleware to the current route/layout
301
+ * ```typescript
302
+ * middleware(async (ctx, next) => {
303
+ * const session = await getSession(ctx.request);
304
+ * if (!session) return redirect("/login");
305
+ * ctx.set("user", session.user);
306
+ * next();
307
+ * })
308
+ *
309
+ * // Chain multiple middleware
310
+ * middleware(authMiddleware, loggingMiddleware, rateLimitMiddleware)
311
+ * ```
312
+ * @param fns - One or more middleware functions to execute in order
313
+ */
314
+ middleware: (...fns: MiddlewareFn<TEnv>[]) => MiddlewareItem;
315
+ /**
316
+ * Control when a segment should revalidate during navigation
317
+ * ```typescript
318
+ * // Revalidate when params change
319
+ * revalidate(({ currentParams, nextParams }) =>
320
+ * currentParams.slug !== nextParams.slug
321
+ * )
322
+ *
323
+ * // Revalidate after specific actions (actionId format: "path/to/file.ts#exportName")
324
+ * revalidate(({ actionId }) =>
325
+ * actionId?.includes("Cart") ?? false
326
+ * )
327
+ *
328
+ * // Soft decision (suggest but allow override)
329
+ * revalidate(({ defaultShouldRevalidate }) =>
330
+ * ({ defaultShouldRevalidate: true })
331
+ * )
332
+ * ```
333
+ * @param fn - Function that returns boolean (hard) or { defaultShouldRevalidate } (soft)
334
+ */
335
+ revalidate: (fn: ShouldRevalidateFn<any, TEnv>) => RevalidateItem;
336
+ /**
337
+ * Attach a data loader to the current route/layout
338
+ * ```typescript
339
+ * loader(ProductLoader)
340
+ *
341
+ * // With loader-specific revalidation (match by file or export name)
342
+ * loader(CartLoader, () => [
343
+ * revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
344
+ * ])
345
+ *
346
+ * // Access loader data in handlers via ctx.use()
347
+ * route("products.detail", async (ctx) => {
348
+ * const product = await ctx.use(ProductLoader);
349
+ * return <ProductPage product={product} />;
350
+ * })
351
+ * ```
352
+ * @param loaderDef - Loader created with createLoader()
353
+ * @param use - Optional callback for loader-specific revalidation rules
354
+ */
355
+ loader: <TData>(
356
+ loaderDef: LoaderDefinition<TData>,
357
+ use?: () => LoaderUseItem[]
358
+ ) => LoaderItem;
359
+ /**
360
+ * Attach a loading component to the current route/layout
361
+ * ```typescript
362
+ * // Show loading on all requests (including SSR)
363
+ * loading(<Skeleton />)
364
+ *
365
+ * // Skip loading on SSR, only show on navigation
366
+ * loading(<Skeleton />, true)
367
+ * ```
368
+ * @param component - The loading UI to show during navigation
369
+ * @param skipSSR - If true, skip showing loading on document requests (SSR)
370
+ */
371
+ loading: (component: ReactNode, skipSSR?: boolean) => LoadingItem;
372
+ /**
373
+ * Attach an error boundary to catch errors in this segment and children
374
+ * ```typescript
375
+ * errorBoundary(<ErrorFallback />)
376
+ *
377
+ * // With dynamic error handler
378
+ * errorBoundary(({ error, reset }) => (
379
+ * <div>
380
+ * <h2>Something went wrong</h2>
381
+ * <p>{error.message}</p>
382
+ * <button onClick={reset}>Try again</button>
383
+ * </div>
384
+ * ))
385
+ * ```
386
+ * @param fallback - Static JSX or handler receiving error info and reset function
387
+ */
388
+ errorBoundary: (
389
+ fallback: ReactNode | ErrorBoundaryHandler
390
+ ) => ErrorBoundaryItem;
391
+ /**
392
+ * Attach a not-found boundary to handle notFound() calls in this segment
393
+ * ```typescript
394
+ * notFoundBoundary(<ProductNotFound />)
395
+ *
396
+ * // With dynamic handler
397
+ * notFoundBoundary(({ notFound }) => (
398
+ * <div>
399
+ * <h2>{notFound.message}</h2>
400
+ * <a href="/products">Browse all products</a>
401
+ * </div>
402
+ * ))
403
+ * ```
404
+ * @param fallback - Static JSX or handler receiving not-found info
405
+ */
406
+ notFoundBoundary: (
407
+ fallback: ReactNode | NotFoundBoundaryHandler
408
+ ) => NotFoundBoundaryItem;
409
+ /**
410
+ * Define a condition for when an intercept should activate
411
+ *
412
+ * Only valid inside intercept() use() callback. When multiple when() calls
413
+ * are present, ALL must return true for the intercept to activate.
414
+ * If no when() is defined, the intercept always activates on soft navigation.
415
+ *
416
+ * Context properties:
417
+ * - `from` - Source URL (where user is navigating from)
418
+ * - `to` - Destination URL (where user is navigating to)
419
+ * - `params` - Matched route params
420
+ * - `segments` - Client's current segments with `path` and `ids`
421
+ *
422
+ * ```typescript
423
+ * // Only intercept when coming from the board page
424
+ * intercept("@modal", "card", <CardModal />, () => [
425
+ * when(({ from }) => from.pathname.startsWith("/board")),
426
+ * loader(CardDetailLoader),
427
+ * ])
428
+ *
429
+ * // Use segments to check current route context
430
+ * intercept("@modal", "card", <CardModal />, () => [
431
+ * when(({ segments }) => segments.path[0] === "kanban"),
432
+ * ])
433
+ *
434
+ * // Multiple conditions (AND logic)
435
+ * intercept("@modal", "card", <CardModal />, () => [
436
+ * when(({ from }) => from.pathname.startsWith("/board")),
437
+ * when(({ segments }) => segments.ids.includes("kanban-layout")),
438
+ * ])
439
+ * ```
440
+ * @param fn - Selector function receiving navigation context, returns boolean
441
+ */
442
+ when: (fn: InterceptWhenFn) => WhenItem;
443
+ /**
444
+ * Define cache configuration for segments
445
+ *
446
+ * Creates a cache boundary that applies to all children unless overridden.
447
+ * Cache config inherits down the route tree like middleware wrapping.
448
+ *
449
+ * When ttl is not specified, uses store defaults (explicit store first,
450
+ * then app-level store). When store is not specified, uses app-level store.
451
+ *
452
+ * Note: Loaders are NOT cached by default. Use cache() inside loader()
453
+ * to explicitly opt-in to loader caching.
454
+ *
455
+ * ```typescript
456
+ * // Using app-level defaults (ttl inherited from store.defaults)
457
+ * cache(() => [
458
+ * layout(<BlogLayout />), // cached with default TTL
459
+ * route("post/:slug"), // cached with default TTL
460
+ * ])
461
+ *
462
+ * // Cache all segments with explicit 60s TTL
463
+ * cache({ ttl: 60 }, () => [
464
+ * layout(<BlogLayout />), // cached
465
+ * route("post/:slug"), // cached
466
+ * ])
467
+ *
468
+ * // With stale-while-revalidate
469
+ * cache({ ttl: 60, swr: 300 }, () => [
470
+ * route("product/:id"),
471
+ * ])
472
+ *
473
+ * // Override for specific section
474
+ * cache({ ttl: 60 }, () => [
475
+ * layout(<RootLayout />),
476
+ * cache({ ttl: 300 }, () => [
477
+ * route("static-page"), // longer TTL
478
+ * ]),
479
+ * cache(false, () => [
480
+ * route("admin"), // not cached
481
+ * ]),
482
+ * ])
483
+ *
484
+ * // Use different store for specific routes
485
+ * cache({ store: kvStore, ttl: 3600 }, () => [
486
+ * route("archive/:year"), // uses KV store
487
+ * ])
488
+ *
489
+ * // Opt-in loader caching
490
+ * route("product/:id", ProductHandler, () => [
491
+ * loader(ProductLoader), // NOT cached (default)
492
+ * loader(StaticMetadata, () => [
493
+ * cache({ ttl: 3600 }), // cached for 1 hour
494
+ * ]),
495
+ * ])
496
+ * ```
497
+ * @param optionsOrChildren - Cache options, false to disable, or children callback
498
+ * @param children - Optional callback returning child segments (when first arg is options)
499
+ */
500
+ cache: {
501
+ (): CacheItem;
502
+ (children: () => AllUseItems[]): CacheItem;
503
+ (
504
+ options: PartialCacheOptions | false,
505
+ use?: () => AllUseItems[]
506
+ ): CacheItem;
507
+ };
508
+ };
509
+
510
+ const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
511
+ const ctx = getContext().getStore();
512
+ if (!ctx) throw new Error("revalidate() must be called inside map()");
513
+
514
+ // Attach to last entry in stack
515
+ const parent = ctx.parent;
516
+ if (!parent || !("revalidate" in parent)) {
517
+ invariant(false, "No parent entry available for revalidate()");
518
+ }
519
+ const name = `$${getContext().getNextIndex("revalidate")}`;
520
+ parent.revalidate.push(fn);
521
+ return { name, type: "revalidate" } as RevalidateItem;
522
+ };
523
+
524
+ /**
525
+ * Error boundary helper - attaches an error fallback to the current entry
526
+ *
527
+ * When an error occurs during rendering of this segment or its children,
528
+ * the fallback will be rendered instead. The fallback can be:
529
+ * - A static ReactNode (e.g., <ErrorPage />)
530
+ * - A handler function that receives error info and reset function
531
+ *
532
+ * Error boundaries catch errors from:
533
+ * - Middleware execution
534
+ * - Loader execution
535
+ * - Handler/component rendering
536
+ *
537
+ * @example
538
+ * ```typescript
539
+ * layout(<ShopLayout />, () => [
540
+ * errorBoundary(<ShopErrorFallback />),
541
+ * route("products.detail", ProductDetail),
542
+ * ])
543
+ *
544
+ * // Or with handler for dynamic error UI:
545
+ * route("products.detail", ProductDetail, () => [
546
+ * errorBoundary(({ error, reset }) => (
547
+ * <div>
548
+ * <h2>Product failed to load</h2>
549
+ * <p>{error.message}</p>
550
+ * <button onClick={reset}>Retry</button>
551
+ * </div>
552
+ * )),
553
+ * ])
554
+ * ```
555
+ */
556
+ const errorBoundary: RouteHelpers<any, any>["errorBoundary"] = (fallback) => {
557
+ const ctx = getContext().getStore();
558
+ if (!ctx) throw new Error("errorBoundary() must be called inside map()");
559
+
560
+ // Attach to parent entry in stack
561
+ const parent = ctx.parent;
562
+ if (!parent || !("errorBoundary" in parent)) {
563
+ invariant(false, "No parent entry available for errorBoundary()");
564
+ }
565
+ const name = `$${getContext().getNextIndex("errorBoundary")}`;
566
+ parent.errorBoundary.push(fallback);
567
+ return { name, type: "errorBoundary" } as ErrorBoundaryItem;
568
+ };
569
+
570
+ /**
571
+ * NotFound boundary helper - attaches a not-found fallback to the current entry
572
+ *
573
+ * When a DataNotFoundError is thrown (via notFound()) during rendering of this
574
+ * segment or its children, the fallback will be rendered instead. The fallback can be:
575
+ * - A static ReactNode (e.g., <ProductNotFound />)
576
+ * - A handler function that receives not found info
577
+ *
578
+ * NotFound boundaries catch DataNotFoundError from:
579
+ * - Loader execution
580
+ * - Handler/component rendering
581
+ *
582
+ * @example
583
+ * ```typescript
584
+ * layout(<ShopLayout />, () => [
585
+ * notFoundBoundary(<ProductNotFound />),
586
+ * route("products.detail", ProductDetail),
587
+ * ])
588
+ *
589
+ * // Or with handler for dynamic not found UI:
590
+ * route("products.detail", ProductDetail, () => [
591
+ * notFoundBoundary(({ notFound }) => (
592
+ * <div>
593
+ * <h2>Product not found</h2>
594
+ * <p>{notFound.message}</p>
595
+ * <a href="/products">Browse all products</a>
596
+ * </div>
597
+ * )),
598
+ * ])
599
+ * ```
600
+ */
601
+ const notFoundBoundary: RouteHelpers<any, any>["notFoundBoundary"] = (
602
+ fallback
603
+ ) => {
604
+ const ctx = getContext().getStore();
605
+ if (!ctx) throw new Error("notFoundBoundary() must be called inside map()");
606
+
607
+ // Attach to parent entry in stack
608
+ const parent = ctx.parent;
609
+ if (!parent || !("notFoundBoundary" in parent)) {
610
+ invariant(false, "No parent entry available for notFoundBoundary()");
611
+ }
612
+ const name = `$${getContext().getNextIndex("notFoundBoundary")}`;
613
+ parent.notFoundBoundary.push(fallback);
614
+ return { name, type: "notFoundBoundary" } as NotFoundBoundaryItem;
615
+ };
616
+
617
+ /**
618
+ * When helper - defines a condition for intercept activation
619
+ *
620
+ * Only valid inside intercept() use() callback. The when() function
621
+ * is captured by the intercept and stored in its `when` array.
622
+ * During soft navigation, all when() conditions must return true
623
+ * for the intercept to activate.
624
+ */
625
+ const when: RouteHelpers<any, any>["when"] = (fn) => {
626
+ const ctx = getContext().getStore();
627
+ if (!ctx) throw new Error("when() must be called inside intercept()");
628
+
629
+ // The when() function needs to be captured by the intercept's tempParent
630
+ // which should have a `when` array. If not present, we're not inside intercept()
631
+ const parent = ctx.parent as any;
632
+ if (!parent || !("when" in parent)) {
633
+ invariant(
634
+ false,
635
+ "when() can only be used inside intercept() use() callback"
636
+ );
637
+ }
638
+
639
+ const name = `$${getContext().getNextIndex("when")}`;
640
+ parent.when.push(fn);
641
+ return { name, type: "when" } as WhenItem;
642
+ };
643
+
644
+ /**
645
+ * Cache helper - defines caching configuration for segments
646
+ *
647
+ * Creates a cache boundary that applies to all children unless overridden.
648
+ * When used without children, attaches cache config to the parent entry
649
+ * (e.g., for loader-specific caching).
650
+ *
651
+ * Supports three call signatures:
652
+ * - cache() - no args, uses app-level defaults (for loader caching)
653
+ * - cache(() => [...]) - wraps children with app-level defaults
654
+ * - cache({ ttl: 60 }, () => [...]) - with explicit options
655
+ */
656
+ const cache: RouteHelpers<any, any>["cache"] = (
657
+ optionsOrChildren?: PartialCacheOptions | false | (() => AllUseItems[]),
658
+ maybeChildren?: () => AllUseItems[]
659
+ ) => {
660
+ const store = getContext();
661
+ const ctx = store.getStore();
662
+ if (!ctx) throw new Error("cache() must be called inside map()");
663
+
664
+ // Handle overloaded signature: cache(), cache(children), or cache(options, children)
665
+ let options: PartialCacheOptions | false;
666
+ let children: (() => AllUseItems[]) | undefined;
667
+
668
+ if (optionsOrChildren === undefined) {
669
+ // cache() - no args, use defaults
670
+ options = {};
671
+ children = undefined;
672
+ } else if (typeof optionsOrChildren === "function") {
673
+ // cache(() => [...]) - use empty options (will use defaults)
674
+ options = {};
675
+ children = optionsOrChildren;
676
+ } else {
677
+ // cache(options, children) - explicit options
678
+ options = optionsOrChildren;
679
+ children = maybeChildren;
680
+ }
681
+
682
+ const name = `$${store.getNextIndex("cache")}`;
683
+ const cacheConfig = { options };
684
+
685
+ // If no children, attach cache config to current parent (for loader caching)
686
+ if (!children) {
687
+ // Check if we're inside a loader() use() callback
688
+ const parent = ctx.parent as any;
689
+ if (parent && "cache" in parent) {
690
+ // Direct assignment to loader entry's cache field
691
+ parent.cache = cacheConfig;
692
+ } else if (parent) {
693
+ // Attach to parent entry (layout/route/parallel)
694
+ parent.cache = cacheConfig;
695
+ }
696
+ return { name, type: "cache" } as CacheItem;
697
+ }
698
+
699
+ // With children: create a cache entry (like layout with caching semantics)
700
+ const namespace = `${ctx.namespace}.${store.getNextIndex("cache")}`;
701
+
702
+ const entry = {
703
+ id: namespace,
704
+ shortCode: store.getShortCode("cache"),
705
+ type: "cache",
706
+ parent: ctx.parent,
707
+ cache: cacheConfig,
708
+ // Cache entries render like layouts (with Outlet as default handler)
709
+ handler: RootLayout, // RootLayout just renders <Outlet />
710
+ middleware: [],
711
+ revalidate: [],
712
+ errorBoundary: [],
713
+ notFoundBoundary: [],
714
+ layout: [],
715
+ parallel: [],
716
+ intercept: [],
717
+ loader: [],
718
+ } as EntryData;
719
+
720
+ // Run children with cache entry as parent
721
+ const result = store.run(namespace, entry, children);
722
+
723
+ invariant(
724
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
725
+ `cache() children callback must return an array of use items [${namespace}]`
726
+ );
727
+
728
+ // Check if this cache has routes (similar to layout logic)
729
+ const hasRoutes =
730
+ result &&
731
+ Array.isArray(result) &&
732
+ result.some((item) => item.type === "route");
733
+
734
+ if (!hasRoutes) {
735
+ const parent = ctx.parent;
736
+ if (parent && "layout" in parent) {
737
+ // Attach to parent's layout array (cache entries are structural like layouts)
738
+ entry.parent = null;
739
+ parent.layout.push(entry);
740
+ }
741
+ }
742
+
743
+ return { name: namespace, type: "cache", uses: result } as CacheItem;
744
+ };
745
+
746
+ const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
747
+ const ctx = getContext().getStore();
748
+ if (!ctx) throw new Error("middleware() must be called inside map()");
749
+
750
+ // Attach to last entry in stack
751
+ const parent = ctx.parent;
752
+ if (!parent || !("middleware" in parent)) {
753
+ invariant(false, "No parent entry available for middleware()");
754
+ }
755
+ const name = `$${getContext().getNextIndex("middleware")}`;
756
+ parent.middleware.push(...fn);
757
+ return { name, type: "middleware" } as MiddlewareItem;
758
+ };
759
+
760
+ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
761
+ const store = getContext();
762
+ const ctx = store.getStore();
763
+ if (!ctx) throw new Error("parallel() must be called inside map()");
764
+
765
+ if (!ctx.parent || !ctx.parent?.parallel) {
766
+ invariant(false, "No parent entry available for parallel()");
767
+ }
768
+
769
+ const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
770
+
771
+ // Create full EntryData for parallel with its own loaders/revalidate/loading
772
+ const entry = {
773
+ id: namespace,
774
+ shortCode: store.getShortCode("parallel"),
775
+ type: "parallel",
776
+ parent: null, // Parallels don't participate in parent chain traversal
777
+ handler: slots,
778
+ loading: undefined, // Allow loading() to attach loading state
779
+ middleware: [],
780
+ revalidate: [],
781
+ errorBoundary: [],
782
+ notFoundBoundary: [],
783
+ layout: [],
784
+ parallel: [],
785
+ intercept: [],
786
+ loader: [],
787
+ } satisfies EntryData;
788
+
789
+ // Run use callback if provided to collect loaders, revalidate, loading
790
+ if (use && typeof use === "function") {
791
+ const result = store.run(namespace, entry, use);
792
+ invariant(
793
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
794
+ `parallel() use() callback must return an array of use items [${namespace}]`
795
+ );
796
+ }
797
+
798
+ ctx.parent.parallel.push(entry);
799
+ return { name: namespace, type: "parallel" } as ParallelItem;
800
+ };
801
+
802
+ /**
803
+ * Intercept helper - defines an intercepting route for soft navigation
804
+ */
805
+ const intercept: RouteHelpers<any, any>["intercept"] = (
806
+ slotName,
807
+ routeName,
808
+ handler,
809
+ use
810
+ ) => {
811
+ const store = getContext();
812
+ const ctx = store.getStore();
813
+ if (!ctx) throw new Error("intercept() must be called inside map()");
814
+
815
+ if (!ctx.parent || !ctx.parent?.intercept) {
816
+ invariant(false, "No parent entry available for intercept()");
817
+ }
818
+
819
+ const namespace = `${ctx.namespace}.$${store.getNextIndex("intercept")}.${slotName}`;
820
+
821
+ // Create intercept entry with its own loaders/revalidate/middleware/when
822
+ const entry: InterceptEntry = {
823
+ slotName: slotName as `@${string}`,
824
+ routeName,
825
+ handler,
826
+ middleware: [],
827
+ revalidate: [],
828
+ errorBoundary: [],
829
+ notFoundBoundary: [],
830
+ loader: [],
831
+ when: [], // Selector conditions for conditional interception
832
+ };
833
+
834
+ // Run use callback if provided to collect loaders, revalidate, middleware, etc.
835
+ if (use && typeof use === "function") {
836
+ // Create a temporary parent context for the use() callback
837
+ // so that middleware, loader, revalidate attach to the intercept entry
838
+ const originalParent = ctx.parent;
839
+
840
+ // Capture layouts in a temporary array
841
+ const capturedLayouts: EntryData[] = [];
842
+
843
+ const tempParent = {
844
+ ...originalParent,
845
+ middleware: entry.middleware,
846
+ revalidate: entry.revalidate,
847
+ errorBoundary: entry.errorBoundary,
848
+ notFoundBoundary: entry.notFoundBoundary,
849
+ loader: entry.loader,
850
+ layout: capturedLayouts, // Capture layout() calls
851
+ when: entry.when, // Capture when() conditions
852
+ // Use getter/setter to capture loading on the entry
853
+ get loading() {
854
+ return entry.loading;
855
+ },
856
+ set loading(value: ReactNode | false | undefined) {
857
+ entry.loading = value;
858
+ },
859
+ };
860
+ ctx.parent = tempParent as EntryData;
861
+
862
+ const result = use();
863
+
864
+ // Restore original parent
865
+ ctx.parent = originalParent;
866
+
867
+ // Extract layout from captured layouts (use first one if multiple)
868
+ // Layout inside intercept should always be ReactNode or Handler, not Record slots
869
+ if (capturedLayouts.length > 0 && capturedLayouts[0].type === "layout") {
870
+ entry.layout = capturedLayouts[0].handler as
871
+ | ReactNode
872
+ | Handler<any, any>;
873
+ }
874
+
875
+ invariant(
876
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
877
+ `intercept() use() callback must return an array of use items [${namespace}]`
878
+ );
879
+ }
880
+
881
+ ctx.parent.intercept.push(entry);
882
+ return { name: namespace, type: "intercept" } as InterceptItem;
883
+ };
884
+
885
+ /**
886
+ * Loader helper - attaches a loader to the current entry
887
+ */
888
+ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
889
+ const store = getContext();
890
+ const ctx = store.getStore();
891
+ if (!ctx) throw new Error("loader() must be called inside map()");
892
+
893
+ // Attach to last entry in stack
894
+ if (!ctx.parent || !ctx.parent?.loader) {
895
+ invariant(false, "No parent entry available for loader()");
896
+ }
897
+
898
+ const name = `${ctx.namespace}.$${store.getNextIndex("loader")}`;
899
+
900
+ // Create loader entry with empty revalidate array
901
+ const loaderEntry = {
902
+ loader: loaderDef,
903
+ revalidate: [] as ShouldRevalidateFn<any, any>[],
904
+ };
905
+
906
+ // If use() callback provided, run it to collect revalidation rules
907
+ if (use && typeof use === "function") {
908
+ // Temporarily set context for revalidate() calls to target this loader
909
+ const originalParent = ctx.parent;
910
+ // Create a temporary "parent" that has the revalidate array we want to populate
911
+ const tempParent = {
912
+ ...originalParent,
913
+ revalidate: loaderEntry.revalidate,
914
+ };
915
+ ctx.parent = tempParent as EntryData;
916
+
917
+ const result = use();
918
+
919
+ // Restore original parent
920
+ ctx.parent = originalParent;
921
+
922
+ invariant(
923
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
924
+ `loader() use() callback must return an array of use items [${name}]`
925
+ );
926
+ }
927
+
928
+ ctx.parent.loader.push(loaderEntry);
929
+ return { name, type: "loader" } as LoaderItem;
930
+ };
931
+
932
+ /**
933
+ * Loading helper - attaches a loading component to the current entry
934
+ * Loading components are static (no context) and shown during navigation
935
+ */
936
+ const loadingFn: RouteHelpers<any, any>["loading"] = (component, skipSSR) => {
937
+ const store = getContext();
938
+ const ctx = store.getStore();
939
+ if (!ctx) throw new Error("loading() must be called inside map()");
940
+
941
+ const parent = ctx.parent;
942
+ if (!parent || !("loading" in parent)) {
943
+ invariant(false, "No parent entry available for loading()");
944
+ }
945
+
946
+ // If skipSSR is true and we're in SSR, set loading to false
947
+ if (skipSSR && ctx.isSSR) {
948
+ parent.loading = false;
949
+ } else {
950
+ parent.loading = component;
951
+ }
952
+
953
+ const name = `$${store.getNextIndex("loading")}`;
954
+ return { name, type: "loading" } as LoadingItem;
955
+ };
956
+
957
+ const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
958
+ const store = getContext();
959
+ const ctx = store.getStore();
960
+ if (!ctx) throw new Error("route() must be called inside map()");
961
+
962
+ const namespace = `${ctx.namespace}.${store.getNextIndex("route")}.${name}`;
963
+
964
+ const entry = {
965
+ id: namespace,
966
+ shortCode: store.getShortCode("route"),
967
+ type: "route",
968
+ parent: ctx.parent,
969
+ handler,
970
+ loading: undefined, // Allow loading() to attach loading state
971
+ middleware: [],
972
+ revalidate: [],
973
+ errorBoundary: [],
974
+ notFoundBoundary: [],
975
+ layout: [],
976
+ parallel: [],
977
+ intercept: [],
978
+ loader: [],
979
+ } satisfies EntryData;
980
+
981
+ /* We will throw if user is registring same route name twice */
982
+ invariant(
983
+ ctx.manifest.get(name) === undefined,
984
+ `Duplicate route name: ${name} at ${namespace}`
985
+ );
986
+ /* Register route entry */
987
+ ctx.manifest.set(name, entry);
988
+ /* Run use and attach handlers */
989
+ if (use && typeof use === "function") {
990
+ const result = store.run(namespace, entry, use);
991
+ invariant(
992
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
993
+ `route() use() callback must return an array of use items [${namespace}]`
994
+ );
995
+ return { name: namespace, type: "route", uses: result } as RouteItem;
996
+ }
997
+
998
+ /* typesafe item */
999
+ return { name: namespace, type: "route" } as RouteItem;
1000
+ };
1001
+
1002
+ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
1003
+ const store = getContext();
1004
+ const ctx = store.getStore();
1005
+ if (!ctx) throw new Error("layout() must be called inside map()");
1006
+ const isRoot = !ctx.parent || ctx.parent === null;
1007
+ const namespace = `${ctx.namespace}.${
1008
+ isRoot ? "$root" : store.getNextIndex("layout")
1009
+ }`;
1010
+
1011
+ const entry = {
1012
+ id: namespace,
1013
+ shortCode: store.getShortCode("layout"),
1014
+ type: "layout",
1015
+ parent: ctx.parent,
1016
+ handler,
1017
+ loading: undefined, // Allow loading() to attach loading state
1018
+ middleware: [],
1019
+ revalidate: [],
1020
+ errorBoundary: [],
1021
+ notFoundBoundary: [],
1022
+ parallel: [],
1023
+ intercept: [],
1024
+ layout: [],
1025
+ loader: [],
1026
+ } satisfies EntryData;
1027
+
1028
+ // Run use callback if provided
1029
+ let result: AllUseItems[] | undefined;
1030
+ if (use && typeof use === "function") {
1031
+ result = store.run(namespace, entry, use);
1032
+
1033
+ invariant(
1034
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
1035
+ `layout() use() callback must return an array of use items [${namespace}]`
1036
+ );
1037
+ }
1038
+
1039
+ // Check if this is an orphan layout (no routes in children)
1040
+ const hasRoutes =
1041
+ result &&
1042
+ Array.isArray(result) &&
1043
+ result.some((item) => item.type === "route");
1044
+
1045
+ if (!hasRoutes) {
1046
+ const parent = ctx.parent;
1047
+
1048
+ // Allow orphan layouts at root level if they're part of map() builder result
1049
+ if (!parent || parent === null) {
1050
+ if (!isRoot) {
1051
+ invariant(
1052
+ false,
1053
+ `Orphan layout cannot be used at non-root level without parent [${namespace}]`
1054
+ );
1055
+ }
1056
+ // Root-level orphan is allowed (e.g., sibling layouts in map() builder)
1057
+ } else {
1058
+ // Has parent - register as orphan layout
1059
+ invariant(
1060
+ parent.type === "route" ||
1061
+ parent.type === "layout" ||
1062
+ parent.type === "cache",
1063
+ `Orphan layouts can only be defined inside route or layout > check [${namespace}]`
1064
+ );
1065
+
1066
+ // Clear parent pointer for orphan layouts to prevent duplicate processing
1067
+ entry.parent = null;
1068
+ parent.layout.push(entry);
1069
+ }
1070
+ }
1071
+
1072
+ if (result) {
1073
+ return { name: namespace, type: "layout", uses: result } as LayoutItem;
1074
+ }
1075
+ return {
1076
+ name: namespace,
1077
+ type: "layout",
1078
+ } as LayoutItem;
1079
+ };
1080
+
1081
+ const isValidUseItem = (item: any): item is AllUseItems | undefined | null => {
1082
+ return (
1083
+ typeof item === "undefined" ||
1084
+ item === null ||
1085
+ (item &&
1086
+ typeof item === "object" &&
1087
+ "type" in item &&
1088
+ [
1089
+ "layout",
1090
+ "route",
1091
+ "middleware",
1092
+ "revalidate",
1093
+ "parallel",
1094
+ "intercept",
1095
+ "loader",
1096
+ "loading",
1097
+ "errorBoundary",
1098
+ "notFoundBoundary",
1099
+ "when",
1100
+ "cache",
1101
+ ].includes(item.type))
1102
+ );
1103
+ };
1104
+
1105
+ const isOrphanLayout = (item: AllUseItems): boolean => {
1106
+ return (
1107
+ item.type === "layout" &&
1108
+ !item.uses?.some(
1109
+ (item) =>
1110
+ item.type === "route" ||
1111
+ (item.type === "layout" && !isOrphanLayout(item))
1112
+ )
1113
+ );
1114
+ };
1115
+
1116
+ /*
1117
+ * Create revalidate helper
1118
+ */
1119
+ const createRevalidateHelper = <TEnv>(): RouteHelpers<
1120
+ any,
1121
+ TEnv
1122
+ >["revalidate"] => {
1123
+ return revalidate as RouteHelpers<any, TEnv>["revalidate"];
1124
+ };
1125
+
1126
+ /**
1127
+ * Create errorBoundary helper
1128
+ */
1129
+ const createErrorBoundaryHelper = <TEnv>(): RouteHelpers<
1130
+ any,
1131
+ TEnv
1132
+ >["errorBoundary"] => {
1133
+ return errorBoundary as RouteHelpers<any, TEnv>["errorBoundary"];
1134
+ };
1135
+
1136
+ /**
1137
+ * Create notFoundBoundary helper
1138
+ */
1139
+ const createNotFoundBoundaryHelper = <TEnv>(): RouteHelpers<
1140
+ any,
1141
+ TEnv
1142
+ >["notFoundBoundary"] => {
1143
+ return notFoundBoundary as RouteHelpers<any, TEnv>["notFoundBoundary"];
1144
+ };
1145
+
1146
+ /**
1147
+ * Create middleware helper
1148
+ */
1149
+ const createMiddlewareHelper = <TEnv>(): RouteHelpers<
1150
+ any,
1151
+ TEnv
1152
+ >["middleware"] => {
1153
+ return middleware as RouteHelpers<any, TEnv>["middleware"];
1154
+ };
1155
+
1156
+ /**
1157
+ * Create parallel helper
1158
+ */
1159
+ const createParallelHelper = <TEnv>(): RouteHelpers<any, TEnv>["parallel"] => {
1160
+ return parallel as RouteHelpers<any, TEnv>["parallel"];
1161
+ };
1162
+
1163
+ /**
1164
+ * Create intercept helper
1165
+ */
1166
+ const createInterceptHelper = <
1167
+ const T extends RouteDefinition,
1168
+ TEnv,
1169
+ >(): RouteHelpers<T, TEnv>["intercept"] => {
1170
+ return intercept as RouteHelpers<T, TEnv>["intercept"];
1171
+ };
1172
+
1173
+ /**
1174
+ * Create loader helper
1175
+ */
1176
+ const createLoaderHelper = <TEnv>(): RouteHelpers<any, TEnv>["loader"] => {
1177
+ return loaderFn as RouteHelpers<any, TEnv>["loader"];
1178
+ };
1179
+
1180
+ /**
1181
+ * Create loading helper
1182
+ */
1183
+ const createLoadingHelper = (): RouteHelpers<any, any>["loading"] => {
1184
+ return loadingFn;
1185
+ };
1186
+
1187
+ /**
1188
+ * Create route helper
1189
+ */
1190
+ const createRouteHelper = <
1191
+ const T extends RouteDefinition,
1192
+ TEnv,
1193
+ >(): RouteHelpers<T, TEnv>["route"] => {
1194
+ return routeFn as RouteHelpers<T, TEnv>["route"];
1195
+ };
1196
+
1197
+ /**
1198
+ * Create layout helper
1199
+ */
1200
+ const createLayoutHelper = <TEnv>(): RouteHelpers<any, TEnv>["layout"] => {
1201
+ return layout as RouteHelpers<any, TEnv>["layout"];
1202
+ };
1203
+
1204
+ /**
1205
+ * Create when helper for intercept conditions
1206
+ */
1207
+ const createWhenHelper = (): RouteHelpers<any, any>["when"] => {
1208
+ return when;
1209
+ };
1210
+
1211
+ /**
1212
+ * Create cache helper for cache configuration
1213
+ */
1214
+ const createCacheHelper = (): RouteHelpers<any, any>["cache"] => {
1215
+ return cache;
1216
+ };
1217
+
1218
+ /**
1219
+ * Type-safe handler definition helper
1220
+ *
1221
+ */
1222
+ export function map<const T extends RouteDefinition, TEnv = DefaultEnv>(
1223
+ builder: (helpers: RouteHelpers<T, TEnv>) => Array<AllUseItems>
1224
+ ): () => Array<AllUseItems> {
1225
+ return () => {
1226
+ // Check if it's a builder function (array-based API)
1227
+ invariant(
1228
+ typeof builder === "function",
1229
+ "map() expects a builder function as its argument"
1230
+ );
1231
+ // Create helpers
1232
+ const helpers: RouteHelpers<T, TEnv> = {
1233
+ route: createRouteHelper<T, TEnv>(),
1234
+ layout: createLayoutHelper<TEnv>(),
1235
+ parallel: createParallelHelper<TEnv>(),
1236
+ intercept: createInterceptHelper<T, TEnv>(),
1237
+ middleware: createMiddlewareHelper<TEnv>(),
1238
+ revalidate: createRevalidateHelper<TEnv>(),
1239
+ loader: createLoaderHelper<TEnv>(),
1240
+ loading: createLoadingHelper(),
1241
+ errorBoundary: createErrorBoundaryHelper<TEnv>(),
1242
+ notFoundBoundary: createNotFoundBoundaryHelper<TEnv>(),
1243
+ when: createWhenHelper(),
1244
+ cache: createCacheHelper(),
1245
+ };
1246
+
1247
+ return [layout(RootLayout, () => builder(helpers))].flat(3);
1248
+ };
1249
+ }
1250
+
1251
+ /**
1252
+ * Create a loader definition
1253
+ *
1254
+ * Loaders are RSC-compatible data fetchers that:
1255
+ * - Run after middleware, before handlers
1256
+ * - Are scoped to where attached (layout/route subtree)
1257
+ * - Revalidate independently from UI segments
1258
+ * - Are memoized per request (multiple ctx.use() calls return same value)
1259
+ *
1260
+ * Use the `"use server"` directive inside the loader function to ensure
1261
+ * the function is stripped from client bundles.
1262
+ *
1263
+ * Return type is automatically inferred from the callback.
1264
+ *
1265
+ * @param fn - Async function that fetches data (should contain "use server" directive)
1266
+ * @param fetchable - Optional flag to make the loader fetchable via useFetchLoader
1267
+ *
1268
+ * @example
1269
+ * ```typescript
1270
+ * // loaders/cart.ts - return type inferred from callback
1271
+ * export const CartLoader = createLoader(async (ctx) => {
1272
+ * "use server";
1273
+ * const user = ctx.get("user");
1274
+ * return await db.cart.get(user.id); // Return type inferred!
1275
+ * });
1276
+ *
1277
+ * // loaders/product.ts - return type inferred
1278
+ * export const ProductLoader = createLoader(async (ctx) => {
1279
+ * "use server";
1280
+ * const { slug } = ctx.params;
1281
+ * return await db.products.findBySlug(slug); // Return type inferred!
1282
+ * });
1283
+ *
1284
+ * // Usage in handlers
1285
+ * layout(<ShopLayout />, () => [
1286
+ * loader(CartLoader),
1287
+ * loader(CartLoader, () => [
1288
+ * revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
1289
+ * ]),
1290
+ * ])
1291
+ *
1292
+ * // Server-side access
1293
+ * route("cart", (ctx) => {
1294
+ * const cart = ctx.use(CartLoader);
1295
+ * return <CartPage cart={cart} />;
1296
+ * });
1297
+ *
1298
+ * // Client-side access
1299
+ * const cart = useLoader(CartLoader);
1300
+ * ```
1301
+ */
1302
+ // Re-export createLoader from loader.rsc.ts for RSC/server context
1303
+ export { createLoader } from "./loader.rsc.js";
1304
+
1305
+ /**
1306
+ * Create a soft redirect Response for middleware short-circuit
1307
+ *
1308
+ * Returns a Response that signals a client-side navigation to the target URL.
1309
+ * Unlike Response.redirect() which causes a full page reload, this redirect
1310
+ * is handled by the router for SPA-style navigation.
1311
+ *
1312
+ * @param url - The URL to redirect to
1313
+ * @param status - HTTP status code (default: 302)
1314
+ *
1315
+ * @example
1316
+ * ```typescript
1317
+ * middleware((ctx, next) => {
1318
+ * if (!ctx.get('user')) {
1319
+ * return redirect('/login');
1320
+ * }
1321
+ * next();
1322
+ * })
1323
+ * ```
1324
+ */
1325
+ export function redirect(url: string, status: number = 302): Response {
1326
+ return new Response(null, {
1327
+ status,
1328
+ headers: {
1329
+ Location: url,
1330
+ "X-RSC-Redirect": "soft",
1331
+ },
1332
+ });
1333
+ }