@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124

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 (120) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +47 -6
  3. package/package.json +61 -21
  4. package/skills/cache-guide/SKILL.md +8 -6
  5. package/skills/caching/SKILL.md +148 -1
  6. package/skills/hooks/SKILL.md +38 -27
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +38 -16
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +27 -15
  15. package/skills/route/SKILL.md +4 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/skills/use-cache/SKILL.md +9 -7
  32. package/src/browser/action-fence.ts +37 -0
  33. package/src/browser/cookie-name.ts +140 -0
  34. package/src/browser/invalidate-client-cache.ts +52 -0
  35. package/src/browser/navigation-bridge.ts +14 -1
  36. package/src/browser/navigation-client.ts +14 -1
  37. package/src/browser/navigation-store-handle.ts +39 -0
  38. package/src/browser/navigation-store.ts +26 -12
  39. package/src/browser/prefetch/fetch.ts +7 -0
  40. package/src/browser/rango-state.ts +176 -97
  41. package/src/browser/react/index.ts +0 -6
  42. package/src/browser/rsc-router.tsx +12 -4
  43. package/src/browser/server-action-bridge.ts +77 -15
  44. package/src/browser/types.ts +7 -1
  45. package/src/cache/cache-error.ts +104 -0
  46. package/src/cache/cache-policy.ts +95 -1
  47. package/src/cache/cache-runtime.ts +79 -13
  48. package/src/cache/cache-scope.ts +55 -4
  49. package/src/cache/cache-tag.ts +135 -0
  50. package/src/cache/cf/cf-cache-store.ts +2080 -224
  51. package/src/cache/cf/index.ts +15 -1
  52. package/src/cache/document-cache.ts +74 -7
  53. package/src/cache/index.ts +17 -0
  54. package/src/cache/memory-segment-store.ts +164 -14
  55. package/src/cache/tag-invalidation.ts +230 -0
  56. package/src/cache/types.ts +27 -0
  57. package/src/client.rsc.tsx +1 -1
  58. package/src/client.tsx +0 -6
  59. package/src/component-utils.ts +19 -0
  60. package/src/handle.ts +29 -9
  61. package/src/host/testing.ts +43 -14
  62. package/src/index.rsc.ts +29 -1
  63. package/src/index.ts +43 -1
  64. package/src/loader.rsc.ts +24 -3
  65. package/src/loader.ts +16 -2
  66. package/src/prerender.ts +24 -3
  67. package/src/router/basename.ts +14 -0
  68. package/src/router/match-handlers.ts +62 -20
  69. package/src/router/prerender-match.ts +6 -0
  70. package/src/router/router-interfaces.ts +7 -0
  71. package/src/router/router-options.ts +30 -0
  72. package/src/router/segment-resolution/loader-cache.ts +8 -17
  73. package/src/router/state-cookie-name.ts +33 -0
  74. package/src/router/telemetry.ts +99 -0
  75. package/src/router.ts +36 -7
  76. package/src/rsc/handler.ts +13 -1
  77. package/src/rsc/helpers.ts +19 -0
  78. package/src/rsc/progressive-enhancement.ts +2 -0
  79. package/src/rsc/response-route-handler.ts +8 -1
  80. package/src/rsc/rsc-rendering.ts +2 -0
  81. package/src/rsc/types.ts +2 -0
  82. package/src/runtime-env.ts +18 -0
  83. package/src/server/cookie-store.ts +52 -1
  84. package/src/server/request-context.ts +105 -2
  85. package/src/static-handler.ts +25 -3
  86. package/src/testing/cache-status.ts +166 -0
  87. package/src/testing/collect-handle.ts +63 -0
  88. package/src/testing/dispatch.ts +581 -0
  89. package/src/testing/dom.entry.ts +22 -0
  90. package/src/testing/e2e/fixture.ts +188 -0
  91. package/src/testing/e2e/index.ts +149 -0
  92. package/src/testing/e2e/matchers.ts +51 -0
  93. package/src/testing/e2e/page-helpers.ts +272 -0
  94. package/src/testing/e2e/parity.ts +387 -0
  95. package/src/testing/e2e/server.ts +195 -0
  96. package/src/testing/flight-matchers.ts +110 -0
  97. package/src/testing/flight-normalize.ts +38 -0
  98. package/src/testing/flight-runtime.d.ts +57 -0
  99. package/src/testing/flight-tree.ts +682 -0
  100. package/src/testing/flight.entry.ts +52 -0
  101. package/src/testing/flight.ts +234 -0
  102. package/src/testing/generated-routes.ts +223 -0
  103. package/src/testing/index.ts +119 -0
  104. package/src/testing/internal/context.ts +390 -0
  105. package/src/testing/internal/flight-client-globals.ts +30 -0
  106. package/src/testing/internal/seed-vars.ts +80 -0
  107. package/src/testing/render-handler.ts +360 -0
  108. package/src/testing/render-route.tsx +594 -0
  109. package/src/testing/run-loader.ts +474 -0
  110. package/src/testing/run-middleware.ts +231 -0
  111. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  112. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  113. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  114. package/src/testing/vitest-stubs/version.ts +5 -0
  115. package/src/testing/vitest.ts +305 -0
  116. package/src/types/cache-types.ts +13 -4
  117. package/src/types/error-types.ts +5 -1
  118. package/src/types/global-namespace.ts +11 -1
  119. package/src/types/handler-context.ts +16 -5
  120. package/src/browser/react/use-client-cache.ts +0 -58
