@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
package/src/router.ts ADDED
@@ -0,0 +1,3876 @@
1
+ import type { ComponentType } from "react";
2
+ import { type ReactNode } from "react";
3
+ import { CacheScope, createCacheScope } from "./cache/cache-scope.js";
4
+ import type { SegmentCacheStore } from "./cache/types.js";
5
+ import { assertClientComponent } from "./component-utils.js";
6
+ import { DefaultDocument } from "./components/DefaultDocument.js";
7
+ import { DefaultErrorFallback } from "./default-error-boundary.js";
8
+ import {
9
+ DataNotFoundError,
10
+ RouteNotFoundError,
11
+ invariant,
12
+ sanitizeError,
13
+ } from "./errors";
14
+ import {
15
+ createHref,
16
+ type HrefFunction,
17
+ type PrefixRoutePatterns,
18
+ } from "./href.js";
19
+ import { registerRouteMap } from "./route-map-builder.js";
20
+ import {
21
+ createRouteHelpers,
22
+ type RouteHelpers,
23
+ type RouteHandlers,
24
+ } from "./route-definition.js";
25
+ import MapRootLayout from "./server/root-layout.js";
26
+ import type { AllUseItems } from "./route-types.js";
27
+ import type { UrlPatterns } from "./urls.js";
28
+ import {
29
+ EntryData,
30
+ InterceptEntry,
31
+ InterceptSelectorContext,
32
+ LoaderEntry,
33
+ getContext,
34
+ RSCRouterContext,
35
+ } from "./server/context";
36
+ import { createHandleStore, type HandleStore } from "./server/handle-store.js";
37
+ import { getRequestContext } from "./server/request-context.js";
38
+ import type {
39
+ ErrorBoundaryHandler,
40
+ ErrorInfo,
41
+ ErrorPhase,
42
+ HandlerContext,
43
+ LoaderDataResult,
44
+ MatchResult,
45
+ NotFoundBoundaryHandler,
46
+ OnErrorCallback,
47
+ OnErrorContext,
48
+ ResolvedRouteMap,
49
+ ResolvedSegment,
50
+ RouteDefinition,
51
+ RouteEntry,
52
+ ShouldRevalidateFn,
53
+ TrailingSlashMode,
54
+ } from "./types";
55
+
56
+ // Extracted router utilities
57
+ import {
58
+ createErrorInfo,
59
+ createErrorSegment,
60
+ createNotFoundInfo,
61
+ createNotFoundSegment,
62
+ findNearestErrorBoundary as findErrorBoundary,
63
+ findNearestNotFoundBoundary as findNotFoundBoundary,
64
+ invokeOnError,
65
+ } from "./router/error-handling.js";
66
+ import { createHandlerContext } from "./router/handler-context.js";
67
+ import {
68
+ revalidate,
69
+ setupLoaderAccess,
70
+ setupLoaderAccessSilent,
71
+ wrapLoaderWithErrorHandling,
72
+ } from "./router/loader-resolution.js";
73
+ import { loadManifest } from "./router/manifest.js";
74
+ import {
75
+ createMetricsStore,
76
+ generateServerTiming,
77
+ logMetrics,
78
+ } from "./router/metrics.js";
79
+ import {
80
+ collectRouteMiddleware,
81
+ executeInterceptMiddleware,
82
+ parsePattern,
83
+ type MiddlewareEntry,
84
+ type MiddlewareFn,
85
+ } from "./router/middleware.js";
86
+ import {
87
+ findMatch as findRouteMatch,
88
+ traverseBack,
89
+ } from "./router/pattern-matching.js";
90
+ import { evaluateRevalidation } from "./router/revalidation.js";
91
+ import {
92
+ type RouterContext,
93
+ runWithRouterContext,
94
+ } from "./router/router-context.js";
95
+ import {
96
+ type ActionContext,
97
+ type MatchContext,
98
+ type MatchPipelineState,
99
+ createPipelineState,
100
+ } from "./router/match-context.js";
101
+ import { createMatchPartialPipeline } from "./router/match-pipelines.js";
102
+ import { collectMatchResult } from "./router/match-result.js";
103
+ import { resolveThemeConfig } from "./theme/constants.js";
104
+
105
+ /**
106
+ * Props passed to the root layout component
107
+ */
108
+ export interface RootLayoutProps {
109
+ children: ReactNode;
110
+ }
111
+
112
+ /**
113
+ * Router configuration options
114
+ */
115
+ export interface RSCRouterOptions<TEnv = any> {
116
+ /**
117
+ * Enable performance metrics collection
118
+ * When enabled, metrics are output to console and available via Server-Timing header
119
+ */
120
+ debugPerformance?: boolean;
121
+
122
+ /**
123
+ * Document component that wraps the entire application.
124
+ *
125
+ * This component provides the HTML structure for your app and wraps
126
+ * both normal route content AND error states, preventing the app shell
127
+ * from unmounting during errors (avoids FOUC).
128
+ *
129
+ * Must be a client component ("use client") that accepts { children }.
130
+ *
131
+ * If not provided, a default document with basic HTML structure is used:
132
+ * `<html><head><meta charset/viewport></head><body>{children}</body></html>`
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * // components/Document.tsx
137
+ * "use client";
138
+ * export function Document({ children }: { children: ReactNode }) {
139
+ * return (
140
+ * <html lang="en">
141
+ * <head>
142
+ * <link rel="stylesheet" href="/styles.css" />
143
+ * </head>
144
+ * <body>
145
+ * <nav>...</nav>
146
+ * {children}
147
+ * </body>
148
+ * </html>
149
+ * );
150
+ * }
151
+ *
152
+ * // router.tsx
153
+ * const router = createRSCRouter<AppEnv>({
154
+ * document: Document,
155
+ * });
156
+ * ```
157
+ */
158
+ document?: ComponentType<RootLayoutProps>;
159
+
160
+ /**
161
+ * Default error boundary fallback used when no error boundary is defined in the route tree
162
+ * If not provided, errors will propagate and crash the request
163
+ */
164
+ defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
165
+
166
+ /**
167
+ * Default not-found boundary fallback used when no notFoundBoundary is defined in the route tree
168
+ * If not provided, DataNotFoundError will be treated as a regular error
169
+ */
170
+ defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
171
+
172
+ /**
173
+ * Component to render when no route matches the requested URL.
174
+ *
175
+ * This is rendered within your document/app shell with a 404 status code.
176
+ * Use this for a custom 404 page that maintains your app's look and feel.
177
+ *
178
+ * If not provided, a default "Page not found" component is rendered.
179
+ *
180
+ * Can be a static ReactNode or a function receiving the pathname.
181
+ *
182
+ * @example
183
+ * ```typescript
184
+ * // Simple static component
185
+ * const router = createRSCRouter<AppEnv>({
186
+ * document: AppShell,
187
+ * notFound: <NotFound404 />,
188
+ * });
189
+ *
190
+ * // Dynamic component with pathname
191
+ * const router = createRSCRouter<AppEnv>({
192
+ * document: AppShell,
193
+ * notFound: ({ pathname }) => (
194
+ * <div>
195
+ * <h1>404 - Not Found</h1>
196
+ * <p>No page exists at {pathname}</p>
197
+ * <a href="/">Go home</a>
198
+ * </div>
199
+ * ),
200
+ * });
201
+ * ```
202
+ */
203
+ notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
204
+
205
+ /**
206
+ * Callback invoked when an error occurs during request handling.
207
+ *
208
+ * This callback is for notification/logging purposes - it cannot modify
209
+ * the error handling flow. Use errorBoundary() in route definitions to
210
+ * customize error UI.
211
+ *
212
+ * The callback receives comprehensive context about the error including:
213
+ * - The error itself
214
+ * - Phase where it occurred (routing, middleware, loader, handler, etc.)
215
+ * - Request info (URL, method, params)
216
+ * - Route info (routeKey, segmentId)
217
+ * - Environment/bindings
218
+ * - Duration from request start
219
+ *
220
+ * @example
221
+ * ```typescript
222
+ * const router = createRSCRouter<AppEnv>({
223
+ * onError: (context) => {
224
+ * // Send to error tracking service
225
+ * Sentry.captureException(context.error, {
226
+ * tags: {
227
+ * phase: context.phase,
228
+ * route: context.routeKey,
229
+ * },
230
+ * extra: {
231
+ * url: context.url.toString(),
232
+ * params: context.params,
233
+ * duration: context.duration,
234
+ * },
235
+ * });
236
+ * },
237
+ * });
238
+ * ```
239
+ */
240
+ onError?: OnErrorCallback<TEnv>;
241
+
242
+ /**
243
+ * Cache store for segment caching.
244
+ *
245
+ * When provided, enables route-level caching via cache() boundaries.
246
+ * The store handles persistence (memory, KV, Redis, etc.).
247
+ *
248
+ * Can be a static config or a function receiving env for runtime bindings.
249
+ * Can be overridden in createRSCHandler.
250
+ *
251
+ * @example Static config
252
+ * ```typescript
253
+ * import { MemorySegmentCacheStore } from "rsc-router/rsc";
254
+ *
255
+ * const router = createRSCRouter({
256
+ * cache: {
257
+ * store: new MemorySegmentCacheStore({ defaults: { ttl: 60 } }),
258
+ * },
259
+ * });
260
+ * ```
261
+ *
262
+ * @example Dynamic config with env
263
+ * ```typescript
264
+ * const router = createRSCRouter<AppEnv>({
265
+ * cache: (env) => ({
266
+ * store: new KVSegmentCacheStore(env.Bindings.MY_CACHE),
267
+ * }),
268
+ * });
269
+ * ```
270
+ */
271
+ cache?:
272
+ | { store: SegmentCacheStore; enabled?: boolean }
273
+ | ((env: TEnv) => { store: SegmentCacheStore; enabled?: boolean });
274
+
275
+ /**
276
+ * Theme configuration for automatic theme management.
277
+ *
278
+ * When provided, enables:
279
+ * - ctx.theme and ctx.setTheme() in route handlers
280
+ * - useTheme() hook for client components
281
+ * - FOUC prevention via inline script in MetaTags
282
+ * - Automatic ThemeProvider wrapping in NavigationProvider
283
+ *
284
+ * @example
285
+ * ```typescript
286
+ * const router = createRSCRouter<AppEnv>({
287
+ * theme: {
288
+ * defaultTheme: "system",
289
+ * themes: ["light", "dark"],
290
+ * }
291
+ * });
292
+ *
293
+ * // In route handler:
294
+ * route("settings", (ctx) => {
295
+ * const theme = ctx.theme; // "light" | "dark" | "system"
296
+ * ctx.setTheme("dark"); // Sets cookie
297
+ * return <SettingsPage />;
298
+ * });
299
+ *
300
+ * // In client component:
301
+ * import { useTheme } from "@rangojs/router/theme";
302
+ *
303
+ * function ThemeToggle() {
304
+ * const { theme, setTheme, themes } = useTheme();
305
+ * return <select value={theme} onChange={e => setTheme(e.target.value)}>
306
+ * {themes.map(t => <option key={t}>{t}</option>)}
307
+ * </select>;
308
+ * }
309
+ * ```
310
+ *
311
+ * Use `theme: true` to enable with all defaults.
312
+ */
313
+ theme?: import("./theme/types.js").ThemeConfig | true;
314
+ }
315
+
316
+ /**
317
+ * Type-level detection of conflicting route keys.
318
+ * Extracts keys that exist in both TExisting and TNew but with different URL patterns.
319
+ * Returns `never` if no conflicts exist.
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * ConflictingKeys<{ a: "/a" }, { a: "/b" }> // "a" (conflict - same key, different URLs)
324
+ * ConflictingKeys<{ a: "/a" }, { a: "/a" }> // never (no conflict - same key and URL)
325
+ * ConflictingKeys<{ a: "/a" }, { b: "/b" }> // never (no conflict - different keys)
326
+ * ```
327
+ */
328
+ type ConflictingKeys<
329
+ TExisting extends Record<string, string>,
330
+ TNew extends Record<string, string>,
331
+ > = {
332
+ [K in keyof TExisting & keyof TNew]: TExisting[K] extends TNew[K]
333
+ ? TNew[K] extends TExisting[K]
334
+ ? never // Same value, no conflict
335
+ : K // Different values, conflict
336
+ : K; // Different values, conflict
337
+ }[keyof TExisting & keyof TNew];
338
+
339
+ /**
340
+ * Error type returned when route keys conflict.
341
+ * Methods require an impossible `never` parameter so TypeScript errors at the call site.
342
+ */
343
+ type RouteConflictError<TConflicts extends string> = {
344
+ __error: `Route key conflict! Key "${TConflicts}" already exists with a different URL pattern.`;
345
+ hint: "Route keys must be globally unique. Use prefixed names like 'blog.index' instead of 'index'.";
346
+ conflictingKeys: TConflicts;
347
+ // These methods require `never` so calling them produces an error at the call site
348
+ routes: (
349
+ __conflict: `Fix route key conflict: "${TConflicts}" is already defined with a different URL pattern`
350
+ ) => never;
351
+ map: (
352
+ __conflict: `Fix route key conflict: "${TConflicts}" is already defined with a different URL pattern`
353
+ ) => never;
354
+ };
355
+
356
+ /**
357
+ * Simplified route helpers for inline route definitions.
358
+ * Uses TRoutes (Record<string, string>) instead of RouteDefinition.
359
+ *
360
+ * Note: Some helpers use `any` for context types as a trade-off for simpler usage.
361
+ * The main type safety is in the `route` helper which enforces valid route names.
362
+ * For full type safety, use the standard map() API with separate handler files.
363
+ */
364
+ type InlineRouteHelpers<
365
+ TRoutes extends Record<string, string>,
366
+ TEnv,
367
+ > = {
368
+ /**
369
+ * Define a route handler for a specific route pattern
370
+ */
371
+ route: <K extends keyof TRoutes & string>(
372
+ name: K,
373
+ handler:
374
+ | ((ctx: HandlerContext<{}, TEnv>) => ReactNode | Promise<ReactNode>)
375
+ | ReactNode
376
+ ) => AllUseItems;
377
+
378
+ /**
379
+ * Define a layout that wraps child routes
380
+ */
381
+ layout: (
382
+ component: ReactNode | ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>),
383
+ use?: () => AllUseItems[]
384
+ ) => AllUseItems;
385
+
386
+ /**
387
+ * Define parallel routes
388
+ */
389
+ parallel: (
390
+ slots: Record<`@${string}`, ReactNode | ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)>,
391
+ use?: () => AllUseItems[]
392
+ ) => AllUseItems;
393
+
394
+ /**
395
+ * Define route middleware
396
+ */
397
+ middleware: (fn: (ctx: any, next: () => Promise<void>) => Promise<void>) => AllUseItems;
398
+
399
+ /**
400
+ * Define revalidation handlers
401
+ */
402
+ revalidate: (fn: (ctx: any) => boolean | Promise<boolean>) => AllUseItems;
403
+
404
+ /**
405
+ * Define data loaders
406
+ */
407
+ loader: (loader: any, use?: () => AllUseItems[]) => AllUseItems;
408
+
409
+ /**
410
+ * Define loading states
411
+ */
412
+ loading: (component: ReactNode) => AllUseItems;
413
+
414
+ /**
415
+ * Define error boundaries
416
+ */
417
+ errorBoundary: (
418
+ handler: ReactNode | ((props: { error: Error }) => ReactNode)
419
+ ) => AllUseItems;
420
+
421
+ /**
422
+ * Define not found boundaries
423
+ */
424
+ notFoundBoundary: (
425
+ handler: ReactNode | ((props: { pathname: string }) => ReactNode)
426
+ ) => AllUseItems;
427
+
428
+ /**
429
+ * Define intercept routes
430
+ */
431
+ intercept: (
432
+ name: string,
433
+ handler: ReactNode | ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>),
434
+ use?: () => AllUseItems[]
435
+ ) => AllUseItems;
436
+
437
+ /**
438
+ * Define when conditions for intercepts
439
+ */
440
+ when: (condition: (ctx: any) => boolean | Promise<boolean>) => AllUseItems;
441
+
442
+ /**
443
+ * Define cache configuration
444
+ */
445
+ cache: (config: { ttl?: number; swr?: number } | false, use?: () => AllUseItems[]) => AllUseItems;
446
+ };
447
+
448
+ /**
449
+ * Router builder for chaining .use() and .map()
450
+ * TRoutes accumulates all registered route types through the chain
451
+ * TLocalRoutes contains the routes for the current .routes() call (for inline handler typing)
452
+ */
453
+ interface RouteBuilder<
454
+ T extends RouteDefinition,
455
+ TEnv,
456
+ TRoutes extends Record<string, string>,
457
+ TLocalRoutes extends Record<string, string> = Record<string, string>,
458
+ > {
459
+ /**
460
+ * Add middleware scoped to this mount
461
+ * Called between .routes() and .map()
462
+ *
463
+ * @example
464
+ * ```typescript
465
+ * .routes("/admin", adminRoutes)
466
+ * .use(authMiddleware) // All of /admin/*
467
+ * .use("/danger/*", superAuth) // Only /admin/danger/*
468
+ * .map(() => import("./admin"))
469
+ * ```
470
+ */
471
+ use(
472
+ patternOrMiddleware: string | MiddlewareFn<TEnv>,
473
+ middleware?: MiddlewareFn<TEnv>
474
+ ): RouteBuilder<T, TEnv, TRoutes, TLocalRoutes>;
475
+
476
+ /**
477
+ * Map routes to handlers
478
+ *
479
+ * Supports two patterns:
480
+ *
481
+ * 1. Lazy loading (code-split):
482
+ * ```typescript
483
+ * .routes(homeRoutes)
484
+ * .map(() => import("./handlers/home"))
485
+ * ```
486
+ *
487
+ * 2. Inline definition:
488
+ * ```typescript
489
+ * .routes({ index: "/", about: "/about" })
490
+ * .map(({ route }) => [
491
+ * route("index", () => <HomePage />),
492
+ * route("about", () => <AboutPage />),
493
+ * ])
494
+ * ```
495
+ */
496
+ // Inline definition overload - handler receives helpers (must be first for correct inference)
497
+ // Uses TLocalRoutes so route names don't need the prefix
498
+ map<H extends (helpers: InlineRouteHelpers<TLocalRoutes, TEnv>) => Array<AllUseItems>>(
499
+ handler: H
500
+ ): RSCRouter<TEnv, TRoutes>;
501
+ // Lazy loading overload - verifies imported handlers match route definition
502
+ map(
503
+ handler: () =>
504
+ | Array<AllUseItems>
505
+ | Promise<{ default: RouteHandlers<TLocalRoutes> }>
506
+ | Promise<RouteHandlers<TLocalRoutes>>
507
+ ): RSCRouter<TEnv, TRoutes>;
508
+
509
+ /**
510
+ * Accumulated route map for typeof extraction
511
+ * Used for module augmentation: `type AppRoutes = typeof _router.routeMap`
512
+ */
513
+ readonly routeMap: TRoutes;
514
+ }
515
+
516
+ /**
517
+ * RSC Router interface
518
+ * TRoutes accumulates all registered route types through the builder chain
519
+ */
520
+ export interface RSCRouter<
521
+ TEnv = any,
522
+ TRoutes extends Record<string, string> = Record<string, string>,
523
+ > {
524
+ /**
525
+ * Register routes with a prefix
526
+ * Route keys stay unchanged, only URL patterns get the prefix applied.
527
+ * This enables composable route modules that work regardless of mount point.
528
+ *
529
+ * @throws Compile-time error if route keys conflict with previously registered routes
530
+ */
531
+ routes<const TPrefix extends string, const T extends Record<string, string>>(
532
+ prefix: TPrefix,
533
+ routes: T
534
+ ): ConflictingKeys<TRoutes, PrefixRoutePatterns<T, TPrefix>> extends never
535
+ ? RouteBuilder<
536
+ RouteDefinition,
537
+ TEnv,
538
+ TRoutes & PrefixRoutePatterns<T, TPrefix>,
539
+ T
540
+ >
541
+ : RouteConflictError<ConflictingKeys<TRoutes, PrefixRoutePatterns<T, TPrefix>> & string>;
542
+
543
+ /**
544
+ * Register routes without a prefix
545
+ * Route types are accumulated through the chain
546
+ *
547
+ * @throws Compile-time error if route keys conflict with previously registered routes
548
+ */
549
+ routes<const T extends Record<string, string>>(
550
+ routes: T
551
+ ): ConflictingKeys<TRoutes, T> extends never
552
+ ? RouteBuilder<RouteDefinition, TEnv, TRoutes & T, T>
553
+ : RouteConflictError<ConflictingKeys<TRoutes, T> & string>;
554
+
555
+ /**
556
+ * Register routes using Django-style URL patterns
557
+ * This is the new API for @rangojs/router - call once with urls() result
558
+ *
559
+ * @example
560
+ * ```typescript
561
+ * createRSCRouter({})
562
+ * .routes(urlpatterns) // Single call with urls() result
563
+ * ```
564
+ */
565
+ routes<T extends UrlPatterns<TEnv, any>>(
566
+ patterns: T
567
+ ): RSCRouter<
568
+ TEnv,
569
+ TRoutes & (T extends UrlPatterns<any, infer R> ? R : Record<string, string>)
570
+ >;
571
+
572
+ /**
573
+ * Add global middleware that runs on all routes
574
+ * Position matters: middleware before any .routes() is global
575
+ *
576
+ * @example
577
+ * ```typescript
578
+ * createRSCRouter({ document: RootLayout })
579
+ * .use(loggerMiddleware) // All routes
580
+ * .use("/api/*", rateLimiter) // Pattern match
581
+ * .routes(homeRoutes)
582
+ * .map(() => import("./home"))
583
+ * ```
584
+ */
585
+ use(
586
+ patternOrMiddleware: string | MiddlewareFn<TEnv>,
587
+ middleware?: MiddlewareFn<TEnv>
588
+ ): RSCRouter<TEnv, TRoutes>;
589
+
590
+ /**
591
+ * Type-safe URL builder for registered routes
592
+ * Types are inferred from the accumulated route registrations
593
+ * Route keys stay unchanged regardless of mount prefix.
594
+ *
595
+ * @example
596
+ * ```typescript
597
+ * // Given: .routes("/shop", { cart: "/cart", detail: "/product/:slug" })
598
+ * router.href("cart"); // "/shop/cart"
599
+ * router.href("detail", { slug: "widget" }); // "/shop/product/widget"
600
+ * ```
601
+ */
602
+ href: HrefFunction<TRoutes>;
603
+
604
+ /**
605
+ * Accumulated route map for typeof extraction
606
+ * Used for module augmentation: `type AppRoutes = typeof _router.routeMap`
607
+ *
608
+ * @example
609
+ * ```typescript
610
+ * const _router = createRSCRouter<AppEnv>()
611
+ * .routes(homeRoutes).map(() => import('./home'))
612
+ * .routes('/shop', shopRoutes).map(() => import('./shop'));
613
+ *
614
+ * type AppRoutes = typeof _router.routeMap;
615
+ *
616
+ * declare global {
617
+ * namespace RSCRouter {
618
+ * interface RegisteredRoutes extends AppRoutes {}
619
+ * }
620
+ * }
621
+ * ```
622
+ */
623
+ readonly routeMap: TRoutes;
624
+
625
+ /**
626
+ * Root layout component that wraps the entire application
627
+ * Access this to pass to renderSegments
628
+ */
629
+ readonly rootLayout?: ComponentType<RootLayoutProps>;
630
+
631
+ /**
632
+ * Error callback for monitoring/alerting
633
+ * Called when errors occur in loaders, actions, or routes
634
+ */
635
+ readonly onError?: RSCRouterOptions<TEnv>["onError"];
636
+
637
+ /**
638
+ * Cache configuration (for internal use by RSC handler)
639
+ */
640
+ readonly cache?: RSCRouterOptions<TEnv>["cache"];
641
+
642
+ /**
643
+ * Not found component to render when no route matches (for internal use by RSC handler)
644
+ */
645
+ readonly notFound?: RSCRouterOptions<TEnv>["notFound"];
646
+
647
+ /**
648
+ * Resolved theme configuration (null if theme not enabled)
649
+ * Used by NavigationProvider to include ThemeProvider and by MetaTags to render theme script
650
+ */
651
+ readonly themeConfig: import("./theme/types.js").ResolvedThemeConfig | null;
652
+
653
+ /**
654
+ * App-level middleware entries (for internal use by RSC handler)
655
+ * These wrap the entire request/response cycle
656
+ */
657
+ readonly middleware: MiddlewareEntry<TEnv>[];
658
+
659
+ match(request: Request, context: TEnv): Promise<MatchResult>;
660
+
661
+ /**
662
+ * Preview match - returns route middleware without segment resolution
663
+ * Used by RSC handler to execute route middleware before full matching
664
+ */
665
+ previewMatch(
666
+ request: Request,
667
+ context: TEnv
668
+ ): Promise<{
669
+ routeMiddleware?: Array<{
670
+ handler: import("./router/middleware.js").MiddlewareFn;
671
+ params: Record<string, string>;
672
+ }>;
673
+ } | null>;
674
+
675
+ matchPartial(
676
+ request: Request,
677
+ context: TEnv,
678
+ actionContext?: {
679
+ actionId?: string;
680
+ actionUrl?: URL;
681
+ actionResult?: any;
682
+ formData?: FormData;
683
+ }
684
+ ): Promise<MatchResult | null>;
685
+
686
+ /**
687
+ * Match an error to the nearest error boundary and return error segments
688
+ *
689
+ * Used when an action or other operation fails and we need to render
690
+ * the error boundary UI. Finds the nearest errorBoundary in the route tree
691
+ * for the current URL and renders it with the error info.
692
+ *
693
+ * @param request - The current request (used to match the route)
694
+ * @param context - Environment context
695
+ * @param error - The error that occurred
696
+ * @param segmentType - Type of segment where error occurred (default: "route")
697
+ * @returns MatchResult with error segment, or null if no error boundary found
698
+ */
699
+ matchError(
700
+ request: Request,
701
+ context: TEnv,
702
+ error: unknown,
703
+ segmentType?: ErrorInfo["segmentType"]
704
+ ): Promise<MatchResult | null>;
705
+ }
706
+
707
+ /**
708
+ * Create an RSC router with generic context type
709
+ * Route types are accumulated automatically through the builder chain
710
+ *
711
+ * @example
712
+ * ```typescript
713
+ * interface AppContext {
714
+ * db: Database;
715
+ * user?: User;
716
+ * }
717
+ *
718
+ * const router = createRSCRouter<AppContext>({
719
+ * debugPerformance: true // Enable metrics
720
+ * });
721
+ *
722
+ * // Route types accumulate through the chain - no module augmentation needed!
723
+ * // Keys stay unchanged, only URL patterns get the prefix
724
+ * router
725
+ * .routes(homeRoutes) // accumulates homeRoutes
726
+ * .map(() => import('./home'))
727
+ * .routes('/shop', shopRoutes) // accumulates shopRoutes with prefixed URLs
728
+ * .map(() => import('./shop'));
729
+ *
730
+ * // router.href now has type-safe autocomplete for all registered routes
731
+ * // Given shopRoutes = { cart: "/cart" }, href uses original key:
732
+ * router.href("cart"); // "/shop/cart"
733
+ * ```
734
+ */
735
+ export function createRSCRouter<TEnv = any>(
736
+ options: RSCRouterOptions<TEnv> = {}
737
+ ): RSCRouter<TEnv, {}> {
738
+ const {
739
+ debugPerformance = false,
740
+ document: documentOption,
741
+ defaultErrorBoundary,
742
+ defaultNotFoundBoundary,
743
+ notFound,
744
+ onError,
745
+ cache,
746
+ theme: themeOption,
747
+ } = options;
748
+
749
+ // Resolve theme config (null if theme not enabled)
750
+ const resolvedThemeConfig = themeOption
751
+ ? resolveThemeConfig(themeOption)
752
+ : null;
753
+
754
+ /**
755
+ * Wrapper for invokeOnError that binds the router's onError callback.
756
+ * Uses the shared utility from router/error-handling.ts for consistent behavior.
757
+ */
758
+ function callOnError(
759
+ error: unknown,
760
+ phase: ErrorPhase,
761
+ context: Parameters<typeof invokeOnError<TEnv>>[3]
762
+ ): void {
763
+ invokeOnError(onError, error, phase, context, "Router");
764
+ }
765
+
766
+ // Validate document is a client component
767
+ if (documentOption !== undefined) {
768
+ assertClientComponent(documentOption, "document");
769
+ }
770
+
771
+ // Use default document if none provided (keeps internal name as rootLayout)
772
+ const rootLayout = documentOption ?? DefaultDocument;
773
+ const routesEntries: RouteEntry<TEnv>[] = [];
774
+ let mountIndex = 0;
775
+
776
+ // Track if .routes() has been called (for single-call enforcement in @rangojs/router)
777
+ let routesCalled = false;
778
+
779
+ // Global middleware storage
780
+ const globalMiddleware: MiddlewareEntry<TEnv>[] = [];
781
+
782
+ // Helper to add middleware entry
783
+ function addMiddleware(
784
+ patternOrMiddleware: string | MiddlewareFn<TEnv>,
785
+ middleware?: MiddlewareFn<TEnv>,
786
+ mountPrefix: string | null = null
787
+ ): void {
788
+ let pattern: string | null = null;
789
+ let handler: MiddlewareFn<TEnv>;
790
+
791
+ if (typeof patternOrMiddleware === "string") {
792
+ // Pattern + middleware
793
+ pattern = patternOrMiddleware;
794
+ if (!middleware) {
795
+ throw new Error(
796
+ "Middleware function required when pattern is provided"
797
+ );
798
+ }
799
+ handler = middleware;
800
+ } else {
801
+ // Just middleware (no pattern)
802
+ handler = patternOrMiddleware;
803
+ }
804
+
805
+ // If mount-scoped, prepend mount prefix to pattern
806
+ let fullPattern = pattern;
807
+ if (mountPrefix && pattern) {
808
+ // e.g., mountPrefix="/blog", pattern="/admin/*" → "/blog/admin/*"
809
+ fullPattern =
810
+ pattern === "*" ? `${mountPrefix}/*` : `${mountPrefix}${pattern}`;
811
+ } else if (mountPrefix && !pattern) {
812
+ // Mount-scoped middleware without pattern applies to all of mount
813
+ fullPattern = `${mountPrefix}/*`;
814
+ }
815
+
816
+ // Parse pattern into regex
817
+ let regex: RegExp | null = null;
818
+ let paramNames: string[] = [];
819
+ if (fullPattern) {
820
+ const parsed = parsePattern(fullPattern);
821
+ regex = parsed.regex;
822
+ paramNames = parsed.paramNames;
823
+ }
824
+
825
+ globalMiddleware.push({
826
+ pattern: fullPattern,
827
+ regex,
828
+ paramNames,
829
+ handler,
830
+ mountPrefix,
831
+ });
832
+ }
833
+
834
+ // Track all registered routes with their prefixes for href()
835
+ const mergedRouteMap: Record<string, string> = {};
836
+
837
+ // Wrapper to pass debugPerformance to external createMetricsStore
838
+ const getMetricsStore = () => createMetricsStore(debugPerformance);
839
+
840
+ // Wrapper to pass defaults to error/notFound boundary finders
841
+ const findNearestErrorBoundary = (entry: EntryData | null) =>
842
+ findErrorBoundary(entry, defaultErrorBoundary);
843
+
844
+ const findNearestNotFoundBoundary = (entry: EntryData | null) =>
845
+ findNotFoundBoundary(entry, defaultNotFoundBoundary);
846
+
847
+ // Helper to get handleStore from request context
848
+ const getHandleStore = (): HandleStore | undefined => {
849
+ return getRequestContext()?._handleStore;
850
+ };
851
+
852
+ // Track a pending handler promise (non-blocking)
853
+ const trackHandler = <T>(promise: Promise<T>): Promise<T> => {
854
+ const store = getHandleStore();
855
+ return store ? store.track(promise) : promise;
856
+ };
857
+
858
+ // Wrapper for wrapLoaderWithErrorHandling that uses router's error boundary finder
859
+ // Includes onError callback for loader error notification
860
+ function wrapLoaderPromise<T>(
861
+ promise: Promise<T>,
862
+ entry: EntryData,
863
+ segmentId: string,
864
+ pathname: string,
865
+ errorContext?: {
866
+ request: Request;
867
+ url: URL;
868
+ routeKey?: string;
869
+ params?: Record<string, string>;
870
+ env?: TEnv;
871
+ isPartial?: boolean;
872
+ requestStartTime?: number;
873
+ }
874
+ ): Promise<LoaderDataResult<T>> {
875
+ return wrapLoaderWithErrorHandling(
876
+ promise,
877
+ entry,
878
+ segmentId,
879
+ pathname,
880
+ findNearestErrorBoundary,
881
+ createErrorInfo,
882
+ // Invoke onError when loader fails
883
+ errorContext
884
+ ? (error, ctx) => {
885
+ callOnError(error, "loader", {
886
+ request: errorContext.request,
887
+ url: errorContext.url,
888
+ routeKey: errorContext.routeKey,
889
+ params: errorContext.params,
890
+ segmentId: ctx.segmentId,
891
+ segmentType: "loader",
892
+ loaderName: ctx.loaderName,
893
+ env: errorContext.env,
894
+ isPartial: errorContext.isPartial,
895
+ handledByBoundary: ctx.handledByBoundary,
896
+ requestStartTime: errorContext.requestStartTime,
897
+ });
898
+ }
899
+ : undefined
900
+ );
901
+ }
902
+
903
+ // Wrapper for findMatch that uses routesEntries
904
+ function findMatch(pathname: string) {
905
+ return findRouteMatch(pathname, routesEntries);
906
+ }
907
+
908
+ /**
909
+ * Resolve loaders for an entry and emit segments
910
+ * Loaders are run lazily via ctx.use() and memoized for parallel execution
911
+ *
912
+ * @param shortCodeOverride - Optional override for the shortCode used in segment IDs.
913
+ * For parallel entries, pass the parent layout/route's shortCode so loaders
914
+ * are correctly associated in the segment tree.
915
+ */
916
+ async function resolveLoaders(
917
+ entry: EntryData,
918
+ ctx: HandlerContext<any, TEnv>,
919
+ belongsToRoute: boolean,
920
+ shortCodeOverride?: string
921
+ ): Promise<ResolvedSegment[]> {
922
+ const loaderEntries = entry.loader ?? [];
923
+ if (loaderEntries.length === 0) return [];
924
+
925
+ const shortCode = shortCodeOverride ?? entry.shortCode;
926
+
927
+ // Check if entry has loading property (cache entries don't)
928
+ const hasLoading = "loading" in entry && entry.loading !== undefined;
929
+ const loadingDisabled = hasLoading && entry.loading === false;
930
+
931
+ // Trigger all loaders in parallel via ctx.use() (memoized, so safe to call multiple times)
932
+ // Don't await - wrap promises with error handling for deferred client-side resolution
933
+ return Promise.all(
934
+ loaderEntries.map(async ({ loader }, i) => {
935
+ const segmentId = `${shortCode}D${i}.${loader.$$id}`;
936
+ return {
937
+ id: segmentId,
938
+ namespace: entry.id,
939
+ type: "loader" as const,
940
+ index: i,
941
+ component: null, // Loaders don't render directly
942
+ params: ctx.params,
943
+ loaderId: loader.$$id,
944
+ loaderData: await wrapLoaderPromise(
945
+ loadingDisabled ? await ctx.use(loader) : ctx.use(loader),
946
+ entry,
947
+ segmentId,
948
+ ctx.pathname
949
+ ),
950
+ belongsToRoute,
951
+ };
952
+ })
953
+ );
954
+ }
955
+
956
+ /**
957
+ * Result of resolving loaders with revalidation
958
+ * Contains both segments to render and all matched segment IDs
959
+ */
960
+ interface LoaderRevalidationResult {
961
+ segments: ResolvedSegment[];
962
+ matchedIds: string[];
963
+ }
964
+
965
+ /**
966
+ * Resolve loaders with revalidation awareness (for partial rendering)
967
+ * Checks each loader's revalidation functions before deciding to emit segment
968
+ * Loaders are run lazily via ctx.use() - this function only handles segment emission
969
+ * Returns both segments to render AND all matched segment IDs (including skipped ones)
970
+ *
971
+ * @param shortCodeOverride - Optional override for the shortCode used in segment IDs.
972
+ * For parallel entries, pass the parent layout/route's shortCode so loaders
973
+ * are correctly associated in the segment tree.
974
+ */
975
+ async function resolveLoadersWithRevalidation(
976
+ entry: EntryData,
977
+ ctx: HandlerContext<any, TEnv>,
978
+ belongsToRoute: boolean,
979
+ clientSegmentIds: Set<string>,
980
+ prevParams: Record<string, string>,
981
+ request: Request,
982
+ prevUrl: URL,
983
+ nextUrl: URL,
984
+ routeKey: string,
985
+ actionContext?: {
986
+ actionId?: string;
987
+ actionUrl?: URL;
988
+ actionResult?: any;
989
+ formData?: FormData;
990
+ },
991
+ shortCodeOverride?: string,
992
+ stale?: boolean
993
+ ): Promise<LoaderRevalidationResult> {
994
+ const loaderEntries = entry.loader ?? [];
995
+ if (loaderEntries.length === 0) return { segments: [], matchedIds: [] };
996
+
997
+ const shortCode = shortCodeOverride ?? entry.shortCode;
998
+
999
+ // Build segment IDs and matchedIds upfront
1000
+ const loaderMeta = loaderEntries.map(
1001
+ ({ loader, revalidate: loaderRevalidateFns }, i) => ({
1002
+ loader,
1003
+ loaderRevalidateFns,
1004
+ segmentId: `${shortCode}D${i}.${loader.$$id}`,
1005
+ index: i,
1006
+ })
1007
+ );
1008
+
1009
+ const matchedIds = loaderMeta.map((m) => m.segmentId);
1010
+
1011
+ // Phase 1: Check all revalidation in parallel
1012
+ const revalidationChecks = await Promise.all(
1013
+ loaderMeta.map(
1014
+ async ({ loader, loaderRevalidateFns, segmentId, index }) => {
1015
+ const shouldRun = await revalidate(
1016
+ async () => {
1017
+ // New segment - always run
1018
+ if (!clientSegmentIds.has(segmentId)) return true;
1019
+
1020
+ // Create dummy segment for evaluation
1021
+ const dummySegment: ResolvedSegment = {
1022
+ id: segmentId,
1023
+ namespace: entry.id,
1024
+ type: "loader",
1025
+ index,
1026
+ component: null,
1027
+ params: ctx.params,
1028
+ loaderId: loader.$$id,
1029
+ belongsToRoute,
1030
+ };
1031
+
1032
+ // Evaluate loader's revalidation functions
1033
+ return await evaluateRevalidation({
1034
+ segment: dummySegment,
1035
+ prevParams,
1036
+ getPrevSegment: null,
1037
+ request,
1038
+ prevUrl,
1039
+ nextUrl,
1040
+ revalidations: loaderRevalidateFns.map((fn, j) => ({
1041
+ name: `loader-revalidate${j}`,
1042
+ fn,
1043
+ })),
1044
+ routeKey,
1045
+ context: ctx,
1046
+ actionContext,
1047
+ stale,
1048
+ });
1049
+ },
1050
+ async () => true,
1051
+ () => false
1052
+ );
1053
+ return { shouldRun, loader, segmentId, index };
1054
+ }
1055
+ )
1056
+ );
1057
+
1058
+ // Phase 2: Build segments for loaders that need revalidation
1059
+ // Don't await - wrap promises with error handling for deferred client-side resolution
1060
+ const loadersToRun = revalidationChecks.filter((c) => c.shouldRun);
1061
+ const segments: ResolvedSegment[] = loadersToRun.map(
1062
+ ({ loader, segmentId, index }) => ({
1063
+ id: segmentId,
1064
+ namespace: entry.id,
1065
+ type: "loader" as const,
1066
+ index,
1067
+ component: null,
1068
+ params: ctx.params,
1069
+ loaderId: loader.$$id,
1070
+ loaderData: wrapLoaderPromise(
1071
+ ctx.use(loader),
1072
+ entry,
1073
+ segmentId,
1074
+ ctx.pathname
1075
+ ),
1076
+ belongsToRoute,
1077
+ })
1078
+ );
1079
+
1080
+ return { segments, matchedIds };
1081
+ }
1082
+ /**
1083
+ * Resolve segments from EntryData
1084
+ * Executes middlewares, loaders, parallels, and handlers in correct order
1085
+ * Returns array: [main segment, ...orphan layout segments]
1086
+ */
1087
+ async function resolveSegment(
1088
+ entry: EntryData,
1089
+ routeKey: string,
1090
+ params: Record<string, string>,
1091
+ context: HandlerContext<any, TEnv>,
1092
+ loaderPromises: Map<string, Promise<any>>,
1093
+ isRouteEntry: boolean = false
1094
+ ): Promise<ResolvedSegment[]> {
1095
+ const segments: ResolvedSegment[] = [];
1096
+
1097
+ if (entry.type === "layout" || entry.type === "cache") {
1098
+ // Layout/Cache execution order:
1099
+ // 1. Loaders → 2. Parallels (emit segments) → 3. Handler (emit segment) → 4. Orphan Layouts
1100
+ // Note: Middleware is now collected and executed at the top level (coreRequestHandler)
1101
+
1102
+ // Step 1: Run layout loaders
1103
+ const loaderSegments = await resolveLoaders(
1104
+ entry,
1105
+ context,
1106
+ false // Parent chain layouts don't belong to specific route
1107
+ );
1108
+ segments.push(...loaderSegments);
1109
+
1110
+ // Step 3: Process and emit layout parallel segments
1111
+ for (const parallelEntry of entry.parallel) {
1112
+ const parallelSegments = await resolveParallelEntry(
1113
+ parallelEntry,
1114
+ params,
1115
+ context,
1116
+ false, // Parent chain parallels don't belong to specific route
1117
+ entry.shortCode // Pass parent layout's shortCode for segment ID association
1118
+ );
1119
+ segments.push(...parallelSegments);
1120
+ }
1121
+
1122
+ // Step 4: Execute layout handler and emit layout segment
1123
+ // Set current segment ID for handle data attribution
1124
+ context._currentSegmentId = entry.shortCode;
1125
+ const component =
1126
+ typeof entry.handler === "function"
1127
+ ? await entry.handler(context)
1128
+ : entry.handler;
1129
+
1130
+ segments.push({
1131
+ id: entry.shortCode,
1132
+ namespace: entry.id,
1133
+ type: "layout", // Cache entries also emit "layout" type segments
1134
+ index: 0,
1135
+ component,
1136
+ loading: entry.loading === false ? null : entry.loading,
1137
+ params,
1138
+ belongsToRoute: false, // Parent chain layouts/cache don't belong to specific route
1139
+ layoutName: entry.id,
1140
+ });
1141
+
1142
+ // Step 5: Process orphan layouts
1143
+ for (const orphan of entry.layout) {
1144
+ const orphanSegments = await resolveOrphanLayout(
1145
+ orphan,
1146
+ params,
1147
+ context,
1148
+ loaderPromises,
1149
+ false // Parent chain layouts don't belong to specific route
1150
+ );
1151
+ segments.push(...orphanSegments);
1152
+ }
1153
+ } else if (entry.type === "route") {
1154
+ // Route execution order:
1155
+ // 1. Route Loader → 2. Orphan Layouts → 3. Route Parallels (emit segments) → 4. Route Handler (emit segment)
1156
+ // Note: Route middleware is now collected and executed at the top level (coreRequestHandler)
1157
+
1158
+ // Step 1: Run route loaders
1159
+ const loaderSegments = await resolveLoaders(
1160
+ entry,
1161
+ context,
1162
+ true // Route loaders belong to the route
1163
+ );
1164
+ segments.push(...loaderSegments);
1165
+
1166
+ // Step 3: Process orphan layouts first
1167
+ for (const orphan of entry.layout) {
1168
+ const orphanSegments = await resolveOrphanLayout(
1169
+ orphan,
1170
+ params,
1171
+ context,
1172
+ loaderPromises,
1173
+ true // Route's orphan layouts belong to the route
1174
+ );
1175
+ segments.push(...orphanSegments);
1176
+ }
1177
+
1178
+ // Step 4: Process and emit route parallel segments
1179
+ for (const parallelEntry of entry.parallel) {
1180
+ const parallelSegments = await resolveParallelEntry(
1181
+ parallelEntry,
1182
+ params,
1183
+ context,
1184
+ true, // Route's parallels belong to the route
1185
+ entry.shortCode // Pass parent route's shortCode for segment ID association
1186
+ );
1187
+ segments.push(...parallelSegments);
1188
+ }
1189
+
1190
+ // Step 5: Execute route handler and emit route segment
1191
+ // If loading is defined, wrap in Suspense for RSC streaming
1192
+ // This allows the fallback to be sent immediately while content streams in
1193
+ // Set current segment ID for handle data attribution
1194
+ context._currentSegmentId = entry.shortCode;
1195
+ let component: ReactNode | Promise<ReactNode>;
1196
+ if (entry.loading) {
1197
+ const result = entry.handler(context);
1198
+ component = result instanceof Promise ? trackHandler(result) : result;
1199
+ } else {
1200
+ component = await entry.handler(context);
1201
+ }
1202
+
1203
+ segments.push({
1204
+ id: entry.shortCode,
1205
+ namespace: entry.id,
1206
+ type: "route",
1207
+ index: 0,
1208
+ component,
1209
+ loading: entry.loading === false ? null : entry.loading,
1210
+ params,
1211
+ belongsToRoute: true, // Route always belongs to itself
1212
+ });
1213
+ } else {
1214
+ throw new Error(`Unknown entry type: ${(entry as any).type}`);
1215
+ }
1216
+
1217
+ return segments;
1218
+ }
1219
+
1220
+ /**
1221
+ * Helper: Resolve orphan layout with its middlewares, loaders, and parallels
1222
+ * Also handles cache entries in the layout array (structural boundaries)
1223
+ */
1224
+ async function resolveOrphanLayout(
1225
+ orphan: EntryData,
1226
+ params: Record<string, string>,
1227
+ context: HandlerContext<any, TEnv>,
1228
+ loaderPromises: Map<string, Promise<any>>,
1229
+ belongsToRoute: boolean
1230
+ ): Promise<ResolvedSegment[]> {
1231
+ // Orphans must be layouts or cache entries
1232
+ invariant(
1233
+ orphan.type === "layout" || orphan.type === "cache",
1234
+ `Expected orphan to be a layout or cache, got: ${orphan.type}`
1235
+ );
1236
+
1237
+ // Orphan Loader → Orphan Parallels → Orphan Handler
1238
+ // Note: Orphan middleware is now collected and executed at the top level (coreRequestHandler)
1239
+
1240
+ // Step 1: Run orphan loaders
1241
+ const loaderSegments = await resolveLoaders(
1242
+ orphan,
1243
+ context,
1244
+ belongsToRoute
1245
+ );
1246
+
1247
+ // Step 3: Process and emit orphan parallel segments
1248
+ const segments: ResolvedSegment[] = [...loaderSegments];
1249
+ for (const parallelEntry of orphan.parallel) {
1250
+ const parallelSegments = await resolveParallelEntry(
1251
+ parallelEntry,
1252
+ params,
1253
+ context,
1254
+ belongsToRoute,
1255
+ orphan.shortCode // Pass parent orphan layout's shortCode for segment ID association
1256
+ );
1257
+ segments.push(...parallelSegments);
1258
+ }
1259
+
1260
+ // Step 4: Execute orphan handler and emit layout segment
1261
+ const component =
1262
+ typeof orphan.handler === "function"
1263
+ ? await orphan.handler(context)
1264
+ : orphan.handler;
1265
+
1266
+ segments.push({
1267
+ id: orphan.shortCode,
1268
+ namespace: orphan.id,
1269
+ type: "layout",
1270
+ index: 0,
1271
+ component,
1272
+ params,
1273
+ belongsToRoute,
1274
+ layoutName: orphan.id,
1275
+ loading: orphan.loading === false ? null : orphan.loading,
1276
+ });
1277
+
1278
+ return segments;
1279
+ }
1280
+
1281
+ /**
1282
+ * Check if an intercept's when conditions are satisfied
1283
+ * All when() functions must return true for the intercept to activate.
1284
+ * If no when() conditions are defined, the intercept always activates.
1285
+ *
1286
+ * IMPORTANT: During action revalidation, when() is NOT evaluated.
1287
+ * The intercept was already activated during navigation, and we preserve
1288
+ * that state to avoid accidentally closing modals after actions.
1289
+ */
1290
+ function evaluateInterceptWhen(
1291
+ intercept: InterceptEntry,
1292
+ selectorContext: InterceptSelectorContext | null,
1293
+ isAction: boolean
1294
+ ): boolean {
1295
+ // During action revalidation, skip when() evaluation - preserve current state
1296
+ // The intercept was already activated during navigation
1297
+ if (isAction) {
1298
+ return true;
1299
+ }
1300
+
1301
+ // If no when conditions, always intercept (backwards compatible)
1302
+ if (!intercept.when || intercept.when.length === 0) {
1303
+ return true;
1304
+ }
1305
+
1306
+ // If no selector context provided, can't evaluate - skip intercept
1307
+ if (!selectorContext) {
1308
+ return false;
1309
+ }
1310
+
1311
+ // All when conditions must return true (AND logic)
1312
+ return intercept.when.every((fn) => fn(selectorContext));
1313
+ }
1314
+
1315
+ /**
1316
+ * Find an intercept for the target route by walking up the entry chain
1317
+ * Returns the first (innermost) matching intercept along with the entry that defines it
1318
+ *
1319
+ * Intercepts are "lazy parallels" that only activate during soft navigation.
1320
+ * They render alternative content in a named slot (like @modal) instead of the
1321
+ * route's normal handler.
1322
+ *
1323
+ * @param targetRouteKey - The route key to find an intercept for (e.g., "card")
1324
+ * @param fromEntry - Starting entry to walk up from (usually the route entry)
1325
+ * @param selectorContext - Navigation context for evaluating when() conditions
1326
+ * @param isAction - Whether this is an action revalidation (skips when() evaluation)
1327
+ * @returns The matching intercept and its defining entry, or null if none found
1328
+ */
1329
+ function findInterceptForRoute(
1330
+ targetRouteKey: string,
1331
+ fromEntry: EntryData | null,
1332
+ selectorContext: InterceptSelectorContext | null = null,
1333
+ isAction: boolean = false
1334
+ ): { intercept: InterceptEntry; entry: EntryData } | null {
1335
+ let current: EntryData | null = fromEntry;
1336
+
1337
+ while (current) {
1338
+ // Check if this entry has intercepts defined
1339
+ if (current.intercept && current.intercept.length > 0) {
1340
+ // Find intercept matching the target route name and when conditions
1341
+ for (const intercept of current.intercept) {
1342
+ if (
1343
+ intercept.routeName === targetRouteKey &&
1344
+ evaluateInterceptWhen(intercept, selectorContext, isAction)
1345
+ ) {
1346
+ return { intercept, entry: current };
1347
+ }
1348
+ }
1349
+ }
1350
+
1351
+ // Also check sibling layouts for intercepts
1352
+ // Intercepts are defined as siblings in the route tree - e.g., an intercept
1353
+ // like (.)card/[cardId] is placed alongside the parent route's layouts
1354
+ if (current.layout && current.layout.length > 0) {
1355
+ for (const siblingLayout of current.layout) {
1356
+ if (siblingLayout.intercept && siblingLayout.intercept.length > 0) {
1357
+ for (const intercept of siblingLayout.intercept) {
1358
+ if (
1359
+ intercept.routeName === targetRouteKey &&
1360
+ evaluateInterceptWhen(intercept, selectorContext, isAction)
1361
+ ) {
1362
+ return { intercept, entry: siblingLayout };
1363
+ }
1364
+ }
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ current = current.parent;
1370
+ }
1371
+
1372
+ return null;
1373
+ }
1374
+
1375
+ /**
1376
+ * Resolve an intercept entry and emit segment with the slot name
1377
+ * Similar to parallel entry resolution but for intercept handlers.
1378
+ *
1379
+ * Intercepts can have their own middleware, loaders, revalidate, and loading.
1380
+ * The handler is rendered in the named slot (e.g., @modal).
1381
+ *
1382
+ * @param interceptEntry - The intercept definition
1383
+ * @param parentEntry - The entry that defines the intercept (for shortCode)
1384
+ * @param params - URL parameters
1385
+ * @param context - Handler context
1386
+ * @param belongsToRoute - Whether this intercept belongs to the matched route
1387
+ * @param revalidationContext - Optional revalidation context for partial updates
1388
+ */
1389
+ async function resolveInterceptEntry(
1390
+ interceptEntry: InterceptEntry,
1391
+ parentEntry: EntryData,
1392
+ params: Record<string, string>,
1393
+ context: HandlerContext<any, TEnv>,
1394
+ belongsToRoute: boolean = true,
1395
+ revalidationContext?: {
1396
+ clientSegmentIds: Set<string>;
1397
+ prevParams: Record<string, string>;
1398
+ request: Request;
1399
+ prevUrl: URL;
1400
+ nextUrl: URL;
1401
+ routeKey: string;
1402
+ actionContext?: {
1403
+ actionId?: string;
1404
+ actionUrl?: URL;
1405
+ actionResult?: any;
1406
+ formData?: FormData;
1407
+ };
1408
+ stale?: boolean;
1409
+ }
1410
+ ): Promise<ResolvedSegment[]> {
1411
+ const segments: ResolvedSegment[] = [];
1412
+
1413
+ // Step 1: Execute intercept middleware
1414
+ if (interceptEntry.middleware.length > 0) {
1415
+ // Get stubResponse from request context for header/cookie collection
1416
+ const requestCtx = getRequestContext();
1417
+ if (!requestCtx?.res) {
1418
+ throw new Error(
1419
+ "Request context with stubResponse is required for intercept middleware"
1420
+ );
1421
+ }
1422
+ const middlewareResponse = await executeInterceptMiddleware(
1423
+ interceptEntry.middleware,
1424
+ context.request,
1425
+ context.env,
1426
+ params,
1427
+ context.var as Record<string, any>,
1428
+ requestCtx.res
1429
+ );
1430
+ if (middlewareResponse) throw middlewareResponse;
1431
+ }
1432
+
1433
+ // Step 2: Collect intercept loaders as promises (with revalidation check)
1434
+ // These will be attached directly to the intercept segment for streaming
1435
+ const loaderPromises: Promise<any>[] = [];
1436
+ const loaderIds: string[] = [];
1437
+
1438
+ for (let i = 0; i < interceptEntry.loader.length; i++) {
1439
+ const { loader, revalidate: loaderRevalidateFns } =
1440
+ interceptEntry.loader[i];
1441
+ const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
1442
+
1443
+ // Check revalidation if context provided (partial updates)
1444
+ if (revalidationContext) {
1445
+ const {
1446
+ clientSegmentIds,
1447
+ prevParams,
1448
+ request,
1449
+ prevUrl,
1450
+ nextUrl,
1451
+ routeKey,
1452
+ actionContext,
1453
+ stale,
1454
+ } = revalidationContext;
1455
+
1456
+ // Check if client has the parent intercept segment (loaders are embedded, not separate segments)
1457
+ const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
1458
+ if (clientSegmentIds.has(interceptSegmentId)) {
1459
+ // Create dummy segment for evaluation
1460
+ const dummySegment: ResolvedSegment = {
1461
+ id: segmentId,
1462
+ namespace: `intercept:${interceptEntry.routeName}`,
1463
+ type: "loader",
1464
+ index: i,
1465
+ component: null,
1466
+ params,
1467
+ loaderId: loader.$$id,
1468
+ belongsToRoute,
1469
+ };
1470
+
1471
+ const shouldRevalidate = await evaluateRevalidation({
1472
+ segment: dummySegment,
1473
+ prevParams,
1474
+ getPrevSegment: null,
1475
+ request,
1476
+ prevUrl,
1477
+ nextUrl,
1478
+ revalidations: loaderRevalidateFns.map((fn, j) => ({
1479
+ name: `intercept-loader-revalidate${j}`,
1480
+ fn,
1481
+ })),
1482
+ routeKey,
1483
+ context,
1484
+ actionContext,
1485
+ stale,
1486
+ });
1487
+
1488
+ if (!shouldRevalidate) {
1489
+ console.log(
1490
+ `[Router] Intercept loader ${loader.$$id} skipped (revalidation=false)`
1491
+ );
1492
+ continue;
1493
+ }
1494
+ console.log(
1495
+ `[Router] Intercept loader ${loader.$$id} revalidating (stale=${stale})`
1496
+ );
1497
+ }
1498
+ }
1499
+
1500
+ loaderIds.push(loader.$$id);
1501
+ loaderPromises.push(
1502
+ wrapLoaderPromise(
1503
+ context.use(loader),
1504
+ parentEntry,
1505
+ segmentId,
1506
+ context.pathname
1507
+ )
1508
+ );
1509
+ }
1510
+
1511
+ // Step 3: Execute intercept handler and prepare component
1512
+ // Get handler result - don't await if we have loading (enables streaming)
1513
+ const handlerResult =
1514
+ typeof interceptEntry.handler === "function"
1515
+ ? interceptEntry.handler(context)
1516
+ : interceptEntry.handler;
1517
+
1518
+ // Step 4: Prepare layout element (if defined)
1519
+ // Layout will be applied in segment-system, not here
1520
+ let layoutElement: ReactNode | undefined;
1521
+ if (interceptEntry.layout) {
1522
+ layoutElement =
1523
+ typeof interceptEntry.layout === "function"
1524
+ ? await interceptEntry.layout(context)
1525
+ : interceptEntry.layout;
1526
+ }
1527
+
1528
+ // Determine if we should await the handler result and loaders
1529
+ // If we have loading, DON'T await - let Suspense handle streaming
1530
+ let component: ReactNode | Promise<ReactNode>;
1531
+ let loaderDataPromise: Promise<any[]> | any[] | undefined;
1532
+
1533
+ if (interceptEntry.loading && loaderPromises.length > 0) {
1534
+ // Has loading skeleton - keep everything as Promises for streaming
1535
+ // Don't track intercept handlers - they're parallels and shouldn't block handle data
1536
+ component =
1537
+ handlerResult instanceof Promise
1538
+ ? handlerResult
1539
+ : Promise.resolve(handlerResult);
1540
+ loaderDataPromise = Promise.all(loaderPromises);
1541
+ } else if (loaderPromises.length > 0) {
1542
+ // No loading skeleton - await loaders and component
1543
+ loaderDataPromise = await Promise.all(loaderPromises);
1544
+ component =
1545
+ handlerResult instanceof Promise ? await handlerResult : handlerResult;
1546
+ } else {
1547
+ // No loaders - don't track intercept handlers (they're parallels)
1548
+ component =
1549
+ interceptEntry.loading && handlerResult instanceof Promise
1550
+ ? handlerResult
1551
+ : handlerResult instanceof Promise
1552
+ ? await handlerResult
1553
+ : handlerResult;
1554
+ }
1555
+
1556
+ const interceptSegment = {
1557
+ id: `${parentEntry.shortCode}.${interceptEntry.slotName}`,
1558
+ namespace: `intercept:${interceptEntry.routeName}`,
1559
+ type: "parallel" as const,
1560
+ index: 0,
1561
+ component,
1562
+ loading: interceptEntry.loading === false ? null : interceptEntry.loading,
1563
+ layout: layoutElement,
1564
+ params,
1565
+ slot: interceptEntry.slotName,
1566
+ belongsToRoute,
1567
+ parallelName: `intercept:${interceptEntry.routeName}.${interceptEntry.slotName}`,
1568
+ // Attach loader info directly to segment for streaming
1569
+ loaderDataPromise,
1570
+ loaderIds: loaderIds.length > 0 ? loaderIds : undefined,
1571
+ };
1572
+ segments.push(interceptSegment);
1573
+
1574
+ return segments;
1575
+ }
1576
+
1577
+ /**
1578
+ * Helper: Resolve only the loaders for a cached intercept segment.
1579
+ * Used on intercept cache hit to get fresh loader data while keeping cached component/layout.
1580
+ * Returns the fresh loaderDataPromise and loaderIds, or null if no loaders need resolution.
1581
+ */
1582
+ async function resolveInterceptLoadersOnly(
1583
+ interceptEntry: InterceptEntry,
1584
+ parentEntry: EntryData,
1585
+ params: Record<string, string>,
1586
+ context: HandlerContext<any, TEnv>,
1587
+ belongsToRoute: boolean = true,
1588
+ revalidationContext: {
1589
+ clientSegmentIds: Set<string>;
1590
+ prevParams: Record<string, string>;
1591
+ request: Request;
1592
+ prevUrl: URL;
1593
+ nextUrl: URL;
1594
+ routeKey: string;
1595
+ actionContext?: {
1596
+ actionId?: string;
1597
+ actionUrl?: URL;
1598
+ actionResult?: any;
1599
+ formData?: FormData;
1600
+ };
1601
+ stale?: boolean;
1602
+ }
1603
+ ): Promise<{
1604
+ loaderDataPromise: Promise<any[]> | any[];
1605
+ loaderIds: string[];
1606
+ } | null> {
1607
+ if (interceptEntry.loader.length === 0) {
1608
+ return null;
1609
+ }
1610
+
1611
+ const loaderPromises: Promise<any>[] = [];
1612
+ const loaderIds: string[] = [];
1613
+
1614
+ const {
1615
+ clientSegmentIds,
1616
+ prevParams,
1617
+ request,
1618
+ prevUrl,
1619
+ nextUrl,
1620
+ routeKey,
1621
+ actionContext,
1622
+ stale,
1623
+ } = revalidationContext;
1624
+
1625
+ for (let i = 0; i < interceptEntry.loader.length; i++) {
1626
+ const { loader, revalidate: loaderRevalidateFns } =
1627
+ interceptEntry.loader[i];
1628
+ const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
1629
+
1630
+ // Check if client has the parent intercept segment (loaders are embedded, not separate segments)
1631
+ const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
1632
+ if (clientSegmentIds.has(interceptSegmentId)) {
1633
+ // Create dummy segment for evaluation
1634
+ const dummySegment: ResolvedSegment = {
1635
+ id: segmentId,
1636
+ namespace: `intercept:${interceptEntry.routeName}`,
1637
+ type: "loader",
1638
+ index: i,
1639
+ component: null,
1640
+ params,
1641
+ loaderId: loader.$$id,
1642
+ belongsToRoute,
1643
+ };
1644
+
1645
+ const shouldRevalidate = await evaluateRevalidation({
1646
+ segment: dummySegment,
1647
+ prevParams,
1648
+ getPrevSegment: null,
1649
+ request,
1650
+ prevUrl,
1651
+ nextUrl,
1652
+ revalidations: loaderRevalidateFns.map((fn, j) => ({
1653
+ name: `intercept-loader-revalidate${j}`,
1654
+ fn,
1655
+ })),
1656
+ routeKey,
1657
+ context,
1658
+ actionContext,
1659
+ stale,
1660
+ });
1661
+
1662
+ if (!shouldRevalidate) {
1663
+ console.log(
1664
+ `[Router] Intercept loader ${loader.$$id} skipped (cache hit, revalidation=false)`
1665
+ );
1666
+ continue;
1667
+ }
1668
+ console.log(
1669
+ `[Router] Intercept loader ${loader.$$id} revalidating on cache hit (stale=${stale})`
1670
+ );
1671
+ }
1672
+
1673
+ loaderIds.push(loader.$$id);
1674
+ loaderPromises.push(
1675
+ wrapLoaderPromise(
1676
+ context.use(loader),
1677
+ parentEntry,
1678
+ segmentId,
1679
+ context.pathname
1680
+ )
1681
+ );
1682
+ }
1683
+
1684
+ if (loaderPromises.length === 0) {
1685
+ return null;
1686
+ }
1687
+
1688
+ // If intercept has loading skeleton, keep as Promise for streaming
1689
+ // Otherwise await immediately
1690
+ const loaderDataPromise =
1691
+ interceptEntry.loading !== undefined
1692
+ ? Promise.all(loaderPromises)
1693
+ : await Promise.all(loaderPromises);
1694
+
1695
+ return { loaderDataPromise, loaderIds };
1696
+ }
1697
+
1698
+ /**
1699
+ * Helper: Resolve parallel EntryData with its loaders and slot handlers
1700
+ * Parallels now have their own loaders, revalidate functions, and loading components
1701
+ *
1702
+ * @param parentShortCode - The shortCode of the parent layout/route that owns this parallel.
1703
+ * Used for segment IDs so the segment tree can correctly associate parallels with their parent.
1704
+ */
1705
+ async function resolveParallelEntry(
1706
+ parallelEntry: EntryData,
1707
+ params: Record<string, string>,
1708
+ context: HandlerContext<any, TEnv>,
1709
+ belongsToRoute: boolean,
1710
+ parentShortCode: string
1711
+ ): Promise<ResolvedSegment[]> {
1712
+ invariant(
1713
+ parallelEntry.type === "parallel",
1714
+ `Expected parallel entry, got: ${parallelEntry.type}`
1715
+ );
1716
+
1717
+ const segments: ResolvedSegment[] = [];
1718
+
1719
+ // Step 1: Execute each slot handler first (they trigger loaders via ctx.use())
1720
+ // Handlers are NOT awaited if loading is defined - this keeps Promises pending for Suspense
1721
+ const slots = parallelEntry.handler as Record<
1722
+ `@${string}`,
1723
+ | ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)
1724
+ | ReactNode
1725
+ >;
1726
+
1727
+ for (const [slot, handler] of Object.entries(slots)) {
1728
+ // If loading is defined, don't await the handler (stream with Suspense)
1729
+ // Don't track parallel handlers - they shouldn't block handle data
1730
+ let component: ReactNode | Promise<ReactNode>;
1731
+ if (parallelEntry.loading) {
1732
+ const result =
1733
+ typeof handler === "function" ? handler(context) : handler;
1734
+ component = result;
1735
+ } else {
1736
+ component =
1737
+ typeof handler === "function" ? await handler(context) : handler;
1738
+ }
1739
+
1740
+ // Use parent's shortCode so segment tree correctly associates this parallel with its parent
1741
+ segments.push({
1742
+ id: `${parentShortCode}.${slot}`,
1743
+ namespace: parallelEntry.id,
1744
+ type: "parallel",
1745
+ index: 0,
1746
+ component,
1747
+ loading: parallelEntry.loading === false ? null : parallelEntry.loading,
1748
+ params,
1749
+ slot,
1750
+ belongsToRoute,
1751
+ parallelName: `${parallelEntry.id}.${slot}`,
1752
+ });
1753
+ }
1754
+
1755
+ // Step 2: Resolve loaders AFTER handlers have run
1756
+ // If loading is defined, do NOT await loaders - this keeps handler Promises pending for Suspense
1757
+ // Loader data flows through component props (via ctx.use() in handler)
1758
+ // If no loading, await loaders to create segments for useLoader() support
1759
+ if (!parallelEntry.loading) {
1760
+ const loaderSegments = await resolveLoaders(
1761
+ parallelEntry,
1762
+ context,
1763
+ belongsToRoute,
1764
+ parentShortCode
1765
+ );
1766
+ segments.push(...loaderSegments);
1767
+ }
1768
+
1769
+ return segments;
1770
+ }
1771
+
1772
+ /**
1773
+ * Wrapper that adds error boundary handling to segment resolution
1774
+ * Catches errors during execution and returns error segments if an error boundary exists
1775
+ *
1776
+ * @param entry - The entry to resolve
1777
+ * @param routeKey - Route key for context
1778
+ * @param params - URL parameters
1779
+ * @param context - Handler context
1780
+ * @param loaderPromises - Shared loader promise map
1781
+ * @param resolveFn - The actual resolution function to call
1782
+ * @param errorContext - Additional context for onError callback
1783
+ * @returns Segments from successful resolution, or an error segment if error boundary caught
1784
+ * @throws If error occurs and no error boundary is defined
1785
+ */
1786
+ async function resolveWithErrorHandling(
1787
+ entry: EntryData,
1788
+ routeKey: string,
1789
+ params: Record<string, string>,
1790
+ context: HandlerContext<any, TEnv>,
1791
+ loaderPromises: Map<string, Promise<any>>,
1792
+ resolveFn: () => Promise<ResolvedSegment[]>,
1793
+ errorContext?: {
1794
+ env?: TEnv;
1795
+ isPartial?: boolean;
1796
+ requestStartTime?: number;
1797
+ }
1798
+ ): Promise<ResolvedSegment[]> {
1799
+ try {
1800
+ return await resolveFn();
1801
+ } catch (error) {
1802
+ // Don't catch Response objects (middleware short-circuit)
1803
+ if (error instanceof Response) {
1804
+ throw error;
1805
+ }
1806
+
1807
+ // Handle DataNotFoundError separately - look for notFoundBoundary first
1808
+ if (error instanceof DataNotFoundError) {
1809
+ const notFoundFallback = findNearestNotFoundBoundary(entry);
1810
+
1811
+ if (notFoundFallback) {
1812
+ // Create notFound info
1813
+ const notFoundInfo = createNotFoundInfo(
1814
+ error,
1815
+ entry.shortCode,
1816
+ entry.type,
1817
+ context.pathname
1818
+ );
1819
+
1820
+ // Invoke onError with notFound context
1821
+ callOnError(error, "handler", {
1822
+ request: context.request,
1823
+ url: context.url,
1824
+ routeKey,
1825
+ params,
1826
+ segmentId: entry.shortCode,
1827
+ segmentType: entry.type as any,
1828
+ env: errorContext?.env,
1829
+ isPartial: errorContext?.isPartial,
1830
+ handledByBoundary: true,
1831
+ metadata: { notFound: true, message: notFoundInfo.message },
1832
+ requestStartTime: errorContext?.requestStartTime,
1833
+ });
1834
+
1835
+ console.log(
1836
+ `[Router] NotFound caught by notFoundBoundary in ${entry.shortCode}:`,
1837
+ notFoundInfo.message
1838
+ );
1839
+
1840
+ // Set response status to 404 for notFound
1841
+ const reqCtx = getRequestContext();
1842
+ if (reqCtx) {
1843
+ reqCtx.res = new Response(null, { status: 404, headers: reqCtx.res.headers });
1844
+ }
1845
+
1846
+ // Create and return notFound segment
1847
+ const notFoundSegment = createNotFoundSegment(
1848
+ notFoundInfo,
1849
+ notFoundFallback,
1850
+ entry,
1851
+ params
1852
+ );
1853
+ return [notFoundSegment];
1854
+ }
1855
+ // If no notFoundBoundary, fall through to error boundary handling
1856
+ }
1857
+
1858
+ // Find nearest error boundary
1859
+ const fallback = findNearestErrorBoundary(entry);
1860
+
1861
+ // Determine segment type for error info
1862
+ const segmentType: ErrorInfo["segmentType"] = entry.type;
1863
+
1864
+ // Create error info
1865
+ const errorInfo = createErrorInfo(error, entry.shortCode, segmentType);
1866
+
1867
+ // Use default fallback if no error boundary found
1868
+ const effectiveFallback = fallback ?? DefaultErrorFallback;
1869
+
1870
+ // Invoke onError callback
1871
+ callOnError(error, "handler", {
1872
+ request: context.request,
1873
+ url: context.url,
1874
+ routeKey,
1875
+ params,
1876
+ segmentId: entry.shortCode,
1877
+ segmentType: entry.type as any,
1878
+ env: errorContext?.env,
1879
+ isPartial: errorContext?.isPartial,
1880
+ handledByBoundary: !!fallback,
1881
+ requestStartTime: errorContext?.requestStartTime,
1882
+ });
1883
+
1884
+ console.log(
1885
+ `[Router] Error caught by ${fallback ? "error boundary" : "default fallback"} in ${entry.shortCode}:`,
1886
+ errorInfo.message
1887
+ );
1888
+
1889
+ // Set response status to 500 for error
1890
+ {
1891
+ const reqCtx = getRequestContext();
1892
+ if (reqCtx) {
1893
+ reqCtx.res = new Response(null, { status: 500, headers: reqCtx.res.headers });
1894
+ }
1895
+ }
1896
+
1897
+ // Create and return error segment
1898
+ const errorSegment = createErrorSegment(
1899
+ errorInfo,
1900
+ effectiveFallback,
1901
+ entry,
1902
+ params
1903
+ );
1904
+ return [errorSegment];
1905
+ }
1906
+ }
1907
+
1908
+ /**
1909
+ * Resolve all segments for a route (used for single-cache-per-request pattern)
1910
+ * Loops through all entries and resolves them with error handling
1911
+ */
1912
+ async function resolveAllSegments(
1913
+ entries: EntryData[],
1914
+ routeKey: string,
1915
+ params: Record<string, string>,
1916
+ context: HandlerContext<any, TEnv>,
1917
+ loaderPromises: Map<string, Promise<any>>
1918
+ ): Promise<ResolvedSegment[]> {
1919
+ const allSegments: ResolvedSegment[] = [];
1920
+
1921
+ for (const entry of entries) {
1922
+ const resolvedSegments = await resolveWithErrorHandling(
1923
+ entry,
1924
+ routeKey,
1925
+ params,
1926
+ context,
1927
+ loaderPromises,
1928
+ () => resolveSegment(entry, routeKey, params, context, loaderPromises)
1929
+ );
1930
+ allSegments.push(...resolvedSegments);
1931
+ }
1932
+
1933
+ return allSegments;
1934
+ }
1935
+
1936
+ /**
1937
+ * Resolve only loader segments for all entries (used when serving cached non-loader segments)
1938
+ * Loaders are always fresh by default, so we resolve them even on cache hit
1939
+ */
1940
+ async function resolveLoadersOnly(
1941
+ entries: EntryData[],
1942
+ context: HandlerContext<any, TEnv>
1943
+ ): Promise<ResolvedSegment[]> {
1944
+ const loaderSegments: ResolvedSegment[] = [];
1945
+
1946
+ for (const entry of entries) {
1947
+ const belongsToRoute = entry.type === "route";
1948
+ const segments = await resolveLoaders(entry, context, belongsToRoute);
1949
+ loaderSegments.push(...segments);
1950
+ }
1951
+
1952
+ return loaderSegments;
1953
+ }
1954
+
1955
+ /**
1956
+ * Resolve only loader segments for all entries with revalidation logic (for matchPartial cache hit)
1957
+ * Loaders are always fresh by default, so we resolve them even on cache hit
1958
+ */
1959
+ async function resolveLoadersOnlyWithRevalidation(
1960
+ entries: EntryData[],
1961
+ context: HandlerContext<any, TEnv>,
1962
+ clientSegmentIds: Set<string>,
1963
+ prevParams: Record<string, string>,
1964
+ request: Request,
1965
+ prevUrl: URL,
1966
+ nextUrl: URL,
1967
+ routeKey: string,
1968
+ actionContext?: ActionContext
1969
+ ): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
1970
+ const allLoaderSegments: ResolvedSegment[] = [];
1971
+ const allMatchedIds: string[] = [];
1972
+
1973
+ for (const entry of entries) {
1974
+ const belongsToRoute = entry.type === "route";
1975
+ const { segments, matchedIds } = await resolveLoadersWithRevalidation(
1976
+ entry,
1977
+ context,
1978
+ belongsToRoute,
1979
+ clientSegmentIds,
1980
+ prevParams,
1981
+ request,
1982
+ prevUrl,
1983
+ nextUrl,
1984
+ routeKey,
1985
+ actionContext
1986
+ );
1987
+ allLoaderSegments.push(...segments);
1988
+ allMatchedIds.push(...matchedIds);
1989
+ }
1990
+
1991
+ return { segments: allLoaderSegments, matchedIds: allMatchedIds };
1992
+ }
1993
+
1994
+ /**
1995
+ * Build a map of segment shortCode → entry with revalidate functions
1996
+ * Used to look up revalidation rules for cached segments
1997
+ */
1998
+ function buildEntryRevalidateMap(
1999
+ entries: EntryData[]
2000
+ ): Map<string, { entry: EntryData; revalidate: ShouldRevalidateFn<any, any>[] }> {
2001
+ const map = new Map<string, { entry: EntryData; revalidate: ShouldRevalidateFn<any, any>[] }>();
2002
+
2003
+ function processEntry(entry: EntryData, parentShortCode?: string) {
2004
+ // Map main entry
2005
+ map.set(entry.shortCode, { entry, revalidate: entry.revalidate });
2006
+
2007
+ // Process nested parallels - they use parallelEntry.shortCode.slotName as ID
2008
+ if (entry.type !== "parallel") {
2009
+ for (const parallelEntry of entry.parallel) {
2010
+ if (parallelEntry.type === "parallel") {
2011
+ // Parallel handlers are Record<slotName, handler>
2012
+ const slots = Object.keys(parallelEntry.handler) as `@${string}`[];
2013
+ for (const slot of slots) {
2014
+ // Segment ID uses parallelEntry.shortCode, not parent entry.shortCode
2015
+ const parallelId = `${parallelEntry.shortCode}.${slot}`;
2016
+ map.set(parallelId, { entry: parallelEntry, revalidate: parallelEntry.revalidate });
2017
+ }
2018
+ }
2019
+ }
2020
+ }
2021
+
2022
+ // Recursively process nested layouts
2023
+ for (const layoutEntry of entry.layout) {
2024
+ processEntry(layoutEntry);
2025
+ }
2026
+ }
2027
+
2028
+ for (const entry of entries) {
2029
+ processEntry(entry);
2030
+ }
2031
+
2032
+ return map;
2033
+ }
2034
+
2035
+ /**
2036
+ * Resolve all segments for a route with revalidation logic (for matchPartial)
2037
+ * Used for single-cache-per-request pattern in partial/navigation requests
2038
+ */
2039
+ async function resolveAllSegmentsWithRevalidation(
2040
+ entries: EntryData[],
2041
+ routeKey: string,
2042
+ params: Record<string, string>,
2043
+ context: HandlerContext<any, TEnv>,
2044
+ clientSegmentSet: Set<string>,
2045
+ prevParams: Record<string, string>,
2046
+ request: Request,
2047
+ prevUrl: URL,
2048
+ nextUrl: URL,
2049
+ loaderPromises: Map<string, Promise<any>>,
2050
+ actionContext: ActionContext | undefined,
2051
+ interceptResult: { intercept: InterceptEntry; entry: EntryData } | null,
2052
+ localRouteName: string,
2053
+ pathname: string
2054
+ ): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
2055
+ const allSegments: ResolvedSegment[] = [];
2056
+ const matchedIds: string[] = [];
2057
+
2058
+ for (const entry of entries) {
2059
+ // When intercepting, skip route entries - intercept replaces route handler
2060
+ if (entry.type === "route" && interceptResult) {
2061
+ console.log(
2062
+ `[Router.matchPartial] Intercepting "${localRouteName}" - skipping route handler`
2063
+ );
2064
+ matchedIds.push(entry.shortCode);
2065
+ continue;
2066
+ }
2067
+
2068
+ // Resolve entry with revalidation logic
2069
+ const nonParallelEntry = entry as Exclude<
2070
+ EntryData,
2071
+ { type: "parallel" }
2072
+ >;
2073
+ const resolved = await resolveWithRevalidationErrorHandling(
2074
+ nonParallelEntry,
2075
+ params,
2076
+ () =>
2077
+ resolveSegmentWithRevalidation(
2078
+ nonParallelEntry,
2079
+ routeKey,
2080
+ params,
2081
+ context,
2082
+ clientSegmentSet,
2083
+ prevParams,
2084
+ request,
2085
+ prevUrl,
2086
+ nextUrl,
2087
+ loaderPromises,
2088
+ actionContext,
2089
+ false // stale = false for fresh resolution
2090
+ ),
2091
+ pathname
2092
+ );
2093
+
2094
+ allSegments.push(...resolved.segments);
2095
+ matchedIds.push(...resolved.matchedIds);
2096
+ }
2097
+
2098
+ return { segments: allSegments, matchedIds };
2099
+ }
2100
+
2101
+ /**
2102
+ * Wrapper for segment resolution with revalidation that adds error boundary handling
2103
+ * Similar to resolveWithErrorHandling but returns SegmentRevalidationResult
2104
+ */
2105
+ async function resolveWithRevalidationErrorHandling(
2106
+ entry: EntryData,
2107
+ params: Record<string, string>,
2108
+ resolveFn: () => Promise<SegmentRevalidationResult>,
2109
+ pathname?: string,
2110
+ errorContext?: {
2111
+ request: Request;
2112
+ url: URL;
2113
+ routeKey?: string;
2114
+ env?: TEnv;
2115
+ isPartial?: boolean;
2116
+ requestStartTime?: number;
2117
+ }
2118
+ ): Promise<SegmentRevalidationResult> {
2119
+ try {
2120
+ return await resolveFn();
2121
+ } catch (error) {
2122
+ // Don't catch Response objects (middleware short-circuit)
2123
+ if (error instanceof Response) {
2124
+ throw error;
2125
+ }
2126
+
2127
+ // Handle DataNotFoundError separately - look for notFoundBoundary first
2128
+ if (error instanceof DataNotFoundError) {
2129
+ const notFoundFallback = findNearestNotFoundBoundary(entry);
2130
+
2131
+ if (notFoundFallback) {
2132
+ // Create notFound info
2133
+ const notFoundInfo = createNotFoundInfo(
2134
+ error,
2135
+ entry.shortCode,
2136
+ entry.type,
2137
+ pathname
2138
+ );
2139
+
2140
+ // Invoke onError with notFound context
2141
+ if (errorContext) {
2142
+ callOnError(error, "handler", {
2143
+ request: errorContext.request,
2144
+ url: errorContext.url,
2145
+ routeKey: errorContext.routeKey,
2146
+ params,
2147
+ segmentId: entry.shortCode,
2148
+ segmentType: entry.type as any,
2149
+ env: errorContext.env,
2150
+ isPartial: errorContext.isPartial,
2151
+ handledByBoundary: true,
2152
+ metadata: { notFound: true, message: notFoundInfo.message },
2153
+ requestStartTime: errorContext.requestStartTime,
2154
+ });
2155
+ }
2156
+
2157
+ console.log(
2158
+ `[Router] NotFound caught by notFoundBoundary in ${entry.shortCode}:`,
2159
+ notFoundInfo.message
2160
+ );
2161
+
2162
+ // Set response status to 404 for notFound
2163
+ const reqCtx = getRequestContext();
2164
+ if (reqCtx) {
2165
+ reqCtx.res = new Response(null, { status: 404, headers: reqCtx.res.headers });
2166
+ }
2167
+
2168
+ // Create notFound segment
2169
+ const notFoundSegment = createNotFoundSegment(
2170
+ notFoundInfo,
2171
+ notFoundFallback,
2172
+ entry,
2173
+ params
2174
+ );
2175
+
2176
+ // Return with the notFound segment and its ID as matched
2177
+ return {
2178
+ segments: [notFoundSegment],
2179
+ matchedIds: [notFoundSegment.id],
2180
+ };
2181
+ }
2182
+ // If no notFoundBoundary, fall through to error boundary handling
2183
+ }
2184
+
2185
+ // Find nearest error boundary
2186
+ const fallback = findNearestErrorBoundary(entry);
2187
+
2188
+ // Determine segment type for error info
2189
+ const segmentType: ErrorInfo["segmentType"] = entry.type;
2190
+
2191
+ // Create error info
2192
+ const errorInfo = createErrorInfo(error, entry.shortCode, segmentType);
2193
+
2194
+ // Use default fallback if no error boundary found
2195
+ const effectiveFallback = fallback ?? DefaultErrorFallback;
2196
+
2197
+ // Invoke onError callback
2198
+ if (errorContext) {
2199
+ callOnError(error, "handler", {
2200
+ request: errorContext.request,
2201
+ url: errorContext.url,
2202
+ routeKey: errorContext.routeKey,
2203
+ params,
2204
+ segmentId: entry.shortCode,
2205
+ segmentType: entry.type as any,
2206
+ env: errorContext.env,
2207
+ isPartial: errorContext.isPartial,
2208
+ handledByBoundary: !!fallback,
2209
+ requestStartTime: errorContext.requestStartTime,
2210
+ });
2211
+ }
2212
+
2213
+ console.log(
2214
+ `[Router] Error caught by ${fallback ? "error boundary" : "default fallback"} in ${entry.shortCode}:`,
2215
+ errorInfo.message
2216
+ );
2217
+
2218
+ // Set response status to 500 for error
2219
+ {
2220
+ const reqCtx = getRequestContext();
2221
+ if (reqCtx) {
2222
+ reqCtx.res = new Response(null, { status: 500, headers: reqCtx.res.headers });
2223
+ }
2224
+ }
2225
+
2226
+ // Create error segment
2227
+ const errorSegment = createErrorSegment(
2228
+ errorInfo,
2229
+ effectiveFallback,
2230
+ entry,
2231
+ params
2232
+ );
2233
+
2234
+ // Return with the error segment and its ID as matched
2235
+ return {
2236
+ segments: [errorSegment],
2237
+ matchedIds: [errorSegment.id],
2238
+ };
2239
+ }
2240
+ }
2241
+
2242
+ /**
2243
+ * Result of resolving segments with revalidation
2244
+ * Contains both segments to render and all matched segment IDs
2245
+ */
2246
+ interface SegmentRevalidationResult {
2247
+ segments: ResolvedSegment[];
2248
+ matchedIds: string[];
2249
+ }
2250
+
2251
+ /**
2252
+ * Action context type for revalidation
2253
+ */
2254
+ type ActionContext = {
2255
+ actionId?: string;
2256
+ actionUrl?: URL;
2257
+ actionResult?: any;
2258
+ formData?: FormData;
2259
+ };
2260
+
2261
+ /**
2262
+ * Helper: Resolve parallel segments with revalidation
2263
+ * Parallels now have their own loaders, revalidate functions, and loading components
2264
+ */
2265
+ async function resolveParallelSegmentsWithRevalidation(
2266
+ entry: EntryData,
2267
+ params: Record<string, string>,
2268
+ context: HandlerContext<any, TEnv>,
2269
+ belongsToRoute: boolean,
2270
+ clientSegmentIds: Set<string>,
2271
+ prevParams: Record<string, string>,
2272
+ request: Request,
2273
+ prevUrl: URL,
2274
+ nextUrl: URL,
2275
+ routeKey: string,
2276
+ actionContext?: ActionContext,
2277
+ stale?: boolean
2278
+ ): Promise<SegmentRevalidationResult> {
2279
+ const segments: ResolvedSegment[] = [];
2280
+ const matchedIds: string[] = [];
2281
+
2282
+ for (const parallelEntry of entry.parallel) {
2283
+ invariant(
2284
+ parallelEntry.type === "parallel",
2285
+ `Expected parallel entry, got: ${parallelEntry.type}`
2286
+ );
2287
+
2288
+ // Step 1: Process each slot handler FIRST (they trigger loaders via ctx.use())
2289
+ const slots = parallelEntry.handler as Record<
2290
+ `@${string}`,
2291
+ | ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)
2292
+ | ReactNode
2293
+ >;
2294
+
2295
+ for (const [slot, handler] of Object.entries(slots)) {
2296
+ // Use parent entry's shortCode so segment tree correctly associates parallel with parent
2297
+ const parallelId = `${entry.shortCode}.${slot}`;
2298
+
2299
+ // Include in matchedIds if:
2300
+ // - Client sent empty segments (HMR/full refetch), OR
2301
+ // - Client already has this parallel segment, OR
2302
+ // - This is a route-scoped parallel (belongsToRoute=true) that should appear
2303
+ // Intercepts (like @modal) are handled separately via resolveInterceptEntry.
2304
+ const isFullRefetch = clientSegmentIds.size === 0;
2305
+ if (
2306
+ isFullRefetch ||
2307
+ clientSegmentIds.has(parallelId) ||
2308
+ belongsToRoute
2309
+ ) {
2310
+ matchedIds.push(parallelId);
2311
+ }
2312
+
2313
+ const component = await revalidate(
2314
+ async () => {
2315
+ // If client sent empty segments (HMR/full refetch), always render
2316
+ if (isFullRefetch) return true;
2317
+
2318
+ // If client doesn't have this parallel:
2319
+ // - Route-scoped parallels (belongsToRoute=true): render them when navigating to the route
2320
+ // - Parent chain parallels (belongsToRoute=false): don't suddenly appear
2321
+ // Intercepts are handled separately via resolveInterceptEntry.
2322
+ if (!clientSegmentIds.has(parallelId)) return belongsToRoute;
2323
+
2324
+ const dummySegment: ResolvedSegment = {
2325
+ id: parallelId,
2326
+ namespace: parallelEntry.id,
2327
+ type: "parallel",
2328
+ index: 0,
2329
+ component: null as any,
2330
+ params,
2331
+ slot,
2332
+ belongsToRoute,
2333
+ parallelName: `${parallelEntry.id}.${slot}`,
2334
+ };
2335
+
2336
+ // Use parallel's own revalidate functions
2337
+ return await evaluateRevalidation({
2338
+ segment: dummySegment,
2339
+ prevParams,
2340
+ getPrevSegment: null,
2341
+ request,
2342
+ prevUrl,
2343
+ nextUrl,
2344
+ revalidations: parallelEntry.revalidate.map((fn, i) => ({
2345
+ name: `revalidate${i}`,
2346
+ fn,
2347
+ })),
2348
+ routeKey,
2349
+ context,
2350
+ actionContext,
2351
+ stale,
2352
+ });
2353
+ },
2354
+ async () => {
2355
+ // If loading is defined, don't await (stream with Suspense)
2356
+ // Don't track parallel handlers - they shouldn't block handle data
2357
+ if (parallelEntry.loading) {
2358
+ const result =
2359
+ typeof handler === "function" ? handler(context) : handler;
2360
+ return result;
2361
+ }
2362
+ return typeof handler === "function"
2363
+ ? await handler(context)
2364
+ : handler;
2365
+ },
2366
+ () => null
2367
+ );
2368
+
2369
+ segments.push({
2370
+ id: parallelId,
2371
+ namespace: parallelEntry.id,
2372
+ type: "parallel",
2373
+ index: 0,
2374
+ component,
2375
+ loading:
2376
+ parallelEntry.loading === false ? null : parallelEntry.loading,
2377
+ params,
2378
+ slot,
2379
+ belongsToRoute,
2380
+ parallelName: `${parallelEntry.id}.${slot}`,
2381
+ });
2382
+ }
2383
+
2384
+ // Step 2: Resolve loaders AFTER handlers have run
2385
+ // If loading is defined, do NOT await loaders - keeps handler Promises pending for Suspense
2386
+ // Loader data flows through component props (via ctx.use() in handler)
2387
+ if (!parallelEntry.loading) {
2388
+ const loaderResult = await resolveLoadersWithRevalidation(
2389
+ parallelEntry,
2390
+ context,
2391
+ belongsToRoute,
2392
+ clientSegmentIds,
2393
+ prevParams,
2394
+ request,
2395
+ prevUrl,
2396
+ nextUrl,
2397
+ routeKey,
2398
+ actionContext,
2399
+ entry.shortCode, // Pass parent's shortCode for segment ID association
2400
+ stale
2401
+ );
2402
+ segments.push(...loaderResult.segments);
2403
+ matchedIds.push(...loaderResult.matchedIds);
2404
+ }
2405
+ }
2406
+
2407
+ return { segments, matchedIds };
2408
+ }
2409
+
2410
+ /**
2411
+ * Helper: Resolve entry handler (layout, cache, or route) with revalidation
2412
+ * Extracted to reduce duplication between layout, cache, and route branches
2413
+ */
2414
+ async function resolveEntryHandlerWithRevalidation(
2415
+ entry: Exclude<EntryData, { type: "parallel" }>,
2416
+ params: Record<string, string>,
2417
+ context: HandlerContext<any, TEnv>,
2418
+ belongsToRoute: boolean,
2419
+ clientSegmentIds: Set<string>,
2420
+ prevParams: Record<string, string>,
2421
+ request: Request,
2422
+ prevUrl: URL,
2423
+ nextUrl: URL,
2424
+ routeKey: string,
2425
+ actionContext?: ActionContext,
2426
+ stale?: boolean
2427
+ ): Promise<{ segment: ResolvedSegment; matchedId: string }> {
2428
+ const matchedId = entry.shortCode;
2429
+
2430
+ const component = await revalidate(
2431
+ async () => {
2432
+ const hasSegment = clientSegmentIds.has(entry.shortCode);
2433
+ console.log(
2434
+ `[Router.resolveEntryHandler] ${entry.shortCode} (${entry.type}): client has=${hasSegment}, belongsToRoute=${belongsToRoute}`
2435
+ );
2436
+ if (!hasSegment) return true;
2437
+
2438
+ const dummySegment: ResolvedSegment = {
2439
+ id: entry.shortCode,
2440
+ namespace: entry.id,
2441
+ type:
2442
+ entry.type === "cache"
2443
+ ? "layout"
2444
+ : (entry.type as "layout" | "route"),
2445
+ index: 0,
2446
+ component: null as any,
2447
+ params,
2448
+ belongsToRoute,
2449
+ ...(entry.type === "layout" || entry.type === "cache"
2450
+ ? { layoutName: entry.id }
2451
+ : {}),
2452
+ };
2453
+
2454
+ const shouldRevalidate = await evaluateRevalidation({
2455
+ segment: dummySegment,
2456
+ prevParams,
2457
+ getPrevSegment: null,
2458
+ request,
2459
+ prevUrl,
2460
+ nextUrl,
2461
+ revalidations: entry.revalidate.map((fn, i) => ({
2462
+ name: `revalidate${i}`,
2463
+ fn,
2464
+ })),
2465
+ routeKey,
2466
+ context,
2467
+ actionContext,
2468
+ stale,
2469
+ });
2470
+ console.log(
2471
+ `[Router.resolveEntryHandler] ${entry.shortCode}: evaluateRevalidation returned ${shouldRevalidate}`
2472
+ );
2473
+ return shouldRevalidate;
2474
+ },
2475
+ async () => {
2476
+ // Set current segment ID for handle data attribution
2477
+ context._currentSegmentId = entry.shortCode;
2478
+ if (entry.type === "layout" || entry.type === "cache") {
2479
+ return typeof entry.handler === "function"
2480
+ ? await entry.handler(context)
2481
+ : entry.handler;
2482
+ }
2483
+ // entry.type === "route" - handler is always callable
2484
+ const routeEntry = entry as Extract<EntryData, { type: "route" }>;
2485
+ // For routes with loading: keep promise pending for navigation (not actions)
2486
+ // This allows client's use() to suspend and show loading skeleton
2487
+ if (!routeEntry.loading) {
2488
+ return await routeEntry.handler(context);
2489
+ }
2490
+ if (!actionContext) {
2491
+ // NOT awaited - keeps promise pending, but track for completion
2492
+ const result = routeEntry.handler(context);
2493
+ return {
2494
+ content: result instanceof Promise ? trackHandler(result) : result,
2495
+ };
2496
+ }
2497
+ console.log(
2498
+ `[Router] Resolving action route with awaited value: ${entry.id}`
2499
+ );
2500
+ // For actions: await handler and return value directly (not wrapped in Promise)
2501
+ // This ensures component instanceof Promise is false in segment-system,
2502
+ // avoiding RouteContentWrapper/Suspense and maintaining consistent tree structure
2503
+ return {
2504
+ content: Promise.resolve(await routeEntry.handler(context)),
2505
+ };
2506
+ },
2507
+ () => null
2508
+ );
2509
+
2510
+ // Extract component from wrapper object if needed (used to prevent promise auto-resolution)
2511
+ const resolvedComponent =
2512
+ component && typeof component === "object" && "content" in component
2513
+ ? (component as { content: ReactNode }).content
2514
+ : component;
2515
+
2516
+ const segment: ResolvedSegment = {
2517
+ id: entry.shortCode,
2518
+ namespace: entry.id,
2519
+ type:
2520
+ entry.type === "cache" ? "layout" : (entry.type as "layout" | "route"),
2521
+ index: 0,
2522
+ component: resolvedComponent,
2523
+ loading: entry.loading === false ? null : entry.loading,
2524
+ params,
2525
+ belongsToRoute,
2526
+ ...(entry.type === "layout" || entry.type === "cache"
2527
+ ? { layoutName: entry.id }
2528
+ : {}),
2529
+ };
2530
+
2531
+ return { segment, matchedId };
2532
+ }
2533
+
2534
+ /**
2535
+ * Resolve segments with revalidation awareness (for partial rendering)
2536
+ * Same as resolveSegment but conditionally executes handlers based on revalidation
2537
+ * Returns both segments to render AND all matched segment IDs (including skipped ones)
2538
+ * Cache entries are handled like layouts (they emit segments)
2539
+ * Parallel entries are handled separately via resolveParallelSegmentsWithRevalidation
2540
+ */
2541
+ async function resolveSegmentWithRevalidation(
2542
+ entry: Exclude<EntryData, { type: "parallel" }>,
2543
+ routeKey: string,
2544
+ params: Record<string, string>,
2545
+ context: HandlerContext<any, TEnv>,
2546
+ clientSegmentIds: Set<string>,
2547
+ prevParams: Record<string, string>,
2548
+ request: Request,
2549
+ prevUrl: URL,
2550
+ nextUrl: URL,
2551
+ loaderPromises: Map<string, Promise<any>>,
2552
+ actionContext?: ActionContext,
2553
+ stale?: boolean
2554
+ ): Promise<SegmentRevalidationResult> {
2555
+ const segments: ResolvedSegment[] = [];
2556
+ const matchedIds: string[] = [];
2557
+
2558
+ const belongsToRoute = entry.type === "route";
2559
+
2560
+ // Note: Middleware is now collected and executed at the top level (coreRequestHandler)
2561
+
2562
+ // Step 1: Run loaders with revalidation
2563
+ const loaderResult = await resolveLoadersWithRevalidation(
2564
+ entry,
2565
+ context,
2566
+ belongsToRoute,
2567
+ clientSegmentIds,
2568
+ prevParams,
2569
+ request,
2570
+ prevUrl,
2571
+ nextUrl,
2572
+ routeKey,
2573
+ actionContext,
2574
+ undefined, // shortCodeOverride
2575
+ stale
2576
+ );
2577
+ segments.push(...loaderResult.segments);
2578
+ matchedIds.push(...loaderResult.matchedIds);
2579
+
2580
+ // Step 3: Process orphan layouts (for routes, these come before parallels)
2581
+ if (entry.type === "route") {
2582
+ for (const orphan of entry.layout) {
2583
+ const orphanResult = await resolveOrphanLayoutWithRevalidation(
2584
+ orphan,
2585
+ params,
2586
+ context,
2587
+ clientSegmentIds,
2588
+ prevParams,
2589
+ request,
2590
+ prevUrl,
2591
+ nextUrl,
2592
+ routeKey,
2593
+ loaderPromises,
2594
+ true, // Route's orphan layouts belong to the route
2595
+ actionContext,
2596
+ stale
2597
+ );
2598
+ segments.push(...orphanResult.segments);
2599
+ matchedIds.push(...orphanResult.matchedIds);
2600
+ }
2601
+ }
2602
+
2603
+ // Step 4: Process parallel segments
2604
+ const parallelResult = await resolveParallelSegmentsWithRevalidation(
2605
+ entry,
2606
+ params,
2607
+ context,
2608
+ belongsToRoute,
2609
+ clientSegmentIds,
2610
+ prevParams,
2611
+ request,
2612
+ prevUrl,
2613
+ nextUrl,
2614
+ routeKey,
2615
+ actionContext,
2616
+ stale
2617
+ );
2618
+ segments.push(...parallelResult.segments);
2619
+ matchedIds.push(...parallelResult.matchedIds);
2620
+
2621
+ // Step 5: Process orphan layouts (for layouts/cache, these come after parallels)
2622
+ if (entry.type === "layout" || entry.type === "cache") {
2623
+ for (const orphan of entry.layout) {
2624
+ const orphanResult = await resolveOrphanLayoutWithRevalidation(
2625
+ orphan,
2626
+ params,
2627
+ context,
2628
+ clientSegmentIds,
2629
+ prevParams,
2630
+ request,
2631
+ prevUrl,
2632
+ nextUrl,
2633
+ routeKey,
2634
+ loaderPromises,
2635
+ false, // Parent chain layouts don't belong to specific route
2636
+ actionContext,
2637
+ stale
2638
+ );
2639
+ segments.push(...orphanResult.segments);
2640
+ matchedIds.push(...orphanResult.matchedIds);
2641
+ }
2642
+ }
2643
+
2644
+ // Step 6: Execute main handler with revalidation
2645
+ const handlerResult = await resolveEntryHandlerWithRevalidation(
2646
+ entry,
2647
+ params,
2648
+ context,
2649
+ belongsToRoute,
2650
+ clientSegmentIds,
2651
+ prevParams,
2652
+ request,
2653
+ prevUrl,
2654
+ nextUrl,
2655
+ routeKey,
2656
+ actionContext,
2657
+ stale
2658
+ );
2659
+ segments.push(handlerResult.segment);
2660
+ matchedIds.push(handlerResult.matchedId);
2661
+
2662
+ return { segments, matchedIds };
2663
+ }
2664
+
2665
+ /**
2666
+ * Helper: Resolve orphan layout with revalidation
2667
+ * Returns both segments to render AND all matched segment IDs (including skipped ones)
2668
+ */
2669
+ async function resolveOrphanLayoutWithRevalidation(
2670
+ orphan: EntryData,
2671
+ params: Record<string, string>,
2672
+ context: HandlerContext<any, TEnv>,
2673
+ clientSegmentIds: Set<string>,
2674
+ prevParams: Record<string, string>,
2675
+ request: Request,
2676
+ prevUrl: URL,
2677
+ nextUrl: URL,
2678
+ routeKey: string,
2679
+ loaderPromises: Map<string, Promise<any>>,
2680
+ belongsToRoute: boolean,
2681
+ actionContext?: {
2682
+ actionId?: string;
2683
+ actionUrl?: URL;
2684
+ actionResult?: any;
2685
+ formData?: FormData;
2686
+ },
2687
+ stale?: boolean
2688
+ ): Promise<SegmentRevalidationResult> {
2689
+ invariant(
2690
+ orphan.type === "layout" || orphan.type === "cache",
2691
+ `Expected orphan to be a layout or cache, got: ${orphan.type}`
2692
+ );
2693
+
2694
+ const segments: ResolvedSegment[] = [];
2695
+ const matchedIds: string[] = [];
2696
+
2697
+ // Note: Orphan middleware is now collected and executed at the top level (coreRequestHandler)
2698
+
2699
+ // Step 1: Run orphan loaders with revalidation
2700
+ const loaderResult = await resolveLoadersWithRevalidation(
2701
+ orphan,
2702
+ context,
2703
+ belongsToRoute,
2704
+ clientSegmentIds,
2705
+ prevParams,
2706
+ request,
2707
+ prevUrl,
2708
+ nextUrl,
2709
+ routeKey,
2710
+ actionContext,
2711
+ undefined, // shortCodeOverride
2712
+ stale
2713
+ );
2714
+ segments.push(...loaderResult.segments);
2715
+ matchedIds.push(...loaderResult.matchedIds);
2716
+
2717
+ // Step 3: Process orphan parallel segments with revalidation
2718
+ // Parallels now have their own loaders, revalidate functions, and loading components
2719
+ for (const parallelEntry of orphan.parallel) {
2720
+ invariant(
2721
+ parallelEntry.type === "parallel",
2722
+ `Expected parallel entry, got: ${parallelEntry.type}`
2723
+ );
2724
+
2725
+ // Step 3a: Resolve parallel's loaders with revalidation
2726
+ const loaderResult = await resolveLoadersWithRevalidation(
2727
+ parallelEntry,
2728
+ context,
2729
+ belongsToRoute,
2730
+ clientSegmentIds,
2731
+ prevParams,
2732
+ request,
2733
+ prevUrl,
2734
+ nextUrl,
2735
+ routeKey,
2736
+ actionContext,
2737
+ undefined, // shortCodeOverride
2738
+ stale
2739
+ );
2740
+ segments.push(...loaderResult.segments);
2741
+ matchedIds.push(...loaderResult.matchedIds);
2742
+
2743
+ // Step 3b: Process each slot in the parallel handler
2744
+ const slots = parallelEntry.handler as Record<
2745
+ `@${string}`,
2746
+ | ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)
2747
+ | ReactNode
2748
+ >;
2749
+
2750
+ for (const [slot, handler] of Object.entries(slots)) {
2751
+ const parallelId = `${parallelEntry.shortCode}.${slot}`;
2752
+
2753
+ // Always add to matchedIds
2754
+ matchedIds.push(parallelId);
2755
+
2756
+ const component = await revalidate(
2757
+ async () => {
2758
+ if (!clientSegmentIds.has(parallelId)) return true;
2759
+
2760
+ const dummySegment: ResolvedSegment = {
2761
+ id: parallelId,
2762
+ namespace: parallelEntry.id,
2763
+ type: "parallel",
2764
+ index: 0,
2765
+ component: null as any,
2766
+ params,
2767
+ slot,
2768
+ belongsToRoute,
2769
+ parallelName: `${parallelEntry.id}.${slot}`,
2770
+ };
2771
+
2772
+ // Use parallel's own revalidate functions
2773
+ return await evaluateRevalidation({
2774
+ segment: dummySegment,
2775
+ prevParams,
2776
+ getPrevSegment: null,
2777
+ request,
2778
+ prevUrl,
2779
+ nextUrl,
2780
+ revalidations: parallelEntry.revalidate.map((fn, i) => ({
2781
+ name: `revalidate${i}`,
2782
+ fn,
2783
+ })),
2784
+ routeKey,
2785
+ context,
2786
+ actionContext,
2787
+ stale,
2788
+ });
2789
+ },
2790
+ async () => {
2791
+ // If loading is defined, don't await (stream with Suspense)
2792
+ // Don't track parallel handlers - they shouldn't block handle data
2793
+ if (parallelEntry.loading) {
2794
+ const result =
2795
+ typeof handler === "function" ? handler(context) : handler;
2796
+ return result;
2797
+ }
2798
+ return typeof handler === "function"
2799
+ ? await handler(context)
2800
+ : handler;
2801
+ },
2802
+ () => null
2803
+ );
2804
+
2805
+ segments.push({
2806
+ id: parallelId,
2807
+ namespace: parallelEntry.id,
2808
+ type: "parallel",
2809
+ index: 0,
2810
+ component,
2811
+ loading:
2812
+ parallelEntry.loading === false ? null : parallelEntry.loading,
2813
+ params,
2814
+ slot,
2815
+ belongsToRoute,
2816
+ parallelName: `${parallelEntry.id}.${slot}`,
2817
+ });
2818
+ }
2819
+ }
2820
+
2821
+ // Step 4: Execute orphan handler with revalidation
2822
+ // Always add orphan layout ID to matchedIds
2823
+ matchedIds.push(orphan.shortCode);
2824
+
2825
+ const component = await revalidate(
2826
+ async () => {
2827
+ if (!clientSegmentIds.has(orphan.shortCode)) return true;
2828
+
2829
+ const dummySegment: ResolvedSegment = {
2830
+ id: orphan.shortCode,
2831
+ namespace: orphan.id,
2832
+ type: "layout",
2833
+ index: 0,
2834
+ component: null as any,
2835
+ params,
2836
+ belongsToRoute,
2837
+ layoutName: orphan.id,
2838
+ };
2839
+
2840
+ return await evaluateRevalidation({
2841
+ segment: dummySegment,
2842
+ prevParams,
2843
+ getPrevSegment: null,
2844
+ request,
2845
+ prevUrl,
2846
+ nextUrl,
2847
+ revalidations: orphan.revalidate.map((fn, i) => ({
2848
+ name: `revalidate${i}`,
2849
+ fn,
2850
+ })),
2851
+ routeKey,
2852
+ context,
2853
+ actionContext,
2854
+ stale,
2855
+ });
2856
+ },
2857
+ async () =>
2858
+ typeof orphan.handler === "function"
2859
+ ? await orphan.handler(context)
2860
+ : orphan.handler,
2861
+ () => null
2862
+ );
2863
+
2864
+ segments.push({
2865
+ id: orphan.shortCode,
2866
+ namespace: orphan.id,
2867
+ type: "layout",
2868
+ index: 0,
2869
+ component,
2870
+ params,
2871
+ belongsToRoute,
2872
+ layoutName: orphan.id,
2873
+ loading: orphan.loading === false ? null : orphan.loading,
2874
+ });
2875
+
2876
+ return { segments, matchedIds };
2877
+ }
2878
+
2879
+ /**
2880
+ * Match request and return segments (document/SSR requests)
2881
+ *
2882
+ * Uses generator middleware pipeline for clean separation of concerns:
2883
+ * - cache-lookup: Check cache first
2884
+ * - segment-resolution: Resolve segments on cache miss
2885
+ * - cache-store: Store results in cache
2886
+ * - background-revalidation: SWR revalidation
2887
+ */
2888
+ async function match(request: Request, env: TEnv): Promise<MatchResult> {
2889
+ // Build RouterContext with all closure functions needed by middleware
2890
+ const routerCtx: RouterContext<TEnv> = {
2891
+ findMatch,
2892
+ loadManifest,
2893
+ traverseBack,
2894
+ createHandlerContext,
2895
+ setupLoaderAccess,
2896
+ setupLoaderAccessSilent,
2897
+ getContext,
2898
+ getMetricsStore,
2899
+ createCacheScope,
2900
+ findInterceptForRoute,
2901
+ resolveAllSegmentsWithRevalidation,
2902
+ resolveInterceptEntry,
2903
+ evaluateRevalidation,
2904
+ getRequestContext,
2905
+ resolveAllSegments,
2906
+ createHandleStore,
2907
+ buildEntryRevalidateMap,
2908
+ resolveLoadersOnlyWithRevalidation,
2909
+ resolveInterceptLoadersOnly,
2910
+ resolveLoadersOnly,
2911
+ };
2912
+
2913
+ return runWithRouterContext(routerCtx, async () => {
2914
+ const result = await createMatchContextForFull(request, env);
2915
+
2916
+ // Handle redirect case
2917
+ if ("type" in result && result.type === "redirect") {
2918
+ return {
2919
+ segments: [],
2920
+ matched: [],
2921
+ diff: [],
2922
+ params: {},
2923
+ redirect: result.redirectUrl,
2924
+ };
2925
+ }
2926
+
2927
+ const ctx = result as MatchContext<TEnv>;
2928
+
2929
+ try {
2930
+ const state = createPipelineState();
2931
+ const pipeline = createMatchPartialPipeline(ctx, state);
2932
+ return await collectMatchResult(pipeline, ctx, state);
2933
+ } catch (error) {
2934
+ if (error instanceof Response) throw error;
2935
+ // Report unhandled errors during full match pipeline
2936
+ callOnError(error, "routing", {
2937
+ request,
2938
+ url: ctx.url,
2939
+ env,
2940
+ isPartial: false,
2941
+ handledByBoundary: false,
2942
+ });
2943
+ throw sanitizeError(error);
2944
+ }
2945
+ });
2946
+ }
2947
+
2948
+ /**
2949
+ * Match an error to the nearest error boundary and return error segments
2950
+ *
2951
+ * This method is used when an action or other operation fails and we need
2952
+ * to render the error boundary UI. It finds the nearest errorBoundary in
2953
+ * the route tree and renders it with the error info.
2954
+ *
2955
+ * The returned segments include all segments up to and including the error
2956
+ * boundary, with the error boundary's fallback rendered in place of its
2957
+ * normal outlet content.
2958
+ */
2959
+ async function matchError(
2960
+ request: Request,
2961
+ _context: TEnv,
2962
+ error: unknown,
2963
+ segmentType: ErrorInfo["segmentType"] = "route"
2964
+ ): Promise<MatchResult | null> {
2965
+ const url = new URL(request.url);
2966
+ const pathname = url.pathname;
2967
+
2968
+ console.log(`[Router.matchError] Matching error for ${pathname}`);
2969
+
2970
+ // Find the route match for the current URL
2971
+ const matched = findMatch(pathname);
2972
+ if (!matched) {
2973
+ console.warn(`[Router.matchError] No route matched for ${pathname}`);
2974
+ return null;
2975
+ }
2976
+
2977
+ // Load manifest to get the entry chain
2978
+ const manifestEntry = await loadManifest(
2979
+ matched.entry,
2980
+ matched.routeKey,
2981
+ pathname,
2982
+ undefined, // No metrics for error matching
2983
+ false // Not SSR
2984
+ );
2985
+
2986
+ // Find the nearest error boundary in the entry chain
2987
+ // If none found, use a default "Internal Server Error" fallback
2988
+ const fallback = findNearestErrorBoundary(manifestEntry);
2989
+ const useDefaultFallback = !fallback;
2990
+
2991
+ // Create error info
2992
+ const errorInfo = createErrorInfo(
2993
+ error,
2994
+ manifestEntry.shortCode || "unknown",
2995
+ segmentType
2996
+ );
2997
+
2998
+ // Find which entry has the error boundary
2999
+ // Also checks orphan layouts (siblings) since they can have error boundaries too
3000
+ let entryWithBoundary: EntryData | null = null;
3001
+ let current: EntryData | null = manifestEntry;
3002
+ while (current) {
3003
+ // Check if this entry has an error boundary
3004
+ if (current.errorBoundary && current.errorBoundary.length > 0) {
3005
+ entryWithBoundary = current;
3006
+ break;
3007
+ }
3008
+
3009
+ // Check orphan layouts/cache for error boundaries
3010
+ if (current.layout && current.layout.length > 0) {
3011
+ for (const orphan of current.layout) {
3012
+ if (orphan.errorBoundary && orphan.errorBoundary.length > 0) {
3013
+ entryWithBoundary = orphan;
3014
+ break;
3015
+ }
3016
+ }
3017
+ if (entryWithBoundary) break;
3018
+ }
3019
+
3020
+ current = current.parent;
3021
+ }
3022
+
3023
+ // Determine which entry has the error boundary and which entry should be replaced
3024
+ // The error content renders in the boundary's <Outlet />, not replacing the boundary itself
3025
+ let boundaryEntry: EntryData;
3026
+ let outletEntry: EntryData; // The entry that renders in boundaryEntry's outlet (gets replaced)
3027
+
3028
+ if (entryWithBoundary) {
3029
+ boundaryEntry = entryWithBoundary;
3030
+
3031
+ // Find the entry that renders in boundaryEntry's <Outlet />
3032
+ // Walk from manifestEntry toward boundaryEntry to find the direct outlet child
3033
+ outletEntry = manifestEntry;
3034
+ current = manifestEntry;
3035
+
3036
+ while (current) {
3037
+ // Case 1: current's direct parent is boundaryEntry
3038
+ if (current.parent === boundaryEntry) {
3039
+ outletEntry = current;
3040
+ break;
3041
+ }
3042
+
3043
+ // Case 2: boundaryEntry is an orphan layout of current's parent
3044
+ // In this case, current renders in the orphan's outlet
3045
+ if (current.parent && current.parent.layout) {
3046
+ if (current.parent.layout.includes(boundaryEntry)) {
3047
+ outletEntry = current;
3048
+ break;
3049
+ }
3050
+ }
3051
+
3052
+ current = current.parent;
3053
+ }
3054
+ } else {
3055
+ // No user-defined error boundary - use root layout for the default fallback
3056
+ // Walk up to find the root entry (no parent)
3057
+ let rootEntry = manifestEntry;
3058
+ while (rootEntry.parent) {
3059
+ rootEntry = rootEntry.parent;
3060
+ }
3061
+ boundaryEntry = rootEntry;
3062
+ outletEntry = rootEntry; // For default, replace at root level
3063
+ }
3064
+
3065
+ // Build the matched IDs list: all entries from root to the error boundary (inclusive)
3066
+ // These segments will be fetched from client cache (parent layouts + their loaders)
3067
+ const matchedIds: string[] = [];
3068
+
3069
+ // Walk from error boundary up to root and collect parent IDs
3070
+ current = boundaryEntry;
3071
+ const stack: {
3072
+ shortCode: string;
3073
+ loaderEntries: LoaderEntry[];
3074
+ }[] = [];
3075
+ while (current) {
3076
+ if (current.shortCode) {
3077
+ stack.push({
3078
+ shortCode: current.shortCode,
3079
+ loaderEntries: current.loader || [],
3080
+ });
3081
+ }
3082
+ current = current.parent;
3083
+ }
3084
+ // Reverse to get root-first order and build matchedIds including loaders
3085
+ for (const item of stack.reverse()) {
3086
+ matchedIds.push(item.shortCode);
3087
+ // Add loader segment IDs for this entry
3088
+ for (let i = 0; i < item.loaderEntries.length; i++) {
3089
+ const loaderId = item.loaderEntries[i].loader?.$$id || "unknown";
3090
+ matchedIds.push(`${item.shortCode}D${i}.${loaderId}`);
3091
+ }
3092
+ }
3093
+
3094
+ // Set response status to 500 for error
3095
+ const reqCtx = getRequestContext();
3096
+ if (reqCtx) {
3097
+ reqCtx.res = new Response(null, { status: 500, headers: reqCtx.res.headers });
3098
+ }
3099
+
3100
+ // Create the error segment using user's fallback or default
3101
+ // The error segment uses the outlet entry's ID so it replaces the outlet content
3102
+ // while keeping the boundary layout (and its UI) rendered
3103
+ const effectiveFallback = fallback || DefaultErrorFallback;
3104
+ const errorSegment = createErrorSegment(
3105
+ errorInfo,
3106
+ effectiveFallback,
3107
+ outletEntry, // Use outletEntry so error content renders in the boundary's outlet
3108
+ matched.params
3109
+ );
3110
+
3111
+ if (useDefaultFallback) {
3112
+ console.log(
3113
+ `[Router.matchError] Using default error boundary (no user-defined boundary found)`
3114
+ );
3115
+ }
3116
+
3117
+ console.log(
3118
+ `[Router.matchError] Boundary: ${boundaryEntry.shortCode}, outlet replaced: ${outletEntry.shortCode}`
3119
+ );
3120
+
3121
+ // Error segment replaces the outlet content, not the boundary layout itself
3122
+ // matched contains all IDs from root to boundary (for caching parent layouts)
3123
+ // diff contains the outlet entry ID that is being replaced with error content
3124
+ return {
3125
+ segments: [errorSegment],
3126
+ matched: matchedIds,
3127
+ diff: [errorSegment.id],
3128
+ params: matched.params,
3129
+ };
3130
+ }
3131
+
3132
+ /**
3133
+ * Create match context for full requests (document/SSR)
3134
+ * Simpler than partial - no revalidation, intercepts, or client state tracking
3135
+ *
3136
+ * @returns MatchContext with isFullMatch: true
3137
+ * @throws RouteNotFoundError if no route matches
3138
+ */
3139
+ async function createMatchContextForFull(
3140
+ request: Request,
3141
+ env: TEnv
3142
+ ): Promise<MatchContext<TEnv> | { type: "redirect"; redirectUrl: string }> {
3143
+ const url = new URL(request.url);
3144
+ const pathname = url.pathname;
3145
+
3146
+ // Initialize metrics store for this request
3147
+ const metricsStore = getMetricsStore();
3148
+
3149
+ // Track route matching
3150
+ const routeMatchStart = metricsStore ? performance.now() : 0;
3151
+ const matched = findMatch(pathname);
3152
+ if (metricsStore) {
3153
+ metricsStore.metrics.push({
3154
+ label: "route-matching",
3155
+ duration: performance.now() - routeMatchStart,
3156
+ startTime: routeMatchStart - metricsStore.requestStart,
3157
+ });
3158
+ }
3159
+
3160
+ if (!matched) {
3161
+ throw new RouteNotFoundError(`No route matched for ${pathname}`, {
3162
+ cause: { pathname, method: request.method },
3163
+ });
3164
+ }
3165
+
3166
+ // Handle trailing slash redirect (pattern defines canonical form)
3167
+ if (matched.redirectTo) {
3168
+ return {
3169
+ type: "redirect",
3170
+ redirectUrl: matched.redirectTo + url.search,
3171
+ };
3172
+ }
3173
+
3174
+ // Load manifest with isSSR=true for document requests
3175
+ const manifestStart = metricsStore ? performance.now() : 0;
3176
+ const manifestEntry = await loadManifest(
3177
+ matched.entry,
3178
+ matched.routeKey,
3179
+ pathname,
3180
+ metricsStore,
3181
+ true // isSSR
3182
+ );
3183
+ if (metricsStore) {
3184
+ metricsStore.metrics.push({
3185
+ label: "manifest-loading",
3186
+ duration: performance.now() - manifestStart,
3187
+ startTime: manifestStart - metricsStore.requestStart,
3188
+ });
3189
+ }
3190
+
3191
+ // Collect route-level middleware
3192
+ const routeMiddleware = collectRouteMiddleware(
3193
+ traverseBack(manifestEntry),
3194
+ matched.params
3195
+ );
3196
+
3197
+ // Extract bindings from context
3198
+ const bindings = (env as any)?.Bindings ?? env;
3199
+
3200
+ const handlerContext = createHandlerContext(
3201
+ matched.params,
3202
+ request,
3203
+ url.searchParams,
3204
+ pathname,
3205
+ url,
3206
+ bindings,
3207
+ mergedRouteMap,
3208
+ matched.routeKey
3209
+ );
3210
+
3211
+ // Create request-scoped loader promises map
3212
+ const loaderPromises = new Map<string, Promise<any>>();
3213
+ setupLoaderAccess(handlerContext, loaderPromises);
3214
+
3215
+ // Get store for metrics context
3216
+ const Store = getContext().getOrCreateStore(matched.routeKey);
3217
+ // Add run helper for cleaner middleware code
3218
+ Store.run = <T>(fn: () => T | Promise<T>) =>
3219
+ getContext().runWithStore(
3220
+ Store,
3221
+ Store.namespace || "#router",
3222
+ Store.parent,
3223
+ fn
3224
+ );
3225
+ if (metricsStore) {
3226
+ Store.metrics = metricsStore;
3227
+ }
3228
+
3229
+ // Collect entries and build cache scope
3230
+ const entries = [...traverseBack(manifestEntry)];
3231
+ let cacheScope: CacheScope | null = null;
3232
+ for (const entry of entries) {
3233
+ if (entry.cache) {
3234
+ cacheScope = createCacheScope(entry.cache, cacheScope);
3235
+ }
3236
+ }
3237
+
3238
+ // Full match context - no intercepts, no client state, no revalidation
3239
+ return {
3240
+ request,
3241
+ url,
3242
+ pathname,
3243
+ env,
3244
+ bindings,
3245
+ clientSegmentIds: [],
3246
+ clientSegmentSet: new Set(),
3247
+ stale: false,
3248
+ prevUrl: url, // Same as current for full match
3249
+ prevParams: {},
3250
+ prevMatch: null,
3251
+ matched,
3252
+ manifestEntry,
3253
+ entries,
3254
+ routeKey: matched.routeKey,
3255
+ localRouteName: matched.routeKey.includes(".")
3256
+ ? matched.routeKey.split(".").pop()!
3257
+ : matched.routeKey,
3258
+ handlerContext,
3259
+ loaderPromises,
3260
+ routeMap: mergedRouteMap,
3261
+ metricsStore,
3262
+ Store,
3263
+ interceptContextMatch: null,
3264
+ interceptSelectorContext: {
3265
+ from: url,
3266
+ to: url,
3267
+ params: matched.params,
3268
+ request,
3269
+ env,
3270
+ segments: { path: [], ids: [] },
3271
+ },
3272
+ isSameRouteNavigation: false,
3273
+ interceptResult: null,
3274
+ cacheScope,
3275
+ isIntercept: false,
3276
+ actionContext: undefined,
3277
+ isAction: false,
3278
+ routeMiddleware,
3279
+ isFullMatch: true,
3280
+ };
3281
+ }
3282
+
3283
+ /**
3284
+ * Create match context for partial requests (navigation/actions)
3285
+ * Extracts all setup logic from matchPartial into a reusable context builder
3286
+ *
3287
+ * @returns MatchContext if setup successful, null if should fall back to full render
3288
+ * @throws RouteNotFoundError if no route matches
3289
+ */
3290
+ async function createMatchContextForPartial(
3291
+ request: Request,
3292
+ env: TEnv,
3293
+ actionContext?: ActionContext
3294
+ ): Promise<MatchContext<TEnv> | null> {
3295
+ const url = new URL(request.url);
3296
+ const pathname = url.pathname;
3297
+
3298
+ // Track request start time for duration in onError (local to this request)
3299
+ const requestStartTime = performance.now();
3300
+
3301
+ // Initialize metrics store for this request
3302
+ const metricsStore = getMetricsStore();
3303
+
3304
+ // Extract client state from query params and header
3305
+ const clientSegmentIds =
3306
+ url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
3307
+ const stale = url.searchParams.get("_rsc_stale") === "true";
3308
+ const previousUrl =
3309
+ request.headers.get("X-RSC-Router-Client-Path") ||
3310
+ request.headers.get("Referer");
3311
+ const interceptSourceUrl = request.headers.get(
3312
+ "X-RSC-Router-Intercept-Source"
3313
+ );
3314
+
3315
+ if (!previousUrl) {
3316
+ return null; // Fall back to full render
3317
+ }
3318
+
3319
+ const prevUrl = new URL(previousUrl, url.origin);
3320
+ const interceptContextUrl = interceptSourceUrl
3321
+ ? new URL(interceptSourceUrl, url.origin)
3322
+ : prevUrl;
3323
+
3324
+ // Track route matching
3325
+ const routeMatchStart = metricsStore ? performance.now() : 0;
3326
+ const prevMatch = findMatch(prevUrl.pathname);
3327
+ const prevParams = prevMatch?.params || {};
3328
+ const interceptContextMatch = interceptSourceUrl
3329
+ ? findMatch(interceptContextUrl.pathname)
3330
+ : prevMatch;
3331
+
3332
+ const matched = findMatch(pathname);
3333
+
3334
+ if (metricsStore) {
3335
+ metricsStore.metrics.push({
3336
+ label: "route-matching",
3337
+ duration: performance.now() - routeMatchStart,
3338
+ startTime: routeMatchStart - metricsStore.requestStart,
3339
+ });
3340
+ }
3341
+
3342
+ if (!matched) {
3343
+ throw new RouteNotFoundError(`No route matched for ${pathname}`, {
3344
+ cause: { pathname, method: request.method, previousUrl },
3345
+ });
3346
+ }
3347
+
3348
+ if (matched.redirectTo) {
3349
+ return null; // Fall back to full match for redirects
3350
+ }
3351
+
3352
+ // Check if routes are from different route groups
3353
+ if (prevMatch && prevMatch.entry !== matched.entry) {
3354
+ console.log(
3355
+ `[Router.matchPartial] Route group changed: ${prevMatch.routeKey} → ${matched.routeKey}, falling back to full render`
3356
+ );
3357
+ return null;
3358
+ }
3359
+
3360
+ // Load manifest
3361
+ const manifestStart = metricsStore ? performance.now() : 0;
3362
+ const manifestEntry = await loadManifest(
3363
+ matched.entry,
3364
+ matched.routeKey,
3365
+ pathname,
3366
+ metricsStore,
3367
+ false
3368
+ );
3369
+ if (metricsStore) {
3370
+ metricsStore.metrics.push({
3371
+ label: "manifest-loading",
3372
+ duration: performance.now() - manifestStart,
3373
+ startTime: manifestStart - metricsStore.requestStart,
3374
+ });
3375
+ }
3376
+
3377
+ // Collect route middleware
3378
+ const routeMiddleware = collectRouteMiddleware(
3379
+ traverseBack(manifestEntry),
3380
+ matched.params
3381
+ );
3382
+
3383
+ // Create handler context
3384
+ const bindings = (env as any)?.Bindings ?? env;
3385
+ const handlerContext = createHandlerContext(
3386
+ matched.params,
3387
+ request,
3388
+ url.searchParams,
3389
+ pathname,
3390
+ url,
3391
+ bindings,
3392
+ mergedRouteMap,
3393
+ matched.routeKey
3394
+ );
3395
+
3396
+ const clientSegmentSet = new Set(clientSegmentIds);
3397
+ console.log(
3398
+ `[Router.matchPartial] Client segments:`,
3399
+ Array.from(clientSegmentSet)
3400
+ );
3401
+
3402
+ // Set up loader promises
3403
+ const loaderPromises = new Map<string, Promise<any>>();
3404
+ setupLoaderAccess(handlerContext, loaderPromises);
3405
+
3406
+ // Get store for metrics context
3407
+ const Store = getContext().getOrCreateStore(matched.routeKey);
3408
+ // Add run helper for cleaner middleware code
3409
+ Store.run = <T>(fn: () => T | Promise<T>) =>
3410
+ getContext().runWithStore(
3411
+ Store,
3412
+ Store.namespace || "#router",
3413
+ Store.parent,
3414
+ fn
3415
+ );
3416
+ if (metricsStore) {
3417
+ Store.metrics = metricsStore;
3418
+ }
3419
+
3420
+ // Intercept detection
3421
+ const isSameRouteNavigation = !!(
3422
+ interceptContextMatch &&
3423
+ interceptContextMatch.routeKey === matched.routeKey
3424
+ );
3425
+
3426
+ if (interceptSourceUrl) {
3427
+ console.log(`[Router.matchPartial] Intercept context detected:`);
3428
+ console.log(` - Current URL: ${pathname}`);
3429
+ console.log(` - Intercept source: ${interceptSourceUrl}`);
3430
+ console.log(` - Context match: ${interceptContextMatch?.routeKey}`);
3431
+ console.log(` - Current route: ${matched.routeKey}`);
3432
+ console.log(` - Same route navigation: ${isSameRouteNavigation}`);
3433
+ }
3434
+
3435
+ const localRouteName = matched.routeKey.includes(".")
3436
+ ? matched.routeKey.split(".").pop()!
3437
+ : matched.routeKey;
3438
+
3439
+ // Build intercept selector context
3440
+ const filteredSegmentIds = clientSegmentIds.filter((id) => {
3441
+ if (id.includes(".@")) return false;
3442
+ if (/D\d+\./.test(id)) return false;
3443
+ return true;
3444
+ });
3445
+ const interceptSelectorContext: InterceptSelectorContext = {
3446
+ from: prevUrl,
3447
+ to: url,
3448
+ params: matched.params,
3449
+ request,
3450
+ env,
3451
+ segments: {
3452
+ path: prevUrl.pathname.split("/").filter(Boolean),
3453
+ ids: filteredSegmentIds,
3454
+ },
3455
+ };
3456
+ const isAction = !!actionContext;
3457
+
3458
+ // Find intercept
3459
+ const clientHasInterceptSegments = [...clientSegmentSet].some((id) =>
3460
+ id.includes(".@")
3461
+ );
3462
+ const skipInterceptForAction = isAction && !clientHasInterceptSegments;
3463
+ const interceptResult =
3464
+ isSameRouteNavigation || skipInterceptForAction
3465
+ ? null
3466
+ : findInterceptForRoute(
3467
+ matched.routeKey,
3468
+ manifestEntry.parent,
3469
+ interceptSelectorContext,
3470
+ isAction
3471
+ ) ||
3472
+ (localRouteName !== matched.routeKey
3473
+ ? findInterceptForRoute(
3474
+ localRouteName,
3475
+ manifestEntry.parent,
3476
+ interceptSelectorContext,
3477
+ isAction
3478
+ )
3479
+ : null);
3480
+
3481
+ // When leaving intercept, force route segment to render
3482
+ if (isSameRouteNavigation && manifestEntry.type === "route") {
3483
+ console.log(
3484
+ `[Router.matchPartial] Leaving intercept - forcing route segment render: ${manifestEntry.shortCode}`
3485
+ );
3486
+ clientSegmentSet.delete(manifestEntry.shortCode);
3487
+ }
3488
+
3489
+ // Collect entries and build cache scope
3490
+ const entries = [...traverseBack(manifestEntry)];
3491
+ let cacheScope: CacheScope | null = null;
3492
+ for (const entry of entries) {
3493
+ if (entry.cache) {
3494
+ cacheScope = createCacheScope(entry.cache, cacheScope);
3495
+ }
3496
+ }
3497
+
3498
+ const isIntercept = !!interceptResult;
3499
+
3500
+ return {
3501
+ request,
3502
+ url,
3503
+ pathname,
3504
+ env,
3505
+ bindings,
3506
+ clientSegmentIds,
3507
+ clientSegmentSet,
3508
+ stale,
3509
+ prevUrl,
3510
+ prevParams,
3511
+ prevMatch,
3512
+ matched,
3513
+ manifestEntry,
3514
+ entries,
3515
+ routeKey: matched.routeKey,
3516
+ localRouteName,
3517
+ handlerContext,
3518
+ loaderPromises,
3519
+ routeMap: mergedRouteMap,
3520
+ metricsStore,
3521
+ Store,
3522
+ interceptContextMatch,
3523
+ interceptSelectorContext,
3524
+ isSameRouteNavigation,
3525
+ interceptResult,
3526
+ cacheScope,
3527
+ isIntercept,
3528
+ actionContext,
3529
+ isAction,
3530
+ routeMiddleware,
3531
+ isFullMatch: false,
3532
+ };
3533
+ }
3534
+
3535
+ /**
3536
+ * Match partial request with revalidation
3537
+ *
3538
+ * Uses generator middleware pipeline for clean separation of concerns:
3539
+ * - cache-lookup: Check cache first
3540
+ * - segment-resolution: Resolve segments on cache miss
3541
+ * - intercept-resolution: Handle intercept routes
3542
+ * - cache-store: Store results in cache
3543
+ * - background-revalidation: SWR revalidation
3544
+ */
3545
+ async function matchPartial(
3546
+ request: Request,
3547
+ context: TEnv,
3548
+ actionContext?: ActionContext
3549
+ ): Promise<MatchResult | null> {
3550
+ // Build RouterContext with all closure functions needed by middleware
3551
+ const routerCtx: RouterContext<TEnv> = {
3552
+ findMatch,
3553
+ loadManifest,
3554
+ traverseBack,
3555
+ createHandlerContext,
3556
+ setupLoaderAccess,
3557
+ setupLoaderAccessSilent,
3558
+ getContext,
3559
+ getMetricsStore,
3560
+ createCacheScope,
3561
+ findInterceptForRoute,
3562
+ resolveAllSegmentsWithRevalidation,
3563
+ resolveInterceptEntry,
3564
+ evaluateRevalidation,
3565
+ getRequestContext,
3566
+ resolveAllSegments,
3567
+ createHandleStore,
3568
+ buildEntryRevalidateMap,
3569
+ resolveLoadersOnlyWithRevalidation,
3570
+ resolveInterceptLoadersOnly,
3571
+ };
3572
+
3573
+ return runWithRouterContext(routerCtx, async () => {
3574
+ const ctx = await createMatchContextForPartial(
3575
+ request,
3576
+ context,
3577
+ actionContext
3578
+ );
3579
+ if (!ctx) return null;
3580
+
3581
+ try {
3582
+ const state = createPipelineState();
3583
+ const pipeline = createMatchPartialPipeline(ctx, state);
3584
+ return await collectMatchResult(pipeline, ctx, state);
3585
+ } catch (error) {
3586
+ if (error instanceof Response) throw error;
3587
+ // Report unhandled errors during partial match pipeline
3588
+ callOnError(error, actionContext ? "action" : "revalidation", {
3589
+ request,
3590
+ url: ctx.url,
3591
+ env: context,
3592
+ actionId: actionContext?.actionId,
3593
+ isPartial: true,
3594
+ handledByBoundary: false,
3595
+ });
3596
+ throw sanitizeError(error);
3597
+ }
3598
+ });
3599
+ }
3600
+
3601
+ /**
3602
+ * Preview match - returns route middleware without segment resolution
3603
+ * Used by RSC handler to execute route middleware before full matching
3604
+ */
3605
+ async function previewMatch(
3606
+ request: Request,
3607
+ context: TEnv
3608
+ ): Promise<{
3609
+ routeMiddleware?: Array<{
3610
+ handler: import("./router/middleware.js").MiddlewareFn;
3611
+ params: Record<string, string>;
3612
+ }>;
3613
+ } | null> {
3614
+ const url = new URL(request.url);
3615
+ const pathname = url.pathname;
3616
+
3617
+ // Quick route matching
3618
+ const matched = findMatch(pathname);
3619
+ if (!matched) {
3620
+ return null;
3621
+ }
3622
+
3623
+ // Skip redirect check - will be handled in full match
3624
+ if (matched.redirectTo) {
3625
+ return { routeMiddleware: undefined };
3626
+ }
3627
+
3628
+ // Load manifest (without segment resolution)
3629
+ const manifestEntry = await loadManifest(
3630
+ matched.entry,
3631
+ matched.routeKey,
3632
+ pathname,
3633
+ undefined, // No metrics store for preview
3634
+ false // isSSR - doesn't matter for preview
3635
+ );
3636
+
3637
+ // Collect route-level middleware from entry tree
3638
+ // Includes middleware from orphan layouts (inline layouts within routes)
3639
+ const routeMiddleware = collectRouteMiddleware(
3640
+ traverseBack(manifestEntry),
3641
+ matched.params
3642
+ );
3643
+
3644
+ return {
3645
+ routeMiddleware: routeMiddleware.length > 0 ? routeMiddleware : undefined,
3646
+ };
3647
+ }
3648
+
3649
+ /**
3650
+ * Create route builder with accumulated route types
3651
+ * The TNewRoutes type parameter captures the new routes being added
3652
+ */
3653
+ function createRouteBuilder<TNewRoutes extends Record<string, string>>(
3654
+ prefix: string,
3655
+ routes: TNewRoutes
3656
+ ): RouteBuilder<RouteDefinition, TEnv, any, TNewRoutes> {
3657
+ const currentMountIndex = mountIndex++;
3658
+
3659
+ // Merge routes into the href map
3660
+ // Keys stay unchanged for composability - only URL patterns get prefixed
3661
+ const routeEntries = routes as Record<string, string>;
3662
+ for (const [key, pattern] of Object.entries(routeEntries)) {
3663
+ // Build prefixed pattern: "/shop" + "/cart" -> "/shop/cart"
3664
+ const prefixedPattern =
3665
+ prefix && pattern !== "/"
3666
+ ? `${prefix}${pattern}`
3667
+ : prefix && pattern === "/"
3668
+ ? prefix
3669
+ : pattern;
3670
+
3671
+ // Runtime validation: warn if key already exists with different pattern
3672
+ const existingPattern = mergedRouteMap[key];
3673
+ if (existingPattern !== undefined && existingPattern !== prefixedPattern) {
3674
+ console.warn(
3675
+ `[rsc-router] Route key conflict: "${key}" already maps to "${existingPattern}", ` +
3676
+ `overwriting with "${prefixedPattern}". Use unique key names to avoid this.`
3677
+ );
3678
+ }
3679
+
3680
+ // Use original key - enables reusable route modules
3681
+ mergedRouteMap[key] = prefixedPattern;
3682
+ }
3683
+
3684
+ // Auto-register route map for runtime href() usage
3685
+ registerRouteMap(mergedRouteMap);
3686
+
3687
+ // Extract trailing slash config if present (attached by route())
3688
+ const trailingSlashConfig = (routes as any).__trailingSlash as
3689
+ | Record<string, TrailingSlashMode>
3690
+ | undefined;
3691
+
3692
+ // Create builder object so .use() can return it
3693
+ const builder: RouteBuilder<RouteDefinition, TEnv, any, TNewRoutes> = {
3694
+ use(
3695
+ patternOrMiddleware: string | MiddlewareFn<TEnv>,
3696
+ middleware?: MiddlewareFn<TEnv>
3697
+ ) {
3698
+ // Mount-scoped middleware - prefix is the mount prefix
3699
+ addMiddleware(patternOrMiddleware, middleware, prefix || null);
3700
+ return builder;
3701
+ },
3702
+
3703
+ map(
3704
+ handler:
3705
+ | ((helpers: InlineRouteHelpers<TNewRoutes, TEnv>) => Array<AllUseItems>)
3706
+ | (() =>
3707
+ | Array<AllUseItems>
3708
+ | Promise<{ default: () => Array<AllUseItems> }>
3709
+ | Promise<() => Array<AllUseItems>>)
3710
+ ) {
3711
+ // Store handler as-is - detection happens at call time based on return type
3712
+ // Both patterns use the same signature:
3713
+ // - Inline: ({ route }) => [...] - receives helpers, returns Array
3714
+ // - Lazy: () => import(...) - ignores helpers, returns Promise
3715
+ routesEntries.push({
3716
+ prefix,
3717
+ routes: routes as ResolvedRouteMap<any>,
3718
+ trailingSlash: trailingSlashConfig,
3719
+ handler: handler as any,
3720
+ mountIndex: currentMountIndex,
3721
+ });
3722
+ // Return router with accumulated types
3723
+ // At runtime this is the same object, but TypeScript tracks the accumulated route types
3724
+ return router as any;
3725
+ },
3726
+
3727
+ // Expose accumulated route map for typeof extraction
3728
+ get routeMap() {
3729
+ return mergedRouteMap as TNewRoutes;
3730
+ },
3731
+ };
3732
+
3733
+ return builder;
3734
+ }
3735
+
3736
+ /**
3737
+ * Router instance
3738
+ * The type system tracks accumulated routes through the builder chain
3739
+ * Initial TRoutes is {} (empty) to avoid poisoning accumulated types with Record<string, string>
3740
+ */
3741
+ const router: RSCRouter<TEnv, {}> = {
3742
+ routes(
3743
+ prefixOrRoutes: string | Record<string, string> | UrlPatterns<TEnv>,
3744
+ maybeRoutes?: Record<string, string>
3745
+ ): any {
3746
+ // Note: Multiple .routes() calls are allowed for backwards compatibility
3747
+ // with the old map() pattern. For new code, prefer urls() with include().
3748
+
3749
+ // Check if argument is UrlPatterns (new Django-style API)
3750
+ // Detect by checking for handler and definitions properties
3751
+ if (
3752
+ typeof prefixOrRoutes === "object" &&
3753
+ prefixOrRoutes !== null &&
3754
+ "handler" in prefixOrRoutes &&
3755
+ "definitions" in prefixOrRoutes &&
3756
+ typeof (prefixOrRoutes as UrlPatterns<TEnv>).handler === "function"
3757
+ ) {
3758
+ const urlPatterns = prefixOrRoutes as UrlPatterns<TEnv>;
3759
+ const currentMountIndex = mountIndex++;
3760
+
3761
+ // Create manifest and patterns maps for route registration
3762
+ const manifest = new Map<string, EntryData>();
3763
+ const patterns = new Map<string, string>();
3764
+ const trailingSlashMap = new Map<string, TrailingSlashMode>();
3765
+
3766
+ // Run the handler once to extract patterns for route matching
3767
+ // Note: loadManifest will re-run the handler to register entries in its context
3768
+ RSCRouterContext.run(
3769
+ {
3770
+ manifest,
3771
+ patterns,
3772
+ trailingSlash: trailingSlashMap,
3773
+ namespace: "root",
3774
+ parent: null,
3775
+ counters: {},
3776
+ },
3777
+ () => {
3778
+ // Execute the handler to collect patterns
3779
+ urlPatterns.handler();
3780
+ }
3781
+ );
3782
+
3783
+ // Build routes object from registered patterns (for route matching)
3784
+ const routesObject: Record<string, string> = {};
3785
+ for (const [name, pattern] of patterns.entries()) {
3786
+ routesObject[name] = pattern;
3787
+ }
3788
+
3789
+ // Store the ORIGINAL handler - loadManifest will re-run it to register manifest entries
3790
+ // Convert trailingSlash map to object for the router
3791
+ const trailingSlashConfig = trailingSlashMap.size > 0
3792
+ ? Object.fromEntries(trailingSlashMap)
3793
+ : undefined;
3794
+
3795
+ routesEntries.push({
3796
+ prefix: "",
3797
+ routes: routesObject as ResolvedRouteMap<any>,
3798
+ trailingSlash: trailingSlashConfig,
3799
+ handler: urlPatterns.handler,
3800
+ mountIndex: currentMountIndex,
3801
+ });
3802
+
3803
+ // Build route map from registered patterns
3804
+ for (const [name, pattern] of patterns.entries()) {
3805
+ // Runtime validation: warn if key already exists with different pattern
3806
+ const existingPattern = mergedRouteMap[name];
3807
+ if (existingPattern !== undefined && existingPattern !== pattern) {
3808
+ console.warn(
3809
+ `[@rangojs/router] Route name conflict: "${name}" already maps to "${existingPattern}", ` +
3810
+ `overwriting with "${pattern}". Use unique route names to avoid this.`
3811
+ );
3812
+ }
3813
+ mergedRouteMap[name] = pattern;
3814
+ }
3815
+
3816
+ // Auto-register route map for runtime href() usage
3817
+ registerRouteMap(mergedRouteMap);
3818
+
3819
+ // Return the router (no .map() needed for UrlPatterns)
3820
+ return router;
3821
+ }
3822
+
3823
+ // Legacy API: route() + map() pattern
3824
+ // If second argument exists, first is prefix
3825
+ if (maybeRoutes !== undefined) {
3826
+ return createRouteBuilder(prefixOrRoutes as string, maybeRoutes);
3827
+ }
3828
+ // Otherwise, first argument is routes with empty prefix
3829
+ return createRouteBuilder("", prefixOrRoutes as Record<string, string>);
3830
+ },
3831
+
3832
+ use(
3833
+ patternOrMiddleware: string | MiddlewareFn<TEnv>,
3834
+ middleware?: MiddlewareFn<TEnv>
3835
+ ): any {
3836
+ // Global middleware - no mount prefix
3837
+ addMiddleware(patternOrMiddleware, middleware, null);
3838
+ return router;
3839
+ },
3840
+
3841
+ // Type-safe URL builder using merged route map
3842
+ // Types are tracked through the builder chain via TRoutes parameter
3843
+ href: createHref(mergedRouteMap),
3844
+
3845
+ // Expose accumulated route map for typeof extraction
3846
+ // Returns {} initially, but builder chain accumulates specific route types
3847
+ get routeMap() {
3848
+ return mergedRouteMap as {};
3849
+ },
3850
+
3851
+ // Expose rootLayout for renderSegments
3852
+ rootLayout,
3853
+
3854
+ // Expose onError callback for error handling
3855
+ onError,
3856
+
3857
+ // Expose cache configuration for RSC handler
3858
+ cache,
3859
+
3860
+ // Expose notFound component for RSC handler
3861
+ notFound,
3862
+
3863
+ // Expose resolved theme configuration for NavigationProvider and MetaTags
3864
+ themeConfig: resolvedThemeConfig,
3865
+
3866
+ // Expose global middleware for RSC handler
3867
+ middleware: globalMiddleware,
3868
+
3869
+ match,
3870
+ matchPartial,
3871
+ matchError,
3872
+ previewMatch,
3873
+ };
3874
+
3875
+ return router;
3876
+ }