@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
@@ -12,6 +12,24 @@ import type { RequestContext } from "../server/request-context.js";
12
12
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
13
13
  import { isRedirectResponse } from "../response-utils.js";
14
14
  import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
15
+ import { formatCacheSignalHeader } from "../router/telemetry.js";
16
+
17
+ /**
18
+ * DEVELOPMENT/TEST ONLY. When the debug cache signal gate is on,
19
+ * match/matchPartial populate ctx._cacheSignal. Emit it as the X-Rango-Cache
20
+ * header. When the gate is off, ctx._cacheSignal is undefined and NOTHING is
21
+ * attached — output is byte-identical to the default. Header mutation failures
22
+ * are swallowed so immutable Response headers (e.g. protocol-switch) are safe.
23
+ */
24
+ function applyCacheSignalHeader(target: Headers, ctx: RequestContext): void {
25
+ const signal = ctx._cacheSignal;
26
+ if (!signal || signal.length === 0) return;
27
+ try {
28
+ target.set("X-Rango-Cache", formatCacheSignalHeader(signal));
29
+ } catch {
30
+ // Headers immutable — skip.
31
+ }
32
+ }
15
33
 
16
34
  /**
17
35
  * Copy stub headers from the request context onto a target Headers instance:
@@ -85,6 +103,7 @@ export function createResponseWithMergedHeaders(
85
103
  const mergedHeaders = new Headers(init.headers);
86
104
  applyStubHeaders(mergedHeaders, ctx.res.headers);
87
105
  ctx.res.headers.delete("set-cookie");
106
+ applyCacheSignalHeader(mergedHeaders, ctx);
88
107
 
89
108
  // ctx.res.status overrides init.status when explicitly set (e.g. 404 for
90
109
  // notFound, 500 for error). Default ctx.res.status is 200.
@@ -254,6 +254,7 @@ export async function handleProgressiveEnhancement<TEnv>(
254
254
  rootLayout: ctx.router.rootLayout,
255
255
  handles: handleStore.stream(),
256
256
  version: ctx.version,
257
+ stateCookieName: ctx.router.resolvedStateCookieName,
257
258
  themeConfig: ctx.router.themeConfig,
258
259
  warmupEnabled: ctx.router.warmupEnabled,
259
260
  initialTheme: requireRequestContext().theme,
@@ -362,6 +363,7 @@ async function renderPeErrorBoundary<TEnv>(
362
363
  rootLayout: ctx.router.rootLayout,
363
364
  handles: handleStore.stream(),
364
365
  version: ctx.version,
366
+ stateCookieName: ctx.router.resolvedStateCookieName,
365
367
  themeConfig: ctx.router.themeConfig,
366
368
  warmupEnabled: ctx.router.warmupEnabled,
367
369
  initialTheme: requireRequestContext().theme,
@@ -12,7 +12,7 @@ import { contextGet } from "../context-var.js";
12
12
  import { NOCACHE_SYMBOL } from "../cache/taint.js";
13
13
  import { traverseBack } from "../router/pattern-matching.js";
14
14
  import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
15
- import { createCacheScope } from "../cache/cache-scope.js";
15
+ import { createCacheScope, resolveCacheTags } from "../cache/cache-scope.js";
16
16
  import { executeMiddleware } from "../router/middleware.js";
17
17
  import {
18
18
  createReverseFunction,
@@ -277,6 +277,11 @@ export async function handleResponseRoute<TEnv>(
277
277
  }
278
278
  }
279
279
 
280
+ // Resolve cache tags for this document entry (static or dynamic),
281
+ // while request context is available. Passed to putResponse so the
282
+ // entry is tag-invalidatable.
283
+ const responseTags = resolveCacheTags(cacheScope.config, reqCtx);
284
+
280
285
  // Save pre-handler callbacks (registered by app-level middleware
281
286
  // before we reach the cache block) and clear the live array.
282
287
  // createResponseWithMergedHeaders (inside the handler) eagerly
@@ -318,6 +323,7 @@ export async function handleResponseRoute<TEnv>(
318
323
  fresh.clone(),
319
324
  cacheScope!.ttl,
320
325
  cacheScope!.swr,
326
+ responseTags,
321
327
  );
322
328
  }
323
329
  } catch (error) {
@@ -346,6 +352,7 @@ export async function handleResponseRoute<TEnv>(
346
352
  response.clone(),
347
353
  cacheScope!.ttl,
348
354
  cacheScope!.swr,
355
+ responseTags,
349
356
  );
350
357
  } catch (error) {
351
358
  console.error(`[ResponseCache] Cache write failed:`, error);
@@ -53,6 +53,7 @@ export async function handleRscRendering<TEnv>(
53
53
  handles: handleStore.stream(),
54
54
  version: ctx.version,
55
55
  prefetchCacheTTL: ctx.router.prefetchCacheTTL,
56
+ stateCookieName: ctx.router.resolvedStateCookieName,
56
57
  themeConfig: ctx.router.themeConfig,
57
58
  initialTheme: reqCtx.theme,
58
59
  },
@@ -99,6 +100,7 @@ export async function handleRscRendering<TEnv>(
99
100
  handles: handleStore.stream(),
100
101
  version: ctx.version,
101
102
  prefetchCacheTTL: ctx.router.prefetchCacheTTL,
103
+ stateCookieName: ctx.router.resolvedStateCookieName,
102
104
  },
103
105
  };
104
106
  }
package/src/rsc/types.ts CHANGED
@@ -43,6 +43,8 @@ export interface RscPayload {
43
43
  version?: string;
44
44
  /** TTL in milliseconds for the client-side in-memory prefetch cache */
