@ivogt/rsc-router 0.0.0-experimental.1

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