@rangojs/router 0.0.0-experimental.d20dd405 → 0.0.0-experimental.d98a8e9d

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 (242) hide show
  1. package/README.md +8 -8
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +917 -500
  5. package/package.json +55 -11
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/bundle-analysis/SKILL.md +159 -0
  8. package/skills/cache-guide/SKILL.md +220 -30
  9. package/skills/caching/SKILL.md +116 -8
  10. package/skills/composability/SKILL.md +27 -2
  11. package/skills/document-cache/SKILL.md +78 -55
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +196 -21
  14. package/skills/host-router/SKILL.md +45 -20
  15. package/skills/intercept/SKILL.md +1 -4
  16. package/skills/layout/SKILL.md +4 -7
  17. package/skills/links/SKILL.md +22 -10
  18. package/skills/loader/SKILL.md +166 -23
  19. package/skills/middleware/SKILL.md +13 -9
  20. package/skills/migrate-nextjs/SKILL.md +1 -1
  21. package/skills/mime-routes/SKILL.md +27 -0
  22. package/skills/observability/SKILL.md +137 -0
  23. package/skills/parallel/SKILL.md +3 -6
  24. package/skills/prerender/SKILL.md +14 -33
  25. package/skills/rango/SKILL.md +243 -26
  26. package/skills/react-compiler/SKILL.md +168 -0
  27. package/skills/response-routes/SKILL.md +114 -47
  28. package/skills/route/SKILL.md +9 -4
  29. package/skills/router-setup/SKILL.md +3 -3
  30. package/skills/server-actions/SKILL.md +53 -41
  31. package/skills/testing/SKILL.md +128 -0
  32. package/skills/testing/bindings.md +89 -0
  33. package/skills/testing/cache-prerender.md +98 -0
  34. package/skills/testing/client-components.md +121 -0
  35. package/skills/testing/e2e-parity.md +124 -0
  36. package/skills/testing/flight.md +89 -0
  37. package/skills/testing/handles.md +127 -0
  38. package/skills/testing/loader.md +108 -0
  39. package/skills/testing/middleware.md +97 -0
  40. package/skills/testing/render-handler.md +102 -0
  41. package/skills/testing/response-routes.md +94 -0
  42. package/skills/testing/reverse-and-types.md +83 -0
  43. package/skills/testing/server-actions.md +89 -0
  44. package/skills/testing/server-tree.md +128 -0
  45. package/skills/testing/setup.md +120 -0
  46. package/skills/typesafety/SKILL.md +310 -26
  47. package/skills/use-cache/SKILL.md +34 -5
  48. package/skills/view-transitions/SKILL.md +85 -3
  49. package/src/__augment-tests__/augment.ts +81 -0
  50. package/src/__augment-tests__/augmented.check.ts +116 -0
  51. package/src/browser/action-coordinator.ts +53 -36
  52. package/src/browser/event-controller.ts +42 -66
  53. package/src/browser/history-state.ts +21 -0
  54. package/src/browser/index.ts +3 -3
  55. package/src/browser/navigation-bridge.ts +9 -67
  56. package/src/browser/navigation-client.ts +68 -83
  57. package/src/browser/navigation-store.ts +7 -8
  58. package/src/browser/navigation-transaction.ts +10 -28
  59. package/src/browser/partial-update.ts +8 -16
  60. package/src/browser/prefetch/cache.ts +58 -27
  61. package/src/browser/prefetch/fetch.ts +92 -33
  62. package/src/browser/react/NavigationProvider.tsx +55 -65
  63. package/src/browser/react/location-state-shared.ts +175 -4
  64. package/src/browser/react/location-state.ts +39 -13
  65. package/src/browser/react/use-handle.ts +17 -9
  66. package/src/browser/react/use-params.ts +3 -4
  67. package/src/browser/react/use-reverse.ts +19 -12
  68. package/src/browser/react/use-router.ts +14 -1
  69. package/src/browser/response-adapter.ts +32 -1
  70. package/src/browser/rsc-router.tsx +35 -16
  71. package/src/browser/scroll-restoration.ts +30 -25
  72. package/src/browser/segment-structure-assert.ts +2 -2
  73. package/src/browser/server-action-bridge.ts +23 -30
  74. package/src/browser/types.ts +2 -0
  75. package/src/build/collect-fallback-refs.ts +107 -0
  76. package/src/build/generate-manifest.ts +60 -35
  77. package/src/build/generate-route-types.ts +2 -0
  78. package/src/build/index.ts +8 -1
  79. package/src/build/prefix-tree-utils.ts +123 -0
  80. package/src/build/route-trie.ts +43 -0
  81. package/src/build/route-types/codegen.ts +4 -4
  82. package/src/build/route-types/include-resolution.ts +1 -1
  83. package/src/build/route-types/per-module-writer.ts +7 -4
  84. package/src/build/route-types/router-processing.ts +55 -14
  85. package/src/build/route-types/scan-filter.ts +1 -1
  86. package/src/build/route-types/source-scan.ts +118 -0
  87. package/src/build/runtime-discovery.ts +9 -20
  88. package/src/cache/cache-scope.ts +28 -42
  89. package/src/cache/cf/cf-cache-store.ts +49 -6
  90. package/src/client.tsx +9 -30
  91. package/src/context-var.ts +5 -5
  92. package/src/decode-loader-results.ts +36 -0
  93. package/src/errors.ts +30 -4
  94. package/src/handle.ts +32 -14
  95. package/src/host/index.ts +2 -2
  96. package/src/host/router.ts +129 -57
  97. package/src/host/types.ts +31 -2
  98. package/src/host/utils.ts +1 -1
  99. package/src/href-client.ts +136 -20
  100. package/src/index.rsc.ts +7 -6
  101. package/src/index.ts +14 -8
  102. package/src/loader-store.ts +500 -0
  103. package/src/loader.rsc.ts +25 -7
  104. package/src/loader.ts +16 -9
  105. package/src/missing-id-error.ts +68 -0
  106. package/src/prerender.ts +27 -6
  107. package/src/response-utils.ts +9 -0
  108. package/src/reverse.ts +16 -13
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +238 -263
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +37 -14
  113. package/src/route-definition/use-item-types.ts +32 -0
  114. package/src/route-types.ts +19 -41
  115. package/src/router/basename.ts +14 -0
  116. package/src/router/content-negotiation.ts +15 -2
  117. package/src/router/error-handling.ts +1 -1
  118. package/src/router/find-match.ts +54 -6
  119. package/src/router/intercept-resolution.ts +4 -18
  120. package/src/router/lazy-includes.ts +35 -16
  121. package/src/router/loader-resolution.ts +79 -36
  122. package/src/router/manifest.ts +19 -6
  123. package/src/router/match-handlers.ts +62 -20
  124. package/src/router/match-middleware/cache-lookup.ts +44 -91
  125. package/src/router/match-middleware/cache-store.ts +3 -2
  126. package/src/router/match-result.ts +32 -30
  127. package/src/router/metrics.ts +1 -1
  128. package/src/router/middleware-types.ts +1 -1
  129. package/src/router/middleware.ts +46 -78
  130. package/src/router/pattern-matching.ts +15 -2
  131. package/src/router/prerender-match.ts +1 -1
  132. package/src/router/preview-match.ts +3 -1
  133. package/src/router/request-classification.ts +4 -28
  134. package/src/router/revalidation.ts +43 -1
  135. package/src/router/router-interfaces.ts +45 -28
  136. package/src/router/router-options.ts +40 -1
  137. package/src/router/router-registry.ts +2 -5
  138. package/src/router/segment-resolution/fresh.ts +19 -6
  139. package/src/router/segment-resolution/revalidation.ts +19 -6
  140. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  141. package/src/router/telemetry.ts +99 -0
  142. package/src/router/trie-matching.ts +22 -3
  143. package/src/router/types.ts +8 -0
  144. package/src/router.ts +51 -28
  145. package/src/rsc/handler-context.ts +2 -2
  146. package/src/rsc/handler.ts +20 -65
  147. package/src/rsc/helpers.ts +22 -2
  148. package/src/rsc/index.ts +1 -1
  149. package/src/rsc/manifest-init.ts +28 -41
  150. package/src/rsc/origin-guard.ts +28 -10
  151. package/src/rsc/response-error.ts +79 -12
  152. package/src/rsc/response-route-handler.ts +43 -60
  153. package/src/rsc/rsc-rendering.ts +27 -53
  154. package/src/rsc/runtime-warnings.ts +9 -10
  155. package/src/rsc/server-action.ts +13 -37
  156. package/src/rsc/ssr-setup.ts +16 -0
  157. package/src/rsc/types.ts +2 -2
  158. package/src/runtime-env.ts +18 -0
  159. package/src/search-params.ts +4 -4
  160. package/src/segment-system.tsx +64 -49
  161. package/src/serialize.ts +243 -0
  162. package/src/server/context.ts +150 -51
  163. package/src/server/cookie-store.ts +28 -4
  164. package/src/server/request-context.ts +57 -9
  165. package/src/static-handler.ts +25 -3
  166. package/src/testing/cache-status.ts +166 -0
  167. package/src/testing/collect-handle.ts +63 -0
  168. package/src/testing/dispatch.ts +581 -0
  169. package/src/testing/dom.entry.ts +22 -0
  170. package/src/testing/e2e/fixture.ts +188 -0
  171. package/src/testing/e2e/index.ts +149 -0
  172. package/src/testing/e2e/matchers.ts +51 -0
  173. package/src/testing/e2e/page-helpers.ts +272 -0
  174. package/src/testing/e2e/parity.ts +326 -0
  175. package/src/testing/e2e/server.ts +195 -0
  176. package/src/testing/flight-matchers.ts +110 -0
  177. package/src/testing/flight-normalize.ts +38 -0
  178. package/src/testing/flight-runtime.d.ts +57 -0
  179. package/src/testing/flight-tree.ts +682 -0
  180. package/src/testing/flight.entry.ts +51 -0
  181. package/src/testing/flight.ts +234 -0
  182. package/src/testing/generated-routes.ts +223 -0
  183. package/src/testing/index.ts +106 -0
  184. package/src/testing/internal/context.ts +304 -0
  185. package/src/testing/internal/flight-client-globals.ts +30 -0
  186. package/src/testing/internal/seed-vars.ts +42 -0
  187. package/src/testing/render-handler.ts +323 -0
  188. package/src/testing/render-route.tsx +590 -0
  189. package/src/testing/run-loader.ts +363 -0
  190. package/src/testing/run-middleware.ts +205 -0
  191. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  192. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  193. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  194. package/src/testing/vitest-stubs/version.ts +5 -0
  195. package/src/testing/vitest.ts +285 -0
  196. package/src/types/global-namespace.ts +39 -26
  197. package/src/types/handler-context.ts +56 -11
  198. package/src/types/index.ts +1 -0
  199. package/src/types/loader-types.ts +6 -3
  200. package/src/types/segments.ts +18 -1
  201. package/src/urls/include-helper.ts +10 -53
  202. package/src/urls/index.ts +1 -5
  203. package/src/urls/path-helper-types.ts +11 -3
  204. package/src/urls/path-helper.ts +17 -52
  205. package/src/urls/pattern-types.ts +36 -19
  206. package/src/urls/response-types.ts +20 -19
  207. package/src/urls/type-extraction.ts +58 -139
  208. package/src/urls/urls-function.ts +1 -5
  209. package/src/use-loader.tsx +413 -42
  210. package/src/vite/debug.ts +1 -0
  211. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  212. package/src/vite/discovery/discover-routers.ts +75 -72
  213. package/src/vite/discovery/discovery-errors.ts +194 -0
  214. package/src/vite/discovery/prerender-collection.ts +19 -25
  215. package/src/vite/discovery/route-types-writer.ts +40 -84
  216. package/src/vite/discovery/state.ts +33 -0
  217. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  218. package/src/vite/index.ts +2 -0
  219. package/src/vite/plugin-types.ts +67 -0
  220. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  221. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  222. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -1
  223. package/src/vite/plugins/expose-action-id.ts +2 -2
  224. package/src/vite/plugins/expose-id-utils.ts +12 -8
  225. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  226. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  227. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  228. package/src/vite/plugins/expose-internal-ids.ts +47 -67
  229. package/src/vite/plugins/performance-tracks.ts +12 -16
  230. package/src/vite/plugins/use-cache-transform.ts +13 -11
  231. package/src/vite/plugins/version-injector.ts +2 -12
  232. package/src/vite/plugins/version-plugin.ts +59 -2
  233. package/src/vite/plugins/virtual-entries.ts +2 -2
  234. package/src/vite/rango.ts +67 -15
  235. package/src/vite/router-discovery.ts +208 -63
  236. package/src/vite/utils/ast-handler-extract.ts +15 -15
  237. package/src/vite/utils/bundle-analysis.ts +4 -2
  238. package/src/vite/utils/client-chunks.ts +190 -0
  239. package/src/vite/utils/forward-user-plugins.ts +193 -0
  240. package/src/vite/utils/manifest-utils.ts +8 -59
  241. package/src/vite/utils/shared-utils.ts +107 -26
  242. package/src/browser/action-response-classifier.ts +0 -99
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { CookieOptions } from "../router/middleware-types.js";
11
11
  import { getRequestContext } from "./request-context.js";
