@rangojs/router 0.0.0-experimental.13 → 0.0.0-experimental.13221847

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 (298) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +884 -4
  3. package/dist/bin/rango.js +1531 -212
  4. package/dist/vite/index.js +3995 -2489
  5. package/package.json +57 -52
  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 +6 -4
  13. package/skills/hooks/SKILL.md +328 -70
  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 +62 -15
  18. package/skills/loader/SKILL.md +368 -42
  19. package/skills/middleware/SKILL.md +171 -34
  20. package/skills/mime-routes/SKILL.md +14 -10
  21. package/skills/parallel/SKILL.md +137 -1
  22. package/skills/prerender/SKILL.md +366 -28
  23. package/skills/rango/SKILL.md +85 -21
  24. package/skills/response-routes/SKILL.md +136 -83
  25. package/skills/route/SKILL.md +195 -21
  26. package/skills/router-setup/SKILL.md +123 -30
  27. package/skills/theme/SKILL.md +9 -8
  28. package/skills/typesafety/SKILL.md +240 -102
  29. package/skills/use-cache/SKILL.md +324 -0
  30. package/src/__internal.ts +102 -4
  31. package/src/bin/rango.ts +312 -15
  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 +92 -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 +24 -4
  38. package/src/browser/logging.ts +11 -0
  39. package/src/browser/merge-segment-loaders.ts +20 -12
  40. package/src/browser/navigation-bridge.ts +266 -558
  41. package/src/browser/navigation-client.ts +132 -75
  42. package/src/browser/navigation-store.ts +33 -50
  43. package/src/browser/navigation-transaction.ts +297 -0
  44. package/src/browser/network-error-handler.ts +61 -0
  45. package/src/browser/partial-update.ts +303 -309
  46. package/src/browser/prefetch/cache.ts +206 -0
  47. package/src/browser/prefetch/fetch.ts +144 -0
  48. package/src/browser/prefetch/observer.ts +65 -0
  49. package/src/browser/prefetch/policy.ts +48 -0
  50. package/src/browser/prefetch/queue.ts +128 -0
  51. package/src/browser/rango-state.ts +112 -0
  52. package/src/browser/react/Link.tsx +190 -70
  53. package/src/browser/react/NavigationProvider.tsx +78 -11
  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 +6 -1
  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 +29 -70
  65. package/src/browser/react/use-link-status.ts +6 -5
  66. package/src/browser/react/use-navigation.ts +22 -63
  67. package/src/browser/react/use-params.ts +65 -0
  68. package/src/browser/react/use-pathname.ts +47 -0
  69. package/src/browser/react/use-router.ts +63 -0
  70. package/src/browser/react/use-search-params.ts +56 -0
  71. package/src/browser/react/use-segments.ts +80 -97
  72. package/src/browser/response-adapter.ts +73 -0
  73. package/src/browser/rsc-router.tsx +188 -57
  74. package/src/browser/scroll-restoration.ts +117 -44
  75. package/src/browser/segment-reconciler.ts +221 -0
  76. package/src/browser/segment-structure-assert.ts +16 -0
  77. package/src/browser/server-action-bridge.ts +488 -606
  78. package/src/browser/shallow.ts +6 -1
  79. package/src/browser/types.ts +116 -47
  80. package/src/browser/validate-redirect-origin.ts +29 -0
  81. package/src/build/generate-manifest.ts +63 -21
  82. package/src/build/generate-route-types.ts +36 -1038
  83. package/src/build/index.ts +2 -5
  84. package/src/build/route-trie.ts +38 -12
  85. package/src/build/route-types/ast-helpers.ts +25 -0
  86. package/src/build/route-types/ast-route-extraction.ts +98 -0
  87. package/src/build/route-types/codegen.ts +102 -0
  88. package/src/build/route-types/include-resolution.ts +411 -0
  89. package/src/build/route-types/param-extraction.ts +48 -0
  90. package/src/build/route-types/per-module-writer.ts +128 -0
  91. package/src/build/route-types/router-processing.ts +479 -0
  92. package/src/build/route-types/scan-filter.ts +78 -0
  93. package/src/build/runtime-discovery.ts +231 -0
  94. package/src/cache/background-task.ts +34 -0
  95. package/src/cache/cache-key-utils.ts +44 -0
  96. package/src/cache/cache-policy.ts +125 -0
  97. package/src/cache/cache-runtime.ts +342 -0
  98. package/src/cache/cache-scope.ts +122 -303
  99. package/src/cache/cf/cf-cache-store.ts +571 -17
  100. package/src/cache/cf/index.ts +13 -3
  101. package/src/cache/document-cache.ts +116 -77
  102. package/src/cache/handle-capture.ts +81 -0
  103. package/src/cache/handle-snapshot.ts +41 -0
  104. package/src/cache/index.ts +1 -15
  105. package/src/cache/memory-segment-store.ts +191 -13
  106. package/src/cache/profile-registry.ts +73 -0
  107. package/src/cache/read-through-swr.ts +134 -0
  108. package/src/cache/segment-codec.ts +256 -0
  109. package/src/cache/taint.ts +98 -0
  110. package/src/cache/types.ts +72 -122
  111. package/src/client.rsc.tsx +3 -1
  112. package/src/client.tsx +84 -126
  113. package/src/component-utils.ts +4 -4
  114. package/src/components/DefaultDocument.tsx +5 -1
  115. package/src/context-var.ts +86 -0
  116. package/src/debug.ts +19 -9
  117. package/src/errors.ts +77 -7
  118. package/src/handle.ts +12 -7
  119. package/src/handles/MetaTags.tsx +73 -20
  120. package/src/handles/breadcrumbs.ts +66 -0
  121. package/src/handles/index.ts +1 -0
  122. package/src/handles/meta.ts +30 -13
  123. package/src/host/cookie-handler.ts +21 -15
  124. package/src/host/errors.ts +8 -8
  125. package/src/host/index.ts +4 -7
  126. package/src/host/pattern-matcher.ts +27 -27
  127. package/src/host/router.ts +61 -39
  128. package/src/host/testing.ts +8 -8
  129. package/src/host/types.ts +15 -7
  130. package/src/host/utils.ts +1 -1
  131. package/src/href-client.ts +65 -45
  132. package/src/index.rsc.ts +104 -40
  133. package/src/index.ts +122 -67
  134. package/src/internal-debug.ts +9 -3
  135. package/src/loader.rsc.ts +18 -93
  136. package/src/loader.ts +26 -9
  137. package/src/network-error-thrower.tsx +3 -1
  138. package/src/outlet-provider.tsx +45 -0
  139. package/src/prerender/param-hash.ts +4 -2
  140. package/src/prerender/store.ts +121 -17
  141. package/src/prerender.ts +325 -20
  142. package/src/reverse.ts +144 -124
  143. package/src/root-error-boundary.tsx +41 -29
  144. package/src/route-content-wrapper.tsx +7 -4
  145. package/src/route-definition/dsl-helpers.ts +959 -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 -1450
  151. package/src/route-map-builder.ts +87 -133
  152. package/src/route-name.ts +53 -0
  153. package/src/route-types.ts +41 -6
  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 +160 -0
  158. package/src/router/handler-context.ts +324 -116
  159. package/src/router/intercept-resolution.ts +11 -4
  160. package/src/router/lazy-includes.ts +237 -0
  161. package/src/router/loader-resolution.ts +179 -133
  162. package/src/router/logging.ts +112 -6
  163. package/src/router/manifest.ts +58 -19
  164. package/src/router/match-api.ts +89 -88
  165. package/src/router/match-context.ts +4 -2
  166. package/src/router/match-handlers.ts +440 -0
  167. package/src/router/match-middleware/background-revalidation.ts +86 -89
  168. package/src/router/match-middleware/cache-lookup.ts +295 -49
  169. package/src/router/match-middleware/cache-store.ts +56 -13
  170. package/src/router/match-middleware/intercept-resolution.ts +45 -22
  171. package/src/router/match-middleware/segment-resolution.ts +20 -9
  172. package/src/router/match-pipelines.ts +10 -45
  173. package/src/router/match-result.ts +44 -21
  174. package/src/router/metrics.ts +240 -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 +327 -369
  178. package/src/router/pattern-matching.ts +169 -31
  179. package/src/router/prerender-match.ts +402 -0
  180. package/src/router/preview-match.ts +170 -0
  181. package/src/router/revalidation.ts +105 -14
  182. package/src/router/router-context.ts +40 -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 +677 -0
  187. package/src/router/segment-resolution/helpers.ts +263 -0
  188. package/src/router/segment-resolution/loader-cache.ts +199 -0
  189. package/src/router/segment-resolution/revalidation.ts +1296 -0
  190. package/src/router/segment-resolution/static-store.ts +67 -0
  191. package/src/router/segment-resolution.ts +21 -1354
  192. package/src/router/segment-wrappers.ts +291 -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 +96 -29
  197. package/src/router/types.ts +15 -9
  198. package/src/router.ts +642 -2366
  199. package/src/rsc/handler-context.ts +45 -0
  200. package/src/rsc/handler.ts +639 -1027
  201. package/src/rsc/helpers.ts +140 -6
  202. package/src/rsc/index.ts +0 -20
  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 +237 -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 +38 -11
  215. package/src/search-params.ts +66 -54
  216. package/src/segment-system.tsx +165 -17
  217. package/src/server/context.ts +237 -54
  218. package/src/server/cookie-store.ts +190 -0
  219. package/src/server/fetchable-loader-store.ts +11 -6
  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 +438 -71
  223. package/src/server.ts +26 -164
  224. package/src/ssr/index.tsx +101 -31
  225. package/src/static-handler.ts +22 -4
  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 +773 -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 +109 -0
  241. package/src/types/segments.ts +150 -0
  242. package/src/types.ts +1 -1795
  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 -1323
  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 +108 -0
  259. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  260. package/src/vite/index.ts +11 -2259
  261. package/src/vite/plugin-types.ts +48 -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 -47
  266. package/src/vite/{expose-id-utils.ts → plugins/expose-id-utils.ts} +8 -43
  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 +266 -0
  277. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
  278. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  279. package/src/vite/rango.ts +445 -0
  280. package/src/vite/router-discovery.ts +777 -0
  281. package/src/vite/{ast-handler-extract.ts → utils/ast-handler-extract.ts} +181 -9
  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 -43
  289. package/dist/vite/index.named-routes.gen.ts +0 -103
  290. package/src/browser/lru-cache.ts +0 -69
  291. package/src/browser/request-controller.ts +0 -164
  292. package/src/cache/memory-store.ts +0 -253
  293. package/src/href-context.ts +0 -33
  294. package/src/router.gen.ts +0 -6
  295. package/src/static-handler.gen.ts +0 -5
  296. package/src/urls.gen.ts +0 -8
  297. package/src/vite/expose-internal-ids.ts +0 -1167
  298. /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