45
45
  prefetchCacheTTL?: number;
46
+ /** Server-resolved rango state cookie name; the client reads it verbatim. */
47
+ stateCookieName?: string;
46
48
  /** Theme configuration for FOUC prevention */
47
49
  themeConfig?: ResolvedThemeConfig | null;
48
50
  /** Initial theme from cookie (for SSR hydration) */
@@ -0,0 +1,18 @@
1
+ // Runtime-safe detection of a test runner (Vitest), used to decide whether a
2
+ // create*() call with no plugin-injected $$id may fall back to a synthetic id (a
3
+ // bare test) or must fail loud (dev / a real build).
4
+ //
5
+ // `process` is absent in some target runtimes (the browser, certain edge/worker
6
+ // RSC environments), so probe it through `globalThis` with optional chaining —
7
+ // NEVER a bare `process.env.VITEST`, which would ReferenceError before the
8
+ // intended error is thrown. Unlike `process.env.NODE_ENV` (folded by the app's
9
+ // build `define`), `VITEST` is not folded, so this stays a small runtime check;
10
+ // it lives only on the create*() error path (id missing), which never runs in a
11
+ // correct production build.
12
+ //
13
+ // Vitest sets `VITEST` in every test process — the node project and the
14
+ // react-server forks alike (the RSC project forces NODE_ENV=production, so NODE_ENV
15
+ // cannot distinguish it from a real build; `VITEST` can). A real build never sets it.
16
+ export function isUnderTestRunner(): boolean {
17
+ return !!globalThis.process?.env?.VITEST;
18
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { CookieOptions } from "../router/middleware-types.js";
11
- import { getRequestContext } from "./request-context.js";
11
+ import { getRequestContext, _getRequestContext } from "./request-context.js";
12
12
  import { isInsideCacheScope } from "./context.js";
13
13
  import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
14
14
 
@@ -168,6 +168,57 @@ export function headers(): ReadonlyHeaders {
168
168
  }) as unknown as ReadonlyHeaders;
169
169
  }
170
170
 
