@rangojs/router 0.0.0-experimental.fb4fdc18 → 0.0.0-experimental.fce7fbd1

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 (214) hide show
  1. package/README.md +9 -9
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +914 -485
  5. package/package.json +55 -11
  6. package/skills/bundle-analysis/SKILL.md +159 -0
  7. package/skills/cache-guide/SKILL.md +220 -30
  8. package/skills/caching/SKILL.md +116 -8
  9. package/skills/composability/SKILL.md +27 -2
  10. package/skills/document-cache/SKILL.md +78 -55
  11. package/skills/handler-use/SKILL.md +3 -1
  12. package/skills/hooks/SKILL.md +214 -18
  13. package/skills/host-router/SKILL.md +45 -20
  14. package/skills/intercept/SKILL.md +26 -4
  15. package/skills/layout/SKILL.md +6 -7
  16. package/skills/links/SKILL.md +173 -17
  17. package/skills/loader/SKILL.md +149 -6
  18. package/skills/middleware/SKILL.md +13 -9
  19. package/skills/migrate-nextjs/SKILL.md +1 -1
  20. package/skills/mime-routes/SKILL.md +27 -0
  21. package/skills/observability/SKILL.md +137 -0
  22. package/skills/parallel/SKILL.md +5 -6
  23. package/skills/prerender/SKILL.md +14 -33
  24. package/skills/rango/SKILL.md +242 -26
  25. package/skills/react-compiler/SKILL.md +168 -0
  26. package/skills/response-routes/SKILL.md +58 -9
  27. package/skills/route/SKILL.md +13 -4
  28. package/skills/router-setup/SKILL.md +3 -3
  29. package/skills/server-actions/SKILL.md +53 -41
  30. package/skills/testing/SKILL.md +599 -0
  31. package/skills/typesafety/SKILL.md +310 -26
  32. package/skills/use-cache/SKILL.md +34 -5
  33. package/skills/view-transitions/SKILL.md +294 -0
  34. package/src/__augment-tests__/augment.ts +81 -0
  35. package/src/__augment-tests__/augmented.check.ts +117 -0
  36. package/src/browser/action-coordinator.ts +53 -36
  37. package/src/browser/event-controller.ts +42 -66
  38. package/src/browser/history-state.ts +21 -0
  39. package/src/browser/index.ts +3 -3
  40. package/src/browser/navigation-bridge.ts +6 -6
  41. package/src/browser/navigation-client.ts +12 -15
  42. package/src/browser/navigation-store.ts +7 -8
  43. package/src/browser/navigation-transaction.ts +10 -28
  44. package/src/browser/partial-update.ts +9 -19
  45. package/src/browser/react/NavigationProvider.tsx +29 -40
  46. package/src/browser/react/index.ts +3 -0
  47. package/src/browser/react/location-state-shared.ts +175 -4
  48. package/src/browser/react/location-state.ts +39 -13
  49. package/src/browser/react/use-handle.ts +17 -9
  50. package/src/browser/react/use-params.ts +3 -4
  51. package/src/browser/react/use-reverse.ts +106 -0
  52. package/src/browser/react/use-router.ts +14 -1
  53. package/src/browser/response-adapter.ts +25 -0
  54. package/src/browser/rsc-router.tsx +30 -16
  55. package/src/browser/scroll-restoration.ts +22 -14
  56. package/src/browser/segment-structure-assert.ts +2 -2
  57. package/src/browser/server-action-bridge.ts +23 -30
  58. package/src/browser/types.ts +2 -0
  59. package/src/build/collect-fallback-refs.ts +107 -0
  60. package/src/build/generate-manifest.ts +60 -35
  61. package/src/build/generate-route-types.ts +2 -0
  62. package/src/build/index.ts +2 -0
  63. package/src/build/route-types/codegen.ts +4 -4
  64. package/src/build/route-types/include-resolution.ts +1 -1
  65. package/src/build/route-types/per-module-writer.ts +7 -4
  66. package/src/build/route-types/router-processing.ts +55 -14
  67. package/src/build/route-types/scan-filter.ts +1 -1
  68. package/src/build/route-types/source-scan.ts +118 -0
  69. package/src/build/runtime-discovery.ts +9 -20
  70. package/src/cache/cache-scope.ts +28 -42
  71. package/src/cache/cf/cf-cache-store.ts +49 -6
  72. package/src/client.rsc.tsx +3 -0
  73. package/src/client.tsx +10 -8
  74. package/src/context-var.ts +5 -5
  75. package/src/decode-loader-results.ts +36 -0
  76. package/src/errors.ts +30 -1
  77. package/src/handle.ts +26 -13
  78. package/src/host/index.ts +2 -2
  79. package/src/host/router.ts +129 -57
  80. package/src/host/types.ts +31 -2
  81. package/src/host/utils.ts +1 -1
  82. package/src/href-client.ts +140 -20
  83. package/src/index.rsc.ts +6 -4
  84. package/src/index.ts +13 -6
  85. package/src/loader-store.ts +500 -0
  86. package/src/loader.rsc.ts +2 -5
  87. package/src/loader.ts +3 -10
  88. package/src/missing-id-error.ts +68 -0
  89. package/src/prerender.ts +4 -4
  90. package/src/response-utils.ts +9 -0
  91. package/src/reverse.ts +65 -41
  92. package/src/route-content-wrapper.tsx +6 -28
  93. package/src/route-definition/dsl-helpers.ts +238 -263
  94. package/src/route-definition/helper-factories.ts +29 -139
  95. package/src/route-definition/helpers-types.ts +37 -14
  96. package/src/route-definition/use-item-types.ts +32 -0
  97. package/src/route-types.ts +19 -41
  98. package/src/router/basename.ts +14 -0
  99. package/src/router/content-negotiation.ts +15 -2
  100. package/src/router/error-handling.ts +1 -1
  101. package/src/router/handler-context.ts +4 -42
  102. package/src/router/intercept-resolution.ts +4 -18
  103. package/src/router/lazy-includes.ts +2 -2
  104. package/src/router/loader-resolution.ts +16 -2
  105. package/src/router/match-handlers.ts +62 -20
  106. package/src/router/match-middleware/cache-lookup.ts +44 -91
  107. package/src/router/match-middleware/cache-store.ts +3 -2
  108. package/src/router/match-result.ts +32 -30
  109. package/src/router/metrics.ts +1 -1
  110. package/src/router/middleware-types.ts +1 -1
  111. package/src/router/middleware.ts +46 -78
  112. package/src/router/prerender-match.ts +1 -1
  113. package/src/router/preview-match.ts +3 -1
  114. package/src/router/request-classification.ts +4 -28
  115. package/src/router/revalidation.ts +43 -1
  116. package/src/router/router-interfaces.ts +45 -28
  117. package/src/router/router-options.ts +40 -1
  118. package/src/router/router-registry.ts +2 -5
  119. package/src/router/segment-resolution/fresh.ts +19 -6
  120. package/src/router/segment-resolution/revalidation.ts +19 -6
  121. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  122. package/src/router/substitute-pattern-params.ts +56 -0
  123. package/src/router/telemetry.ts +99 -0
  124. package/src/router/types.ts +8 -0
  125. package/src/router.ts +37 -21
  126. package/src/rsc/handler-context.ts +2 -2
  127. package/src/rsc/handler.ts +20 -65
  128. package/src/rsc/helpers.ts +22 -2
  129. package/src/rsc/index.ts +1 -1
  130. package/src/rsc/origin-guard.ts +28 -10
  131. package/src/rsc/response-route-handler.ts +32 -52
  132. package/src/rsc/rsc-rendering.ts +27 -53
  133. package/src/rsc/runtime-warnings.ts +9 -10
  134. package/src/rsc/server-action.ts +13 -37
  135. package/src/rsc/ssr-setup.ts +16 -0
  136. package/src/rsc/types.ts +2 -2
  137. package/src/search-params.ts +4 -4
  138. package/src/segment-system.tsx +121 -65
  139. package/src/serialize.ts +243 -0
  140. package/src/server/context.ts +118 -51
  141. package/src/server/cookie-store.ts +28 -4
  142. package/src/server/request-context.ts +10 -0
  143. package/src/static-handler.ts +1 -1
  144. package/src/testing/cache-status.ts +166 -0
  145. package/src/testing/collect-handle.ts +63 -0
  146. package/src/testing/dispatch.ts +440 -0
  147. package/src/testing/dom.entry.ts +22 -0
  148. package/src/testing/e2e/fixture.ts +154 -0
  149. package/src/testing/e2e/index.ts +149 -0
  150. package/src/testing/e2e/matchers.ts +51 -0
  151. package/src/testing/e2e/page-helpers.ts +272 -0
  152. package/src/testing/e2e/parity.ts +306 -0
  153. package/src/testing/e2e/server.ts +183 -0
  154. package/src/testing/flight-matchers.ts +104 -0
  155. package/src/testing/flight-runtime.d.ts +21 -0
  156. package/src/testing/flight.entry.ts +22 -0
  157. package/src/testing/flight.ts +182 -0
  158. package/src/testing/generated-routes.ts +223 -0
  159. package/src/testing/index.ts +105 -0
  160. package/src/testing/internal/context.ts +193 -0
  161. package/src/testing/render-route.tsx +536 -0
  162. package/src/testing/run-loader.ts +296 -0
  163. package/src/testing/run-middleware.ts +170 -0
  164. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  165. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  166. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  167. package/src/testing/vitest-stubs/version.ts +5 -0
  168. package/src/testing/vitest.ts +183 -0
  169. package/src/types/global-namespace.ts +39 -26
  170. package/src/types/handler-context.ts +56 -11
  171. package/src/types/index.ts +1 -0
  172. package/src/types/segments.ts +18 -1
  173. package/src/urls/include-helper.ts +10 -53
  174. package/src/urls/index.ts +0 -3
  175. package/src/urls/path-helper-types.ts +11 -3
  176. package/src/urls/path-helper.ts +17 -52
  177. package/src/urls/pattern-types.ts +36 -19
  178. package/src/urls/response-types.ts +20 -19
  179. package/src/urls/type-extraction.ts +26 -116
  180. package/src/urls/urls-function.ts +1 -5
  181. package/src/use-loader.tsx +413 -42
  182. package/src/vite/debug.ts +1 -0
  183. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  184. package/src/vite/discovery/discover-routers.ts +70 -48
  185. package/src/vite/discovery/discovery-errors.ts +194 -0
  186. package/src/vite/discovery/prerender-collection.ts +19 -25
  187. package/src/vite/discovery/route-types-writer.ts +40 -84
  188. package/src/vite/discovery/state.ts +33 -0
  189. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  190. package/src/vite/index.ts +2 -0
  191. package/src/vite/plugin-types.ts +67 -0
  192. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  193. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  194. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -1
  195. package/src/vite/plugins/expose-action-id.ts +2 -2
  196. package/src/vite/plugins/expose-id-utils.ts +12 -8
  197. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  198. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  199. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  200. package/src/vite/plugins/expose-internal-ids.ts +47 -67
  201. package/src/vite/plugins/performance-tracks.ts +12 -16
  202. package/src/vite/plugins/use-cache-transform.ts +13 -11
  203. package/src/vite/plugins/version-injector.ts +2 -12
  204. package/src/vite/plugins/version-plugin.ts +59 -2
  205. package/src/vite/plugins/virtual-entries.ts +2 -2
  206. package/src/vite/rango.ts +67 -15
  207. package/src/vite/router-discovery.ts +208 -63
  208. package/src/vite/utils/ast-handler-extract.ts +15 -15
  209. package/src/vite/utils/bundle-analysis.ts +4 -2
  210. package/src/vite/utils/client-chunks.ts +190 -0
  211. package/src/vite/utils/forward-user-plugins.ts +193 -0
  212. package/src/vite/utils/manifest-utils.ts +21 -5
  213. package/src/vite/utils/shared-utils.ts +107 -26
  214. package/src/browser/action-response-classifier.ts +0 -99