@@ -104,8 +104,9 @@ 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
- import { debugLog, debugWarn } from "../logging.js";
109
110
 
110
111
  /**
111
112
  * Creates cache store middleware
@@ -120,7 +121,6 @@ export function withCacheStore<TEnv>(
120
121
  return async function* (
121
122
  source: AsyncGenerator<ResolvedSegment>,
122
123
  ): AsyncGenerator<ResolvedSegment> {
123
- const pipelineStart = performance.now();
124
124
  const ms = ctx.metricsStore;
125
125
 
126
126
  // Collect all segments while passing them through
@@ -130,6 +130,9 @@ export function withCacheStore<TEnv>(
130
130
  yield segment;
131
131
  }
132
132
 
133
+ // Measure own work only (after source iteration completes)
134
+ const ownStart = performance.now();
135
+
133
136
  // Skip caching if:
134
137
  // 1. Cache miss but cache scope is disabled
135
138
  // 2. This is an action (actions don't cache)
@@ -142,16 +145,21 @@ export function withCacheStore<TEnv>(
142
145
  ctx.request.method !== "GET"
143
146
  ) {
144
147
  if (ms) {
145
- ms.metrics.push({ label: "pipeline:cache-store", duration: performance.now() - pipelineStart, startTime: pipelineStart - ms.requestStart });
148
+ ms.metrics.push({
149
+ label: "pipeline:cache-store",
150
+ duration: performance.now() - ownStart,
151
+ startTime: ownStart - ms.requestStart,
152
+ });
146
153
  }
147
154
  return;
148
155
  }
149
156
 
150
157
  const {
151
158
  createHandlerContext,
152
- setupLoaderAccessSilent,
159
+ setupLoaderAccess,
153
160
  resolveAllSegments,
154
161
  resolveInterceptEntry,
162
+ createHandleStore,
155
163
  } = getRouterContext<TEnv>();
156
164
 
157
165
  // Combine main segments with intercept segments
@@ -167,6 +175,9 @@ export function withCacheStore<TEnv>(
167
175
  if (!requestCtx) return;
168
176
 
169
177
  const cacheScope = ctx.cacheScope;
178
+ const reqId = INTERNAL_RANGO_DEBUG
179
+ ? getOrCreateRequestId(ctx.request)
180
+ : undefined;
170
181
 
171
182
  // Register onResponse callback to skip caching for non-200 responses
172
183
  // Note: error/notFound status codes are set elsewhere (not caching-specific)
@@ -184,29 +195,35 @@ export function withCacheStore<TEnv>(
184
195
  // Proactive caching: render all segments fresh in background
185
196
  // This ensures cache has complete components for future requests
186
197
  requestCtx.waitUntil(async () => {
198
+ const start = performance.now();
187
199
  debugLog("cacheStore", "proactive caching started", {
188
200
  pathname: ctx.pathname,
189
201
  });
202
+ // Swap to a fresh HandleStore so handle.push() calls from
203
+ // proactive resolution are captured (not silenced). The original
204
+ // store's stream is already sent by waitUntil time.
205
+ // cacheRoute reads from requestCtx._handleStore, so this ensures
206
+ // complete handle data (e.g. breadcrumbs) is cached.
207
+ const originalHandleStore = requestCtx._handleStore;
208
+ requestCtx._handleStore = createHandleStore();
190
209
  try {
191
210
  // Create fresh context for proactive caching
192
- // This prevents handle data from polluting the response stream
193
211
  const proactiveHandlerContext = createHandlerContext(
194
212
  ctx.matched.params,
195
213
  ctx.request,
196
214
  ctx.url.searchParams,
197
215
  ctx.pathname,
198
216
  ctx.url,
199
- ctx.bindings,
217
+ ctx.env,
200
218
  ctx.routeMap,
201
- ctx.matched.routeKey
219
+ ctx.matched.routeKey,
220
+ ctx.matched.responseType,
221
+ ctx.matched.pt === true,
202
222
  );
203
223
  const proactiveLoaderPromises = new Map<string, Promise<any>>();
204
224
 
205
- // Set up loader access that ignores handle pushes
206
- setupLoaderAccessSilent(
207
- proactiveHandlerContext,
208
- proactiveLoaderPromises,
209
- );
225
+ // Use normal loader access so handle data is captured
226
+ setupLoaderAccess(proactiveHandlerContext, proactiveLoaderPromises);
210
227
 
211
228
  // Re-resolve ALL segments without revalidation
212
229
  const Store = ctx.Store;
@@ -239,32 +256,54 @@ export function withCacheStore<TEnv>(
239
256
  ...freshSegments,
240
257
  ...freshInterceptSegments,
241
258
  ];
259
+ requestCtx._handleStore.seal();
242
260
  await cacheScope.cacheRoute(
243
261
  ctx.pathname,
244
262
  ctx.matched.params,
245
263
  completeSegments,
246
264
  ctx.isIntercept,
247
265
  );
266
+ if (INTERNAL_RANGO_DEBUG) {
267
+ const dur = performance.now() - start;
268
+ console.log(
269
+ `[RSC Background][req:${reqId}] Proactive cache ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${completeSegments.length}`,
270
+ );
271
+ }
248
272
  debugLog("cacheStore", "proactive caching complete", {
249
273
  pathname: ctx.pathname,
250
274
  });
251
275
  } catch (error) {
276
+ if (INTERNAL_RANGO_DEBUG) {
277
+ const dur = performance.now() - start;
278
+ console.log(
279
+ `[RSC Background][req:${reqId}] Proactive cache ${ctx.pathname} FAILED (${dur.toFixed(2)}ms) error=${String(error)}`,
280
+ );
281
+ }
252
282
  debugWarn("cacheStore", "proactive caching failed", {
253
283
  pathname: ctx.pathname,
254
284
  error: String(error),
255
285
  });
286
+ } finally {
287
+ requestCtx._handleStore = originalHandleStore;
256
288
  }
257
289
  });
258
290
  } else {
259
291
  // All segments have components - cache directly
260
292
  // Schedule caching in waitUntil since cacheRoute is now async (key resolution)
261
293
  requestCtx.waitUntil(async () => {
294
+ const start = performance.now();
262
295
  await cacheScope.cacheRoute(
263
296
  ctx.pathname,
264
297
  ctx.matched.params,
265
298
  allSegmentsToCache,
266
299
  ctx.isIntercept,
267
300
  );
301
+ if (INTERNAL_RANGO_DEBUG) {
302
+ const dur = performance.now() - start;
303
+ console.log(
304
+ `[RSC Background][req:${reqId}] Cache store ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${allSegmentsToCache.length}`,
305
+ );
306
+ }
268
307
  });
269
308
  }
270
309
 
@@ -272,7 +311,11 @@ export function withCacheStore<TEnv>(
272
311
  });
273
312
 
274
313
  if (ms) {
275
- ms.metrics.push({ label: "pipeline:cache-store", duration: performance.now() - pipelineStart, startTime: pipelineStart - ms.requestStart });
314
+ ms.metrics.push({
315
+ label: "pipeline:cache-store",
316
+ duration: performance.now() - ownStart,
317
+ startTime: ownStart - ms.requestStart,
318
+ });
276
319
  }
277
320
  };
278
321
  }
@@ -118,12 +118,11 @@ import { debugLog } from "../logging.js";
118
118
  */