package/src/loader.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  LoaderFn,
20
20
  } from "./types.js";
21
21
  import { missingInjectedIdError } from "./missing-id-error.js";
22
+ import { isUnderTestRunner } from "./runtime-env.js";
22
23
 
23
24
  // Overload 1: With function only (not fetchable)
24
25
  export function createLoader<T>(
@@ -46,8 +47,21 @@ export function createLoader<T>(
46
47
  ): LoaderDefinition<Awaited<T>, Record<string, string | undefined>> {
47
48
  const loaderId = __injectedId || "";
48
49
 
49
- if (!loaderId && process.env.NODE_ENV === "development") {
50
- throw missingInjectedIdError("Loader", "createLoader");
50
+ // Client/SSR build of createLoader. Under a test runner it needs no id
51
+ // (loaderId stays ""; the react-server build in loader.rsc.ts adds the runtime
52
+ // fallback for whole-router construction). Otherwise (dev or a real build) a
53
+ // missing id means an UNSUPPORTED shape the plugin skipped — fail loud rather
54
+ // than ship `$$id: ""` (which would make a client useLoader read the wrong
55
+ // key). The rich diagnostic stays behind the NODE_ENV check so production folds
56
+ // it away and ships the small throw. isUnderTestRunner() is runtime-safe.
57
+ if (!loaderId && !isUnderTestRunner()) {
58
+ if (process.env.NODE_ENV !== "production") {
59
+ throw missingInjectedIdError("Loader", "createLoader");
60
+ }
61
+ throw new Error(
62
+ "[rango] Loader is missing $$id — the build plugin did not inject one. " +
63
+ "Export it as `export const X = createLoader(...)`.",
64
+ );
51
65
  }
52
66
 
53
67
  return {
package/src/prerender.ts CHANGED
@@ -38,6 +38,7 @@ import type { ReverseFunction } from "./reverse.js";
38
38
  import type { DefaultReverseRouteMap } from "./types/global-namespace.js";
39
39
  import type { UseItems, HandlerUseItem } from "./route-types.js";
40
40
  import { isCachedFunction } from "./cache/taint.js";
41
+ import { isUnderTestRunner } from "./runtime-env.js";
41
42
 
42
43
  // -- Named route resolution types -------------------------------------------
43
44
 
@@ -273,6 +274,11 @@ export interface PrerenderHandlerDefinition<
273
274
  use?: () => UseItems<HandlerUseItem>;
274
275
  }
275
276
 
277
+ // Process-stable fallback id counter (mirrors createHandle / createLoader). Only
278
+ // assigned in a bare unit test where the Vite plugin did not inject an id; never
279
+ // fires in a real build (the plugin always injects).
280
+ let runtimePrerenderIdCounter = 0;
281
+
276
282
  // -- Overloads --------------------------------------------------------------
277
283
  //
278
284
  // T accepts: named route string (global or .local) OR explicit param object.
@@ -376,12 +382,27 @@ export function Prerender<TParams extends Record<string, any>>(
376
382
  );
377
383
  }
378
384
 
379
- if (!id) {
385
+ // Throw unless under a test runner. The plugin always injects $$id for a
386
+ // supported `export const` Prerender on every build, so a missing id means
387
+ // either no plugin (a bare test — fall back below) or an UNSUPPORTED shape the
388
+ // plugin silently skipped (dev OR a real build — fail loud; a synthetic id
389
+ // would degrade to a silent prerender miss). The message is already small (no
390
+ // stack-parsing diagnostic), so it ships as-is. isUnderTestRunner() is
391
+ // runtime-safe — never a bare `process.env` access.
392
+ if (!id && !isUnderTestRunner()) {
380
393
  throw new Error(
381
- "[rango] Prerender: missing $$id. " +
382
- "Ensure the exposeInternalIds Vite plugin is configured.",
394
+ "[rango] Prerender: missing $$id. Use `export const X = Prerender(...)` " +
395
+ "and ensure the exposeInternalIds Vite plugin is configured.",
383
396
  );
384
397
  }
398
+ // Under vitest with no plugin id: assign a process-stable runtime id so a
399
+ // whole-app router with Prerender routes constructs in a bare test (for
400
+ // dispatch / assertGeneratedRoutesMatch). Never reached in a real build (the
401
+ // throw above fires there); prerender storage/lookup keys on routeName +
402
+ // paramHash, never $$id (mirrors createHandle / createLoader).
403
+ if (!id) {
404
+ id = `__rango_runtime_prerender_${runtimePrerenderIdCounter++}`;
405
+ }
385
406
 
386
407
  return {
387
408
  __brand: "prerenderHandler" as const,
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Normalize a router basename to its canonical form: a single leading slash,
3
+ * no trailing slash, and `undefined` for an empty or bare-"/" value.
4
+ *
5
+ * This is the single source of truth used by both createRouter() (so the RSC
6
+ * handler stores a canonical basename on the request context) and the testing
7
+ * primitives (so a consumer can pass the same un-normalized string their
8
+ * createRouter() accepts and observe the same redirect() prefixing).
9
+ */
10
+ export function normalizeBasename(basename?: string): string | undefined {
11
+ if (!basename) return undefined;
12
+ const trimmed = basename.replace(/^\/+|\/+$/g, "");
13
+ return trimmed ? "/" + trimmed : undefined;
14
+ }
@@ -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(),
@@ -211,11 +211,14 @@ export async function matchForPrerender<TEnv = any>(
211
211
  header: () => {},
212
212
  setStatus: () => {},
213
213
  _setStatus: () => {},
214
+ _rotateStateCookie: () => {},
215
+ _setKeepCacheDirective: () => {},
214
216
  use: (() => {
215
217
  throw new Error("use() not available during pre-rendering");
216
218
  }) as any,
217
219
  method: "GET",
218
220
  _handleStore: handleStore,
221
+ _requestTags: new Set<string>(),
219
222
  waitUntil: () => {},
220
223
  onResponse: () => {},
221
224
  _onResponseCallbacks: [],
@@ -460,11 +463,14 @@ export async function renderStaticSegment<TEnv = any>(
460
463
  header: () => {},
461
464
  setStatus: () => {},
462
465
  _setStatus: () => {},
466
+ _rotateStateCookie: () => {},
467
+ _setKeepCacheDirective: () => {},
463
468
  use: (() => {
464
469
  throw new Error("use() not available during static pre-rendering");
465
470
  }) as any,
466
471
  method: "GET",
467
472
  _handleStore: handleStore,
473
+ _requestTags: new Set<string>(),
468
474
  waitUntil: () => {},
469
475
  onResponse: () => {},
470
476
  _onResponseCallbacks: [],
@@ -290,6 +290,13 @@ export interface RangoInternal<
290
290
  */
291
291
  readonly prefetchCacheTTL: number;
292
292
 
293
+ /**
294
+ * Resolved rango state cookie name (`{prefix}_{routerId}`), composed once at
295
+ * router init and shipped to the client in payload metadata. The server-side
296
+ * cookie writer reads it from here; the client reads it from metadata.
297
+ */
298
+ readonly resolvedStateCookieName: string;
299
+
293
300
  /**
294
301
  * Whether connection warmup is enabled.
295
302
  * When true, the client sends HEAD /?_rsc_warmup after idle periods
@@ -132,6 +132,21 @@ export interface RangoOptions<TEnv = any> {
132
132
  */
133
133
  allowDebugManifest?: boolean;
134
134
 
135
+ /**
136
+ * DEVELOPMENT/TEST ONLY. Emit an `X-Rango-Cache` response header describing
137
+ * the cache status of the matched route, for use by testing primitives such
138
+ * as `assertCacheStatus`.
139
+ *
140
+ * Defaults to `false`. When neither this option nor the
141
+ * `RANGO_TEST_SIGNALS=1` environment flag is set, NO header is emitted and
142
+ * router output is byte-identical to the default.
143
+ *
144
+ * The header encodes per-segment (v1: coarse route-level) status keyed by the
145
+ * route NAME, e.g. `X-Rango-Cache: product.detail=hit`. Do NOT enable in
146
+ * production — it exposes internal cache decisions.
147
+ */
148
+ debugCacheSignal?: boolean;
149
+
135
150
  /**
136
151
  * Document component that wraps the entire application.
137
152
  *
@@ -481,6 +496,21 @@ export interface RangoOptions<TEnv = any> {
481
496
  */
482
497
  prefetchCacheTTL?: number | false;
483
498
 
499
+ /**
500
+ * Prefix for the rango state cookie name. The resolved name is
501
+ * `{prefix}_{routerId}`; the prefix is sanitized to cookie-name-safe
502
+ * characters (`[A-Za-z0-9-]`) and an empty result falls back to the default.
503
+ *
504
+ * The rango state cookie keys the client's prefetch / HTTP caches. Overriding
505
+ * the prefix lets you align it with cookie-naming policies or consent-manager
506
+ * classification lists, or avoid colliding with an existing `rango-state`
507
+ * cookie. It is not a full-name override: the `_{routerId}` suffix is what
508
+ * keeps sibling apps on one origin from clobbering each other's state.
509
+ *
510
+ * @default "rango-state"
511
+ */
512
+ stateCookiePrefix?: string;
513
+
484
514
  /**
485
515
  * Enable connection warmup to keep TCP+TLS alive after idle periods.
486
516
  *
@@ -28,9 +28,11 @@ import {
28
28
  resolveSwrWindow,
29
29
  resolveCacheKey,
30
30
  resolveCacheStore,
31
+ resolveTagsOption,
31
32
  DEFAULT_ROUTE_TTL,
32
33
  } from "../../cache/cache-policy.js";
33
34
  import { readThroughItem } from "../../cache/read-through-swr.js";
35
+ import { recordRequestTags } from "../../cache/cache-tag.js";
34
36
  // Lazy-loaded to avoid pulling @vitejs/plugin-rsc/rsc into modules that
35
37
  // import segment-resolution but never use loader caching.
36
38
  let _serializeResult: typeof import("../../cache/segment-codec.js").serializeResult;
@@ -87,23 +89,8 @@ async function resolveLoaderKey(
87
89
  */
88
90
  function resolveTags(loaderEntry: LoaderEntry): string[] | undefined {
89
91
  const options = loaderEntry.cache?.options;
90
- if (!options || !options.tags) return undefined;
91
-
92
- if (typeof options.tags === "function") {
93
- const requestCtx = getRequestContext();
94
- if (!requestCtx) return undefined;
95
- try {
96
- return options.tags(requestCtx);
97
- } catch (error) {
98
- console.error(
99
- `[LoaderCache] Tags function failed, caching without tags:`,
100
- error,
101
- );
102
- return undefined;
103
- }
104
- }
105
-
106
- return options.tags;
92
+ if (!options) return undefined;
93
+ return resolveTagsOption(options.tags, getRequestContext(), "LoaderCache");
107
94
  }
108
95
 
109
96
  function getLoaderStore(
@@ -152,6 +139,10 @@ export function resolveLoaderData<TEnv>(
152
139
  const swrWindow = resolveSwrWindow(options.swr, store.defaults);
153
140
  const swr = swrWindow || undefined;
154
141
  const tags = resolveTags(loaderEntry);
142
+ // Loader tags are config-derived, so they are the complete set whether this is
143
+ // a cache hit or miss; record them every time so a document built from this
144
+ // loader is tagged for invalidation.
145
+ recordRequestTags(tags);
155
146
 
156
147
  // Wrap ctx.use() so cache HIT primes the handler's memoization map.
157
148
  // ctx.use() closes over the match context's loaderPromises (not request context's).
@@ -0,0 +1,33 @@
1
+ import { DEFAULT_STATE_COOKIE_PREFIX } from "../browser/cookie-name.js";
2
+
3
+ /**
4
+ * Resolve the rango state cookie name once, server-side, at router init. The
5
+ * resolved string is shipped in payload metadata and the client reads it
6
+ * verbatim, so composition happens in exactly one place.
7
+ *
8
+ * Shape: `{sanitizedPrefix}_{sanitizedRouterId}`. The prefix charset excludes
9
+ * `_` so the FIRST `_` is always the prefix/routerId boundary; that keeps the
10
+ * name injective even though a routerId may legitimately contain `_` (the
11
+ * counter fallback is `router_{n}`). Without that exclusion, prefix
12
+ * `rango-state` + id `router_0` and prefix `rango-state_router` + id `0` would
13
+ * both resolve to `rango-state_router_0` and silently share a cache key.
14
+ */
15
+
16
+ // Prefix excludes `_` so it can never collide with the separator.
17
+ function sanitizePrefix(prefix: string): string {
18
+ return prefix.replace(/[^A-Za-z0-9-]/g, "");
19
+ }
20
+
21
+ // routerId keeps `_` (so `router_0` survives); other illegal chars are dropped.
22
+ function sanitizeRouterId(routerId: string): string {
23
+ return routerId.replace(/[^A-Za-z0-9_-]/g, "");
24
+ }
25
+
26
+ export function resolveStateCookieName(
27
+ prefix: string | undefined,
28
+ routerId: string,
29
+ ): string {
30
+ const sanitized = sanitizePrefix(prefix ?? DEFAULT_STATE_COOKIE_PREFIX);
31
+ const finalPrefix = sanitized || DEFAULT_STATE_COOKIE_PREFIX;
32
+ return `${finalPrefix}_${sanitizeRouterId(routerId)}`;
33
+ }
@@ -90,6 +90,34 @@ export interface HandlerErrorEvent extends BaseEvent {
90
90
  params?: Record<string, string>;
91
91
  }
92
92
 
93
+ /**
94
+ * Per-segment (or coarse route-level) cache status carried on the
95
+ * cache.decision telemetry event and the X-Rango-Cache debug header.
96
+ *
97
+ * v1 is COARSE: the router's pipeline tracks cache decisions at the
98
+ * route/entry level (cacheHit/cacheSource/shouldRevalidate), not per
99
+ * individual segment. The `segments` array therefore contains a single
100
+ * route-level entry keyed by the route key. The shape is forward-compatible
101
+ * with genuine per-segment status if the pipeline later exposes it.
102
+ */
103
+ export type CacheSegmentStatus =
104
+ | "hit"
105
+ | "miss"
106
+ | "stale"
107
+ | "prerendered"
108
+ | "passthrough";
109
+
110
+ export interface CacheSegmentSignal {
111
+ /** Segment id (v1: the route key, since status is route-level). */
112
+ id: string;
113
+ /** Segment type (v1: "route" for the coarse route-level entry). */
114
+ type: string;
115
+ /** Resolved cache status for this segment. */
116
+ cacheStatus: CacheSegmentStatus;
117
+ /** Whether stale-while-revalidate was triggered for this segment. */
118
+ shouldRevalidate?: boolean;
119
+ }
120
+
93
121
  export interface CacheDecisionEvent extends BaseEvent {
94
122
  type: "cache.decision";
95
123
  pathname: string;
@@ -98,6 +126,12 @@ export interface CacheDecisionEvent extends BaseEvent {
98
126
  /** Whether stale-while-revalidate was triggered */
99
127
  shouldRevalidate: boolean;
100
128
  source?: "runtime" | "prerender";
129
+ /**
130
+ * Optional per-segment (v1: coarse route-level) cache status. Present only
131
+ * when telemetry or the debug cache signal is enabled. Optional so existing
132
+ * sinks are unaffected.
133
+ */
134
+ segments?: CacheSegmentSignal[];
101
135
  }
102
136
 
103
137
  export interface RevalidationDecisionEvent extends BaseEvent {
@@ -140,6 +174,71 @@ export type TelemetryEvent =
140
174
  | RequestTimeoutEvent
141
175
  | OriginCheckRejectedEvent;
142
176
 
177
+ // ---------------------------------------------------------------------------
178
+ // Cache signal derivation (coarse, route-level)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Derive the coarse, route-level cache status from pipeline cache state.
183
+ *
184
+ * v1 mapping (route-level — see CacheSegmentSignal):
185
+ * - prerender hit -> "prerendered"
186
+ * - runtime hit + shouldRevalidate (SWR) -> "stale"
187
+ * - runtime hit -> "hit"
188
+ * - no hit -> "miss"
189
+ *
190
+ * Note: "passthrough" is a build-time prerender concept (a route opts out of
191
+ * being prerendered for some params). At runtime a passthrough route renders
192
+ * fresh and is indistinguishable from a normal miss in the pipeline state, so
193
+ * v1 reports it as "miss". The "passthrough" status remains in the type union
194
+ * for forward compatibility.
195
+ */
196
+ export function deriveCacheStatus(state: {
197
+ cacheHit: boolean;
198
+ cacheSource?: "runtime" | "prerender";
199
+ shouldRevalidate?: boolean;
200
+ }): CacheSegmentStatus {
201
+ if (state.cacheHit) {
202
+ if (state.cacheSource === "prerender") return "prerendered";
203
+ if (state.shouldRevalidate) return "stale";
204
+ return "hit";
205
+ }
206
+ return "miss";
207
+ }
208
+
209
+ /**
210
+ * Build the coarse route-level cache signal array (a single entry keyed by
211
+ * the route key). Used for both the cache.decision telemetry event and the
212
+ * X-Rango-Cache debug header.
213
+ */
214
+ export function buildCacheSignalSegments(
215
+ routeKey: string,
216
+ state: {
217
+ cacheHit: boolean;
218
+ cacheSource?: "runtime" | "prerender";
219
+ shouldRevalidate?: boolean;
220
+ },
221
+ ): CacheSegmentSignal[] {
222
+ return [
223
+ {
224
+ id: routeKey,
225
+ type: "route",
226
+ cacheStatus: deriveCacheStatus(state),
227
+ shouldRevalidate: !!state.shouldRevalidate,
228
+ },
229
+ ];
230
+ }
231
+
232
+ /**
233
+ * Serialize cache signal segments into the X-Rango-Cache header value:
234
+ * `<segId>=<status>, <segId2>=<status2>`.
235
+ */
236
+ export function formatCacheSignalHeader(
237
+ segments: CacheSegmentSignal[],
238
+ ): string {
239
+ return segments.map((s) => `${s.id}=${s.cacheStatus}`).join(", ");
240
+ }
241
+
143
242
  // ---------------------------------------------------------------------------
144
243
  // Sink interface
145
244
  // ---------------------------------------------------------------------------
package/src/router.ts CHANGED
@@ -57,6 +57,7 @@ import { buildDebugManifest } from "./router/debug-manifest.js";
57
57
 
58
58
  import type { SegmentResolutionDeps, MatchApiDeps } from "./router/types.js";
59
59
  import { createHandlerContext } from "./router/handler-context.js";
60
+ import { normalizeBasename } from "./router/basename.js";
60
61
  import {
61
62
  setupLoaderAccess,
62
63
  setupLoaderAccessSilent,
@@ -110,6 +111,7 @@ import {
110
111
  matchForPrerender as _matchForPrerender,
111
112
  renderStaticSegment as _renderStaticSegment,
112
113
  } from "./router/prerender-match.js";
114
+ import { resolveStateCookieName } from "./router/state-cookie-name.js";
113
115
 
114
116
  // Re-export public types and values from extracted modules
115
117
  export { RSC_ROUTER_BRAND, RouterRegistry } from "./router/router-registry.js";
@@ -149,6 +151,7 @@ export function createRouter<TEnv = any>(
149
151
  nonce,
150
152
  version,
151
153
  prefetchCacheTTL: prefetchCacheTTLOption,
154
+ stateCookiePrefix: stateCookiePrefixOption,
152
155
  warmup: warmupOption,
153
156
  allowDebugManifest: allowDebugManifestOption = false,
154
157
  telemetry: telemetrySink,
@@ -158,14 +161,22 @@ export function createRouter<TEnv = any>(
158
161
  onTimeout,
159
162
  originCheck: originCheckOption,
160
163
  viewTransition: viewTransitionOption = "auto",
164
+ debugCacheSignal: debugCacheSignalOption = false,
161
165
  } = options;
162
166
 
167
+ // Debug cache signal gate (DEVELOPMENT/TEST ONLY). Enabled by the
168
+ // debugCacheSignal option OR the RANGO_TEST_SIGNALS=1 env flag. When off,
169
+ // no X-Rango-Cache header is emitted and output is byte-identical.
170
+ const cacheSignalEnabled =
171
+ debugCacheSignalOption ||
172
+ (typeof process !== "undefined" &&
173
+ (process as { env?: Record<string, string | undefined> }).env
174
+ ?.RANGO_TEST_SIGNALS === "1");
175
+
163
176
  // Normalize basename: ensure leading slash, strip trailing slash.
164
- // A bare "/" is equivalent to no basename.
165
- const basename =
166
- basenameOption && basenameOption.replace(/^\/+|\/+$/g, "")
167
- ? "/" + basenameOption.replace(/^\/+|\/+$/g, "")
168
- : undefined;
177
+ // A bare "/" is equivalent to no basename. Shared with the testing
178
+ // primitives via normalizeBasename so they can never drift.
179
+ const basename = normalizeBasename(basenameOption);
169
180
 
170
181
  // Resolve telemetry sink (no-op when not configured)
171
182
  const telemetry = resolveSink(telemetrySink);
@@ -209,6 +220,14 @@ export function createRouter<TEnv = any>(
209
220
  const routerId =
210
221
  userProvidedId ?? injectedId ?? `router_${nextRouterAutoId()}`;
211
222
 
223
+ // Resolve the rango state cookie name once, here, so the two cookie writers
224
+ // (the client document.cookie writer and the server Set-Cookie writer)
225
+ // consume one pre-composed name and cannot drift.
226
+ const resolvedStateCookieName = resolveStateCookieName(
227
+ stateCookiePrefixOption,
228
+ routerId,
229
+ );
230
+
212
231
  // Resolve prefetch cache TTL (default: 300 seconds / 5 minutes)
213
232
  // Clamp to a non-negative integer for valid Cache-Control max-age.
214
233
  const rawTTL =
@@ -255,9 +274,14 @@ export function createRouter<TEnv = any>(
255
274
  invokeOnError(onError, error, phase, context, "Router");
256
275
  }
257
276
 
258
- // Validate document is a client component
277
+ // Validate document is a client component. Under a test runner the "use
278
+ // client" transform has not run, so a real exported document has no marker;
279
+ // allowServerInTest lets the router construct in a bare unit test (for
280
+ // dispatch / assertGeneratedRoutesMatch) while a real build still throws.
259
281
  if (documentOption !== undefined) {
260
- assertClientComponent(documentOption, "document");
282
+ assertClientComponent(documentOption, "document", {
283
+ allowServerInTest: true,
284
+ });
261
285
  }
262
286
 
263
287
  // Use default document if none provided (keeps internal name as rootLayout)
@@ -667,6 +691,7 @@ export function createRouter<TEnv = any>(
667
691
  findMatch,
668
692
  findInterceptForRoute,
669
693
  telemetry: telemetrySink,
694
+ cacheSignalEnabled,
670
695
  });
671
696
 
672
697
  const { match, matchPartial, matchError, previewMatch } = matchHandlers;
@@ -938,6 +963,10 @@ export function createRouter<TEnv = any>(
938
963
  prefetchCacheControl,
939
964
  prefetchCacheTTL,
940
965
 
966
+ // Expose the resolved rango state cookie name for the server-side writer
967
+ // (invalidateClientCache) and for shipping to the client in metadata.
968
+ resolvedStateCookieName,
969
+
941
970
  // Expose warmup enabled flag for handler and client
942
971
  warmupEnabled,
943
972
 
@@ -57,6 +57,7 @@ import {
57
57
  getRouterTrie,
58
58
  } from "../route-map-builder.js";
59
59
  import type { HandlerContext } from "./handler-context.js";
60
+ import type { CacheErrorCategory } from "../cache/cache-error.js";
60
61
  import type { SegmentCacheStore } from "../cache/types.js";
61
62
  import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
62
63
  import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
@@ -150,6 +151,13 @@ export function createRSCHandler<
150
151
  >(options: CreateRSCHandlerOptions<TEnv, TRoutes>) {
151
152
  const { router, version = VERSION, nonce: nonceProvider } = options;
152
153
 
154
+ // Handler-owned registry of explicit per-scope stores from cache({ store }).
155
+ // Lives in the closure so it is scoped per handler (multi-router deployments
156
+ // get separate registries) and accumulates every explicit store this handler
157
+ // resolves across requests. updateTag()/revalidateTag() iterate it to reach
158
+ // stores not covered by the app-level ctx._cacheStore.
159
+ const explicitTaggedStores = new Set<SegmentCacheStore>();
160
+
153
161
  // Use provided deps or default to @vitejs/plugin-rsc/rsc exports
154
162
  const deps = options.deps ?? rscDeps;
155
163
  const {
@@ -441,9 +449,12 @@ export function createRSCHandler<
441
449
  url,
442
450
  variables,
443
451
  cacheStore,
452
+ explicitTaggedStores,
444
453
  cacheProfiles: router.cacheProfiles,
445
454
  executionContext: executionCtx,
446
455
  themeConfig: router.themeConfig,
456
+ stateCookieName: router.resolvedStateCookieName,
457
+ version,
447
458
  });
448
459
  if (earlyMetricsStore) {
449
460
  requestContext._debugPerformance = true;
@@ -453,7 +464,7 @@ export function createRSCHandler<
453
464
  // can surface non-fatal errors through the router's onError callback.
454
465
  requestContext._reportBackgroundError = (
455
466
  error: unknown,
456
- category: string,
467
+ category: CacheErrorCategory,
457
468
  ) => {
458
469
  callOnError(error, "cache", {
459
470
  request,
@@ -1070,6 +1081,7 @@ export function createRSCHandler<
1070
1081
  rootLayout: router.rootLayout,
1071
1082
  handles: handleStore.stream(),
1072
1083
  version,
1084
+ stateCookieName: router.resolvedStateCookieName,
1073
1085
  themeConfig: router.themeConfig,
1074
1086
  warmupEnabled: router.warmupEnabled,
1075
1087
  initialTheme: requireRequestContext().theme,