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

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 (212) 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 +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 +1 -1
  12. package/skills/hooks/SKILL.md +196 -21
  13. package/skills/host-router/SKILL.md +45 -20
  14. package/skills/intercept/SKILL.md +1 -4
  15. package/skills/layout/SKILL.md +4 -7
  16. package/skills/links/SKILL.md +22 -10
  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 +3 -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 +9 -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 +778 -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 +85 -3
  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 +9 -67
  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 +8 -16
  45. package/src/browser/react/NavigationProvider.tsx +55 -65
  46. package/src/browser/react/location-state-shared.ts +175 -4
  47. package/src/browser/react/location-state.ts +39 -13
  48. package/src/browser/react/use-handle.ts +17 -9
  49. package/src/browser/react/use-params.ts +3 -4
  50. package/src/browser/react/use-reverse.ts +19 -12
  51. package/src/browser/react/use-router.ts +14 -1
  52. package/src/browser/response-adapter.ts +25 -0
  53. package/src/browser/rsc-router.tsx +30 -16
  54. package/src/browser/scroll-restoration.ts +30 -25
  55. package/src/browser/segment-structure-assert.ts +2 -2
  56. package/src/browser/server-action-bridge.ts +23 -30
  57. package/src/browser/types.ts +2 -0
  58. package/src/build/collect-fallback-refs.ts +107 -0
  59. package/src/build/generate-manifest.ts +60 -35
  60. package/src/build/generate-route-types.ts +2 -0
  61. package/src/build/index.ts +2 -0
  62. package/src/build/route-types/codegen.ts +4 -4
  63. package/src/build/route-types/include-resolution.ts +1 -1
  64. package/src/build/route-types/per-module-writer.ts +7 -4
  65. package/src/build/route-types/router-processing.ts +55 -14
  66. package/src/build/route-types/scan-filter.ts +1 -1
  67. package/src/build/route-types/source-scan.ts +118 -0
  68. package/src/build/runtime-discovery.ts +9 -20
  69. package/src/cache/cache-scope.ts +28 -42
  70. package/src/cache/cf/cf-cache-store.ts +49 -6
  71. package/src/client.tsx +5 -7
  72. package/src/context-var.ts +5 -5
  73. package/src/decode-loader-results.ts +36 -0
  74. package/src/errors.ts +30 -1
  75. package/src/handle.ts +26 -13
  76. package/src/host/index.ts +2 -2
  77. package/src/host/router.ts +129 -57
  78. package/src/host/types.ts +31 -2
  79. package/src/host/utils.ts +1 -1
  80. package/src/href-client.ts +136 -19
  81. package/src/index.rsc.ts +6 -4
  82. package/src/index.ts +13 -6
  83. package/src/loader-store.ts +500 -0
  84. package/src/loader.rsc.ts +21 -6
  85. package/src/loader.ts +3 -10
  86. package/src/missing-id-error.ts +68 -0
  87. package/src/prerender.ts +4 -4
  88. package/src/response-utils.ts +9 -0
  89. package/src/reverse.ts +16 -13
  90. package/src/route-content-wrapper.tsx +6 -28
  91. package/src/route-definition/dsl-helpers.ts +238 -263
  92. package/src/route-definition/helper-factories.ts +29 -139
  93. package/src/route-definition/helpers-types.ts +37 -14
  94. package/src/route-definition/use-item-types.ts +32 -0
  95. package/src/route-types.ts +19 -41
  96. package/src/router/basename.ts +14 -0
  97. package/src/router/content-negotiation.ts +15 -2
  98. package/src/router/error-handling.ts +1 -1
  99. package/src/router/intercept-resolution.ts +4 -18
  100. package/src/router/lazy-includes.ts +2 -2
  101. package/src/router/loader-resolution.ts +16 -2
  102. package/src/router/match-handlers.ts +62 -20
  103. package/src/router/match-middleware/cache-lookup.ts +44 -91
  104. package/src/router/match-middleware/cache-store.ts +3 -2
  105. package/src/router/match-result.ts +32 -30
  106. package/src/router/metrics.ts +1 -1
  107. package/src/router/middleware-types.ts +1 -1
  108. package/src/router/middleware.ts +46 -78
  109. package/src/router/prerender-match.ts +1 -1
  110. package/src/router/preview-match.ts +3 -1
  111. package/src/router/request-classification.ts +4 -28
  112. package/src/router/revalidation.ts +43 -1
  113. package/src/router/router-interfaces.ts +45 -28
  114. package/src/router/router-options.ts +40 -1
  115. package/src/router/router-registry.ts +2 -5
  116. package/src/router/segment-resolution/fresh.ts +19 -6
  117. package/src/router/segment-resolution/revalidation.ts +19 -6
  118. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  119. package/src/router/telemetry.ts +99 -0
  120. package/src/router/types.ts +8 -0
  121. package/src/router.ts +37 -21
  122. package/src/rsc/handler-context.ts +2 -2
  123. package/src/rsc/handler.ts +20 -65
  124. package/src/rsc/helpers.ts +22 -2
  125. package/src/rsc/index.ts +1 -1
  126. package/src/rsc/origin-guard.ts +28 -10
  127. package/src/rsc/response-route-handler.ts +32 -52
  128. package/src/rsc/rsc-rendering.ts +27 -53
  129. package/src/rsc/runtime-warnings.ts +9 -10
  130. package/src/rsc/server-action.ts +13 -37
  131. package/src/rsc/ssr-setup.ts +16 -0
  132. package/src/rsc/types.ts +2 -2
  133. package/src/search-params.ts +4 -4
  134. package/src/segment-system.tsx +64 -49
  135. package/src/serialize.ts +243 -0
  136. package/src/server/context.ts +118 -51
  137. package/src/server/cookie-store.ts +28 -4
  138. package/src/server/request-context.ts +10 -0
  139. package/src/static-handler.ts +1 -1
  140. package/src/testing/cache-status.ts +166 -0
  141. package/src/testing/collect-handle.ts +63 -0
  142. package/src/testing/dispatch.ts +440 -0
  143. package/src/testing/dom.entry.ts +22 -0
  144. package/src/testing/e2e/fixture.ts +154 -0
  145. package/src/testing/e2e/index.ts +149 -0
  146. package/src/testing/e2e/matchers.ts +51 -0
  147. package/src/testing/e2e/page-helpers.ts +272 -0
  148. package/src/testing/e2e/parity.ts +306 -0
  149. package/src/testing/e2e/server.ts +183 -0
  150. package/src/testing/flight-matchers.ts +104 -0
  151. package/src/testing/flight-runtime.d.ts +57 -0
  152. package/src/testing/flight-tree.ts +320 -0
  153. package/src/testing/flight.entry.ts +39 -0
  154. package/src/testing/flight.ts +197 -0
  155. package/src/testing/generated-routes.ts +223 -0
  156. package/src/testing/index.ts +106 -0
  157. package/src/testing/internal/context.ts +331 -0
  158. package/src/testing/internal/flight-client-globals.ts +30 -0
  159. package/src/testing/render-route.tsx +565 -0
  160. package/src/testing/run-loader.ts +341 -0
  161. package/src/testing/run-middleware.ts +188 -0
  162. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  163. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  164. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  165. package/src/testing/vitest-stubs/version.ts +5 -0
  166. package/src/testing/vitest.ts +270 -0
  167. package/src/types/global-namespace.ts +39 -26
  168. package/src/types/handler-context.ts +56 -11
  169. package/src/types/index.ts +1 -0
  170. package/src/types/segments.ts +18 -1
  171. package/src/urls/include-helper.ts +10 -53
  172. package/src/urls/index.ts +0 -3
  173. package/src/urls/path-helper-types.ts +11 -3
  174. package/src/urls/path-helper.ts +17 -52
  175. package/src/urls/pattern-types.ts +36 -19
  176. package/src/urls/response-types.ts +20 -19
  177. package/src/urls/type-extraction.ts +26 -116
  178. package/src/urls/urls-function.ts +1 -5
  179. package/src/use-loader.tsx +413 -42
  180. package/src/vite/debug.ts +1 -0
  181. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  182. package/src/vite/discovery/discover-routers.ts +70 -48
  183. package/src/vite/discovery/discovery-errors.ts +194 -0
  184. package/src/vite/discovery/prerender-collection.ts +19 -25
  185. package/src/vite/discovery/route-types-writer.ts +40 -84
  186. package/src/vite/discovery/state.ts +33 -0
  187. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  188. package/src/vite/index.ts +2 -0
  189. package/src/vite/plugin-types.ts +67 -0
  190. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  191. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  192. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -1
  193. package/src/vite/plugins/expose-action-id.ts +2 -2
  194. package/src/vite/plugins/expose-id-utils.ts +12 -8
  195. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  196. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  197. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  198. package/src/vite/plugins/expose-internal-ids.ts +47 -67
  199. package/src/vite/plugins/performance-tracks.ts +12 -16
  200. package/src/vite/plugins/use-cache-transform.ts +13 -11
  201. package/src/vite/plugins/version-injector.ts +2 -12
  202. package/src/vite/plugins/version-plugin.ts +59 -2
  203. package/src/vite/plugins/virtual-entries.ts +2 -2
  204. package/src/vite/rango.ts +67 -15
  205. package/src/vite/router-discovery.ts +208 -63
  206. package/src/vite/utils/ast-handler-extract.ts +15 -15
  207. package/src/vite/utils/bundle-analysis.ts +4 -2
  208. package/src/vite/utils/client-chunks.ts +190 -0
  209. package/src/vite/utils/forward-user-plugins.ts +193 -0
  210. package/src/vite/utils/manifest-utils.ts +21 -5
  211. package/src/vite/utils/shared-utils.ts +107 -26
  212. package/src/browser/action-response-classifier.ts +0 -99