119
119
  export function withInterceptResolution<TEnv>(
120
120
  ctx: MatchContext<TEnv>,
121
- state: MatchPipelineState
121
+ state: MatchPipelineState,
122
122
  ): GeneratorMiddleware<ResolvedSegment> {
123
123
  return async function* (
124
- source: AsyncGenerator<ResolvedSegment>
124
+ source: AsyncGenerator<ResolvedSegment>,
125
125
  ): AsyncGenerator<ResolvedSegment> {
126
- const pipelineStart = performance.now();
127
126
  const ms = ctx.metricsStore;
128
127
 
129
128
  // First, yield all segments from the source (main segment resolution or cache)
@@ -133,10 +132,17 @@ export function withInterceptResolution<TEnv>(
133
132
  yield segment;
134
133
  }
135
134
 
135
+ // Measure own work only (after source iteration completes)
136
+ const ownStart = performance.now();
137
+
136
138
  // Skip intercept resolution for full match (document requests don't have intercepts)
137
139
  if (ctx.isFullMatch) {
138
140
  if (ms) {
139
- ms.metrics.push({ label: "pipeline:intercept", duration: performance.now() - pipelineStart, startTime: pipelineStart - ms.requestStart });
141
+ ms.metrics.push({
142
+ label: "pipeline:intercept",
143
+ duration: performance.now() - ownStart,
144
+ startTime: ownStart - ms.requestStart,
145
+ });
140
146
  }
141
147
  return;
142
148
  }
@@ -157,7 +163,11 @@ export function withInterceptResolution<TEnv>(
157
163
  await handleCacheHitIntercept(ctx, state, segments);
158
164
  }
159
165
  if (ms) {
160
- ms.metrics.push({ label: "pipeline:intercept", duration: performance.now() - pipelineStart, startTime: pipelineStart - ms.requestStart });
166
+ ms.metrics.push({
167
+ label: "pipeline:intercept",
168
+ duration: performance.now() - ownStart,
169
+ startTime: ownStart - ms.requestStart,
170
+ });
161
171
  }
162
172
  return;
163
173
  }
@@ -189,8 +199,8 @@ export function withInterceptResolution<TEnv>(
189
199
  routeKey: ctx.routeKey,
190
200
  actionContext: ctx.actionContext,
191
201
  stale: ctx.stale,
192
- }
193
- )
202
+ },
203
+ ),
194
204
  );