12
+ import { isInsideCacheScope } from "./context.js";
12
13
  import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
13
14
 
14
15
  /**
@@ -84,10 +85,23 @@ export interface ReadonlyHeaders {
84
85
  type HeadersIterator<T> = IterableIterator<T>;
85
86
 
86
87
  /**
87
- * Throw if called inside a "use cache" function.
88
- * Reading request-scoped data (cookies, headers) inside a cached function
89
- * produces results that vary per request but the cache key does not include
90
- * those values, leading to one user's data being served to another.
88
+ * Throw if called inside a cache boundary — either a "use cache" function
89
+ * (`INSIDE_CACHE_EXEC` stamped on ctx by the cache runtime) or a `cache()`
90
+ * DSL boundary (`isInsideCacheScope()` the render-store flag set while
91
+ * resolving a `type: "cache"` route entry).
92
+ *
93
+ * Reading request-scoped data (cookies, headers) inside a cached scope
94
+ * produces per-request values that are NOT reflected in the cache key, so
95
+ * they would be frozen into the shared cache entry and served to the wrong
96
+ * users. This is the same hazard for both scopes: a `cache()` boundary caches
97
+ * everything except loaders (it is the document-level "PPR shell"), so a read
98
+ * here is baked into the shell exactly like a `"use cache"` return value is
99
+ * baked into its cache entry.
100
+ *
101
+ * `isInsideCacheScope()` returns false inside loaders (loaders always run
102
+ * fresh on every request, even on a cache hit), so reading cookies()/headers()
103
+ * from a loader is allowed — loaders are the dynamic "holes" of a cached
104
+ * document.
91
105
  */
