@rangojs/router 0.0.0-experimental.20 → 0.0.0-experimental.204030a9

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 (293) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +242 -55
  3. package/dist/bin/rango.js +277 -99
  4. package/dist/vite/index.js +2929 -1132
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +68 -21
  8. package/skills/breadcrumbs/SKILL.md +252 -0
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +243 -21
  11. package/skills/caching/SKILL.md +159 -10
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +262 -51
  16. package/skills/host-router/SKILL.md +243 -0
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +249 -17
  21. package/skills/loader/SKILL.md +291 -31
  22. package/skills/middleware/SKILL.md +49 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +197 -6
  28. package/skills/prerender/SKILL.md +125 -102
  29. package/skills/rango/SKILL.md +242 -23
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +66 -9
  32. package/skills/route/SKILL.md +91 -8
  33. package/skills/router-setup/SKILL.md +98 -8
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +511 -188
  37. package/skills/typesafety/SKILL.md +354 -50
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +117 -0
  42. package/src/__internal.ts +92 -0
  43. package/src/browser/action-coordinator.ts +53 -36
  44. package/src/browser/app-shell.ts +52 -0
  45. package/src/browser/app-version.ts +14 -0
  46. package/src/browser/event-controller.ts +91 -70
  47. package/src/browser/history-state.ts +21 -0
  48. package/src/browser/index.ts +3 -3
  49. package/src/browser/link-interceptor.ts +4 -0
  50. package/src/browser/navigation-bridge.ts +183 -18
  51. package/src/browser/navigation-client.ts +187 -57
  52. package/src/browser/navigation-store.ts +75 -17
  53. package/src/browser/navigation-transaction.ts +21 -37
  54. package/src/browser/partial-update.ts +143 -40
  55. package/src/browser/prefetch/cache.ts +275 -28
  56. package/src/browser/prefetch/fetch.ts +191 -46
  57. package/src/browser/prefetch/policy.ts +6 -0
  58. package/src/browser/prefetch/queue.ts +123 -20
  59. package/src/browser/prefetch/resource-ready.ts +77 -0
  60. package/src/browser/rango-state.ts +53 -13
  61. package/src/browser/react/Link.tsx +98 -14
  62. package/src/browser/react/NavigationProvider.tsx +110 -33
  63. package/src/browser/react/context.ts +7 -2
  64. package/src/browser/react/filter-segment-order.ts +51 -7
  65. package/src/browser/react/index.ts +3 -0
  66. package/src/browser/react/location-state-shared.ts +175 -4
  67. package/src/browser/react/location-state.ts +39 -13
  68. package/src/browser/react/use-handle.ts +23 -64
  69. package/src/browser/react/use-navigation.ts +22 -2
  70. package/src/browser/react/use-params.ts +20 -8
  71. package/src/browser/react/use-reverse.ts +106 -0
  72. package/src/browser/react/use-router.ts +43 -10
  73. package/src/browser/react/use-segments.ts +11 -8
  74. package/src/browser/response-adapter.ts +25 -0
  75. package/src/browser/rsc-router.tsx +200 -75
  76. package/src/browser/scroll-restoration.ts +46 -39
  77. package/src/browser/segment-reconciler.ts +36 -9
  78. package/src/browser/segment-structure-assert.ts +2 -2
  79. package/src/browser/server-action-bridge.ts +31 -36
  80. package/src/browser/types.ts +81 -5
  81. package/src/build/collect-fallback-refs.ts +107 -0
  82. package/src/build/generate-manifest.ts +65 -40
  83. package/src/build/generate-route-types.ts +5 -0
  84. package/src/build/index.ts +2 -0
  85. package/src/build/route-trie.ts +69 -26
  86. package/src/build/route-types/codegen.ts +4 -4
  87. package/src/build/route-types/include-resolution.ts +9 -2
  88. package/src/build/route-types/per-module-writer.ts +7 -4
  89. package/src/build/route-types/router-processing.ts +278 -88
  90. package/src/build/route-types/scan-filter.ts +9 -2
  91. package/src/build/route-types/source-scan.ts +118 -0
  92. package/src/build/runtime-discovery.ts +9 -20
  93. package/src/cache/cache-runtime.ts +15 -11
  94. package/src/cache/cache-scope.ts +76 -49
  95. package/src/cache/cf/cf-cache-store.ts +501 -18
  96. package/src/cache/cf/index.ts +5 -1
  97. package/src/cache/document-cache.ts +17 -7
  98. package/src/cache/index.ts +1 -0
  99. package/src/cache/taint.ts +55 -0
  100. package/src/client.rsc.tsx +5 -1
  101. package/src/client.tsx +95 -284
  102. package/src/context-var.ts +72 -2
  103. package/src/debug.ts +2 -2
  104. package/src/decode-loader-results.ts +36 -0
  105. package/src/errors.ts +30 -1
  106. package/src/handle.ts +65 -12
  107. package/src/handles/breadcrumbs.ts +66 -0
  108. package/src/handles/index.ts +1 -0
  109. package/src/host/index.ts +2 -5
  110. package/src/host/router.ts +129 -57
  111. package/src/host/types.ts +31 -2
  112. package/src/host/utils.ts +1 -1
  113. package/src/href-client.ts +140 -20
  114. package/src/index.rsc.ts +15 -40
  115. package/src/index.ts +92 -76
  116. package/src/loader-store.ts +500 -0
  117. package/src/loader.rsc.ts +2 -5
  118. package/src/loader.ts +3 -10
  119. package/src/missing-id-error.ts +68 -0
  120. package/src/outlet-context.ts +1 -1
  121. package/src/prerender/store.ts +57 -15
  122. package/src/prerender.ts +141 -80
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -15
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +435 -260
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +110 -34
  129. package/src/route-definition/index.ts +3 -3
  130. package/src/route-definition/redirect.ts +11 -3
  131. package/src/route-definition/resolve-handler-use.ts +155 -0
  132. package/src/route-definition/use-item-types.ts +32 -0
  133. package/src/route-map-builder.ts +7 -1
  134. package/src/route-types.ts +37 -41
  135. package/src/router/basename.ts +14 -0
  136. package/src/router/content-negotiation.ts +113 -1
  137. package/src/router/error-handling.ts +1 -1
  138. package/src/router/find-match.ts +4 -2
  139. package/src/router/handler-context.ts +105 -39
  140. package/src/router/intercept-resolution.ts +15 -22
  141. package/src/router/lazy-includes.ts +12 -9
  142. package/src/router/loader-resolution.ts +175 -23
  143. package/src/router/logging.ts +5 -2
  144. package/src/router/manifest.ts +31 -16
  145. package/src/router/match-api.ts +129 -193
  146. package/src/router/match-handlers.ts +63 -20
  147. package/src/router/match-middleware/background-revalidation.ts +30 -2
  148. package/src/router/match-middleware/cache-lookup.ts +136 -106
  149. package/src/router/match-middleware/cache-store.ts +54 -10
  150. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  151. package/src/router/match-middleware/segment-resolution.ts +61 -5
  152. package/src/router/match-result.ts +124 -18
  153. package/src/router/metrics.ts +239 -14
  154. package/src/router/middleware-types.ts +61 -31
  155. package/src/router/middleware.ts +226 -124
  156. package/src/router/navigation-snapshot.ts +182 -0
  157. package/src/router/pattern-matching.ts +118 -19
  158. package/src/router/prerender-match.ts +114 -10
  159. package/src/router/preview-match.ts +32 -102
  160. package/src/router/request-classification.ts +286 -0
  161. package/src/router/revalidation.ts +85 -9
  162. package/src/router/route-snapshot.ts +245 -0
  163. package/src/router/router-context.ts +6 -1
  164. package/src/router/router-interfaces.ts +91 -29
  165. package/src/router/router-options.ts +89 -19
  166. package/src/router/router-registry.ts +2 -5
  167. package/src/router/segment-resolution/fresh.ts +240 -23
  168. package/src/router/segment-resolution/helpers.ts +30 -25
  169. package/src/router/segment-resolution/loader-cache.ts +1 -0
  170. package/src/router/segment-resolution/revalidation.ts +483 -289
  171. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  172. package/src/router/segment-wrappers.ts +2 -0
  173. package/src/router/substitute-pattern-params.ts +56 -0
  174. package/src/router/telemetry.ts +99 -0
  175. package/src/router/trie-matching.ts +38 -15
  176. package/src/router/types.ts +9 -0
  177. package/src/router/url-params.ts +49 -0
  178. package/src/router.ts +120 -32
  179. package/src/rsc/handler-context.ts +2 -2
  180. package/src/rsc/handler.ts +524 -370
  181. package/src/rsc/helpers.ts +91 -43
  182. package/src/rsc/index.ts +1 -21
  183. package/src/rsc/loader-fetch.ts +23 -3
  184. package/src/rsc/manifest-init.ts +5 -1
  185. package/src/rsc/origin-guard.ts +28 -10
  186. package/src/rsc/progressive-enhancement.ts +39 -10
  187. package/src/rsc/response-route-handler.ts +46 -53
  188. package/src/rsc/rsc-rendering.ts +69 -89
  189. package/src/rsc/runtime-warnings.ts +9 -10
  190. package/src/rsc/server-action.ts +39 -47
  191. package/src/rsc/ssr-setup.ts +144 -0
  192. package/src/rsc/types.ts +19 -3
  193. package/src/search-params.ts +20 -17
  194. package/src/segment-content-promise.ts +67 -0
  195. package/src/segment-loader-promise.ts +122 -0
  196. package/src/segment-system.tsx +219 -67
  197. package/src/serialize.ts +243 -0
  198. package/src/server/context.ts +285 -63
  199. package/src/server/cookie-store.ts +28 -4
  200. package/src/server/handle-store.ts +19 -0
  201. package/src/server/loader-registry.ts +9 -8
  202. package/src/server/request-context.ts +228 -65
  203. package/src/server.ts +6 -0
  204. package/src/ssr/index.tsx +9 -1
  205. package/src/static-handler.ts +19 -7
  206. package/src/testing/cache-status.ts +166 -0
  207. package/src/testing/collect-handle.ts +63 -0
  208. package/src/testing/dispatch.ts +440 -0
  209. package/src/testing/dom.entry.ts +22 -0
  210. package/src/testing/e2e/fixture.ts +154 -0
  211. package/src/testing/e2e/index.ts +149 -0
  212. package/src/testing/e2e/matchers.ts +51 -0
  213. package/src/testing/e2e/page-helpers.ts +272 -0
  214. package/src/testing/e2e/parity.ts +306 -0
  215. package/src/testing/e2e/server.ts +183 -0
  216. package/src/testing/flight-matchers.ts +104 -0
  217. package/src/testing/flight-runtime.d.ts +21 -0
  218. package/src/testing/flight.entry.ts +22 -0
  219. package/src/testing/flight.ts +182 -0
  220. package/src/testing/generated-routes.ts +223 -0
  221. package/src/testing/index.ts +98 -0
  222. package/src/testing/internal/context.ts +151 -0
  223. package/src/testing/render-route.tsx +536 -0
  224. package/src/testing/run-loader.ts +296 -0
  225. package/src/testing/run-middleware.ts +170 -0
  226. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  227. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  228. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  229. package/src/testing/vitest-stubs/version.ts +5 -0
  230. package/src/testing/vitest.ts +112 -0
  231. package/src/theme/index.ts +4 -13
  232. package/src/types/cache-types.ts +4 -4
  233. package/src/types/global-namespace.ts +39 -26
  234. package/src/types/handler-context.ts +197 -79
  235. package/src/types/index.ts +1 -0
  236. package/src/types/loader-types.ts +41 -15
  237. package/src/types/request-scope.ts +126 -0
  238. package/src/types/route-config.ts +17 -8
  239. package/src/types/route-entry.ts +19 -1
  240. package/src/types/segments.ts +37 -6
  241. package/src/urls/include-helper.ts +34 -67
  242. package/src/urls/index.ts +0 -3
  243. package/src/urls/path-helper-types.ts +50 -9
  244. package/src/urls/path-helper.ts +63 -63
  245. package/src/urls/pattern-types.ts +48 -19
  246. package/src/urls/response-types.ts +25 -22
  247. package/src/urls/type-extraction.ts +26 -116
  248. package/src/urls/urls-function.ts +1 -5
  249. package/src/use-loader.tsx +487 -44
  250. package/src/vite/debug.ts +185 -0
  251. package/src/vite/discovery/bundle-postprocess.ts +63 -91
  252. package/src/vite/discovery/discover-routers.ts +106 -53
  253. package/src/vite/discovery/discovery-errors.ts +194 -0
  254. package/src/vite/discovery/gate-state.ts +171 -0
  255. package/src/vite/discovery/prerender-collection.ts +222 -107
  256. package/src/vite/discovery/route-types-writer.ts +40 -84
  257. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  258. package/src/vite/discovery/state.ts +50 -13
  259. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  260. package/src/vite/index.ts +10 -3
  261. package/src/vite/plugin-types.ts +111 -72
  262. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  263. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  264. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  265. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  266. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  267. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  268. package/src/vite/plugins/expose-action-id.ts +55 -33
  269. package/src/vite/plugins/expose-id-utils.ts +24 -8
  270. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  271. package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
  272. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  273. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  274. package/src/vite/plugins/expose-internal-ids.ts +544 -317
  275. package/src/vite/plugins/performance-tracks.ts +92 -0
  276. package/src/vite/plugins/refresh-cmd.ts +127 -0
  277. package/src/vite/plugins/use-cache-transform.ts +65 -50
  278. package/src/vite/plugins/version-injector.ts +39 -23
  279. package/src/vite/plugins/version-plugin.ts +72 -3
  280. package/src/vite/plugins/virtual-entries.ts +2 -2
  281. package/src/vite/rango.ts +265 -226
  282. package/src/vite/router-discovery.ts +924 -137
  283. package/src/vite/utils/ast-handler-extract.ts +15 -15
  284. package/src/vite/utils/banner.ts +4 -4
  285. package/src/vite/utils/bundle-analysis.ts +4 -2
  286. package/src/vite/utils/client-chunks.ts +190 -0
  287. package/src/vite/utils/forward-user-plugins.ts +193 -0
  288. package/src/vite/utils/manifest-utils.ts +21 -5
  289. package/src/vite/utils/package-resolution.ts +41 -1
  290. package/src/vite/utils/prerender-utils.ts +98 -5
  291. package/src/vite/utils/shared-utils.ts +109 -27
  292. package/src/browser/action-response-classifier.ts +0 -99
  293. package/src/route-definition/route-function.ts +0 -119