195
205
 
196
206
  // Update state
@@ -206,7 +216,11 @@ export function withInterceptResolution<TEnv>(
206
216
  }
207
217
 
208
218
  if (ms) {
209
- ms.metrics.push({ label: "pipeline:intercept", duration: performance.now() - pipelineStart, startTime: pipelineStart - ms.requestStart });
219
+ ms.metrics.push({
220
+ label: "pipeline:intercept",
221
+ duration: performance.now() - ownStart,
222
+ startTime: ownStart - ms.requestStart,
223
+ });
210
224
  }
211
225
  };
212
226
  }
@@ -219,7 +233,7 @@ export function withInterceptResolution<TEnv>(
219
233
  async function handleCacheHitIntercept<TEnv>(
220
234
  ctx: MatchContext<TEnv>,
221
235
  state: MatchPipelineState,
222
- segments: ResolvedSegment[]
236
+ segments: ResolvedSegment[],
223
237
  ): Promise<void> {
224
238
  if (!ctx.interceptResult) return;
225
239
 
@@ -229,7 +243,7 @@ async function handleCacheHitIntercept<TEnv>(
229
243
 
230
244
  // Find intercept segments from cached segments (namespace starts with "intercept:")
231
245
  const interceptSegments = segments.filter((s) =>
232
- s.namespace?.startsWith("intercept:")
246
+ s.namespace?.startsWith("intercept:"),
233
247
  );
234
248
  state.interceptSegments = interceptSegments;
235
249
 
@@ -253,28 +267,37 @@ async function handleCacheHitIntercept<TEnv>(
253
267
  routeKey: ctx.routeKey,
254
268
  actionContext: ctx.actionContext,
255
269
  stale: ctx.stale,
256
- }
257
- )
270
+ },
271
+ ),
258
272
  );