92
106
  function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
93
107
  if (
@@ -106,6 +120,16 @@ function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
106
120
  ` const data = await getCachedData(locale); // locale is now in the cache key`,
107
121
  );
108
122
  }
123
+ if (isInsideCacheScope()) {
124
+ throw new Error(
125
+ `${fnName}() cannot be called inside a cache() boundary. ` +
126
+ `A cache() scope caches everything except loaders, so request-scoped ` +
127
+ `data (cookies, headers) read here would be frozen into the shared ` +
128
+ `cached shell and served to other users. Read it inside a loader ` +
129
+ `instead — loaders always run fresh on every request, even on a cache hit:\n\n` +
130
+ ` loader("user", () => getUser(cookies().get("session")?.value));`,
131
+ );
132
+ }
109
133
  }
110
134
 
111
135
  const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
@@ -273,7 +273,9 @@ export interface RequestContext<
273
273
 
274
274
  /**
275
275
  * @internal Set to true when the matched entry tree contains any `loading()`
276
- * entries (streaming). Used by rendered() to fail fast.
276
+ * entries (streaming). On a streaming tree rendered() waits for the streaming
277
+ * handlers to settle (via handleStore.settled) before resolving, and the
278
+ * deadlock guard state is kept live until that wait completes.
277
279
  */
