@rangojs/router 0.0.0-experimental.7 → 0.0.0-experimental.788796d8

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 (300) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +884 -4
  3. package/dist/bin/rango.js +1606 -0
  4. package/dist/vite/index.js +4488 -938
  5. package/package.json +69 -59
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +262 -0
  8. package/skills/caching/SKILL.md +85 -23
  9. package/skills/composability/SKILL.md +172 -0
  10. package/skills/debug-manifest/SKILL.md +12 -8
  11. package/skills/document-cache/SKILL.md +18 -16
  12. package/skills/fonts/SKILL.md +167 -0
  13. package/skills/hooks/SKILL.md +334 -72
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +131 -8
  16. package/skills/layout/SKILL.md +100 -3
  17. package/skills/links/SKILL.md +89 -30
  18. package/skills/loader/SKILL.md +388 -38
  19. package/skills/middleware/SKILL.md +171 -34
  20. package/skills/mime-routes/SKILL.md +128 -0
  21. package/skills/parallel/SKILL.md +137 -1
  22. package/skills/prerender/SKILL.md +643 -0
  23. package/skills/rango/SKILL.md +85 -16
  24. package/skills/response-routes/SKILL.md +411 -0
  25. package/skills/route/SKILL.md +226 -14
  26. package/skills/router-setup/SKILL.md +123 -30
  27. package/skills/tailwind/SKILL.md +129 -0
  28. package/skills/theme/SKILL.md +9 -8
  29. package/skills/typesafety/SKILL.md +318 -89
  30. package/skills/use-cache/SKILL.md +324 -0
  31. package/src/__internal.ts +102 -4
  32. package/src/bin/rango.ts +321 -0
  33. package/src/browser/action-coordinator.ts +97 -0
  34. package/src/browser/action-response-classifier.ts +99 -0
  35. package/src/browser/event-controller.ts +92 -64
  36. package/src/browser/history-state.ts +80 -0
  37. package/src/browser/intercept-utils.ts +52 -0
  38. package/src/browser/link-interceptor.ts +24 -4
  39. package/src/browser/logging.ts +55 -0
  40. package/src/browser/merge-segment-loaders.ts +20 -12
  41. package/src/browser/navigation-bridge.ts +282 -557
  42. package/src/browser/navigation-client.ts +157 -71
  43. package/src/browser/navigation-store.ts +33 -50
  44. package/src/browser/navigation-transaction.ts +297 -0
  45. package/src/browser/network-error-handler.ts +61 -0
  46. package/src/browser/partial-update.ts +303 -310
  47. package/src/browser/prefetch/cache.ts +206 -0
  48. package/src/browser/prefetch/fetch.ts +144 -0
  49. package/src/browser/prefetch/observer.ts +65 -0
  50. package/src/browser/prefetch/policy.ts +48 -0
  51. package/src/browser/prefetch/queue.ts +128 -0
  52. package/src/browser/rango-state.ts +112 -0
  53. package/src/browser/react/Link.tsx +193 -73
  54. package/src/browser/react/NavigationProvider.tsx +78 -11
  55. package/src/browser/react/context.ts +6 -0
  56. package/src/browser/react/filter-segment-order.ts +11 -0
  57. package/src/browser/react/index.ts +12 -12
  58. package/src/browser/react/location-state-shared.ts +95 -53
  59. package/src/browser/react/location-state.ts +60 -15
  60. package/src/browser/react/mount-context.ts +6 -1
  61. package/src/browser/react/nonce-context.ts +23 -0
  62. package/src/browser/react/shallow-equal.ts +27 -0
  63. package/src/browser/react/use-action.ts +29 -51
  64. package/src/browser/react/use-client-cache.ts +5 -3
  65. package/src/browser/react/use-handle.ts +32 -79
  66. package/src/browser/react/use-href.tsx +2 -2
  67. package/src/browser/react/use-link-status.ts +6 -5
  68. package/src/browser/react/use-navigation.ts +22 -63
  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 +188 -55
  76. package/src/browser/scroll-restoration.ts +117 -44
  77. package/src/browser/segment-reconciler.ts +221 -0
  78. package/src/browser/segment-structure-assert.ts +16 -0
  79. package/src/browser/server-action-bridge.ts +504 -599
  80. package/src/browser/shallow.ts +6 -1
  81. package/src/browser/types.ts +118 -47
  82. package/src/browser/validate-redirect-origin.ts +29 -0
  83. package/src/build/generate-manifest.ts +235 -24
  84. package/src/build/generate-route-types.ts +36 -0
  85. package/src/build/index.ts +13 -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 +479 -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 +342 -0
  100. package/src/cache/cache-scope.ts +122 -305
  101. package/src/cache/cf/cf-cache-store.ts +571 -17
  102. package/src/cache/cf/index.ts +13 -3
  103. package/src/cache/document-cache.ts +116 -77
  104. package/src/cache/handle-capture.ts +81 -0
  105. package/src/cache/handle-snapshot.ts +41 -0
  106. package/src/cache/index.ts +1 -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 +3 -1
  114. package/src/client.tsx +106 -126
  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 +19 -9
  119. package/src/errors.ts +108 -2
  120. package/src/handle.ts +15 -29
  121. package/src/handles/MetaTags.tsx +73 -20
  122. package/src/handles/breadcrumbs.ts +66 -0
  123. package/src/handles/index.ts +1 -0
  124. package/src/handles/meta.ts +30 -13
  125. package/src/host/cookie-handler.ts +21 -15
  126. package/src/host/errors.ts +8 -8
  127. package/src/host/index.ts +4 -7
  128. package/src/host/pattern-matcher.ts +27 -27
  129. package/src/host/router.ts +61 -39
  130. package/src/host/testing.ts +8 -8
  131. package/src/host/types.ts +15 -7
  132. package/src/host/utils.ts +1 -1
  133. package/src/href-client.ts +119 -29
  134. package/src/index.rsc.ts +153 -19
  135. package/src/index.ts +211 -30
  136. package/src/internal-debug.ts +11 -0
  137. package/src/loader.rsc.ts +26 -157
  138. package/src/loader.ts +27 -10
  139. package/src/network-error-thrower.tsx +3 -1
  140. package/src/outlet-provider.tsx +45 -0
  141. package/src/prerender/param-hash.ts +37 -0
  142. package/src/prerender/store.ts +185 -0
  143. package/src/prerender.ts +463 -0
  144. package/src/reverse.ts +330 -0
  145. package/src/root-error-boundary.tsx +41 -29
  146. package/src/route-content-wrapper.tsx +7 -4
  147. package/src/route-definition/dsl-helpers.ts +959 -0
  148. package/src/route-definition/helper-factories.ts +200 -0
  149. package/src/route-definition/helpers-types.ts +430 -0
  150. package/src/route-definition/index.ts +52 -0
  151. package/src/route-definition/redirect.ts +93 -0
  152. package/src/route-definition.ts +1 -1428
  153. package/src/route-map-builder.ts +217 -123
  154. package/src/route-name.ts +53 -0
  155. package/src/route-types.ts +59 -8
  156. package/src/router/content-negotiation.ts +116 -0
  157. package/src/router/debug-manifest.ts +72 -0
  158. package/src/router/error-handling.ts +9 -9
  159. package/src/router/find-match.ts +160 -0
  160. package/src/router/handler-context.ts +374 -81
  161. package/src/router/intercept-resolution.ts +397 -0
  162. package/src/router/lazy-includes.ts +237 -0
  163. package/src/router/loader-resolution.ts +215 -122
  164. package/src/router/logging.ts +251 -0
  165. package/src/router/manifest.ts +154 -35
  166. package/src/router/match-api.ts +620 -0
  167. package/src/router/match-context.ts +5 -3
  168. package/src/router/match-handlers.ts +440 -0
  169. package/src/router/match-middleware/background-revalidation.ts +108 -93
  170. package/src/router/match-middleware/cache-lookup.ts +433 -10
  171. package/src/router/match-middleware/cache-store.ts +86 -23
  172. package/src/router/match-middleware/intercept-resolution.ts +57 -17
  173. package/src/router/match-middleware/segment-resolution.ts +27 -6
  174. package/src/router/match-pipelines.ts +10 -45
  175. package/src/router/match-result.ts +55 -33
  176. package/src/router/metrics.ts +240 -15
  177. package/src/router/middleware-cookies.ts +55 -0
  178. package/src/router/middleware-types.ts +222 -0
  179. package/src/router/middleware.ts +327 -369
  180. package/src/router/pattern-matching.ts +211 -43
  181. package/src/router/prerender-match.ts +402 -0
  182. package/src/router/preview-match.ts +170 -0
  183. package/src/router/revalidation.ts +137 -38
  184. package/src/router/router-context.ts +41 -21
  185. package/src/router/router-interfaces.ts +452 -0
  186. package/src/router/router-options.ts +592 -0
  187. package/src/router/router-registry.ts +24 -0
  188. package/src/router/segment-resolution/fresh.ts +677 -0
  189. package/src/router/segment-resolution/helpers.ts +263 -0
  190. package/src/router/segment-resolution/loader-cache.ts +199 -0
  191. package/src/router/segment-resolution/revalidation.ts +1296 -0
  192. package/src/router/segment-resolution/static-store.ts +67 -0
  193. package/src/router/segment-resolution.ts +21 -0
  194. package/src/router/segment-wrappers.ts +291 -0
  195. package/src/router/telemetry-otel.ts +299 -0
  196. package/src/router/telemetry.ts +300 -0
  197. package/src/router/timeout.ts +148 -0
  198. package/src/router/trie-matching.ts +239 -0
  199. package/src/router/types.ts +77 -3
  200. package/src/router.ts +696 -4257
  201. package/src/rsc/handler-context.ts +45 -0
  202. package/src/rsc/handler.ts +764 -754
  203. package/src/rsc/helpers.ts +140 -6
  204. package/src/rsc/index.ts +0 -20
  205. package/src/rsc/loader-fetch.ts +209 -0
  206. package/src/rsc/manifest-init.ts +86 -0
  207. package/src/rsc/nonce.ts +14 -0
  208. package/src/rsc/origin-guard.ts +141 -0
  209. package/src/rsc/progressive-enhancement.ts +379 -0
  210. package/src/rsc/response-error.ts +37 -0
  211. package/src/rsc/response-route-handler.ts +347 -0
  212. package/src/rsc/rsc-rendering.ts +237 -0
  213. package/src/rsc/runtime-warnings.ts +42 -0
  214. package/src/rsc/server-action.ts +348 -0
  215. package/src/rsc/ssr-setup.ts +128 -0
  216. package/src/rsc/types.ts +38 -11
  217. package/src/search-params.ts +230 -0
  218. package/src/segment-system.tsx +165 -17
  219. package/src/server/context.ts +266 -58
  220. package/src/server/cookie-store.ts +190 -0
  221. package/src/server/fetchable-loader-store.ts +37 -0
  222. package/src/server/handle-store.ts +94 -15
  223. package/src/server/loader-registry.ts +15 -56
  224. package/src/server/request-context.ts +439 -73
  225. package/src/server.ts +35 -130
  226. package/src/ssr/index.tsx +101 -31
  227. package/src/static-handler.ts +114 -0
  228. package/src/theme/ThemeProvider.tsx +21 -15
  229. package/src/theme/ThemeScript.tsx +5 -5
  230. package/src/theme/constants.ts +5 -2
  231. package/src/theme/index.ts +4 -14
  232. package/src/theme/theme-context.ts +4 -30
  233. package/src/theme/theme-script.ts +21 -18
  234. package/src/types/boundaries.ts +158 -0
  235. package/src/types/cache-types.ts +198 -0
  236. package/src/types/error-types.ts +192 -0
  237. package/src/types/global-namespace.ts +100 -0
  238. package/src/types/handler-context.ts +773 -0
  239. package/src/types/index.ts +88 -0
  240. package/src/types/loader-types.ts +183 -0
  241. package/src/types/route-config.ts +170 -0
  242. package/src/types/route-entry.ts +109 -0
  243. package/src/types/segments.ts +150 -0
  244. package/src/types.ts +1 -1623
  245. package/src/urls/include-helper.ts +197 -0
  246. package/src/urls/index.ts +53 -0
  247. package/src/urls/path-helper-types.ts +339 -0
  248. package/src/urls/path-helper.ts +329 -0
  249. package/src/urls/pattern-types.ts +95 -0
  250. package/src/urls/response-types.ts +106 -0
  251. package/src/urls/type-extraction.ts +372 -0
  252. package/src/urls/urls-function.ts +98 -0
  253. package/src/urls.ts +1 -802
  254. package/src/use-loader.tsx +85 -77
  255. package/src/vite/discovery/bundle-postprocess.ts +184 -0
  256. package/src/vite/discovery/discover-routers.ts +344 -0
  257. package/src/vite/discovery/prerender-collection.ts +385 -0
  258. package/src/vite/discovery/route-types-writer.ts +258 -0
  259. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  260. package/src/vite/discovery/state.ts +108 -0
  261. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  262. package/src/vite/index.ts +11 -1129
  263. package/src/vite/plugin-types.ts +48 -0
  264. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  265. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  266. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  267. package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +72 -53
  268. package/src/vite/plugins/expose-id-utils.ts +287 -0
  269. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  270. package/src/vite/plugins/expose-ids/handler-transform.ts +179 -0
  271. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  272. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  273. package/src/vite/plugins/expose-ids/types.ts +45 -0
  274. package/src/vite/plugins/expose-internal-ids.ts +569 -0
  275. package/src/vite/plugins/refresh-cmd.ts +65 -0
  276. package/src/vite/plugins/use-cache-transform.ts +323 -0
  277. package/src/vite/plugins/version-injector.ts +83 -0
  278. package/src/vite/plugins/version-plugin.ts +266 -0
  279. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
  280. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  281. package/src/vite/rango.ts +445 -0
  282. package/src/vite/router-discovery.ts +777 -0
  283. package/src/vite/utils/ast-handler-extract.ts +517 -0
  284. package/src/vite/utils/banner.ts +36 -0
  285. package/src/vite/utils/bundle-analysis.ts +137 -0
  286. package/src/vite/utils/manifest-utils.ts +70 -0
  287. package/src/vite/{package-resolution.ts → utils/package-resolution.ts} +25 -29
  288. package/src/vite/utils/prerender-utils.ts +189 -0
  289. package/src/vite/utils/shared-utils.ts +169 -0
  290. package/CLAUDE.md +0 -43
  291. package/src/browser/lru-cache.ts +0 -69
  292. package/src/browser/request-controller.ts +0 -164
  293. package/src/cache/memory-store.ts +0 -253
  294. package/src/href-context.ts +0 -33
  295. package/src/href.ts +0 -255
  296. package/src/server/route-manifest-cache.ts +0 -173
  297. package/src/vite/expose-handle-id.ts +0 -209
  298. package/src/vite/expose-loader-id.ts +0 -426
  299. package/src/vite/expose-location-state-id.ts +0 -177
  300. /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