@@ -24,7 +24,10 @@ import { isHandle, collectHandleData, type Handle } from "../handle.js";
24
24
  import { buildHandleSnapshot } from "../server/handle-store.js";
25
25
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
26
26
  import { _getRequestContext } from "../server/request-context.js";
27
- import { isInsideLoaderScope } from "../server/context.js";
27
+ import {
28
+ isInsideLoaderScope,
29
+ runInsideLoaderBodyScope,
30
+ } from "../server/context.js";
28
31
  import { debugLog } from "./logging.js";
29
32
 
30
33
  /**
@@ -353,8 +356,19 @@ function createLoaderExecutor<TEnv>(
353
356
  };
354
357
 
355
358
  const doneLoader = track(`loader:${loader.$$id}`, 2);
359
+ // Run the loader body inside loader scope so request-scoped reads
360
+ // (cookies()/headers() and non-cacheable ctx.get) are exempt from the
361
+ // cache-purity guards: loaders always run fresh, so their reads never leak
362
+ // into a cached segment. DSL loaders are already wrapped by fresh.ts; this
363
+ // also covers handler-invoked loaders (ctx.use(Loader) from a handler),
364
+ // which otherwise execute in the caller's cache scope and would wrongly
365
+ // throw. rendered() gating uses the captured isDslLoader (above), so this
366
+ // does not grant rendered() to handler-invoked loaders. Uses a body-only
367
+ // scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
356
368
  const promise = Promise.resolve(
357
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
369
+ runInsideLoaderBodyScope(() =>
370
+ loaderFn(loaderCtx as LoaderContext<any, TEnv>),
371
+ ),
358
372
  ).finally(() => {
359
373
  pendingLoaders.delete(loader.$$id);
360
374
  doneLoader();
@@ -33,10 +33,13 @@ import type { ErrorBoundaryHandler, NotFoundBoundaryHandler } from "../types";
33
33
  import type { MiddlewareFn } from "./middleware.js";
34
34
  import {
35
35
  type TelemetrySink,
36
+ type CacheSegmentSignal,
36
37
  safeEmit,
37
38
  resolveSink,
38
39
  getRequestId,
40
+ buildCacheSignalSegments,
39
41
  } from "./telemetry.js";
42
+ import { _getRequestContext } from "../server/request-context.js";
40
43
 
41
44
  export interface MatchHandlerDeps<TEnv = any> {
42
45
  buildRouterContext: () => RouterContext<TEnv>;
@@ -51,6 +54,12 @@ export interface MatchHandlerDeps<TEnv = any> {
51
54
  isAction: boolean,
52
55
  ) => { intercept: InterceptEntry; entry: EntryData } | null;
53
56
  telemetry?: TelemetrySink;
57
+ /**
58
+ * DEVELOPMENT/TEST ONLY gate for the X-Rango-Cache debug header. When true,
59
+ * match/matchPartial stash a coarse route-level cache signal on the request
60
+ * context for the response-finalization path to emit. Default off.
61
+ */
62
+ cacheSignalEnabled?: boolean;
54
63
  }