278
280
  _treeHasStreaming?: boolean;
279
281
 
@@ -297,6 +299,18 @@ export interface RequestContext<
297
299
  */
298
300
  _renderBarrierHandleSnapshot?: HandleData;
299
301
 
302
+ /**
303
+ * @internal The deadlock guard window is closed (no further handler-awaits-
304
+ * loader cycle is possible). For non-streaming trees this is set when the
305
+ * barrier resolves. For streaming trees the window stays open until
306
+ * handleStore.settled — rendered() keeps waiting past the barrier and a
307
+ * loading() handler can still resume and await a still-waiting loader — so it
308
+ * is set only after settled. The guard (loader-resolution `setupLoaderAccess`)
309
+ * reads this instead of `_renderBarrierSegmentOrder` so it does not go blind
310
+ * during the streaming settle wait.
311
+ */
312
+ _renderBarrierGuardClosed?: boolean;
313
+
300
314
  /** @internal Per-request error dedup set for onError reporting */
301
315
  _reportedErrors: WeakSet<object>;
302
316
 
@@ -322,6 +336,15 @@ export interface RequestContext<
322
336
  * to avoid a second resolveRoute call. Cleared on HMR invalidation.
323
337
  */
324
338
  _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
339
+
340
+ /**
341
+ * @internal Coarse route-level cache signal for the X-Rango-Cache debug
342
+ * header. Populated by match/matchPartial only when the debug cache signal
343
+ * gate is enabled (debugCacheSignal option or RANGO_TEST_SIGNALS=1). Read by
344
+ * the response-finalization path (createResponseWithMergedHeaders). Undefined
345
+ * when the gate is off, so no header is emitted.
346
+ */
347
+ _cacheSignal?: import("../router/telemetry.js").CacheSegmentSignal[];
325
348
  }