@@ -104,6 +104,8 @@ import type { ResolvedSegment } from "../../types.js";
104
104
  import { getRequestContext } from "../../server/request-context.js";
105
105
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
106
106
  import { getRouterContext } from "../router-context.js";
107
+ import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
108
+ import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
107
109
  import type { GeneratorMiddleware } from "./cache-lookup.js";
108
110
 
109
111
  /**
@@ -119,6 +121,8 @@ export function withCacheStore<TEnv>(
119
121
  return async function* (
120
122
  source: AsyncGenerator<ResolvedSegment>,
121
123
  ): AsyncGenerator<ResolvedSegment> {
124
+ const ms = ctx.metricsStore;
125
+
122
126
  // Collect all segments while passing them through
123
127
  const allSegments: ResolvedSegment[] = [];
124
128
  for await (const segment of source) {
@@ -126,6 +130,9 @@ export function withCacheStore<TEnv>(
126
130
  yield segment;
127
131
  }
128
132
 
133
+ // Measure own work only (after source iteration completes)
134
+ const ownStart = performance.now();
135
+
129
136
  // Skip caching if:
130
137
  // 1. Cache miss but cache scope is disabled
131
138
  // 2. This is an action (actions don't cache)
@@ -137,14 +144,22 @@ export function withCacheStore<TEnv>(
137
144
  state.cacheHit ||
138
145
  ctx.request.method !== "GET"
139
146
  ) {
147
+ if (ms) {
148
+ ms.metrics.push({
149
+ label: "pipeline:cache-store",
150
+ duration: performance.now() - ownStart,
151
+ startTime: ownStart - ms.requestStart,
152
+ });
153
+ }
140
154
  return;
141
155
  }
142
156
 
143
157
  const {
144
158
  createHandlerContext,
145
- setupLoaderAccessSilent,
159
+ setupLoaderAccess,
146
160
  resolveAllSegments,
147
161
  resolveInterceptEntry,
162
+ createHandleStore,
148
163
  } = getRouterContext<TEnv>();
149
164
 
150
165
  // Combine main segments with intercept segments
@@ -160,15 +175,19 @@ export function withCacheStore<TEnv>(
160
175
  if (!requestCtx) return;
161
176
 
162
177
  const cacheScope = ctx.cacheScope;
178
+ const reqId = INTERNAL_RANGO_DEBUG
179
+ ? getOrCreateRequestId(ctx.request)
180
+ : undefined;
163
181
 
164
182
  // Register onResponse callback to skip caching for non-200 responses
165
183
  // Note: error/notFound status codes are set elsewhere (not caching-specific)
166
184
  requestCtx.onResponse((response) => {
167
185
  // Only cache successful responses
168
186
  if (response.status !== 200) {
169
- console.log(
170
- `[CacheStore] Skipping cache: non-200 status ${response.status} for ${ctx.pathname}`,
171
- );
187
+ debugLog("cacheStore", "skipping cache for non-200 response", {
188
+ status: response.status,
189
+ pathname: ctx.pathname,
190
+ });
172
191
  return response;
173
192
  }
174
193
 
@@ -176,31 +195,43 @@ export function withCacheStore<TEnv>(
176
195
  // Proactive caching: render all segments fresh in background
177
196
  // This ensures cache has complete components for future requests
178
197
  requestCtx.waitUntil(async () => {
179
- console.log(
180
- `[Router.matchPartial] Proactive caching: ${ctx.pathname} (rendering null-component segments)`,
181
- );
198
+ // Prevent background metrics from polluting foreground timeline.
199
+ const savedMetrics = ctx.Store.metrics;
200
+ ctx.Store.metrics = undefined;
201
+
202
+ const start = performance.now();
203
+ debugLog("cacheStore", "proactive caching started", {
204
+ pathname: ctx.pathname,
205
+ });
206
+ // Swap to a fresh HandleStore so handle.push() calls from
207
+ // proactive resolution are captured (not silenced). The original
208
+ // store's stream is already sent by waitUntil time.
209
+ // cacheRoute reads from requestCtx._handleStore, so this ensures
210
+ // complete handle data (e.g. breadcrumbs) is cached.
211
+ const originalHandleStore = requestCtx._handleStore;
212
+ requestCtx._handleStore = createHandleStore();
182
213
  try {
183
214
  // Create fresh context for proactive caching
184
- // This prevents handle data from polluting the response stream
185
215
  const proactiveHandlerContext = createHandlerContext(
186
216
  ctx.matched.params,
187
217
  ctx.request,
188
218
  ctx.url.searchParams,
189
219
  ctx.pathname,
190
220
  ctx.url,
191
- ctx.bindings,
221
+ ctx.env,
192
222
  ctx.routeMap,
193
- ctx.matched.routeKey
223
+ ctx.matched.routeKey,
224
+ ctx.matched.responseType,
225
+ ctx.matched.pt === true,
194
226
  );
195
227
  const proactiveLoaderPromises = new Map<string, Promise<any>>();
196
228
 
197
- // Set up loader access that ignores handle pushes
198
- setupLoaderAccessSilent(
199
- proactiveHandlerContext,
200
- proactiveLoaderPromises,
201
- );
229
+ // Use normal loader access so handle data is captured
230
+ setupLoaderAccess(proactiveHandlerContext, proactiveLoaderPromises);
202
231
 
203
- // Re-resolve ALL segments without revalidation
232
+ // Re-resolve ALL segments without revalidation.
233
+ // Skip DSL loaders — they are never cached (cacheRoute filters them)
234
+ // and are always resolved fresh on each request.
204
235
  const Store = ctx.Store;
205
236
  const freshSegments = await Store.run(() =>
206
237
  resolveAllSegments(
@@ -209,6 +240,7 @@ export function withCacheStore<TEnv>(
209
240
  ctx.matched.params,
210
241
  proactiveHandlerContext,
211
242
  proactiveLoaderPromises,
243
+ { skipLoaders: true },
212
244
  ),
213
245
  );
214
246
 
@@ -231,36 +263,67 @@ export function withCacheStore<TEnv>(
231
263
  ...freshSegments,
232
264
  ...freshInterceptSegments,
233
265
  ];
266
+ requestCtx._handleStore.seal();
234
267
  await cacheScope.cacheRoute(
235
268
  ctx.pathname,
236
269
  ctx.matched.params,
237
270
  completeSegments,
238
271
  ctx.isIntercept,
239
272
  );
240
- console.log(
241
- `[Router.matchPartial] Proactive caching complete: ${ctx.pathname}`,
242
- );
273
+ if (INTERNAL_RANGO_DEBUG) {
274
+ const dur = performance.now() - start;
275
+ console.log(
276
+ `[RSC Background][req:${reqId}] Proactive cache ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${completeSegments.length}`,
277
+ );
278
+ }
279
+ debugLog("cacheStore", "proactive caching complete", {
280
+ pathname: ctx.pathname,
281
+ });
243
282
  } catch (error) {
244
- console.error(
245
- `[Router.matchPartial] Proactive caching failed:`,
246
- error,
247
- );
283
+ if (INTERNAL_RANGO_DEBUG) {
284
+ const dur = performance.now() - start;
285
+ console.log(
286
+ `[RSC Background][req:${reqId}] Proactive cache ${ctx.pathname} FAILED (${dur.toFixed(2)}ms) error=${String(error)}`,
287
+ );
288
+ }
289
+ debugWarn("cacheStore", "proactive caching failed", {
290
+ pathname: ctx.pathname,
291
+ error: String(error),
292
+ });
293
+ } finally {
294
+ requestCtx._handleStore = originalHandleStore;
295
+ ctx.Store.metrics = savedMetrics;
248
296
  }
249
297
  });
250
298
  } else {
251
299
  // All segments have components - cache directly
252
300
  // Schedule caching in waitUntil since cacheRoute is now async (key resolution)
253
301
  requestCtx.waitUntil(async () => {
302
+ const start = performance.now();
254
303
  await cacheScope.cacheRoute(
255
304
  ctx.pathname,
256
305
  ctx.matched.params,
257
306
  allSegmentsToCache,
258
307
  ctx.isIntercept,
259
308
  );
309
+ if (INTERNAL_RANGO_DEBUG) {
310
+ const dur = performance.now() - start;
311
+ console.log(
312
+ `[RSC Background][req:${reqId}] Cache store ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${allSegmentsToCache.length}`,
313
+ );
314
+ }
260
315
  });
261
316
  }
262
317
 
263
318
  return response;
264
319
  });
320
+
321
+ if (ms) {
322
+ ms.metrics.push({
323
+ label: "pipeline:cache-store",
324
+ duration: performance.now() - ownStart,
325
+ startTime: ownStart - ms.requestStart,
326
+ });
327
+ }
265
328
  };
266
329
  }
@@ -105,6 +105,7 @@ import type { ResolvedSegment } from "../../types.js";
105
105
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
106
106
  import { getRouterContext } from "../router-context.js";
107
107
  import type { GeneratorMiddleware } from "./cache-lookup.js";
108
+ import { debugLog } from "../logging.js";
108
109
 
109
110
  /**
110
111
  * Creates intercept resolution middleware
@@ -117,11 +118,13 @@ import type { GeneratorMiddleware } from "./cache-lookup.js";
117
118
  */