259
273
 
260
274
  // Update intercept segment's loaderDataPromise with fresh data
261
275
  if (freshLoaderResult) {
262
276
  const interceptMainSegment = interceptSegments.find(
263
- (s) => s.type === "parallel" && s.slot
277
+ (s) => s.type === "parallel" && s.slot,
264
278
  );
265
279
  if (interceptMainSegment) {
266
- interceptMainSegment.loaderDataPromise = freshLoaderResult.loaderDataPromise;
280
+ interceptMainSegment.loaderDataPromise =
281
+ freshLoaderResult.loaderDataPromise;
267
282
  interceptMainSegment.loaderIds = freshLoaderResult.loaderIds;
268
- debugLog("matchPartial.intercept", "cache hit with fresh intercept loaders", {
269
- routeName: ctx.localRouteName,
270
- slotName,
271
- });
283
+ debugLog(
284
+ "matchPartial.intercept",
285
+ "cache hit with fresh intercept loaders",
286
+ {
287
+ routeName: ctx.localRouteName,
288
+ slotName,
289
+ },
290
+ );
272
291
  }
273
292
  } else {
274
- debugLog("matchPartial.intercept", "cache hit without intercept loader revalidation", {
275
- routeName: ctx.localRouteName,
276
- slotName,
277
- });
293
+ debugLog(
294
+ "matchPartial.intercept",
295
+ "cache hit without intercept loader revalidation",
296
+ {
297
+ routeName: ctx.localRouteName,
298
+ slotName,
299
+ },
300
+ );
278
301
  }