@@ -87,10 +87,49 @@
87
87
  * if (state.cacheHit) return; // Now we can check
88
88
  */
89
89
  import type { ResolvedSegment } from "../../types.js";
90
+ import type { EntryData } from "../../server/context.js";
91
+ import { _getRequestContext } from "../../server/request-context.js";
90
92
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
91
93
  import { getRouterContext } from "../router-context.js";
92
94
  import type { GeneratorMiddleware } from "./cache-lookup.js";
93
95
 
96
+ /**
97
+ * Check whether any entry in the tree uses loading() (streaming).
98
+ * Matches the router's streaming semantics in fresh.ts: streaming is
99
+ * enabled when `loading` is defined AND not `false`. `loading: false`
100
+ * explicitly disables streaming; `undefined` means no loading at all.
101
+ */
102
+ export function treeHasStreaming(entries: EntryData[]): boolean {
103
+ for (const entry of entries) {
104
+ if (
105
+ "loading" in entry &&
106
+ entry.loading !== undefined &&
107
+ entry.loading !== false
108
+ )
109
+ return true;
110
+ if (entry.layout) {
111
+ if (treeHasStreaming(entry.layout)) return true;
112
+ }
113
+ if (entry.parallel) {
114
+ for (const key in entry.parallel) {
115
+ const parallelEntry = entry.parallel[key as `@${string}`];
116
+ if (parallelEntry) {
117
+ if (
118
+ "loading" in parallelEntry &&
119
+ parallelEntry.loading !== undefined &&
120
+ parallelEntry.loading !== false
121
+ )
122
+ return true;
123
+ if (parallelEntry.layout) {
124
+ if (treeHasStreaming(parallelEntry.layout)) return true;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+
94
133
  /**
95
134
  * Creates segment resolution middleware
96
135
  *
@@ -104,7 +143,6 @@ export function withSegmentResolution<TEnv>(
104
143
  return async function* (
105
144
  source: AsyncGenerator<ResolvedSegment>,
106
145
  ): AsyncGenerator<ResolvedSegment> {
107
- const pipelineStart = performance.now();
108
146
  const ms = ctx.metricsStore;
109
147
 
110
148
  // IMPORTANT: Always iterate source first to give cache-lookup a chance
@@ -113,18 +151,27 @@ export function withSegmentResolution<TEnv>(
113
151
  yield segment;
114
152
  }
115
153
 
154
+ // Measure own work only (after source iteration completes)
155
+ const ownStart = performance.now();
156
+
116
157
  // If cache hit, segments were already yielded by cache lookup
158
+ // (render barrier is resolved on the cache-hit path)
117
159
  if (state.cacheHit) {
118
160
  if (ms) {
119
161
  ms.metrics.push({
120
162
  label: "pipeline:segment-resolve",
121
- duration: performance.now() - pipelineStart,
122
- startTime: pipelineStart - ms.requestStart,
163
+ duration: performance.now() - ownStart,
164
+ startTime: ownStart - ms.requestStart,
123
165
  });
124
166
  }
125
167
  return;
126
168
  }
127
169
 
170
+ const reqCtx = _getRequestContext();
171
+ if (reqCtx && reqCtx._treeHasStreaming === undefined) {
172
+ reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
173
+ }
174
+
128
175
  const { resolveAllSegmentsWithRevalidation, resolveAllSegments } =
129
176
  getRouterContext<TEnv>();
130
177
 
@@ -146,6 +193,10 @@ export function withSegmentResolution<TEnv>(
146
193
  state.segments = segments;
147
194
  state.matchedIds = segments.map((s: { id: string }) => s.id);
148
195
 
196
+ if (reqCtx) {
197
+ reqCtx._resolveRenderBarrier(segments);
198
+ }
199
+
149
200
  // Yield all resolved segments
150
201
  for (const segment of segments) {
151
202
  yield segment;
@@ -168,6 +219,7 @@ export function withSegmentResolution<TEnv>(
168
219
  ctx.interceptResult,
169
220
  ctx.localRouteName,
170
221
  ctx.pathname,
222
+ ctx.stale,
171
223
  ),
172
224
  );
173
225
 
@@ -175,6 +227,10 @@ export function withSegmentResolution<TEnv>(
175
227
  state.segments = result.segments;
176
228
  state.matchedIds = result.matchedIds;
177
229
 
230
+ if (reqCtx) {
231
+ reqCtx._resolveRenderBarrier(result.segments);
232
+ }
233
+
178
234
  // Yield all resolved segments
179
235
  for (const segment of result.segments) {
180
236
  yield segment;
@@ -184,8 +240,8 @@ export function withSegmentResolution<TEnv>(
184
240
  if (ms) {
185
241
  ms.metrics.push({
186
242
  label: "pipeline:segment-resolve",
187
- duration: performance.now() - pipelineStart,
188
- startTime: pipelineStart - ms.requestStart,
243
+ duration: performance.now() - ownStart,
244
+ startTime: ownStart - ms.requestStart,
189
245
  });
190
246
  }
191
247
  };
@@ -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,8 +109,8 @@
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
@@ -124,6 +125,76 @@ export async function collectSegments(
124
125
  return segments;
125
126
  }
126
127
 
128
+ /**
129
+ * Deduplicate inherited loader segments by loaderId.
130
+ *
131
+ * When a route has loaders and a child layout has parallel slots, the same
132
+ * loader is resolved twice: once for the route and once inherited into the
133
+ * layout (tagged with `_inherited`). The inherited copy is only needed when
134
+ * the route uses `loading()` — in that case, the loader data is inside a
135
+ * LoaderBoundary/Suspense that parallel slots can't reach through. Without
136
+ * loading(), useLoader() traverses parent contexts and finds the data.
137
+ */
138
+ function deduplicateLoaderSegments(
139
+ segments: ResolvedSegment[],
140
+ logPrefix: string,
141
+ ): { segments: ResolvedSegment[]; removedIds: Set<string> } {
142
+ // Single pass: original (non-inherited) loaderIds, all loaderIds grouped by
143
+ // namespace, and namespaces of segments that declare loading().
144
+ const originalLoaders = new Set<string>();
145
+ const loaderIdsByNamespace = new Map<string, string[]>();
146
+ const namespacesWithLoading = new Set<string>();
147
+ for (const s of segments) {
148
+ if (s.type === "loader" && s.loaderId) {
149
+ if (!s._inherited) originalLoaders.add(s.loaderId);
150
+ const ids = loaderIdsByNamespace.get(s.namespace);
151
+ if (ids) ids.push(s.loaderId);
152
+ else loaderIdsByNamespace.set(s.namespace, [s.loaderId]);
153
+ } else if (
154
+ s.type !== "loader" &&
155
+ s.loading !== undefined &&
156
+ s.loading !== false
157
+ ) {
158
+ namespacesWithLoading.add(s.namespace);
159
+ }
160
+ }
161
+
162
+ // An inherited loader is needed when it shares a namespace with a
163
+ // loading-bearing segment (its data sits behind that LoaderBoundary).
164
+ const loadersWithLoading = new Set<string>();
165
+ for (const ns of namespacesWithLoading) {
166
+ for (const id of loaderIdsByNamespace.get(ns) ?? []) {
167
+ loadersWithLoading.add(id);
168
+ }
169
+ }
170
+
171
+ const result: ResolvedSegment[] = [];
172
+ const removedIds = new Set<string>();
173
+
174
+ for (const s of segments) {
175
+ if (
176
+ s.type === "loader" &&
177
+ s.loaderId &&
178
+ s._inherited &&
179
+ originalLoaders.has(s.loaderId) &&
180
+ !loadersWithLoading.has(s.loaderId)
181
+ ) {
182
+ removedIds.add(s.id);
183
+ continue;
184
+ }
185
+ result.push(s);
186
+ }
187
+
188
+ if (removedIds.size > 0) {
189
+ debugLog(
190
+ logPrefix,
191
+ `deduped ${removedIds.size} inherited loader segment(s)`,
192
+ );
193
+ }
194
+
195
+ return { segments: result, removedIds };
196
+ }
197
+
127
198
  /**
128
199
  * Build the final MatchResult from collected segments and context
129
200
  */
@@ -168,13 +239,23 @@ export function buildMatchResult<TEnv>(
168
239
  // Deduplicate allIds (defense-in-depth for partial match path)
169
240
  allIds = [...new Set(allIds)];
170
241
 
171
- // Filter out segments with null components (client already has them)
172
- // BUT always include loader segments - they carry data even with null component
242
+ // Filter out null-component segments only when the client already has
243
+ // them cached (revalidation skip). If the client doesn't have the segment,
244
+ // it must be included even with null component — it's structurally required
245
+ // as a parent node for child layouts/parallels to reconcile against.
246
+ // Loader segments are always included as they carry data.
247
+ const clientIdSet = new Set(ctx.clientSegmentIds);
173
248
  segmentsToRender = allSegments.filter(
174
- (s) => s.component !== null || s.type === "loader",
249
+ (s) =>
250
+ s.component !== null || s.type === "loader" || !clientIdSet.has(s.id),
175
251
  );
176
252
  }
177
253
 
254
+ const { segments: dedupedSegments, removedIds } = deduplicateLoaderSegments(
255
+ segmentsToRender,
256
+ logPrefix,
257
+ );
258
+
178
259
  debugLog(logPrefix, "all segments", {
179
260
  segments: allSegments.map((s) => ({
180
261
  id: s.id,
@@ -183,23 +264,39 @@ export function buildMatchResult<TEnv>(
183
264
  })),
184
265
  });
185
266
  debugLog(logPrefix, "segments to render", {
186
- segmentIds: segmentsToRender.map((s) => s.id),
267
+ segmentIds: dedupedSegments.map((s) => s.id),
187
268
  });
188
269
 
189
- // Output metrics if enabled
190
- let serverTiming: string | undefined;
191
- if (ctx.metricsStore) {
192
- logMetrics(ctx.request.method, ctx.pathname, ctx.metricsStore);
193
- serverTiming = generateServerTiming(ctx.metricsStore);
194
- }
270
+ // Remove deduped loader IDs from matched so the client doesn't treat
271
+ // them as missing segments and trigger a fallback refetch.
272
+ const matchedIds =
273
+ removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
274
+
275
+ // resolvedIds: every segment whose handler actually ran this request.
276
+ // For full-match every segment is fresh; for partial-match we filter by
277
+ // the internal `_handlerRan` flag set in revalidation.ts. Drives the
278
+ // client's handle-bucket cleanup — a slot that re-resolved and pushed
279
+ // nothing must have its previous handle data cleared, but `diff` won't
280
+ // carry it because the segment payload skips null-component cached
281
+ // segments to save bytes.
282
+ const resolvedIds = ctx.isFullMatch
283
+ ? allSegments.map((s) => s.id)
284
+ : allSegments.filter((s) => s._handlerRan).map((s) => s.id);
285
+
286
+ // Strip internal-only fields from the segments going on the wire.
287
+ const cleanedSegments = dedupedSegments.map((s) => {
288
+ if (s._handlerRan === undefined) return s;
289
+ const { _handlerRan: _drop, ...rest } = s;
290
+ return rest as ResolvedSegment;
291
+ });
195
292
 
196
293
  return {
197
- segments: segmentsToRender,
198
- matched: allIds,
199
- diff: segmentsToRender.map((s) => s.id),
294
+ segments: cleanedSegments,
295
+ matched: matchedIds,
296
+ diff: cleanedSegments.map((s) => s.id),
297
+ resolvedIds,
200
298
  params: ctx.matched.params,
201
299
  routeName: ctx.routeKey,
202
- serverTiming,
203
300
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
204
301
  routeMiddleware:
205
302
  ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
@@ -219,10 +316,19 @@ export async function collectMatchResult<TEnv>(
219
316
  ): Promise<MatchResult> {
220
317
  const allSegments = await collectSegments(pipeline);
221
318
 
319
+ const buildStart = performance.now();
320
+
222
321
  // Update state with collected segments if not already set
223
322
  if (state.segments.length === 0) {
224
323
  state.segments = allSegments;
225
324
  }
226
325
 
227
- return buildMatchResult(allSegments, ctx, state);
326
+ const result = buildMatchResult(allSegments, ctx, state);
327
+ appendMetric(
328
+ ctx.metricsStore,
329
+ "collect-result",
330
+ buildStart,
331
+ performance.now() - buildStart,
332
+ );
333
+ return result;
228
334
  }
@@ -1,27 +1,220 @@
1
1
  /**
2
2
  * Router Metrics Utilities
3
3
  *
4
- * Performance metrics collection and reporting for RSC Router.
4
+ * Performance metrics collection and reporting for Rango.
5
5
  */
6
6
 
7
- import type { MetricsStore } from "../server/context";
7
+ import type { MetricsStore, PerformanceMetric } from "../server/context";
8
+
9
+ const BASE_INDENT = 2;
10
+ const DEPTH_INDENT = 2;
11
+ const TIMELINE_WIDTH = 40;
12
+
13
+ function formatMs(value: number): string {
14
+ return `${value.toFixed(2)}ms`;
15
+ }
16
+
17
+ function sortMetrics(metrics: PerformanceMetric[]): PerformanceMetric[] {
18
+ return [...metrics].sort((a, b) => {
19
+ // handler:total always goes last (it wraps everything)
20
+ if (a.label === "handler:total") return 1;
21
+ if (b.label === "handler:total") return -1;
22
+ return a.startTime - b.startTime;
23
+ });
24
+ }
25
+
26
+ interface Span {
27
+ startTime: number;
28
+ duration: number;
29
+ }
30
+
31
+ function renderTimeline(spans: Span[], total: number): string {
32
+ if (TIMELINE_WIDTH <= 0) {
33
+ return "||";
34
+ }
35
+
36
+ const cells = Array(TIMELINE_WIDTH).fill(".");
37
+
38
+ if (!(total > 0)) {
39
+ cells[0] = "#";
40
+ return `|${cells.join("")}|`;
41
+ }
42
+
43
+ for (const span of spans) {
44
+ const start = Math.max(0, span.startTime);
45
+ const end = Math.max(start, span.startTime + span.duration);
46
+ const startColumn = Math.min(
47
+ TIMELINE_WIDTH - 1,
48
+ Math.floor((start / total) * TIMELINE_WIDTH),
49
+ );
50
+ const endColumn = Math.max(
51
+ startColumn + 1,
52
+ Math.min(
53
+ TIMELINE_WIDTH,
54
+ Math.ceil((Math.min(total, end) / total) * TIMELINE_WIDTH),
55
+ ),
56
+ );
57
+
58
+ cells.fill("#", startColumn, endColumn);
59
+ }
60
+
61
+ return `|${cells.join("")}|`;
62
+ }
63
+
64
+ function createTimelineAxis(total: number): string {
65
+ const totalLabel = formatMs(total);
66
+ return `0ms${" ".repeat(
67
+ Math.max(1, TIMELINE_WIDTH - "0ms".length - totalLabel.length),
68
+ )}${totalLabel}`;
69
+ }
8
70
 
9
71
  /**
10
- * Create a metrics store for the request if debugPerformance is enabled
72
+ * Create a metrics store for the request if debugPerformance is enabled.
73
+ * An optional `requestStart` timestamp can anchor the store to an earlier
74
+ * point (e.g. handler start) so that handler:total has startTime=0.
11
75
  */
12
76
  export function createMetricsStore(
13
77
  debugPerformance: boolean,
78
+ requestStart?: number,
14
79
  ): MetricsStore | undefined {
15
80
  if (!debugPerformance) return undefined;
16
81
  return {
17
82
  enabled: true,
18
- requestStart: performance.now(),
83
+ requestStart: requestStart ?? performance.now(),
19
84
  metrics: [],
20
85
  };
21
86
  }
22
87
 
23
88
  /**
24
- * Log metrics to console in a formatted way
89
+ * Append a metric to the request store using an absolute start timestamp.
90
+ */
91
+ export function appendMetric(
92
+ metricsStore: MetricsStore | undefined,
93
+ label: string,
94
+ start: number,
95
+ duration: number,
96
+ depth?: number,
97
+ ): void {
98
+ if (!metricsStore) return;
99
+ metricsStore.metrics.push({
100
+ label,
101
+ duration,
102
+ startTime: start - metricsStore.requestStart,
103
+ depth,
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Log the current request metrics and return the corresponding Server-Timing value.
109
+ */
110
+ export function buildMetricsTiming(
111
+ method: string,
112
+ pathname: string,
113
+ metricsStore: MetricsStore | undefined,
114
+ ): string | undefined {
115
+ if (!metricsStore) return undefined;
116
+ logMetrics(method, pathname, metricsStore);
117
+ return generateServerTiming(metricsStore) || undefined;
118
+ }
119
+
120
+ /** Display row produced by merging :pre/:post metric pairs. */
121
+ interface DisplayRow {
122
+ label: string;
123
+ startTime: number;
124
+ duration: number;
125
+ depth: number | undefined;
126
+ spans: Span[];
127
+ }
128
+
129
+ /**
130
+ * Build display rows from sorted metrics, merging :pre/:post pairs into
131
+ * a single row with disjoint timeline segments.
132
+ */
133
+ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
134
+ // Index :pre and :post metrics by their base label
135
+ const preMap = new Map<string, PerformanceMetric>();
136
+ const postMap = new Map<string, PerformanceMetric>();
137
+ const consumed = new Set<PerformanceMetric>();
138
+
139
+ for (const m of sorted) {
140
+ if (m.label.endsWith(":pre")) {
141
+ preMap.set(m.label.slice(0, -4), m);
142
+ } else if (m.label.endsWith(":post")) {
143
+ postMap.set(m.label.slice(0, -5), m);
144
+ }
145
+ }
146
+
147
+ const rows: DisplayRow[] = [];
148
+
149
+ for (const m of sorted) {
150
+ if (consumed.has(m)) continue;
151
+
152
+ if (m.label.endsWith(":pre")) {
153
+ const base = m.label.slice(0, -4);
154
+ const post = postMap.get(base);
155
+ if (post) {
156
+ // Merge into a single row with two disjoint spans
157
+ consumed.add(m);
158
+ consumed.add(post);
159
+ rows.push({
160
+ label: base,
161
+ startTime: m.startTime,
162
+ duration: m.duration + post.duration,
163
+ depth: m.depth,
164
+ spans: [
165
+ { startTime: m.startTime, duration: m.duration },
166
+ { startTime: post.startTime, duration: post.duration },
167
+ ],
168
+ });
169
+ continue;
170
+ }
171
+ // Lone :pre — display with base label
172
+ consumed.add(m);
173
+ rows.push({
174
+ label: base,
175
+ startTime: m.startTime,
176
+ duration: m.duration,
177
+ depth: m.depth,
178
+ spans: [{ startTime: m.startTime, duration: m.duration }],
179
+ });
180
+ continue;
181
+ }
182
+
183
+ if (m.label.endsWith(":post")) {
184
+ const base = m.label.slice(0, -5);
185
+ if (preMap.has(base)) {
186
+ // Already consumed as part of the pair above
187
+ continue;
188
+ }
189
+ // Lone :post — display with base label
190
+ consumed.add(m);
191
+ rows.push({
192
+ label: base,
193
+ startTime: m.startTime,
194
+ duration: m.duration,
195
+ depth: m.depth,
196
+ spans: [{ startTime: m.startTime, duration: m.duration }],
197
+ });
198
+ continue;
199
+ }
200
+
201
+ // Regular metric
202
+ rows.push({
203
+ label: m.label,
204
+ startTime: m.startTime,
205
+ duration: m.duration,
206
+ depth: m.depth,
207
+ spans: [{ startTime: m.startTime, duration: m.duration }],
208
+ });
209
+ }
210
+
211
+ return rows;
212
+ }
213
+
214
+ /**
215
+ * Log metrics to console in a formatted way.
216
+ * Uses a shared-axis timeline so overlapping work stays visible.
217
+ * Merges :pre/:post pairs onto one row with disjoint timeline segments.
25
218
  */
26
219
  export function logMetrics(
27
220
  method: string,
@@ -30,32 +223,64 @@ export function logMetrics(
30
223
  ): void {
31
224
  const total = performance.now() - metricsStore.requestStart;
32
225
 
33
- // Find max label length for alignment
34
- const maxLabelLen = Math.max(
35
- ...metricsStore.metrics.map((m) => m.label.length),
36
- 20,
226
+ const sorted = sortMetrics(metricsStore.metrics);
227
+ const displayRows = buildDisplayRows(sorted);
228
+
229
+ const labels = displayRows.map(
230
+ (r) =>
231
+ `${" ".repeat(BASE_INDENT + (r.depth ?? 0) * DEPTH_INDENT)}${r.label}`,
232
+ );
233
+ const startValues = displayRows.map((r) => formatMs(r.startTime));
234
+ const durationValues = displayRows.map((r) => formatMs(r.duration));
235
+ const startWidth = Math.max(
236
+ "start".length,
237
+ ...startValues.map((v) => v.length),
238
+ );
239
+ const durationWidth = Math.max(
240
+ "dur".length,
241
+ ...durationValues.map((v) => v.length),
242
+ );
243
+ const spanWidth = Math.max(
244
+ "span".length,
245
+ ...labels.map((label) => label.length),
246
+ 22,
247
+ );
248
+ const timelinePadding = " ".repeat(
249
+ startWidth + 2 + durationWidth + 2 + spanWidth + 2,
250
+ );
251
+
252
+ console.log(`[RSC Perf] ${method} ${pathname} (${total.toFixed(2)}ms)`);
253
+ console.log(
254
+ `${"start".padStart(startWidth)} ${"dur".padStart(durationWidth)} ${"span".padEnd(spanWidth)} timeline`,
37
255
  );
256
+ console.log(`${timelinePadding}${createTimelineAxis(total)}`);
38
257
 
39
- console.log(`[RSC Perf] ${method} ${pathname} (${total.toFixed(1)}ms)`);
258
+ for (let index = 0; index < displayRows.length; index++) {
259
+ const row = displayRows[index];
260
+ const label = labels[index].padEnd(spanWidth);
261
+ const start = formatMs(row.startTime).padStart(startWidth);
262
+ const duration = formatMs(row.duration).padStart(durationWidth);
40
263
 
41
- for (const m of metricsStore.metrics) {
42
- const paddedLabel = m.label.padEnd(maxLabelLen);
43
- console.log(` ${paddedLabel} ${m.duration.toFixed(1)}ms`);
264
+ console.log(
265
+ `${start} ${duration} ${label} ${renderTimeline(row.spans, total)}`,
266
+ );
44
267
  }
45
268
  }
46
269
 
47
270
  /**
48
271
  * Generate Server-Timing header value from metrics
49
272
  * Format: metric-name;dur=X.XX
273
+ * Depth is encoded as a "d{N}-" prefix for nested metrics.
50
274
  */
51
275
  export function generateServerTiming(metricsStore: MetricsStore): string {
52
276
  return metricsStore.metrics
53
277
  .map((m) => {
54
278
  // Convert label to valid Server-Timing name (alphanumeric and hyphens)
55
- const name = m.label
279
+ const base = m.label
56
280
  .replace(/:/g, "-")
57
281
  .replace(/[^a-zA-Z0-9-]/g, "")
58
282
  .toLowerCase();
283
+ const name = m.depth ? `d${m.depth}-${base}` : base;
59
284
  return `${name};dur=${m.duration.toFixed(2)}`;
60
285
  })
61
286
  .join(", ");