118
119
  export function withInterceptResolution<TEnv>(
119
120
  ctx: MatchContext<TEnv>,
120
- state: MatchPipelineState
121
+ state: MatchPipelineState,
121
122
  ): GeneratorMiddleware<ResolvedSegment> {
122
123
  return async function* (
123
- source: AsyncGenerator<ResolvedSegment>
124
+ source: AsyncGenerator<ResolvedSegment>,
124
125
  ): AsyncGenerator<ResolvedSegment> {
126
+ const ms = ctx.metricsStore;
127
+
125
128
  // First, yield all segments from the source (main segment resolution or cache)
126
129
  const segments: ResolvedSegment[] = [];
127
130
  for await (const segment of source) {
@@ -129,8 +132,18 @@ export function withInterceptResolution<TEnv>(
129
132
  yield segment;
130
133
  }
131
134
 
135
+ // Measure own work only (after source iteration completes)
136
+ const ownStart = performance.now();
137
+
132
138
  // Skip intercept resolution for full match (document requests don't have intercepts)
133
139
  if (ctx.isFullMatch) {
140
+ if (ms) {
141
+ ms.metrics.push({
142
+ label: "pipeline:intercept",
143
+ duration: performance.now() - ownStart,
144
+ startTime: ownStart - ms.requestStart,
145
+ });
146
+ }
134
147
  return;
135
148
  }
136
149
 
@@ -149,6 +162,13 @@ export function withInterceptResolution<TEnv>(
149
162
  if (ctx.interceptResult && state.cacheHit && ctx.isIntercept) {
150
163
  await handleCacheHitIntercept(ctx, state, segments);
151
164
  }
165
+ if (ms) {
166
+ ms.metrics.push({
167
+ label: "pipeline:intercept",
168
+ duration: performance.now() - ownStart,
169
+ startTime: ownStart - ms.requestStart,
170
+ });
171
+ }
152
172
  return;
153
173
  }
154
174
 
@@ -156,9 +176,10 @@ export function withInterceptResolution<TEnv>(
156
176
  const { resolveInterceptEntry } = getRouterContext<TEnv>();
157
177
 
158
178
  const slotName = ctx.interceptResult!.intercept.slotName;
159
- console.log(
160
- `[Router.matchPartial] Found intercept for "${ctx.localRouteName}" -> slot "${slotName}"`
161
- );
179
+ debugLog("matchPartial.intercept", "intercept resolved", {
180
+ routeName: ctx.localRouteName,
181
+ slotName,
182
+ });
162
183
 
163
184
  // Resolve intercept entry (middleware, loaders, handler)
164
185
  const Store = ctx.Store;
@@ -178,8 +199,8 @@ export function withInterceptResolution<TEnv>(
178
199
  routeKey: ctx.routeKey,
179
200
  actionContext: ctx.actionContext,
180
201
  stale: ctx.stale,
181
- }
182
- )
202
+ },
203
+ ),
183
204
  );