171
+ /**
172
+ * Force the calling client's caches to miss from now on, from the server seat:
173
+ * write a rotated `Set-Cookie` for the rango state. The responding client
174
+ * applies it on receipt, and its history cache is marked stale by the
175
+ * jar-divergence observer at its next read. Per-client and lazy — it rotates
176
+ * only the client that receives this response, not every client.
177
+ *
178
+ * Idempotent within a request (one `Set-Cookie`). Inert (a dev warning) when
179
+ * called outside a request context. Like `cookies()`, it throws inside a
180
+ * `"use cache"` / `cache()` boundary, but is allowed from a loader (loaders are
181
+ * the dynamic holes of a cached document).
182
+ */
183
+ export function invalidateClientCache(): void {
184
+ const ctx = _getRequestContext();
185
+ if (!ctx) {
186
+ if (process.env.NODE_ENV !== "production") {
187
+ console.warn(
188
+ "[rango] invalidateClientCache() was called outside a request context; ignored.",
189
+ );
190
+ }
191
+ return;
192
+ }
193
+ assertNotInsideCacheContext(ctx, "invalidateClientCache");
194
+ ctx._rotateStateCookie();
195
+ }
196
+
197
+ /**
198
+ * Suppress a server action's automatic client-cache invalidation: tell the
199
+ * action bridge this action changed nothing a route renders, so it should leave
200
+ * the client's state and caches alone (no rotation, no prefetch wipe, no
201
+ * broadcast, no revalidation refetch). Per-response, not per-action-definition —
202
+ * only the execution knows whether anything changed.
203
+ *
204
+ * Sets an internal response header the bridge reads. Idempotent within a
205
+ * request. Inert (a dev warning) outside a request context — there is no
206
+ * automatic invalidation to suppress.
207
+ */
208
+ export function keepClientCache(): void {
209
+ const ctx = _getRequestContext();
210
+ if (!ctx) {
211
+ if (process.env.NODE_ENV !== "production") {
212
+ console.warn(
213
+ "[rango] keepClientCache() was called outside a request context; ignored.",
214
+ );
215
+ }
216
+ return;
217
+ }
218
+ assertNotInsideCacheContext(ctx, "keepClientCache");
219
+ ctx._setKeepCacheDirective();
220
+ }
221
+
171
222
  /**
172
223
  * Create a CookieStore backed by a RequestContext.
173
224
  * @internal Shared between cookies() shorthand and context methods.
@@ -11,7 +11,14 @@
11
11
  */
12
12
 
13
13
  import { AsyncLocalStorage } from "node:async_hooks";
14
+ import type { CacheErrorCategory } from "../cache/cache-error.js";
14
15
  import type { CookieOptions } from "../router/middleware.js";
16
+ import {
17
+ KEEP_CACHE_HEADER,
18
+ getRawCookieValue,
19
+ mintStateValue,
20
+ serializeStateCookie,
21
+ } from "../browser/cookie-name.js";
15
22
  import type { LoaderDefinition, LoaderContext } from "../types.js";
16
23
  import type { ScopedReverseFunction } from "../reverse.js";
