@rangojs/router 0.0.0-experimental.3 → 0.0.0-experimental.30

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