184
205
 
185
206
  // Update state
@@ -193,6 +214,14 @@ export function withInterceptResolution<TEnv>(
193
214
  for (const segment of interceptSegments) {
194
215
  yield segment;
195
216
  }
217
+
218
+ if (ms) {
219
+ ms.metrics.push({
220
+ label: "pipeline:intercept",
221
+ duration: performance.now() - ownStart,
222
+ startTime: ownStart - ms.requestStart,
223
+ });
224
+ }
196
225
  };
197
226
  }
198
227
 
@@ -204,7 +233,7 @@ export function withInterceptResolution<TEnv>(
204
233
  async function handleCacheHitIntercept<TEnv>(
205
234
  ctx: MatchContext<TEnv>,
206
235
  state: MatchPipelineState,
207
- segments: ResolvedSegment[]
236
+ segments: ResolvedSegment[],
208
237
  ): Promise<void> {
209
238
  if (!ctx.interceptResult) return;
210
239
 
@@ -214,7 +243,7 @@ async function handleCacheHitIntercept<TEnv>(
214
243
 
215
244
  // Find intercept segments from cached segments (namespace starts with "intercept:")
216
245
  const interceptSegments = segments.filter((s) =>
217
- s.namespace?.startsWith("intercept:")
246
+ s.namespace?.startsWith("intercept:"),
218
247
  );
219
248
  state.interceptSegments = interceptSegments;
220
249
 
@@ -238,25 +267,36 @@ async function handleCacheHitIntercept<TEnv>(
238
267
  routeKey: ctx.routeKey,
239
268
  actionContext: ctx.actionContext,
240
269
  stale: ctx.stale,
241
- }
242
- )
270
+ },
271
+ ),
243
272
  );