17
24
  import type {
@@ -102,6 +109,10 @@ export interface RequestContext<
102
109
  setStatus(status: number): void;
103
110
  /** @internal Set status bypassing cache-exec guard (for framework error handling) */
104
111
  _setStatus(status: number): void;
112
+ /** @internal Rotate the rango state cookie (server seat of invalidateClientCache). */
113
+ _rotateStateCookie(): void;
114
+ /** @internal Set the keepClientCache() directive header on the response. */
115
+ _setKeepCacheDirective(): void;
105
116
 
106
117
  /**
107
118
  * Access loader data or push handle data.
@@ -140,6 +151,25 @@ export interface RequestContext<
140
151
  /** @internal Cache store for segment caching (optional, used by CacheScope) */
141
152
  _cacheStore?: SegmentCacheStore;
142
153
 
154
+ /**
155
+ * @internal Handler-owned registry of explicit per-scope stores from
156
+ * cache({ store }). Created once per createRSCHandler() and threaded into
157
+ * every request context, so it accumulates every explicit store the handler
158
+ * resolves. updateTag()/revalidateTag() iterate this set plus _cacheStore to
159
+ * reach every store that may hold tagged entries. The app-level store is not
160
+ * added here (it is always reachable via _cacheStore).
161
+ */
162
+ _explicitTaggedStores?: Set<SegmentCacheStore>;
163
+
164
+ /**
165
+ * @internal Union of every cache tag resolved while producing this request's
166
+ * response (from cache({ tags }), runtime cacheTag(), and loader cache tags).
167
+ * Populated at the tag-resolution sites via recordRequestTags(). Read by the
168
+ * document cache middleware so a full-page entry is tagged with everything its
169
+ * content used and can therefore be invalidated by updateTag()/revalidateTag().
170
+ */
171
+ _requestTags: Set<string>;
172
+
143
173
  /** @internal Cache profiles for "use cache" profile resolution (per-router) */
144
174
  _cacheProfiles?: Record<
145
175
  string,
@@ -318,9 +348,13 @@ export interface RequestContext<
318
348
  * @internal Report a non-fatal background error through the router's
319
349
  * onError callback. Wired by the RSC handler / router during request
320
350
  * creation. Cache-runtime and other subsystems call this to surface
321
- * errors without failing the response.
351
+ * errors without failing the response. `category` is surfaced to consumers as
352
+ * `metadata.category` on the onError context (phase `cache`).
322
353
  */
323
- _reportBackgroundError?: (error: unknown, category: string) => void;
354
+ _reportBackgroundError?: (
355
+ error: unknown,
356
+ category: CacheErrorCategory,
357
+ ) => void;
324
358
 
325
359
  /** @internal Per-request debug performance override (set via ctx.debugPerformance()) */
326
360
  _debugPerformance?: boolean;
@@ -336,6 +370,15 @@ export interface RequestContext<
336
370
  * to avoid a second resolveRoute call. Cleared on HMR invalidation.
337
371
  */
338
372
  _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
373
+
374
+ /**
375
+ * @internal Coarse route-level cache signal for the X-Rango-Cache debug
376
+ * header. Populated by match/matchPartial only when the debug cache signal
377
+ * gate is enabled (debugCacheSignal option or RANGO_TEST_SIGNALS=1). Read by
378
+ * the response-finalization path (createResponseWithMergedHeaders). Undefined
379
+ * when the gate is off, so no header is emitted.
380
+ */
381
+ _cacheSignal?: import("../router/telemetry.js").CacheSegmentSignal[];
339
382
  }
340
383
 
341
384
  /**
@@ -355,6 +398,8 @@ export type PublicRequestContext<
355
398
  | "deleteCookie"
356
399
  | "_handleStore"
357
400
  | "_cacheStore"
401
+ | "_explicitTaggedStores"
402
+ | "_requestTags"
358
403
  | "_cacheProfiles"
359
404
  | "_onResponseCallbacks"
360
405
  | "_themeConfig"
@@ -375,8 +420,11 @@ export type PublicRequestContext<
375
420
  | "_metricsStore"
376
421
  | "_basename"
377
422
  | "_setStatus"
423
+ | "_rotateStateCookie"
424
+ | "_setKeepCacheDirective"
378
425
  | "_variables"
379
426
  | "_classifiedRoute"
427
+ | "_cacheSignal"
380
428
  | "res"
381
429
  >;
382
430
 
@@ -500,6 +548,11 @@ export interface CreateRequestContextOptions<TEnv> {
500
548
  initialResponse?: Response;
501
549
  /** Optional cache store for segment caching (used by CacheScope) */
502
550
  cacheStore?: SegmentCacheStore;
551
+ /**
552
+ * Handler-owned registry of explicit per-scope stores for cross-store tag
553
+ * invalidation. Created once per handler, reused across requests.
554
+ */
555
+ explicitTaggedStores?: Set<SegmentCacheStore>;
503
556
  /** Optional cache profiles for "use cache" resolution (per-router) */
504
557
  cacheProfiles?: Record<
505
558
  string,
@@ -509,6 +562,10 @@ export interface CreateRequestContextOptions<TEnv> {
509
562
  executionContext?: ExecutionContext;
510
563
  /** Optional theme configuration (enables ctx.theme and ctx.setTheme) */
511
564
  themeConfig?: ResolvedThemeConfig | null;
565
+ /** Resolved rango state cookie name, for the server seat of invalidateClientCache(). */
566
+ stateCookieName?: string;
567
+ /** Build version, used as the prefix of a server-rotated rango state value. */
568
+ version?: string;
512
569
  }