279
302
  }
280
303
 
@@ -99,12 +99,11 @@ 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 pipelineStart = performance.now();
108
107
  const ms = ctx.metricsStore;
109
108
 
110
109
  // IMPORTANT: Always iterate source first to give cache-lookup a chance
@@ -113,10 +112,17 @@ export function withSegmentResolution<TEnv>(
113
112
  yield segment;
114
113
  }
115
114
 
115
+ // Measure own work only (after source iteration completes)
116
+ const ownStart = performance.now();
117
+
116
118
  // If cache hit, segments were already yielded by cache lookup
117
119
  if (state.cacheHit) {
118
120
  if (ms) {
119
- ms.metrics.push({ label: "pipeline:segment-resolve", duration: performance.now() - pipelineStart, startTime: pipelineStart - ms.requestStart });
121
+ ms.metrics.push({
122
+ label: "pipeline:segment-resolve",
123
+ duration: performance.now() - ownStart,
124
+ startTime: ownStart - ms.requestStart,
125
+ });
120
126
  }
121
127
  return;
122
128
  }
@@ -134,8 +140,8 @@ export function withSegmentResolution<TEnv>(
134
140
  ctx.routeKey,
135
141
  ctx.matched.params,
136
142
  ctx.handlerContext,
137
- ctx.loaderPromises
138
- )
143
+ ctx.loaderPromises,
144
+ ),
139
145
  );
140
146
 
141
147
  // Update state with resolved segments
@@ -163,8 +169,9 @@ export function withSegmentResolution<TEnv>(
163
169
  ctx.actionContext,
164
170
  ctx.interceptResult,
165
171
  ctx.localRouteName,
166
- ctx.pathname
167
- )
172
+ ctx.pathname,
173
+ ctx.stale,
174
+ ),
168
175
  );
169
176
 
170
177
  // Update state with resolved segments
@@ -178,7 +185,11 @@ export function withSegmentResolution<TEnv>(
178
185
  }
179
186
 