244
273
 
245
274
  // Update intercept segment's loaderDataPromise with fresh data
246
275
  if (freshLoaderResult) {
247
276
  const interceptMainSegment = interceptSegments.find(
248
- (s) => s.type === "parallel" && s.slot
277
+ (s) => s.type === "parallel" && s.slot,
249
278
  );
250
279
  if (interceptMainSegment) {
251
- interceptMainSegment.loaderDataPromise = freshLoaderResult.loaderDataPromise;
280
+ interceptMainSegment.loaderDataPromise =
281
+ freshLoaderResult.loaderDataPromise;
252
282
  interceptMainSegment.loaderIds = freshLoaderResult.loaderIds;
253
- console.log(
254
- `[Router.matchPartial] Cache HIT + fresh loaders for intercept "${ctx.localRouteName}" -> slot "${slotName}"`
283
+ debugLog(
284
+ "matchPartial.intercept",
285
+ "cache hit with fresh intercept loaders",
286
+ {
287
+ routeName: ctx.localRouteName,
288
+ slotName,
289
+ },
255
290
  );
256
291
  }
257
292
  } else {
258
- console.log(
259
- `[Router.matchPartial] Cache HIT for intercept "${ctx.localRouteName}" -> slot "${slotName}" (no loader revalidation)`
293
+ debugLog(
294
+ "matchPartial.intercept",
295
+ "cache hit without intercept loader revalidation",
296
+ {
297
+ routeName: ctx.localRouteName,
298
+ slotName,
299
+ },
260
300
  );
261
301
  }
262
302
  }
@@ -99,19 +99,31 @@ import type { GeneratorMiddleware } from "./cache-lookup.js";
99
99
  */
100
100
  export function withSegmentResolution<TEnv>(
101
101
  ctx: MatchContext<TEnv>,
102
- state: MatchPipelineState
102
+ state: MatchPipelineState,
103
103
  ): GeneratorMiddleware<ResolvedSegment> {
104
104
  return async function* (
105
- source: AsyncGenerator<ResolvedSegment>
105
+ source: AsyncGenerator<ResolvedSegment>,
106
106
  ): AsyncGenerator<ResolvedSegment> {
107
+ const ms = ctx.metricsStore;
108
+
107
109
  // IMPORTANT: Always iterate source first to give cache-lookup a chance
108
110
  // to run and set state.cacheHit. Without this, cache-lookup never executes!
109
111
  for await (const segment of source) {
110
112
  yield segment;
111
113
  }
112
114
 
115
+ // Measure own work only (after source iteration completes)
116
+ const ownStart = performance.now();
117
+
113
118
  // If cache hit, segments were already yielded by cache lookup
114
119
  if (state.cacheHit) {
120
+ if (ms) {
121
+ ms.metrics.push({
122
+ label: "pipeline:segment-resolve",
123
+ duration: performance.now() - ownStart,
124
+ startTime: ownStart - ms.requestStart,
125
+ });
126
+ }
115
127
  return;
116
128
  }
117
129
 
@@ -128,8 +140,8 @@ export function withSegmentResolution<TEnv>(
128
140
  ctx.routeKey,
129
141
  ctx.matched.params,
130
142
  ctx.handlerContext,
131
- ctx.loaderPromises
132
- )
143
+ ctx.loaderPromises,
144
+ ),
133
145
  );
134
146
 
135
147
  // Update state with resolved segments
@@ -157,8 +169,9 @@ export function withSegmentResolution<TEnv>(
157
169
  ctx.actionContext,
158
170
  ctx.interceptResult,
159
171
  ctx.localRouteName,
160
- ctx.pathname
161
- )
172
+ ctx.pathname,
173
+ ctx.stale,
174
+ ),
162
175
  );