@@ -10,7 +10,7 @@ import type {
10
10
  ShouldRevalidateFn,
11
11
  TransitionConfig,
12
12
  } from "../types";
13
- import { invariant } from "../errors";
13
+ import { invariant, DslContextError } from "../errors";
14
14
  import type { DefaultRouteName } from "../types/global-namespace.js";
15
15
 
16
16
  // ============================================================================
@@ -40,7 +40,7 @@ export interface MetricsStore {
40
40
  metrics: PerformanceMetric[];
41
41
  }
42
42
  // ============================================================================
43
- // RSC Router Context
43
+ // Rango Context
44
44
  // ============================================================================
45
45
 
46
46
  /**
@@ -71,6 +71,10 @@ export type EntryPropCommon = {
71
71
  };
72
72
 
73
73
  /**
74
+ * Attachments resolved by walking the parent chain, not owned by the entry:
75
+ * middleware composes downward; revalidate and the error/notFound boundaries are
76
+ * resolved by nearest-ancestor lookup. Inherited, not a single execution chain.
77
+ *
74
78
  * @internal This type is an implementation detail and may change without notice.
75
79
  */
76
80
  export type EntryPropDatas = {
@@ -80,6 +84,16 @@ export type EntryPropDatas = {
80
84
  notFoundBoundary: (ReactNode | NotFoundBoundaryHandler)[];
81
85
  };
82
86
 
87
+ /**
88
+ * Render-time presentation fields shared by every entry variant.
89
+ *
90
+ * @internal This type is an implementation detail and may change without notice.
91
+ */
92
+ export type EntryPropRender = {
93
+ loading?: ReactNode | false;
94
+ transition?: TransitionConfig;
95
+ };
96
+
83
97
  /**
84
98
  * Loader entry stored in EntryData
85
99
  * Contains the loader definition and its revalidation rules
@@ -158,11 +172,9 @@ export type InterceptEntry = {
158
172
  };
159
173
 
160
174
  export interface ParallelEntryData
161
- extends EntryPropCommon, EntryPropDatas, EntryPropSegments {
175
+ extends EntryPropCommon, EntryPropDatas, EntryPropSegments, EntryPropRender {
162
176
  type: "parallel";
163
177
  handler: Record<`@${string}`, Handler<any, any, any> | ReactNode>;
164
- loading?: ReactNode | false;
165
- transition?: TransitionConfig;
166
178
  /** Set when any parallel slot is a Static definition */
167
179
  isStaticPrerender?: true;
168
180
  /** Per-slot static handler $$ids for build-time store lookup */
@@ -171,6 +183,13 @@ export interface ParallelEntryData
171
183
 
172
184
  export type ParallelEntries = Partial<Record<`@${string}`, ParallelEntryData>>;
173
185
 
186
+ /**
187
+ * This entry's own structural children plus its owned loaders. `loader` lives
188
+ * here (not in EntryPropDatas) because loaders are owned by the entry, not
189
+ * inherited from ancestors.
190
+ *
191
+ * @internal This type is an implementation detail and may change without notice.
192
+ */
174
193
  export type EntryPropSegments = {
175
194
  loader: LoaderEntry[];
176
195
  layout: EntryData[];
@@ -182,8 +201,6 @@ export type EntryData =
182
201
  | ({
183
202
  type: "route";
184
203
  handler: Handler<any, any, any>;
185
- loading?: ReactNode | false;
186
- transition?: TransitionConfig;
187
204
  /** URL pattern for this route (used by path() in urls()) */
188
205
  pattern?: string;
189
206
  /** Set when handler is a Prerender definition */
@@ -205,29 +222,28 @@ export type EntryData =
205
222
  responseType?: string;
206
223
  } & EntryPropCommon &
207
224
  EntryPropDatas &
208
- EntryPropSegments)
225
+ EntryPropSegments &
226
+ EntryPropRender)
209
227
  | ({
210
228
  type: "layout";
211
229
  handler: ReactNode | Handler<any, any, any>;
212
- loading?: ReactNode | false;
213
- transition?: TransitionConfig;
214
230
  /** Set when handler is a Static definition (build-time only) */
215
231
  isStaticPrerender?: true;
216
232
  /** Static handler $$id for build-time store lookup */
217
233
  staticHandlerId?: string;
218
234
  } & EntryPropCommon &
219
235
  EntryPropDatas &
220
- EntryPropSegments)
236
+ EntryPropSegments &
237
+ EntryPropRender)
221
238
  | ParallelEntryData