55
64
 
56
65
  export interface MatchHandlers<TEnv = any> {
@@ -113,6 +122,25 @@ export function createMatchHandlers<TEnv = any>(
113
122
  } = deps;
114
123
  const hasTelemetry = !!deps.telemetry;
115
124
  const telemetry = resolveSink(deps.telemetry);
125
+ const cacheSignalEnabled = !!deps.cacheSignalEnabled;
126
+ // Compute the coarse cache signal when EITHER telemetry needs it (for the
127
+ // cache.decision event) OR the debug header gate is on. When neither is set,
128
+ // this is never called — zero extra work on the hot path.
129
+ const buildSignal = (
130
+ routeKey: string,
131
+ state: {
132
+ cacheHit: boolean;
133
+ cacheSource?: "runtime" | "prerender";
134
+ shouldRevalidate?: boolean;
135
+ },
136
+ ): CacheSegmentSignal[] => buildCacheSignalSegments(routeKey, state);
137
+ // Stash the signal on the request context for the response path to emit as
138
+ // the X-Rango-Cache header. Only when the debug gate is on.
139
+ const recordSignalIfEnabled = (segments: CacheSegmentSignal[]): void => {
140
+ if (!cacheSignalEnabled) return;
141
+ const reqCtx = _getRequestContext();
142
+ if (reqCtx) reqCtx._cacheSignal = segments;
143
+ };
116
144
 
117
145
  async function createMatchContextForFull(
118
146
  request: Request,
@@ -208,17 +236,24 @@ export function createMatchHandlers<TEnv = any>(
208
236
  const state = createPipelineState();
209
237
  const pipeline = createMatchPartialPipeline(ctx, state);
210
238
  const matchResult = await collectMatchResult(pipeline, ctx, state);
239
+ if (hasTelemetry || cacheSignalEnabled) {
240
+ const signalSegments = buildSignal(ctx.routeKey, state);
241
+ recordSignalIfEnabled(signalSegments);
242
+ if (hasTelemetry) {
243
+ safeEmit(telemetry, {
244
+ type: "cache.decision",
245
+ timestamp: performance.now(),
246
+ requestId,
247
+ pathname,
248
+ routeKey: ctx.routeKey,
249
+ hit: state.cacheHit,
250
+ shouldRevalidate: !!state.shouldRevalidate,
251
+ source: state.cacheSource,
252
+ segments: signalSegments,
253
+ });
254
+ }
255
+ }
211
256
  if (hasTelemetry) {
212
- safeEmit(telemetry, {
213
- type: "cache.decision",
214
- timestamp: performance.now(),
215
- requestId,
216
- pathname,
217
- routeKey: ctx.routeKey,
218
- hit: state.cacheHit,
219
- shouldRevalidate: !!state.shouldRevalidate,
220
- source: state.cacheSource,
221
- });
222
257
  safeEmit(telemetry, {
223
258
  type: "request.end",
224
259
  timestamp: performance.now(),
@@ -363,17 +398,24 @@ export function createMatchHandlers<TEnv = any>(
363
398
  state,
364
399
  );
365
400
  flushRevalidationTrace();
401
+ if (hasTelemetry || cacheSignalEnabled) {
402
+ const signalSegments = buildSignal(ctx.routeKey, state);
403
+ recordSignalIfEnabled(signalSegments);
404
+ if (hasTelemetry) {
405
+ safeEmit(telemetry, {
406
+ type: "cache.decision",
407
+ timestamp: performance.now(),
408
+ requestId: partialRequestId,
409
+ pathname,
410
+ routeKey: ctx.routeKey,
411
+ hit: state.cacheHit,
412
+ shouldRevalidate: !!state.shouldRevalidate,
413
+ source: state.cacheSource,
414
+ segments: signalSegments,
415
+ });
416
+ }
417
+ }
366
418
  if (hasTelemetry) {
367
- safeEmit(telemetry, {
368
- type: "cache.decision",
369
- timestamp: performance.now(),
370
- requestId: partialRequestId,
371
- pathname,
372
- routeKey: ctx.routeKey,
373
- hit: state.cacheHit,
374
- shouldRevalidate: !!state.shouldRevalidate,
375
- source: state.cacheSource,
376
- });
377
419
  safeEmit(telemetry, {
378
420
  type: "request.end",
379
421
  timestamp: performance.now(),
@@ -282,6 +282,38 @@ async function* yieldFromStore<TEnv>(
282
282
  }
283
283
  }
284
284
 
285
+ /**
286
+ * Look up a prerendered (build-time cached) entry for the current route and, on
287
+ * a hit, yield its segments. Returns true when an entry was served (the caller
288
+ * should stop the pipeline) and false on a miss. Intercept navigations consult
289
+ * only the intercept-specific entry (`paramHash + "/i"`); a miss there falls
290
+ * through to the normal pipeline so intercept-resolution can run. Callers must
291
+ * guard on `prerenderStoreInstance` after `ensurePrerenderDeps()`.
292
+ */
293
+ async function* tryPrerenderLookup<TEnv>(
294
+ ctx: MatchContext<TEnv>,
295
+ state: MatchPipelineState,
296
+ pipelineStart: number,
297
+ handleStoreRef?: HandleStore,
298
+ ): AsyncGenerator<ResolvedSegment, boolean> {
299
+ const paramHash = _hashParams!(ctx.matched.params);
300
+ const isPassthroughPrerenderRoute = ctx.entries.some(
301
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
302
+ );
303
+ const lookupHash = ctx.isIntercept ? paramHash + "/i" : paramHash;
304
+ const entry = await prerenderStoreInstance!.get(
305
+ ctx.matched.routeKey,
306
+ lookupHash,
307
+ {
308
+ pathname: ctx.pathname,
309
+ isPassthroughRoute: isPassthroughPrerenderRoute,
310
+ },
311
+ );
312
+ if (!entry) return false;
313
+ yield* yieldFromStore(entry, ctx, state, pipelineStart, handleStoreRef);
314
+ return true;
315
+ }
316
+
285
317
  /**
286
318
  * Async generator middleware type
287
319
  */
@@ -334,54 +366,13 @@ export function withCacheLookup<TEnv>(
334
366
  if (!ctx.isAction && !isHmr && ctx.matched.pr) {
335
367
  await ensurePrerenderDeps();
336
368
  if (prerenderStoreInstance) {
337
- const paramHash = _hashParams!(ctx.matched.params);
338
- const isPassthroughPrerenderRoute = ctx.entries.some(
339
- (entry) => entry.type === "route" && entry.isPassthrough === true,
369
+ const served = yield* tryPrerenderLookup(
370
+ ctx,
371
+ state,
372
+ pipelineStart,
373
+ handleStoreRef,
340
374
  );
341
-
342
- if (ctx.isIntercept) {
343
- // Intercept navigation: try intercept-specific prerender entry
344
- const entry = await prerenderStoreInstance.get(
345
- ctx.matched.routeKey,
346
- paramHash + "/i",
347
- {
348
- pathname: ctx.pathname,
349
- isPassthroughRoute: isPassthroughPrerenderRoute,
350
- },
351
- );
352
- if (entry) {
353
- yield* yieldFromStore(
354
- entry,
355
- ctx,
356
- state,
357
- pipelineStart,
358
- handleStoreRef,
359
- );
360
- return;
361
- }
362
- // No intercept prerender -- fall through to normal pipeline
363
- // (skip non-intercept prerender to let intercept-resolution run)
364
- } else {
365
- // Normal navigation: existing behavior
366
- const entry = await prerenderStoreInstance.get(
367
- ctx.matched.routeKey,
368
- paramHash,
369
- {
370
- pathname: ctx.pathname,
371
- isPassthroughRoute: isPassthroughPrerenderRoute,
372
- },
373
- );
374
- if (entry) {
375
- yield* yieldFromStore(
376
- entry,
377
- ctx,
378
- state,
379
- pipelineStart,
380
- handleStoreRef,
381
- );
382
- return;
383
- }
384
- }
375
+ if (served) return;
385
376
  }
386
377
  }
387
378
 
@@ -404,51 +395,13 @@ export function withCacheLookup<TEnv>(
404
395
  if (hasStatic) {
405
396
  await ensurePrerenderDeps();
406
397
  if (prerenderStoreInstance) {
407
- const paramHash = _hashParams!(ctx.matched.params);
408
- const isPassthroughPrerenderRoute = ctx.entries.some(
409
- (entry) => entry.type === "route" && entry.isPassthrough === true,
398
+ const served = yield* tryPrerenderLookup(
399
+ ctx,
400
+ state,
401
+ pipelineStart,
402
+ handleStoreRef,
410
403
  );
411
-
412
- if (ctx.isIntercept) {
413
- const entry = await prerenderStoreInstance.get(
414
- ctx.matched.routeKey,
415
- paramHash + "/i",
416
- {
417
- pathname: ctx.pathname,
418
- isPassthroughRoute: isPassthroughPrerenderRoute,
419
- },
420
- );
421
- if (entry) {
422
- yield* yieldFromStore(
423
- entry,
424
- ctx,
425
- state,
426
- pipelineStart,
427
- handleStoreRef,
428
- );
429
- return;
430
- }
431
- // No intercept prerender -- fall through to normal pipeline
432
- } else {
433
- const entry = await prerenderStoreInstance.get(
434
- ctx.matched.routeKey,
435
- paramHash,
436
- {
437
- pathname: ctx.pathname,
438
- isPassthroughRoute: isPassthroughPrerenderRoute,
439
- },
440
- );
441
- if (entry) {
442
- yield* yieldFromStore(
443
- entry,
444
- ctx,
445
- state,
446
- pipelineStart,
447
- handleStoreRef,
448
- );
449
- return;
450
- }
451
- }
404
+ if (served) return;
452
405
  }
453
406
  }
454
407
  }
@@ -169,10 +169,11 @@ export function withCacheStore<TEnv>(
169
169
  // skip (client already had them). Segments where the handler intentionally
170
170
  // returned null are not revalidation skips — re-rendering them will still
171
171
  // produce null, so proactive caching would be wasted work.
172
- const clientIdSet = new Set(ctx.clientSegmentIds);
173
172
  const hasNullComponents = allSegmentsToCache.some(
174
173
  (s) =>
175
- s.component === null && s.type !== "loader" && clientIdSet.has(s.id),
174
+ s.component === null &&
175
+ s.type !== "loader" &&
176
+ ctx.clientSegmentSet.has(s.id),
176
177
  );
177
178
 
178
179
  const requestCtx = getRequestContext();
@@ -138,34 +138,38 @@ export async function collectSegments(
138
138
  function deduplicateLoaderSegments(
139
139
  segments: ResolvedSegment[],
140
140
  logPrefix: string,
141
- ): ResolvedSegment[] {
142
- // First pass: collect loaderIds of original (non-inherited) segments
143
- // and whether their parent entry uses loading()
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
144
  const originalLoaders = new Set<string>();
145
- const loadersWithLoading = new Set<string>();
145
+ const loaderIdsByNamespace = new Map<string, string[]>();
146
+ const namespacesWithLoading = new Set<string>();
146
147
  for (const s of segments) {
147
- if (s.type === "loader" && s.loaderId && !s._inherited) {
148
- originalLoaders.add(s.loaderId);
149
- // If the segment has a sibling with loading, the parent uses loading()
150
- // We detect this by checking if any non-loader segment in the same
151
- // namespace has loading defined
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);
152
159
  }
153
160
  }
154
- // Check if any layout/route segment has loading — if a loader's namespace
155
- // matches a segment with loading, the inherited copy is needed
156
- for (const s of segments) {
157
- if (s.type !== "loader" && s.loading !== undefined && s.loading !== false) {
158
- // Find loaders in this namespace
159
- for (const l of segments) {
160
- if (l.type === "loader" && l.namespace === s.namespace && l.loaderId) {
161
- loadersWithLoading.add(l.loaderId);
162
- }
163
- }
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);
164
168
  }
165
169
  }
166
170
 
167
171
  const result: ResolvedSegment[] = [];
168
- let dedupCount = 0;
172
+ const removedIds = new Set<string>();
169
173
 
170
174
  for (const s of segments) {
171
175
  if (
@@ -175,17 +179,20 @@ function deduplicateLoaderSegments(
175
179
  originalLoaders.has(s.loaderId) &&
176
180
  !loadersWithLoading.has(s.loaderId)
177
181
  ) {
178
- dedupCount++;
182
+ removedIds.add(s.id);
179
183
  continue;
180
184
  }
181
185
  result.push(s);
182
186
  }
183
187
 
184
- if (dedupCount > 0) {
185
- debugLog(logPrefix, `deduped ${dedupCount} inherited loader segment(s)`);
188
+ if (removedIds.size > 0) {
189
+ debugLog(
190
+ logPrefix,
191
+ `deduped ${removedIds.size} inherited loader segment(s)`,
192
+ );
186
193
  }
187
194
 
188
- return result;
195
+ return { segments: result, removedIds };
189
196
  }
190
197
 
191
198
  /**
@@ -244,7 +251,7 @@ export function buildMatchResult<TEnv>(
244
251
  );
245
252
  }
246
253
 
247
- const dedupedSegments = deduplicateLoaderSegments(
254
+ const { segments: dedupedSegments, removedIds } = deduplicateLoaderSegments(
248
255
  segmentsToRender,
249
256
  logPrefix,
250
257
  );
@@ -262,11 +269,6 @@ export function buildMatchResult<TEnv>(
262
269
 
263
270
  // Remove deduped loader IDs from matched so the client doesn't treat
264
271
  // them as missing segments and trigger a fallback refetch.
265
- const removedIds = new Set(
266
- segmentsToRender
267
- .filter((s) => !dedupedSegments.includes(s))
268
- .map((s) => s.id),
269
- );
270
272
  const matchedIds =
271
273
  removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
272
274
 
@@ -1,7 +1,7 @@
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
7
  import type { MetricsStore, PerformanceMetric } from "../server/context";
@@ -140,7 +140,7 @@ export interface MiddlewareContext<
140
140
  * @template TEnv - Environment type - defaults to any for internal flexibility
141
141
  * @template TParams - URL params type (typed for route middleware)
142
142
  *
143
- * When using middleware with global augmentation (RSCRouter.Env), explicitly
143
+ * When using middleware with global augmentation (Rango.Env), explicitly
144
144
  * annotate your middleware functions, or the types will be inferred from context:
145
145
  *
146
146
  * @example
@@ -307,6 +307,46 @@ export function matchMiddleware<TEnv>(
307
307
  return matches;
308
308
  }
309
309
 
310
+ // Set-Cookie is appended; for other headers stubOverridesNonCookie=true
311
+ // overwrites (chain ran to completion), false fills only missing slots (an
312
+ // explicit short-circuit Response's own headers win).
313
+ function mergeStubHeaders(
314
+ target: Headers,
315
+ stub: Headers,
316
+ stubOverridesNonCookie: boolean,
317
+ ): void {
318
+ stub.forEach((value, name) => {
319
+ if (name.toLowerCase() === "set-cookie") {
320
+ target.append(name, value);
321
+ } else if (stubOverridesNonCookie || !target.has(name)) {
322
+ target.set(name, value);
323
+ }
324
+ });
325
+ }
326
+
327
+ // Set-Cookie is deduped so a nested inner executeMiddleware that already merged
328
+ // the same reqCtx cookies does not duplicate them; other headers fill if missing.
329
+ function mergeReqCtxStub(
330
+ target: Headers,
331
+ reqCtx: ReturnType<typeof _getRequestContext>,
332
+ ): void {
333
+ if (!reqCtx) return;
334
+ const stubCookies = reqCtx.res.headers.getSetCookie();
335
+ if (stubCookies.length > 0) {
336
+ const existing = new Set(target.getSetCookie());
337
+ for (const cookie of stubCookies) {
338
+ if (!existing.has(cookie)) {
339
+ target.append("set-cookie", cookie);
340
+ }
341
+ }
342
+ }
343
+ reqCtx.res.headers.forEach((value, name) => {
344
+ if (name !== "set-cookie" && !target.has(name)) {
345
+ target.set(name, value);
346
+ }
347
+ });
348
+ }
349
+
310
350
  /**
311
351
  * Execute middleware chain
312
352
  *
@@ -345,36 +385,9 @@ export async function executeMiddleware<TEnv>(
345
385
  // End of chain - call actual RSC handler
346
386
  const response = await finalHandler();
347
387
 
348
- // Merge headers set on stub into the real response.
349
- // Use append for Set-Cookie to preserve multiple cookies.
350
388
  const mergedHeaders = new Headers(response.headers);
351
- stubResponse.headers.forEach((value, name) => {
352
- if (name.toLowerCase() === "set-cookie") {
353
- mergedHeaders.append(name, value);
354
- } else {
355
- mergedHeaders.set(name, value);
356
- }
357
- });
358
- // Also merge shared RequestContext stub (cookies written via cookies().set()).
359
- // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
360
- // may have already merged the same reqCtx cookies into the response.
361
- const reqCtx = _getRequestContext();
362
- if (reqCtx) {
363
- const stubCookies = reqCtx.res.headers.getSetCookie();
364
- if (stubCookies.length > 0) {
365
- const existing = new Set(mergedHeaders.getSetCookie());
366
- for (const cookie of stubCookies) {
367
- if (!existing.has(cookie)) {
368
- mergedHeaders.append("set-cookie", cookie);
369
- }
370
- }
371
- }
372
- reqCtx.res.headers.forEach((value, name) => {
373
- if (name !== "set-cookie" && !mergedHeaders.has(name)) {
374
- mergedHeaders.set(name, value);
375
- }
376
- });
377
- }
389
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, true);
390
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
378
391
 
379
392
  if (isWebSocketUpgradeResponse(response)) {
380
393
  responseHolder.response = response;
@@ -485,33 +498,8 @@ export async function executeMiddleware<TEnv>(
485
498
  return result;
486
499
  }
487
500
  const mergedHeaders = new Headers(result.headers);
488
- stubResponse.headers.forEach((value, name) => {
489
- if (name.toLowerCase() === "set-cookie") {
490
- mergedHeaders.append(name, value);
491
- } else if (!mergedHeaders.has(name)) {
492
- mergedHeaders.set(name, value);
493
- }
494
- });
495
- // Also merge shared RequestContext stub (cookies written via setCookie).
496
- // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
497
- // may have already merged the same reqCtx cookies into the response.
498
- const reqCtx = _getRequestContext();
499
- if (reqCtx) {
500
- const stubCookies = reqCtx.res.headers.getSetCookie();
501
- if (stubCookies.length > 0) {
502
- const existing = new Set(mergedHeaders.getSetCookie());
503
- for (const cookie of stubCookies) {
504
- if (!existing.has(cookie)) {
505
- mergedHeaders.append("set-cookie", cookie);
506
- }
507
- }
508
- }
509
- reqCtx.res.headers.forEach((value, name) => {
510
- if (name !== "set-cookie" && !mergedHeaders.has(name)) {
511
- mergedHeaders.set(name, value);
512
- }
513
- });
514
- }
501
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, false);
502
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
515
503
  const merged = new Response(result.body, {
516
504
  status: result.status,
517
505
  statusText: result.statusText,
@@ -565,21 +553,7 @@ export async function executeMiddleware<TEnv>(
565
553
  // set-cookie on an upgrade is not meaningful.
566
554
  const reqCtx = _getRequestContext();
567
555
  if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
568
- const stubCookies = reqCtx.res.headers.getSetCookie();
569
- if (stubCookies.length > 0) {
570
- const existingCookies = new Set(finalResponse.headers.getSetCookie());
571
- for (const cookie of stubCookies) {
572
- if (!existingCookies.has(cookie)) {
573
- finalResponse.headers.append("set-cookie", cookie);
574
- }
575
- }
576
- }
577
- // Fill in non-cookie headers that aren't already on the response
578
- reqCtx.res.headers.forEach((value, name) => {
579
- if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
580
- finalResponse.headers.set(name, value);
581
- }
582
- });
556
+ mergeReqCtxStub(finalResponse.headers, reqCtx);
583
557
  }
584
558
 
585
559
  return finalResponse;
@@ -688,13 +662,7 @@ export async function executeInterceptMiddleware<TEnv>(
688
662
  // Only fill in missing headers — the returned Response's explicit
689
663
  // headers take precedence, matching executeMiddleware behavior.
690
664
  const mergedHeaders = new Headers(response.headers);
691
- stubResponse.headers.forEach((value, name) => {
692
- if (name.toLowerCase() === "set-cookie") {
693
- mergedHeaders.append(name, value);
694
- } else if (!mergedHeaders.has(name)) {
695
- mergedHeaders.set(name, value);
696
- }
697
- });
665
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, false);
698
666
  return new Response(response.body, {
699
667
  status: response.status,
700
668
  statusText: response.statusText,
@@ -126,7 +126,7 @@ export async function matchForPrerender<TEnv = any>(
126
126
  get env() {
127
127
  if (buildEnv !== undefined) return buildEnv;
128
128
  throw new Error(
129
- "[rsc-router] ctx.env is not available during dev-mode getParams(). " +
129
+ "[rango] ctx.env is not available during dev-mode getParams(). " +
130
130
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
131
131
  );
132
132
  },
@@ -67,9 +67,11 @@ export async function previewMatch<TEnv = any>(
67
67
  responseType: negotiation.responseType,
68
68
  handler: negotiation.handler,
69
69
  params: matched.params,
70
- negotiated: true,
71
70
  manifestEntry: negotiation.manifestEntry,
72
71
  routeKey: matched.routeKey,
72
+ // omitted unless a variant negotiated, preserving the prior public
73
+ // shape (absent for plain response routes, not negotiated:false)
74
+ ...(negotiation.negotiated ? { negotiated: true } : {}),
73
75
  };
74
76
  }
75
77
 
@@ -278,33 +278,9 @@ async function classifyResponseRoute<TEnv>(
278
278
  pathname: string,
279
279
  snapshot: RouteSnapshot<TEnv>,
280
280
  ): Promise<ResponseRoutePlan<TEnv> | null> {
281
- const { manifestEntry, responseType } = snapshot;
282
-
281
+ // negotiateRoute returns the response plan (variant or plain) or null for RSC.
283
282
  const negotiation = await negotiateRoute(request, pathname, snapshot);
284
- if (negotiation) {
285
- return {
286
- mode: "response",
287
- route: snapshot,
288
- ...negotiation,
289
- };
290
- }
291
-
292
- // Non-negotiated response route (no variants, or RSC won negotiation)
293
- if (responseType) {
294
- const handler =
295
- manifestEntry.type === "route" ? manifestEntry.handler : undefined;
296
- if (handler) {
297
- return {
298
- mode: "response",
299
- route: snapshot,
300
- handler,
301
- responseType,
302
- negotiated: false,
303
- manifestEntry,
304
- routeMiddleware: snapshot.routeMiddleware,
305
- };
306
- }
307
- }
308
-
309
- return null;
283
+ return negotiation
284
+ ? { mode: "response", route: snapshot, ...negotiation }
285
+ : null;
310
286
  }