163
176
 
164
177
  // Update state with resolved segments
@@ -170,5 +183,13 @@ export function withSegmentResolution<TEnv>(
170
183
  yield segment;
171
184
  }
172
185
  }
186
+
187
+ if (ms) {
188
+ ms.metrics.push({
189
+ label: "pipeline:segment-resolve",
190
+ duration: performance.now() - ownStart,
191
+ startTime: ownStart - ms.requestStart,
192
+ });
193
+ }
173
194
  };
174
195
  }
@@ -86,19 +86,14 @@
86
86
  * -> output: cached segments + fresh loader data
87
87
  *
88
88
  *
89
- * TWO PIPELINE VARIANTS
90
- * =====================
91
- *
92
- * 1. createMatchPipeline (Full Match)
93
- * - Used for document requests (initial page load)
94
- * - No revalidation logic (no previous state to compare)
95
- * - Simpler segment resolution
96
- *
97
- * 2. createMatchPartialPipeline (Partial Match)
98
- * - Used for client-side navigation
99
- * - Includes revalidation for SWR
100
- * - Compares with previous params/URL
101
- * - Supports intercepts (soft navigation modals)
89
+ * PIPELINE VARIANT
90
+ * ================
91
+ *
92
+ * createMatchPartialPipeline handles both full (document) and partial
93
+ * (navigation) requests. The middleware steps adapt based on ctx.isFullMatch:
94
+ * - cache-lookup/store work for both
95
+ * - background-revalidation is a no-op for full matches (no stale state)
96
+ * - intercept-resolution is a no-op for full matches (no previous navigation)
102
97
  */