513
570
 
514
571
  /**
@@ -529,11 +586,16 @@ export function createRequestContext<TEnv>(
529
586
  variables,
530
587
  initialResponse,
531
588
  cacheStore,
589
+ explicitTaggedStores,
532
590
  cacheProfiles,
533
591
  executionContext,
534
592
  themeConfig,
593
+ stateCookieName,
594
+ version: stateVersion,
535
595
  } = options;
536
596
  const cookieHeader = request.headers.get("Cookie");
597
+ // One Set-Cookie per request no matter how many invalidateClientCache() calls.
598
+ let rangoStateRotated = false;
537
599
  let parsedCookies: Record<string, string> | null = null;
538
600
 
539
601
  // Create stub response for collecting headers/cookies.
@@ -723,6 +785,45 @@ export function createRequestContext<TEnv>(
723
785
  stubResponse.headers.set(name, value);
724
786
  },
725
787
 
788
+ // Rotate the rango state cookie for the responding client (the server seat
789
+ // of invalidateClientCache). Writes ONE Set-Cookie per request with the
790
+ // value {version}:{timestamp}; the `:` stays raw (the cookie-name.ts
791
+ // serializer), not the URL-encoded form serializeCookieValue would produce.
792
+ // The timestamp is strictly greater than the client's current one (inbound
793
+ // X-Rango-State), so a same-millisecond server rotation still differs from
794
+ // the client value and the divergence observer fires.
795
+ _rotateStateCookie(): void {
796
+ if (rangoStateRotated) return;
797
+ rangoStateRotated = true;
798
+ if (!stateCookieName) return;
799
+ // The client's current value, for the monotonic guard: prefer the
800
+ // X-Rango-State header (router navigation/prefetch fetches send it), but
801
+ // fall back to the request's rango state cookie — action POSTs / plain
802
+ // app fetch()s carry no router header yet DO send the cookie. Without the
803
+ // fallback, prevTs stays 0 and a same-ms mint can equal the client value,
804
+ // leaving the divergence observer silent. `|| null` so an empty header
805
+ // ('' from proxy normalization) falls through instead of short-circuiting.
806
+ // getRawCookieValue reads the cookie undecoded (the wire value
807
+ // decodeStateValue decodes exactly once) AND is the same parser the client
808
+ // mirror uses, so both seats read the same jar entry.
809
+ const prevRaw =
810
+ (request.headers.get("x-rango-state") || null) ??
811
+ getRawCookieValue(cookieHeader, stateCookieName);
812
+ const value = mintStateValue(stateVersion ?? "0", prevRaw);
813
+ stubResponse.headers.append(
814
+ "Set-Cookie",
815
+ serializeStateCookie(stateCookieName, value, url.protocol === "https:"),
816
+ );
817
+ invalidateResponseCookieCache();
818
+ },
819
+
820
+ // Set the keepClientCache() directive header. The action bridge reads it on
821
+ // the response and suppresses its automatic invalidation. `.set` makes this
822
+ // idempotent (one header regardless of call count).
823
+ _setKeepCacheDirective(): void {
824
+ stubResponse.headers.set(KEEP_CACHE_HEADER, "1");
825
+ },
826
+
726
827
  setStatus(status: number): void {
727
828
  assertNotInsideCacheExec(ctx, "setStatus");
728
829
  assertNotInsideCacheScopeALS("setStatus");
@@ -746,6 +847,8 @@ export function createRequestContext<TEnv>(
746
847
 
747
848
  _handleStore: handleStore,
748
849
  _cacheStore: cacheStore,
850
+ _explicitTaggedStores: explicitTaggedStores,
851
+ _requestTags: new Set<string>(),
749
852
  _cacheProfiles: cacheProfiles,
750
853
 
751
854
  waitUntil(fn: () => Promise<void>): void {
@@ -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
- "[rango] 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
+ }