222
239
  | ({
223
240
  type: "cache";
224
241
  /** Cache entries create cache boundaries and render like layouts (with Outlet) */
225
242
  handler: ReactNode | Handler<any, any, any>;
226
- loading?: ReactNode | false;
227
- transition?: TransitionConfig;
228
243
  } & EntryPropCommon &
229
244
  EntryPropDatas &
230
- EntryPropSegments);
245
+ EntryPropSegments &
246
+ EntryPropRender);
231
247
 
232
248
  /**
233
249
  * Tracked include info for build-time manifest generation
@@ -303,10 +319,28 @@ interface HelperContext {
303
319
  // hold references to the old instance — causing getStore() to return
304
320
  // undefined even inside a run() callback.
305
321
  const RSC_CONTEXT_KEY = Symbol.for("rangojs-router:rsc-context");
306
- export const RSCRouterContext: AsyncLocalStorage<HelperContext> = ((
322
+ export const RangoContext: AsyncLocalStorage<HelperContext> = ((
307
323
  globalThis as any
308
324
  )[RSC_CONTEXT_KEY] ??= new AsyncLocalStorage<HelperContext>());
309
325
 
326
+ /** shortCode prefix letter per entry type (e.g. "L0", "R2", "M1C0"). */
327
+ const SHORT_CODE_PREFIX: Record<
328
+ "layout" | "parallel" | "route" | "loader" | "cache",
329
+ string
330
+ > = {
331
+ layout: "L",
332
+ parallel: "P",
333
+ route: "R",
334
+ loader: "D",
335
+ cache: "C",
336
+ };
337
+
338
+ /** Post-increment a named per-store counter, returning the prior value. */
339
+ function bumpCounter(store: HelperContext, key: string): number {
340
+ store.counters[key] ??= 0;
341
+ return store.counters[key]++;
342
+ }
343
+
310
344
  export const getContext = (): {
311
345
  context: AsyncLocalStorage<HelperContext>;
312
346
  getStore: () => HelperContext;
@@ -330,12 +364,12 @@ export const getContext = (): {
330
364
  callback: (...args: any[]) => T,
331
365
  ) => T;
332
366
  } => {
333
- const context = RSCRouterContext;
367
+ const context = RangoContext;
334
368
 
335
369
  return {
336
370
  context,
337
371
  getOrCreateStore: (forRoute?: string): HelperContext => {
338
- let store = RSCRouterContext.getStore();
372
+ let store = RangoContext.getStore();
339
373
  if (!store) {
340
374
  store = {
341
375
  manifest: new Map<string, EntryData>(),
@@ -355,7 +389,7 @@ export const getContext = (): {
355
389
  const store = context.getStore();
356
390
  if (!store) {
357
391
  throw new Error(
358
- "RSC Router context store is not available. Make sure to run within RSC Router context.",
392
+ "Rango context store is not available. Make sure to run within Rango context.",
359
393
  );
360
394
  }
361
395
  return store;
@@ -372,29 +406,17 @@ export const getContext = (): {
372
406
  type: (string & {}) | "layout" | "parallel" | "middleware" | "revalidate",
373
407
  ) => {
374
408
  const store = context.getStore();
375
- invariant(store, "No context RSCRouterContext available");
376
- store.counters[type] ??= 0;
377
- const index = store.counters[type];
378
- store.counters[type] = index + 1;
379
- return `$${type}.${index}`;
409
+ invariant(store, "No context RangoContext available");
410
+ return `$${type}.${bumpCounter(store, type)}`;
380
411
  },
381
412
  getShortCode: (
382
413
  type: "layout" | "parallel" | "route" | "loader" | "cache",
383
414
  ) => {
384
415
  const store = context.getStore();
385
- invariant(store, "No context RSCRouterContext available");
416
+ invariant(store, "No context RangoContext available");
386
417
 
387
418
  const parent = store.parent;
388
- const prefix =
389
- type === "layout"
390
- ? "L"
391
- : type === "parallel"
392
- ? "P"
393
- : type === "loader"
394
- ? "D"
395
- : type === "cache"
396
- ? "C"
397
- : "R";
419
+ const prefix = SHORT_CODE_PREFIX[type];
398
420
  const mountPrefix =
399
421
  store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
400
422
 
@@ -405,10 +427,7 @@ export const getContext = (): {
405
427
  const counterKey = mountPrefix
406
428
  ? `${mountPrefix}_root_${type}`
407
429
  : `root_${type}`;
408
- store.counters[counterKey] ??= 0;
409
- const index = store.counters[counterKey];
410
- store.counters[counterKey] = index + 1;
411
- return `${mountPrefix}${prefix}${index}`;
430
+ return `${mountPrefix}${prefix}${bumpCounter(store, counterKey)}`;
412
431
  } else {
413
432
  // Child entry: use parent-scoped counter with includeScope appended.
414
433
  // When we're evaluating a lazy include's direct children, includeScope
@@ -416,10 +435,7 @@ export const getContext = (): {
416
435
  // parent's counter namespace so routes inside one include cannot
417
436
  // collide with siblings declared outside it.
418
437
  const counterKey = `${parent.shortCode}${includeScope}_${type}`;
419
- store.counters[counterKey] ??= 0;
420
- const index = store.counters[counterKey];
421
- store.counters[counterKey] = index + 1;
422
- return `${parent.shortCode}${includeScope}${prefix}${index}`;
438
+ return `${parent.shortCode}${includeScope}${prefix}${bumpCounter(store, counterKey)}`;
423
439
  }
424
440
  },
425
441
  runWithStore: <T>(
@@ -493,6 +509,31 @@ export const getContext = (): {
493
509
  };
494
510
  };
495
511
 
512
+ /**
513
+ * Acquire the active DSL build context, throwing `message` if a helper was
514
+ * called outside a urls()/map() builder. Returns the store API and the live
515
+ * HelperContext so callers avoid a second getContext() lookup.
516
+ */
517
+ export function requireDslContext(message: string): {
518
+ store: ReturnType<typeof getContext>;
519
+ ctx: HelperContext;
520
+ } {
521
+ const store = getContext();
522
+ const ctx = store.context.getStore();
523
+ if (!ctx) {
524
+ // The only reason the store is absent here is that a route-definition helper
525
+ // ran with no active RangoContext — i.e. outside a urls()/map() builder.
526
+ // Record that as the cause so the throw is self-explanatory, not a bare
527
+ // "must be called inside urls()" with no indication of the mechanism.
528
+ throw new DslContextError(message, {
529
+ cause:
530
+ "RangoContext store is undefined: a route-definition helper was called " +
531
+ "outside an active urls()/map() builder.",
532
+ });
533
+ }
534
+ return { store, ctx };
535
+ }
536
+
496
537
  /**
497
538
  * Run a callback with specific URL and name prefixes
498
539
  * Used by include() to apply prefixes to nested patterns
@@ -502,7 +543,7 @@ export function runWithPrefixes<T>(
502
543
  namePrefix: string | undefined,
503
544
  callback: () => T,
504
545
  ): T {
505
- const store = RSCRouterContext.getStore();
546
+ const store = RangoContext.getStore();
506
547
  if (!store) {
507
548
  throw new Error("runWithPrefixes must be called within router context");
508
549
  }
@@ -547,7 +588,7 @@ export function runWithPrefixes<T>(
547
588
  ? (store.rootScoped ?? false)
548
589
  : store.rootScoped;
549
590
 
550
- return RSCRouterContext.run(
591
+ return RangoContext.run(
551
592
  {
552
593
  ...store,
553
594
  urlPrefix: combinedUrlPrefix,
@@ -562,7 +603,7 @@ export function runWithPrefixes<T>(
562
603
  * Get current URL prefix from context
563
604
  */
564
605
  export function getUrlPrefix(): string {
565
- const store = RSCRouterContext.getStore();
606
+ const store = RangoContext.getStore();
566
607
  return store?.urlPrefix || "";
567
608
  }
568
609
 
@@ -570,7 +611,7 @@ export function getUrlPrefix(): string {
570
611
  * Get current name prefix from context
571
612
  */
572
613
  export function getNamePrefix(): string | undefined {
573
- const store = RSCRouterContext.getStore();
614
+ const store = RangoContext.getStore();
574
615
  return store?.namePrefix;
575
616
  }
576
617
 
@@ -579,7 +620,7 @@ export function getNamePrefix(): string | undefined {
579
620
  * Returns true at root or inside { name: "" } includes, false inside named includes.
580
621
  */
581
622
  export function getRootScoped(): boolean {
582
- const store = RSCRouterContext.getStore();
623
+ const store = RangoContext.getStore();
583
624
  return store?.rootScoped ?? true;
584
625
  }
585
626
 
@@ -676,7 +717,7 @@ export function getParallelSlotCount(
676
717
  * ```
677
718
  */
678
719
  export function track(label: string, depth?: number): () => void {
679
- const store = RSCRouterContext.getStore();
720
+ const store = RangoContext.getStore();
680
721
 
681
722
  // No-op if context unavailable or metrics not enabled
682
723
  if (!store?.metrics?.enabled) {
@@ -699,25 +740,40 @@ export function track(label: string, depth?: number): () => void {
699
740
 
700
741
  /**
701
742
  * Separate ALS for tracking loader execution scope.
702
- * Uses a dedicated ALS (not RSCRouterContext) to avoid issues with
703
- * nested RSCRouterContext.run() calls in Vite's module runner.
743
+ * Uses a dedicated ALS (not RangoContext) to avoid issues with
744
+ * nested RangoContext.run() calls in Vite's module runner.
704
745
  */
705
746
  const LOADER_SCOPE_KEY = Symbol.for("rangojs-router:loader-scope");
706
747
  const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
707
748
  globalThis as any
708
749
  )[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
709
750
 
751
+ // Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
752
+ // of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
753
+ // invoked via ctx.use). Consulted ONLY by isInsideCacheScope() to exempt
754
+ // request-scoped reads. It deliberately does NOT affect isInsideLoaderScope(),
755
+ // so rendered()/barrier/deadlock gating (which must distinguish DSL from
756
+ // handler-invoked loaders) is unchanged.
757
+ const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
758
+ const loaderBodyScopeALS: AsyncLocalStorage<{ active: true }> = ((
759
+ globalThis as any
760
+ )[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
761
+
710
762
  /**
711
763
  * Check if the current execution is inside a cache() DSL boundary.
712
764
  * Returns false inside loader execution — loaders are always fresh
713
765
  * (never cached), so non-cacheable reads are safe.
714
766
  */
715
767
  export function isInsideCacheScope(): boolean {
716
- if (RSCRouterContext.getStore()?.insideCacheScope !== true) return false;
768
+ if (RangoContext.getStore()?.insideCacheScope !== true) return false;
717
769
  // Loaders are always fresh — even inside a cache() boundary, the loader
718
770
  // function re-executes on every request. Skip the guard when running
719
771
  // inside a loader.
720
772
  if (loaderScopeALS.getStore()?.active) return false;
773
+ // Also exempt handler-invoked loaders: their bodies run in a loader-body
774
+ // scope (not the DSL loader scope above), so request-scoped reads inside any
775
+ // loader — however invoked — are safe (loaders always re-run fresh).
776
+ if (loaderBodyScopeALS.getStore()?.active) return false;
721
777
  return true;
722
778
  }
723
779
 
@@ -738,3 +794,14 @@ export function isInsideLoaderScope(): boolean {
738
794
  export function runInsideLoaderScope<T>(fn: () => T): T {
739
795
  return loaderScopeALS.run({ active: true }, fn);
740
796
  }
797
+
798
+ /**
799
+ * Run `fn` inside a loader BODY scope. Marks loader-function execution for the
800
+ * cache-purity guard only (isInsideCacheScope), WITHOUT affecting
801
+ * isInsideLoaderScope()/rendered() gating. Applied to every loader body (DSL
802
+ * and handler-invoked via ctx.use) so request-scoped reads inside a loader
803
+ * never trip the cache-scope guards — loaders always run fresh.
804
+ */
805
+ export function runInsideLoaderBodyScope<T>(fn: () => T): T {
806
+ return loaderBodyScopeALS.run({ active: true }, fn);
807
+ }
@@ -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"]);
@@ -322,6 +322,15 @@ export interface RequestContext<
322
322
  * to avoid a second resolveRoute call. Cleared on HMR invalidation.
323
323
  */
324
324
  _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
325
+
326
+ /**
327
+ * @internal Coarse route-level cache signal for the X-Rango-Cache debug
328
+ * header. Populated by match/matchPartial only when the debug cache signal
329
+ * gate is enabled (debugCacheSignal option or RANGO_TEST_SIGNALS=1). Read by
330
+ * the response-finalization path (createResponseWithMergedHeaders). Undefined
331
+ * when the gate is off, so no header is emitted.
332
+ */
333
+ _cacheSignal?: import("../router/telemetry.js").CacheSegmentSignal[];
325
334
  }
326
335
 
327
336
  /**
@@ -362,6 +371,7 @@ export type PublicRequestContext<
362
371
  | "_setStatus"
363
372
  | "_variables"
364
373
  | "_classifiedRoute"
374
+ | "_cacheSignal"
365
375
  | "res"
366
376
  >;
367
377
 
@@ -96,7 +96,7 @@ export function Static<TParams extends Record<string, any>>(
96
96
 
97
97
  if (!id) {
98
98
  throw new Error(
99
- "[rsc-router] Static: missing $$id. " +
99
+ "[rango] Static: missing $$id. " +
100
100
  "Ensure the exposeInternalIds Vite plugin is configured.",
101
101
  );
102
102
  }
@@ -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
+ }