180
187
  if (ms) {
181
- ms.metrics.push({ label: "pipeline:segment-resolve", duration: performance.now() - pipelineStart, startTime: pipelineStart - ms.requestStart });
188
+ ms.metrics.push({
189
+ label: "pipeline:segment-resolve",
190
+ duration: performance.now() - ownStart,
191
+ startTime: ownStart - ms.requestStart,
192
+ });
182
193
  }
183
194
  };
184
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,14 +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
112
  import { debugLog } from "./logging.js";
113
+ import { appendMetric } from "./metrics.js";
113
114
 
114
115
  /**
115
116
  * Collect all segments from an async generator
116
117
  */
117
118
  export async function collectSegments(
118
- generator: AsyncGenerator<ResolvedSegment>
119
+ generator: AsyncGenerator<ResolvedSegment>,
119
120
  ): Promise<ResolvedSegment[]> {
120
121
  const segments: ResolvedSegment[] = [];
121
122
  for await (const segment of generator) {
@@ -130,17 +131,30 @@ export async function collectSegments(
130
131
  export function buildMatchResult<TEnv>(
131
132
  allSegments: ResolvedSegment[],
132
133
  ctx: MatchContext<TEnv>,
133
- state: MatchPipelineState
134
+ state: MatchPipelineState,
134
135
  ): MatchResult {
135
- const logPrefix = ctx.isFullMatch ? "[Router.match]" : "[Router.matchPartial]";
136
+ const logPrefix = ctx.isFullMatch
137
+ ? "[Router.match]"
138
+ : "[Router.matchPartial]";
136
139
 
137
140
  let allIds: string[];
138
141
  let segmentsToRender: ResolvedSegment[];
139
142
 
140
143
  if (ctx.isFullMatch) {
141
144
  // Full match (document request) - all segments are rendered
142
- allIds = allSegments.map((s) => s.id);
143
- 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);
144
158
  } else {
145
159
  // Partial match (navigation) - filter and handle intercepts
146
160
  // When intercepting, tell browser to keep its current segments + add modal
@@ -152,10 +166,18 @@ export function buildMatchResult<TEnv>(
152
166
  : allSegments.map((s) => s.id) // Use actual segments, not matchedIds
153
167
  : [...state.matchedIds, ...state.interceptSegments.map((s) => s.id)];
154
168
 
155
- // Filter out segments with null components (client already has them)
156
- // BUT always include loader segments - they carry data even with null component
169
+ // Deduplicate allIds (defense-in-depth for partial match path)
170
+ allIds = [...new Set(allIds)];
171
+
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);
157
178
  segmentsToRender = allSegments.filter(
158
- (s) => s.component !== null || s.type === "loader"
179
+ (s) =>
180
+ s.component !== null || s.type === "loader" || !clientIdSet.has(s.id),
159
181
  );
160
182
  }
161
183
 
@@ -170,20 +192,12 @@ export function buildMatchResult<TEnv>(
170
192
  segmentIds: segmentsToRender.map((s) => s.id),
171
193
  });
172
194
 
173
- // Output metrics if enabled
174
- let serverTiming: string | undefined;
175
- if (ctx.metricsStore) {
176
- logMetrics(ctx.request.method, ctx.pathname, ctx.metricsStore);
177
- serverTiming = generateServerTiming(ctx.metricsStore);
178
- }
179
-
180
195
  return {
181
196
  segments: segmentsToRender,
182
197
  matched: allIds,
183
198
  diff: segmentsToRender.map((s) => s.id),
184
199
  params: ctx.matched.params,
185
200
  routeName: ctx.routeKey,
186
- serverTiming,
187
201
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
188
202
  routeMiddleware:
189
203
  ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
@@ -199,14 +213,23 @@ export function buildMatchResult<TEnv>(
199
213
  export async function collectMatchResult<TEnv>(
200
214
  pipeline: AsyncGenerator<ResolvedSegment>,
201
215
  ctx: MatchContext<TEnv>,
202
- state: MatchPipelineState
216
+ state: MatchPipelineState,
203
217
  ): Promise<MatchResult> {
204
218
  const allSegments = await collectSegments(pipeline);
205
219
 
220
+ const buildStart = performance.now();
221
+
206
222
  // Update state with collected segments if not already set
207
223
  if (state.segments.length === 0) {
208
224
  state.segments = allSegments;
209
225
  }
210
226
 
211
- 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;
212
235
  }