103
98
  import type { ResolvedSegment } from "../types.js";
104
99
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
@@ -163,7 +158,7 @@ export async function* empty<T>(): AsyncGenerator<T> {
163
158
  */
164
159
  export function createMatchPartialPipeline<TEnv>(
165
160
  ctx: MatchContext<TEnv>,
166
- state: MatchPipelineState
161
+ state: MatchPipelineState,
167
162
  ): AsyncGenerator<ResolvedSegment> {
168
163
  // Build the middleware chain
169
164
  const pipeline = compose<ResolvedSegment>(
@@ -176,39 +171,9 @@ export function createMatchPartialPipeline<TEnv>(
176
171
  // Resolves segments on cache miss
177
172
  withSegmentResolution(ctx, state),
178
173
  // Innermost - checks cache first
179
- withCacheLookup(ctx, state)
174
+ withCacheLookup(ctx, state),
180
175
  );
181
176
 
182
177
  // Start with empty source - cache lookup or segment resolution will produce segments
183
178
  return pipeline(empty());
184
179
  }
185
-
186
- /**
187
- * Create the full match pipeline (simpler, no revalidation)
188
- *
189
- * Used for document requests (initial page load) where we don't need
190
- * revalidation logic since there's no previous state to compare against.
191
- */
192
- export function createMatchPipeline<TEnv>(
193
- ctx: MatchContext<TEnv>,
194
- state: MatchPipelineState
195
- ): AsyncGenerator<ResolvedSegment> {
196
- // For full match, we only need:
197
- // 1. Cache lookup
198
- // 2. Segment resolution (without revalidation)
199
- // 3. Intercept resolution
200
- // 4. Cache store
201
-
202
- // Note: Full match uses different resolution logic (resolveAllSegments instead of
203
- // resolveAllSegmentsWithRevalidation). This will be handled by the segment resolution
204
- // middleware checking ctx.isFullMatch or similar flag.
205
-
206
- const pipeline = compose<ResolvedSegment>(
207
- withCacheStore(ctx, state),
208
- withInterceptResolution(ctx, state),
209
- withSegmentResolution(ctx, state),
210
- withCacheLookup(ctx, state)
211
- );
212
-
213
- return pipeline(empty());
214
- }
@@ -67,10 +67,11 @@
67
67
  * Keep if:
68
68
  * - component !== null (needs rendering)
69
69
  * - type === "loader" (carries data even with null component)
70
+ * - client doesn't have the segment (structurally required parent node)
70
71
  *
71
72
  * Skip if:
72
- * - component === null AND type !== "loader"
73
- * - (Client already has this segment's UI)
73
+ * - component === null AND type !== "loader" AND client has it cached
74
+ * - (Revalidation skip — client already has this segment's UI)
74
75
  *
75
76
  *
76
77
  * INTERCEPT HANDLING
@@ -108,13 +109,14 @@
108
109
  */
109
110
  import type { MatchResult, ResolvedSegment } from "../types.js";
110
111
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
111
- import { generateServerTiming, logMetrics } from "./metrics.js";
112
+ import { debugLog } from "./logging.js";
113
+ import { appendMetric } from "./metrics.js";
112
114
 
113
115
  /**
114
116
  * Collect all segments from an async generator
115
117
  */
116
118
  export async function collectSegments(
117
- generator: AsyncGenerator<ResolvedSegment>
119
+ generator: AsyncGenerator<ResolvedSegment>,
118
120
  ): Promise<ResolvedSegment[]> {
119
121
  const segments: ResolvedSegment[] = [];
120
122
  for await (const segment of generator) {
@@ -129,17 +131,30 @@ export async function collectSegments(
129
131
  export function buildMatchResult<TEnv>(
130
132
  allSegments: ResolvedSegment[],
131
133
  ctx: MatchContext<TEnv>,
132
- state: MatchPipelineState
134
+ state: MatchPipelineState,
133
135
  ): MatchResult {
134
- const logPrefix = ctx.isFullMatch ? "[Router.match]" : "[Router.matchPartial]";
136
+ const logPrefix = ctx.isFullMatch
137
+ ? "[Router.match]"
138
+ : "[Router.matchPartial]";
135
139
 
136
140
  let allIds: string[];
137
141
  let segmentsToRender: ResolvedSegment[];
138
142
 
139
143
  if (ctx.isFullMatch) {
140
144
  // Full match (document request) - all segments are rendered
141
- allIds = allSegments.map((s) => s.id);
142
- segmentsToRender = allSegments;
145
+ // Deduplicate by segment ID (defense-in-depth). The primary dedup is in
146
+ // resolveAllSegments, but this guards against any path that bypasses it.
147
+ // include() scopes can produce entries that resolve the same shared layout,
148
+ // and duplicate IDs change the client's React tree depth causing remounts.
149
+ const seen = new Set<string>();
150
+ segmentsToRender = [];
151
+ for (const s of allSegments) {
152
+ if (!seen.has(s.id)) {
153
+ seen.add(s.id);
154
+ segmentsToRender.push(s);
155
+ }
156
+ }
157
+ allIds = segmentsToRender.map((s) => s.id);
143
158
  } else {
144
159
  // Partial match (navigation) - filter and handle intercepts
145
160
  // When intercepting, tell browser to keep its current segments + add modal
@@ -151,32 +166,31 @@ export function buildMatchResult<TEnv>(
151
166
  : allSegments.map((s) => s.id) // Use actual segments, not matchedIds
152
167
  : [...state.matchedIds, ...state.interceptSegments.map((s) => s.id)];
153
168
 
154
- // Filter out segments with null components (client already has them)
155
- // BUT always include loader segments - they carry data even with null component
156
- segmentsToRender = allSegments.filter(
157
- (s) => s.component !== null || s.type === "loader"
158
- );
159
- }
169
+ // Deduplicate allIds (defense-in-depth for partial match path)
170
+ allIds = [...new Set(allIds)];
160
171
 
161
- if (process.env.NODE_ENV === "development") {
162
- console.log(
163
- `${logPrefix} All segments:`,
164
- allSegments
165
- .map((s) => `${s.id}(${s.type}, component=${s.component !== null})`)
166
- .join(", ")
167
- );
168
- console.log(
169
- `${logPrefix} Segments to render:`,
170
- segmentsToRender.map((s) => s.id).join(", ")
172
+ // Filter out null-component segments only when the client already has
173
+ // them cached (revalidation skip). If the client doesn't have the segment,
174
+ // it must be included even with null component — it's structurally required
175
+ // as a parent node for child layouts/parallels to reconcile against.
176
+ // Loader segments are always included as they carry data.
177
+ const clientIdSet = new Set(ctx.clientSegmentIds);
178
+ segmentsToRender = allSegments.filter(
179
+ (s) =>
180
+ s.component !== null || s.type === "loader" || !clientIdSet.has(s.id),
171
181
  );
172
182
  }
173
183
 
174
- // Output metrics if enabled
175
- let serverTiming: string | undefined;
176
- if (ctx.metricsStore) {
177
- logMetrics(ctx.request.method, ctx.pathname, ctx.metricsStore);
178
- serverTiming = generateServerTiming(ctx.metricsStore);
179
- }
184
+ debugLog(logPrefix, "all segments", {
185
+ segments: allSegments.map((s) => ({
186
+ id: s.id,
187
+ type: s.type,
188
+ hasComponent: s.component !== null,
189
+ })),
190
+ });
191
+ debugLog(logPrefix, "segments to render", {
192
+ segmentIds: segmentsToRender.map((s) => s.id),
193
+ });
180
194
 
181
195
  return {
182
196
  segments: segmentsToRender,
@@ -184,7 +198,6 @@ export function buildMatchResult<TEnv>(
184
198
  diff: segmentsToRender.map((s) => s.id),
185
199
  params: ctx.matched.params,
186
200
  routeName: ctx.routeKey,
187
- serverTiming,
188
201
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
189
202
  routeMiddleware:
190
203
  ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
@@ -200,14 +213,23 @@ export function buildMatchResult<TEnv>(
200
213
  export async function collectMatchResult<TEnv>(
201
214
  pipeline: AsyncGenerator<ResolvedSegment>,
202
215
  ctx: MatchContext<TEnv>,
203
- state: MatchPipelineState
216
+ state: MatchPipelineState,
204
217
  ): Promise<MatchResult> {
205
218
  const allSegments = await collectSegments(pipeline);
206
219
 
220
+ const buildStart = performance.now();
221
+
207
222
  // Update state with collected segments if not already set
208
223
  if (state.segments.length === 0) {
209
224
  state.segments = allSegments;
210
225
  }
211
226
 
212
- return buildMatchResult(allSegments, ctx, state);
227
+ const result = buildMatchResult(allSegments, ctx, state);
228
+ appendMetric(
229
+ ctx.metricsStore,
230
+ "collect-result",
231
+ buildStart,
232
+ performance.now() - buildStart,
233
+ );
234
+ return result;
213
235
  }