326
349
 
327
350
  /**
@@ -355,6 +378,7 @@ export type PublicRequestContext<
355
378
  | "_renderBarrierWaiters"
356
379
  | "_handlerLoaderDeps"
357
380
  | "_renderBarrierHandleSnapshot"
381
+ | "_renderBarrierGuardClosed"
358
382
  | "_reportBackgroundError"
359
383
  | "_debugPerformance"
360
384
  | "_metricsStore"
@@ -362,6 +386,7 @@ export type PublicRequestContext<
362
386
  | "_setStatus"
363
387
  | "_variables"
364
388
  | "_classifiedRoute"
389
+ | "_cacheSignal"
365
390
  | "res"
366
391
  >;
367
392
 
@@ -797,14 +822,37 @@ export function createRequestContext<TEnv>(
797
822
  .filter((s) => s.type !== "loader")
798
823
  .map((s) => s.id);
799
824
  ctx._renderBarrierSegmentOrder = segOrder;
800
- // Build and cache handle snapshot so loader ctx.use(handle) calls
801
- // don't rebuild it on every invocation.
802
- ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
803
- handleStore,
804
- segOrder,
805
- );
806
- ctx._renderBarrierWaiters = undefined;
807
- ctx._handlerLoaderDeps = undefined;
825
+
826
+ // Closing the guard window means no handler can still form a deadlock cycle
827
+ // with a rendered() loader: drop the dependency-tracking state and mark it
828
+ // closed. WHEN this runs is the only streaming/non-streaming difference.
829
+ const closeGuard = () => {
830
+ ctx._renderBarrierWaiters = undefined;
831
+ ctx._handlerLoaderDeps = undefined;
832
+ ctx._renderBarrierGuardClosed = true;
833
+ };
834
+
835
+ if (ctx._treeHasStreaming) {
836
+ // Streaming: rendered() keeps waiting on handleStore.settled past this
837
+ // point, and loading() handlers are still in flight. The eager snapshot
838
+ // here would be incomplete, so leave it unset — rendered() builds and
839
+ // caches the complete one after settled. Keep the guard window OPEN so a
840
+ // handler that resumes and awaits a still-waiting rendered() loader is
841
+ // still caught; close it once settled (every tracked handler has finished
842
+ // then, so none can await a loader anymore). settled resolves after
843
+ // rendered() seals; if no loader used rendered(), nothing seals and the
844
+ // (empty) guard state is simply GC'd at request end.
845
+ handleStore.settled.then(closeGuard);
846
+ } else {
847
+ // Non-streaming: all handlers have settled by now. Build and cache the
848
+ // snapshot so loader ctx.use(handle) calls don't rebuild it, and close the
849
+ // guard window immediately.
850
+ ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
851
+ handleStore,
852
+ segOrder,
853
+ );
854
+ closeGuard();
855
+ }
808
856
  if (resolveBarrier) resolveBarrier();
809
857
  };
810
858
  Object.defineProperty(ctx, "_renderBarrier", {
@@ -35,6 +35,7 @@ import type { Handler } from "./types.js";
35
35
  import type { StaticBuildContext } from "./prerender.js";
36
36
  import type { UseItems, HandlerUseItem } from "./route-types.js";
37
37
  import { isCachedFunction } from "./cache/taint.js";
38
+ import { isUnderTestRunner } from "./runtime-env.js";
38
39
 
39
40
  // -- Types ------------------------------------------------------------------
40
41
 
@@ -63,6 +64,11 @@ export interface StaticHandlerDefinition<
63
64
 
64
65
  // -- Function ---------------------------------------------------------------
65
66
 
67
+ // Process-stable fallback id counter (mirrors createHandle / createLoader /
68
+ // Prerender). Only assigned in a bare unit test where the Vite plugin did not
69
+ // inject an id; never fires in a real build (the plugin always injects).
70
+ let runtimeStaticIdCounter = 0;
71
+
66
72
  export function Static<TParams extends Record<string, any> = {}>(
67
73
  handler: (ctx: StaticBuildContext) => ReactNode | Promise<ReactNode>,
68
74
  options?: StaticHandlerOptions,
@@ -94,12 +100,28 @@ export function Static<TParams extends Record<string, any>>(
94
100
  id = maybeId ?? "";
95
101
  }
96
102
 
97
- if (!id) {
103
+ // Throw unless under a test runner. The plugin always injects $$id for a
104
+ // supported `export const` Static on every build, so a missing id means either
105
+ // no plugin (a bare test — fall back below) or an UNSUPPORTED shape the plugin
106
+ // silently skipped (dev OR a real build — fail loud; a synthetic id would
107
+ // degrade to a silent static/prerender miss). The message is already small (no
108
+ // stack-parsing diagnostic), so it ships as-is. isUnderTestRunner() is
109
+ // runtime-safe — never a bare `process.env` access.
110
+ if (!id && !isUnderTestRunner()) {
98
111
  throw new Error(
99
- "[rsc-router] Static: missing $$id. " +
100
- "Ensure the exposeInternalIds Vite plugin is configured.",
112
+ "[rango] Static: missing $$id. Use `export const X = Static(...)` and " +
113
+ "ensure the exposeInternalIds Vite plugin is configured.",
101
114
  );
102
115
  }
116
+ // Under vitest with no plugin id: assign a process-stable runtime id so a
117
+ // whole-app router with Static() routes constructs in a bare test. Never
118
+ // reached in a real build (the throw above fires there); staticHandlerId is
119
+ // read only during RSC serving (never in dispatch / assertGeneratedRoutesMatch),
120
+ // and the build static manifest keys on the plugin id. Mirrors createHandle /
121
+ // createLoader / Prerender.
122
+ if (!id) {
123
+ id = `__rango_runtime_static_${runtimeStaticIdCounter++}`;
124
+ }
103
125
 
104
126
  return {
105
127
  __brand: "staticHandler" as const,
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Cache-status testing primitives for @rangojs/router consumers.
3
+ *
4
+ * Two complementary paths, both DEVELOPMENT/TEST ONLY:
5
+ *
6
+ * 1. Header path — `parseCacheHeader` / `assertCacheStatus` read the
7
+ * `X-Rango-Cache` response header. The header is emitted only when the
8
+ * router's debug cache signal gate is on (the `debugCacheSignal` option or
9
+ * `RANGO_TEST_SIGNALS=1`). With the gate off there is no header and these
10
+ * helpers throw a clear "header missing" error.
11
+ *
12
+ * 2. Telemetry path — `createCacheSink` returns a `{ sink, events }` pair the
13
+ * consumer wires via `createRouter({ telemetry: sink })`. This has ZERO
14
+ * production surface: no header, just structured `cache.decision` events
15
+ * (which carry the same coarse `segments` cache signal).
16
+ *
17
+ * v1 cache status is COARSE (route-level): the router reports a single entry
18
+ * keyed by the route key (the route NAME), not per individual segment.
19
+ *
20
+ * Import path: from a Vitest unit/integration test use `@rangojs/router/testing`;
21
+ * from a Playwright e2e use `@rangojs/router/testing/e2e` (the barrel pulls a
22
+ * build-only virtual that does not resolve in a plain Playwright runner).
23
+ */
24
+
25
+ import type {
26
+ CacheDecisionEvent,
27
+ CacheSegmentStatus,
28
+ TelemetryEvent,
29
+ TelemetrySink,
30
+ } from "../router/telemetry.js";
31
+
32
+ const CACHE_HEADER = "X-Rango-Cache";
33
+
34
+ /** Expected cache status passed to assertCacheStatus. */
35
+ export type ExpectedCacheStatus = CacheSegmentStatus;
36
+
37
+ /** A target carrying response headers (a Response or a `{ headers }` object). */
38
+ export type CacheStatusTarget = Response | { headers: Headers };
39
+
40
+ /**
41
+ * Parse an `X-Rango-Cache` header value into a `{ routeKey: status }` map.
42
+ *
43
+ * Header format: `<routeKey>=<status>, <routeKey2>=<status2>`. The key is the
44
+ * route NAME (ctx.routeKey, e.g. `product.detail`), NOT the URL pattern —
45
+ * see assertCacheStatus. Whitespace around entries and the `=` is tolerated.
46
+ * Entries without a status are ignored.
47
+ *
48
+ * @example
49
+ * parseCacheHeader("product.detail=hit, shop.layout=stale")
50
+ * // => { "product.detail": "hit", "shop.layout": "stale" }
51
+ */
52
+ export function parseCacheHeader(
53
+ headerValue: string | null | undefined,
54
+ ): Record<string, string> {
55
+ const result: Record<string, string> = {};
56
+ if (!headerValue) return result;
57
+ for (const rawEntry of headerValue.split(",")) {
58
+ const entry = rawEntry.trim();
59
+ if (entry.length === 0) continue;
60
+ const eq = entry.indexOf("=");
61
+ if (eq === -1) continue;
62
+ const id = entry.slice(0, eq).trim();
63
+ const status = entry.slice(eq + 1).trim();
64
+ if (id.length === 0 || status.length === 0) continue;
65
+ result[id] = status;
66
+ }
67
+ return result;
68
+ }
69
+
70
+ function getHeaders(target: CacheStatusTarget): Headers {
71
+ return target.headers;
72
+ }
73
+
74
+ /**
75
+ * Assert that the `X-Rango-Cache` header reports `expected` status for the
76
+ * given route. Throws a descriptive error when the header is missing (gate
77
+ * off), the route is absent, or the status differs.
78
+ *
79
+ * `routeKey` is the route NAME (e.g. `product.detail`), the same id the header
80
+ * carries — NOT the URL pattern (`/products/:id`). The signal is built from
81
+ * ctx.routeKey (telemetry.ts), so a pattern-shaped key never matches.
82
+ *
83
+ * The header is produced by the RSC render pipeline, so get the Response from
84
+ * the router's real fetch path (`router.fetch(...)`), with the debug cache
85
+ * signal gate enabled (`debugCacheSignal: true` or `RANGO_TEST_SIGNALS=1`).
86
+ * NOTE: `dispatch()` is the non-RSC primitive and never emits this header.
87
+ *
88
+ * @example
89
+ * // debugCacheSignal must be enabled on the router under test.
90
+ * const res = await router.fetch(new Request("https://app/products/42"));
91
+ * assertCacheStatus(res, "product.detail", "hit");
92
+ */
93
+ export function assertCacheStatus(
94
+ target: CacheStatusTarget,
95
+ segment: string,
96
+ expected: ExpectedCacheStatus,
97
+ ): void {
98
+ const headerValue = getHeaders(target).get(CACHE_HEADER);
99
+ if (headerValue === null) {
100
+ throw new Error(
101
+ `assertCacheStatus: response has no ${CACHE_HEADER} header. ` +
102
+ `Enable the debug cache signal via createRouter({ debugCacheSignal: true }) ` +
103
+ `or RANGO_TEST_SIGNALS=1.`,
104
+ );
105
+ }
106
+ const map = parseCacheHeader(headerValue);
107
+ const actual = map[segment];
108
+ if (actual === undefined) {
109
+ const known = Object.keys(map);
110
+ throw new Error(
111
+ `assertCacheStatus: segment "${segment}" not found in ${CACHE_HEADER} ` +
112
+ `("${headerValue}"). Known segments: ${
113
+ known.length > 0 ? known.join(", ") : "(none)"
114
+ }.`,
115
+ );
116
+ }
117
+ if (actual !== expected) {
118
+ throw new Error(
119
+ `assertCacheStatus: segment "${segment}" expected "${expected}" but got "${actual}".`,
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * A telemetry sink paired with the array it records events into.
126
+ */
127
+ export interface CacheSink {
128
+ /** Wire into `createRouter({ telemetry: sink })`. */
129
+ sink: TelemetrySink;
130
+ /** All telemetry events captured so far, in emit order. */
131
+ events: TelemetryEvent[];
132
+ }
133
+
134
+ /**
135
+ * Create a capturing telemetry sink for asserting on `cache.decision` events.
136
+ *
137
+ * This is the ZERO-production-surface path: no response header is emitted, the
138
+ * consumer just inspects the captured events.
139
+ *
140
+ * @example
141
+ * const { sink, events } = createCacheSink();
142
+ * const router = createRouter({ telemetry: sink, ... });
143
+ * // ...send a request through the router's RSC fetch path...
144
+ * const decisions = filterCacheDecisions(events);
145
+ * expect(decisions[0].segments?.[0].cacheStatus).toBe("hit");
146
+ */
147
+ export function createCacheSink(): CacheSink {
148
+ const events: TelemetryEvent[] = [];
149
+ const sink: TelemetrySink = {
150
+ emit(event: TelemetryEvent): void {
151
+ events.push(event);
152
+ },
153
+ };
154
+ return { sink, events };
155
+ }
156
+
157
+ /**
158
+ * Filter captured telemetry events down to `cache.decision` events.
159
+ */
160
+ export function filterCacheDecisions(
161
+ events: readonly TelemetryEvent[],
162
+ ): CacheDecisionEvent[] {
163
+ return events.filter(
164
+ (e): e is CacheDecisionEvent => e.type === "cache.decision",
165
+ );
166
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * collectHandle — unit-test a handle's `collect`/accumulator function directly.
3
+ *
4
+ * A handle's collect function (the `createHandle(collect)` argument that maps the
5
+ * per-segment pushed values into the accumulated result) is otherwise not
6
+ * directly reachable: createHandle keeps it in a private registry keyed by the
7
+ * handle's `$$id` and returns only `{ __brand, $$id }`. This primitive runs that
8
+ * REAL registered collect on per-segment values you provide and returns the
9
+ * accumulated result — so the mapper/accumulator is unit-testable without a full
10
+ * route match.
11
+ *
12
+ * It relies on createHandle registering the collect even in a bare test (it
13
+ * assigns a runtime fallback id when the Vite plugin did not inject one). If a
14
+ * handle's module was never imported (so createHandle never ran), the collect is
15
+ * unregistered and this falls back to a flat array with a warning.
16
+ */
17
+
18
+ import { getCollectFn, type Handle } from "../handle.js";
19
+
20
+ /**
21
+ * Run a handle's collect function on per-segment pushed values.
22
+ *
23
+ * @param handle - The handle whose collect to run.
24
+ * @param segments - Per-segment pushed values: each entry is the array of values
25
+ * one route segment pushed for this handle, in parent -> child order. Empty
26
+ * per-segment arrays are dropped before the collect runs, matching production
27
+ * collectHandleData (a segment that pushed nothing is not passed through).
28
+ * @returns The accumulated value the handle's collect produces.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * // Default flatten
33
+ * collectHandle(Breadcrumbs, [[{ label: "Home", href: "/" }], [{ label: "P", href: "/p" }]]);
34
+ * // -> [{ label: "Home", href: "/" }, { label: "P", href: "/p" }]
35
+ *
36
+ * // Custom "last wins"
37
+ * const PageTitle = createHandle<string, string>((s) => s.flat().at(-1) ?? "");
38
+ * collectHandle(PageTitle, [["Home"], ["Product"]]); // -> "Product"
39
+ * ```
40
+ */
41
+ export function collectHandle<TData, TAccumulated>(
42
+ handle: Handle<TData, TAccumulated>,
43
+ segments: ReadonlyArray<ReadonlyArray<TData>>,
44
+ ): TAccumulated {
45
+ const collectFn = getCollectFn(handle.$$id) as
46
+ | ((segments: TData[][]) => TAccumulated)
47
+ | undefined;
48
+
49
+ if (!collectFn) {
50
+ console.warn(
51
+ `[rango] collectHandle: handle "${handle.$$id}" has no registered collect ` +
52
+ `function. Import the handle's module so createHandle() runs. Falling ` +
53
+ `back to a flat array.`,
54
+ );
55
+ return segments.flat() as unknown as TAccumulated;
56
+ }
57
+
58
+ // Match production collectHandleData (handle.ts): segments that pushed
59
+ // nothing (empty arrays) are dropped before the collect runs, so a collect
60
+ // that inspects segment count or indices sees the same input as at runtime.
61
+ const nonEmpty = segments.filter((seg) => seg.length > 0) as TData[][];
62
+ return collectFn(nonEmpty);
63
+ }