@rangojs/router 0.0.0-experimental.122 → 0.0.0-experimental.125

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 (260) hide show
  1. package/dist/bin/rango.js +10 -6
  2. package/dist/testing/vitest.js +82 -0
  3. package/dist/vite/index.js +55 -48
  4. package/package.json +61 -21
  5. package/skills/caching/SKILL.md +2 -1
  6. package/skills/hooks/SKILL.md +40 -29
  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 +3 -1
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +12 -0
  15. package/skills/route/SKILL.md +10 -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/src/__internal.ts +0 -65
  32. package/src/browser/action-coordinator.ts +1 -1
  33. package/src/browser/action-fence.ts +47 -0
  34. package/src/browser/cookie-name.ts +140 -0
  35. package/src/browser/event-controller.ts +1 -83
  36. package/src/browser/invalidate-client-cache.ts +52 -0
  37. package/src/browser/navigation-bridge.ts +14 -1
  38. package/src/browser/navigation-client.ts +14 -1
  39. package/src/browser/navigation-store-handle.ts +38 -0
  40. package/src/browser/navigation-store.ts +26 -51
  41. package/src/browser/navigation-transaction.ts +0 -32
  42. package/src/browser/partial-update.ts +1 -83
  43. package/src/browser/prefetch/cache.ts +6 -45
  44. package/src/browser/prefetch/fetch.ts +7 -0
  45. package/src/browser/prefetch/queue.ts +6 -3
  46. package/src/browser/rango-state.ts +157 -99
  47. package/src/browser/react/Link.tsx +0 -2
  48. package/src/browser/react/NavigationProvider.tsx +2 -1
  49. package/src/browser/react/ScrollRestoration.tsx +10 -6
  50. package/src/browser/react/filter-segment-order.ts +0 -2
  51. package/src/browser/react/index.ts +0 -51
  52. package/src/browser/react/location-state-shared.ts +0 -13
  53. package/src/browser/react/location-state.ts +0 -1
  54. package/src/browser/react/use-action.ts +6 -15
  55. package/src/browser/react/use-handle.ts +0 -5
  56. package/src/browser/react/use-link-status.ts +0 -4
  57. package/src/browser/react/use-navigation.ts +0 -3
  58. package/src/browser/react/use-params.ts +0 -2
  59. package/src/browser/react/use-search-params.ts +0 -5
  60. package/src/browser/react/use-segments.ts +0 -13
  61. package/src/browser/rsc-router.tsx +12 -4
  62. package/src/browser/server-action-bridge.ts +77 -15
  63. package/src/browser/types.ts +7 -2
  64. package/src/browser/validate-redirect-origin.ts +4 -5
  65. package/src/build/route-trie.ts +3 -0
  66. package/src/build/route-types/param-extraction.ts +6 -3
  67. package/src/build/route-types/router-processing.ts +0 -8
  68. package/src/cache/cache-policy.ts +0 -54
  69. package/src/cache/cache-runtime.ts +27 -24
  70. package/src/cache/cache-scope.ts +0 -27
  71. package/src/cache/cache-tag.ts +0 -37
  72. package/src/cache/cf/cf-cache-store.ts +94 -46
  73. package/src/cache/cf/index.ts +0 -24
  74. package/src/cache/document-cache.ts +11 -36
  75. package/src/cache/handle-snapshot.ts +0 -40
  76. package/src/cache/index.ts +0 -27
  77. package/src/cache/memory-segment-store.ts +2 -48
  78. package/src/cache/profile-registry.ts +7 -3
  79. package/src/cache/read-through-swr.ts +41 -11
  80. package/src/cache/segment-codec.ts +0 -16
  81. package/src/cache/types.ts +0 -98
  82. package/src/client.rsc.tsx +1 -22
  83. package/src/client.tsx +14 -38
  84. package/src/component-utils.ts +19 -0
  85. package/src/deps/ssr.ts +0 -1
  86. package/src/handle.ts +28 -18
  87. package/src/handles/MetaTags.tsx +0 -14
  88. package/src/handles/meta.ts +0 -39
  89. package/src/host/cookie-handler.ts +0 -36
  90. package/src/host/errors.ts +0 -24
  91. package/src/host/index.ts +6 -0
  92. package/src/host/pattern-matcher.ts +7 -50
  93. package/src/host/router.ts +1 -65
  94. package/src/host/testing.ts +40 -27
  95. package/src/host/types.ts +6 -2
  96. package/src/href-client.ts +0 -4
  97. package/src/index.rsc.ts +42 -3
  98. package/src/index.ts +31 -1
  99. package/src/internal-debug.ts +2 -4
  100. package/src/loader.rsc.ts +19 -9
  101. package/src/loader.ts +12 -4
  102. package/src/network-error-thrower.tsx +1 -6
  103. package/src/outlet-provider.tsx +1 -5
  104. package/src/prerender/param-hash.ts +10 -11
  105. package/src/prerender/store.ts +23 -30
  106. package/src/prerender.ts +58 -3
  107. package/src/root-error-boundary.tsx +1 -19
  108. package/src/route-content-wrapper.tsx +1 -44
  109. package/src/route-definition/dsl-helpers.ts +7 -19
  110. package/src/route-definition/helpers-types.ts +3 -3
  111. package/src/route-definition/redirect.ts +11 -1
  112. package/src/route-map-builder.ts +0 -16
  113. package/src/router/basename.ts +14 -0
  114. package/src/router/content-negotiation.ts +0 -13
  115. package/src/router/error-handling.ts +12 -16
  116. package/src/router/find-match.ts +4 -30
  117. package/src/router/intercept-resolution.ts +10 -1
  118. package/src/router/lazy-includes.ts +1 -57
  119. package/src/router/loader-resolution.ts +3 -2
  120. package/src/router/logging.ts +0 -6
  121. package/src/router/manifest.ts +1 -25
  122. package/src/router/match-api.ts +0 -20
  123. package/src/router/match-context.ts +0 -22
  124. package/src/router/match-handlers.ts +57 -58
  125. package/src/router/match-middleware/background-revalidation.ts +0 -7
  126. package/src/router/match-middleware/cache-lookup.ts +1 -54
  127. package/src/router/match-middleware/cache-store.ts +0 -31
  128. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  129. package/src/router/match-middleware/segment-resolution.ts +0 -21
  130. package/src/router/match-pipelines.ts +1 -42
  131. package/src/router/match-result.ts +1 -52
  132. package/src/router/metrics.ts +0 -34
  133. package/src/router/middleware-cookies.ts +0 -13
  134. package/src/router/middleware-types.ts +0 -115
  135. package/src/router/middleware.ts +7 -30
  136. package/src/router/navigation-snapshot.ts +0 -51
  137. package/src/router/params-util.ts +23 -0
  138. package/src/router/pattern-matching.ts +1 -33
  139. package/src/router/prerender-match.ts +33 -45
  140. package/src/router/request-classification.ts +1 -38
  141. package/src/router/revalidation.ts +5 -58
  142. package/src/router/router-context.ts +0 -26
  143. package/src/router/router-interfaces.ts +7 -0
  144. package/src/router/router-options.ts +30 -0
  145. package/src/router/segment-resolution/fresh.ts +25 -57
  146. package/src/router/segment-resolution/helpers.ts +34 -0
  147. package/src/router/segment-resolution/loader-cache.ts +10 -13
  148. package/src/router/segment-resolution/revalidation.ts +5 -42
  149. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  150. package/src/router/segment-resolution.ts +4 -1
  151. package/src/router/state-cookie-name.ts +33 -0
  152. package/src/router/telemetry-otel.ts +0 -20
  153. package/src/router/telemetry.ts +96 -19
  154. package/src/router/timeout.ts +0 -20
  155. package/src/router/trie-matching.ts +63 -40
  156. package/src/router/types.ts +1 -63
  157. package/src/router/url-params.ts +0 -5
  158. package/src/router.ts +40 -9
  159. package/src/rsc/handler.ts +14 -2
  160. package/src/rsc/helpers.ts +34 -0
  161. package/src/rsc/origin-guard.ts +0 -12
  162. package/src/rsc/progressive-enhancement.ts +4 -1
  163. package/src/rsc/rsc-rendering.ts +4 -7
  164. package/src/rsc/runtime-warnings.ts +14 -0
  165. package/src/rsc/server-action.ts +30 -28
  166. package/src/rsc/types.ts +2 -1
  167. package/src/runtime-env.ts +18 -0
  168. package/src/search-params.ts +0 -16
  169. package/src/segment-loader-promise.ts +14 -2
  170. package/src/segment-system.tsx +79 -88
  171. package/src/server/cookie-store.ts +52 -1
  172. package/src/server/handle-store.ts +7 -24
  173. package/src/server/loader-registry.ts +5 -24
  174. package/src/server/request-context.ts +74 -77
  175. package/src/ssr/index.tsx +14 -14
  176. package/src/static-handler.ts +10 -13
  177. package/src/testing/cache-status.ts +119 -0
  178. package/src/testing/collect-handle.ts +40 -0
  179. package/src/testing/dispatch.ts +581 -0
  180. package/src/testing/dom.entry.ts +22 -0
  181. package/src/testing/e2e/fixture.ts +188 -0
  182. package/src/testing/e2e/index.ts +127 -0
  183. package/src/testing/e2e/matchers.ts +35 -0
  184. package/src/testing/e2e/page-helpers.ts +272 -0
  185. package/src/testing/e2e/parity.ts +387 -0
  186. package/src/testing/e2e/server.ts +195 -0
  187. package/src/testing/flight-matchers.ts +97 -0
  188. package/src/testing/flight-normalize.ts +11 -0
  189. package/src/testing/flight-runtime.d.ts +57 -0
  190. package/src/testing/flight-tree.ts +682 -0
  191. package/src/testing/flight.entry.ts +52 -0
  192. package/src/testing/flight.ts +186 -0
  193. package/src/testing/generated-routes.ts +183 -0
  194. package/src/testing/index.ts +98 -0
  195. package/src/testing/internal/context.ts +348 -0
  196. package/src/testing/internal/flight-client-globals.ts +30 -0
  197. package/src/testing/internal/seed-vars.ts +54 -0
  198. package/src/testing/render-handler.ts +311 -0
  199. package/src/testing/render-route.tsx +504 -0
  200. package/src/testing/run-loader.ts +378 -0
  201. package/src/testing/run-middleware.ts +205 -0
  202. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  203. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  204. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  205. package/src/testing/vitest-stubs/version.ts +5 -0
  206. package/src/testing/vitest.ts +305 -0
  207. package/src/theme/ThemeProvider.tsx +0 -52
  208. package/src/theme/ThemeScript.tsx +0 -6
  209. package/src/theme/constants.ts +0 -12
  210. package/src/theme/index.ts +0 -7
  211. package/src/theme/theme-context.ts +1 -5
  212. package/src/theme/theme-script.ts +0 -14
  213. package/src/theme/use-theme.ts +0 -3
  214. package/src/types/boundaries.ts +0 -35
  215. package/src/types/error-types.ts +25 -89
  216. package/src/types/global-namespace.ts +15 -15
  217. package/src/types/handler-context.ts +16 -13
  218. package/src/types/index.ts +0 -10
  219. package/src/types/request-scope.ts +0 -19
  220. package/src/types/route-config.ts +6 -50
  221. package/src/types/route-entry.ts +0 -6
  222. package/src/types/segments.ts +0 -13
  223. package/src/urls/include-helper.ts +0 -4
  224. package/src/urls/index.ts +0 -6
  225. package/src/urls/path-helper-types.ts +2 -2
  226. package/src/urls/path-helper.ts +0 -54
  227. package/src/urls/urls-function.ts +0 -13
  228. package/src/use-loader.tsx +0 -186
  229. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  230. package/src/vite/discovery/discover-routers.ts +6 -7
  231. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  232. package/src/vite/plugin-types.ts +3 -1
  233. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  234. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  235. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  236. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  237. package/src/vite/plugins/expose-action-id.ts +2 -73
  238. package/src/vite/plugins/expose-id-utils.ts +0 -55
  239. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  240. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  241. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  242. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  243. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  244. package/src/vite/plugins/performance-tracks.ts +0 -3
  245. package/src/vite/plugins/use-cache-transform.ts +0 -36
  246. package/src/vite/plugins/version-injector.ts +0 -20
  247. package/src/vite/plugins/version-plugin.ts +1 -49
  248. package/src/vite/plugins/virtual-entries.ts +0 -15
  249. package/src/vite/rango.ts +1 -108
  250. package/src/vite/router-discovery.ts +2 -1
  251. package/src/vite/utils/ast-handler-extract.ts +0 -16
  252. package/src/vite/utils/bundle-analysis.ts +6 -13
  253. package/src/vite/utils/client-chunks.ts +0 -6
  254. package/src/vite/utils/forward-user-plugins.ts +0 -22
  255. package/src/vite/utils/manifest-utils.ts +0 -4
  256. package/src/vite/utils/package-resolution.ts +1 -73
  257. package/src/vite/utils/prerender-utils.ts +0 -35
  258. package/src/vite/utils/shared-utils.ts +3 -35
  259. package/src/browser/react/use-client-cache.ts +0 -58
  260. package/src/browser/shallow.ts +0 -40
@@ -13,6 +13,12 @@
13
13
  import { AsyncLocalStorage } from "node:async_hooks";
14
14
  import type { CacheErrorCategory } from "../cache/cache-error.js";
15
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";
16
22
  import type { LoaderDefinition, LoaderContext } from "../types.js";
17
23
  import type { ScopedReverseFunction } from "../reverse.js";
18
24
  import type {
@@ -103,6 +109,10 @@ export interface RequestContext<
103
109
  setStatus(status: number): void;
104
110
  /** @internal Set status bypassing cache-exec guard (for framework error handling) */
105
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;
106
116
 
107
117
  /**
108
118
  * Access loader data or push handle data.
@@ -360,6 +370,15 @@ export interface RequestContext<
360
370
  * to avoid a second resolveRoute call. Cleared on HMR invalidation.
361
371
  */
362
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[];
363
382
  }
364
383
 
365
384
  /**
@@ -401,8 +420,11 @@ export type PublicRequestContext<
401
420
  | "_metricsStore"
402
421
  | "_basename"
403
422
  | "_setStatus"
423
+ | "_rotateStateCookie"
424
+ | "_setKeepCacheDirective"
404
425
  | "_variables"
405
426
  | "_classifiedRoute"
427
+ | "_cacheSignal"
406
428
  | "res"
407
429
  >;
408
430
 
@@ -540,6 +562,10 @@ export interface CreateRequestContextOptions<TEnv> {
540
562
  executionContext?: ExecutionContext;
541
563
  /** Optional theme configuration (enables ctx.theme and ctx.setTheme) */
542
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;
543
569
  }
544
570
 
545
571
  /**
@@ -564,12 +590,13 @@ export function createRequestContext<TEnv>(
564
590
  cacheProfiles,
565
591
  executionContext,
566
592
  themeConfig,
593
+ stateCookieName,
594
+ version: stateVersion,
567
595
  } = options;
568
596
  const cookieHeader = request.headers.get("Cookie");
597
+ let rangoStateRotated = false;
569
598
  let parsedCookies: Record<string, string> | null = null;
570
599
 
571
- // Create stub response for collecting headers/cookies.
572
- // All cookie/header mutations go here; cookie reads derive from it.
573
600
  let stubResponse = initialResponse
574
601
  ? new Response(null, {
575
602
  status: initialResponse.status,
@@ -578,11 +605,9 @@ export function createRequestContext<TEnv>(
578
605
  })
579
606
  : new Response(null, { status: 200 });
580
607
 
581
- // Create handle store and loader memoization for this request
582
608
  const handleStore = createHandleStore();
583
609
  const loaderPromises = new Map<string, Promise<any>>();
584
610
 
585
- // Lazy parse cookies from the original Cookie header
586
611
  const getParsedCookies = (): Record<string, string> => {
587
612
  if (!parsedCookies) {
588
613
  parsedCookies = parseCookiesFromHeader(cookieHeader);
@@ -590,7 +615,6 @@ export function createRequestContext<TEnv>(
590
615
  return parsedCookies;
591
616
  };
592
617
 
593
- // Cached response cookie mutations — invalidated on setCookie/deleteCookie/setTheme
594
618
  let responseCookieCache: Map<string, string | null> | null = null;
595
619
  const getResponseCookies = (): Map<string, string | null> => {
596
620
  if (!responseCookieCache) {
@@ -602,8 +626,6 @@ export function createRequestContext<TEnv>(
602
626
  responseCookieCache = null;
603
627
  };
604
628
 
605
- // Guard: throw if a response-level side effect is called inside a cache() scope.
606
- // Uses ALS to detect the scope (set during segment resolution).
607
629
  function assertNotInsideCacheScopeALS(methodName: string): void {
608
630
  if (isInsideCacheScope()) {
609
631
  throw new Error(
@@ -614,8 +636,7 @@ export function createRequestContext<TEnv>(
614
636
  }
615
637
  }
616
638
 
617
- // Effective cookie read: response stub Set-Cookie wins, then original header.
618
- // The stub IS the source of truth for same-request mutations.
639
+ // Response stub Set-Cookie wins, then original header (source of truth for mutations).
619
640
  const effectiveCookie = (name: string): string | undefined => {
620
641
  const mutations = getResponseCookies();
621
642
  if (mutations.has(name)) {
@@ -625,14 +646,11 @@ export function createRequestContext<TEnv>(
625
646
  return getParsedCookies()[name];
626
647
  };
627
648
 
628
- // Theme helpers (only used when themeConfig is provided)
629
649
  const getTheme = (): Theme | undefined => {
630
650
  if (!themeConfig) return undefined;
631
651
 
632
- // Use overlay-aware read so setTheme() in the same request is reflected
633
652
  const stored = effectiveCookie(themeConfig.storageKey);
634
653
  if (stored) {
635
- // Validate stored value
636
654
  if (stored === "system" && themeConfig.enableSystem) {
637
655
  return "system";
638
656
  }
@@ -646,7 +664,6 @@ export function createRequestContext<TEnv>(
646
664
  const setTheme = (theme: Theme): void => {
647
665
  if (!themeConfig) return;
648
666
 
649
- // Validate theme value
650
667
  if (theme !== "system" && !themeConfig.themes.includes(theme)) {
651
668
  console.warn(
652
669
  `[Theme] Invalid theme value: "${theme}". Valid values: system, ${themeConfig.themes.join(", ")}`,
@@ -654,7 +671,6 @@ export function createRequestContext<TEnv>(
654
671
  return;
655
672
  }
656
673
 
657
- // Write to stub — effectiveCookie() will pick it up on next read
658
674
  stubResponse.headers.append(
659
675
  "Set-Cookie",
660
676
  serializeCookieValue(themeConfig.storageKey, theme, {
@@ -666,10 +682,8 @@ export function createRequestContext<TEnv>(
666
682
  invalidateResponseCookieCache();
667
683
  };
668
684
 
669
- // Strip internal _rsc* params so userland sees a clean URL.
670
685
  const cleanUrl = stripInternalParams(url);
671
686
 
672
- // Build the context object first (without use), then add use
673
687
  const ctx: RequestContext<TEnv> = {
674
688
  env,
675
689
  request,
@@ -755,6 +769,45 @@ export function createRequestContext<TEnv>(
755
769
  stubResponse.headers.set(name, value);
756
770
  },
757
771
 
772
+ // Rotate the rango state cookie for the responding client (the server seat
773
+ // of invalidateClientCache). Writes ONE Set-Cookie per request with the
774
+ // value {version}:{timestamp}; the `:` stays raw (the cookie-name.ts
775
+ // serializer), not the URL-encoded form serializeCookieValue would produce.
776
+ // The timestamp is strictly greater than the client's current one (inbound
777
+ // X-Rango-State), so a same-millisecond server rotation still differs from
778
+ // the client value and the divergence observer fires.
779
+ _rotateStateCookie(): void {
780
+ if (rangoStateRotated) return;
781
+ rangoStateRotated = true;
782
+ if (!stateCookieName) return;
783
+ // The client's current value, for the monotonic guard: prefer the
784
+ // X-Rango-State header (router navigation/prefetch fetches send it), but
785
+ // fall back to the request's rango state cookie — action POSTs / plain
786
+ // app fetch()s carry no router header yet DO send the cookie. Without the
787
+ // fallback, prevTs stays 0 and a same-ms mint can equal the client value,
788
+ // leaving the divergence observer silent. `|| null` so an empty header
789
+ // ('' from proxy normalization) falls through instead of short-circuiting.
790
+ // getRawCookieValue reads the cookie undecoded (the wire value
791
+ // decodeStateValue decodes exactly once) AND is the same parser the client
792
+ // mirror uses, so both seats read the same jar entry.
793
+ const prevRaw =
794
+ (request.headers.get("x-rango-state") || null) ??
795
+ getRawCookieValue(cookieHeader, stateCookieName);
796
+ const value = mintStateValue(stateVersion ?? "0", prevRaw);
797
+ stubResponse.headers.append(
798
+ "Set-Cookie",
799
+ serializeStateCookie(stateCookieName, value, url.protocol === "https:"),
800
+ );
801
+ invalidateResponseCookieCache();
802
+ },
803
+
804
+ // Set the keepClientCache() directive header. The action bridge reads it on
805
+ // the response and suppresses its automatic invalidation. `.set` makes this
806
+ // idempotent (one header regardless of call count).
807
+ _setKeepCacheDirective(): void {
808
+ stubResponse.headers.set(KEEP_CACHE_HEADER, "1");
809
+ },
810
+
758
811
  setStatus(status: number): void {
759
812
  assertNotInsideCacheExec(ctx, "setStatus");
760
813
  assertNotInsideCacheScopeALS("setStatus");
@@ -771,7 +824,6 @@ export function createRequestContext<TEnv>(
771
824
  });
772
825
  },
773
826
 
774
- // Placeholder - will be replaced below
775
827
  use: null as any,
776
828
 
777
829
  method: request.method,
@@ -800,7 +852,6 @@ export function createRequestContext<TEnv>(
800
852
  this._onResponseCallbacks.push(callback);
801
853
  },
802
854
 
803
- // Theme properties (only set when themeConfig is provided)
804
855
  get theme() {
805
856
  return themeConfig ? getTheme() : undefined;
806
857
  },
@@ -824,19 +875,17 @@ export function createRequestContext<TEnv>(
824
875
  _reportedErrors: new WeakSet<object>(),
825
876
  _metricsStore: undefined,
826
877
 
827
- // Render barrier: deferred promise resolved after non-loader segments settle.
828
- _renderBarrier: null as any, // set below
829
- _resolveRenderBarrier: null as any, // set below
878
+ _renderBarrier: null as any,
879
+ _resolveRenderBarrier: null as any,
830
880
  _renderBarrierSegmentOrder: undefined,
831
881
 
832
882
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
833
883
  };
834
884
 
835
- // Lazy render barrier: only allocate the Promise when a loader actually
836
- // calls rendered(). Requests that don't use rendered() pay zero cost.
885
+ // Lazy allocation: only create Promise when a loader calls rendered().
837
886
  let barrierResolved = false;
838
887
  let resolveBarrier: (() => void) | undefined;
839
- ctx._renderBarrier = null as any; // lazy — created on first access
888
+ ctx._renderBarrier = null as any;
840
889
  ctx._resolveRenderBarrier = (
841
890
  segments: Array<{ type: string; id: string }>,
842
891
  ) => {
@@ -847,9 +896,6 @@ export function createRequestContext<TEnv>(
847
896
  .map((s) => s.id);
848
897
  ctx._renderBarrierSegmentOrder = segOrder;
849
898
 
850
- // Closing the guard window means no handler can still form a deadlock cycle
851
- // with a rendered() loader: drop the dependency-tracking state and mark it
852
- // closed. WHEN this runs is the only streaming/non-streaming difference.
853
899
  const closeGuard = () => {
854
900
  ctx._renderBarrierWaiters = undefined;
855
901
  ctx._handlerLoaderDeps = undefined;
@@ -857,20 +903,8 @@ export function createRequestContext<TEnv>(
857
903
  };
858
904
 
859
905
  if (ctx._treeHasStreaming) {
860
- // Streaming: rendered() keeps waiting on handleStore.settled past this
861
- // point, and loading() handlers are still in flight. The eager snapshot
862
- // here would be incomplete, so leave it unset — rendered() builds and
863
- // caches the complete one after settled. Keep the guard window OPEN so a
864
- // handler that resumes and awaits a still-waiting rendered() loader is
865
- // still caught; close it once settled (every tracked handler has finished
866
- // then, so none can await a loader anymore). settled resolves after
867
- // rendered() seals; if no loader used rendered(), nothing seals and the
868
- // (empty) guard state is simply GC'd at request end.
869
906
  handleStore.settled.then(closeGuard);
870
907
  } else {
871
- // Non-streaming: all handlers have settled by now. Build and cache the
872
- // snapshot so loader ctx.use(handle) calls don't rebuild it, and close the
873
- // guard window immediately.
874
908
  ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
875
909
  handleStore,
876
910
  segOrder,
@@ -881,9 +915,6 @@ export function createRequestContext<TEnv>(
881
915
  };
882
916
  Object.defineProperty(ctx, "_renderBarrier", {
883
917
  get() {
884
- // Barrier already resolved (cache/prerender hit) or first lazy access.
885
- // Either way, replace the getter with a concrete value to avoid
886
- // repeated Promise.resolve() allocations on subsequent reads.
887
918
  const p = barrierResolved
888
919
  ? Promise.resolve()
889
920
  : new Promise<void>((resolve) => {
@@ -899,24 +930,16 @@ export function createRequestContext<TEnv>(
899
930
  configurable: true,
900
931
  });
901
932
 
902
- // Now create use() with access to ctx
903
933
  ctx.use = createUseFunction({
904
934
  handleStore,
905
935
  loaderPromises,
906
936
  getContext: () => ctx,
907
937
  });
908
938
 
909
- // Brand with taint symbol so "use cache" excludes ctx from cache keys
910
939
  (ctx as any)[NOCACHE_SYMBOL] = true;
911
940
  return ctx;
912
941
  }
913
942
 
914
- /**
915
- * Parse Set-Cookie headers from a response into effective cookie state.
916
- * Returns a map of cookie name -> value (string) or name -> null (deleted).
917
- * Last-write-wins: later Set-Cookie entries for the same name overwrite earlier ones.
918
- * Max-Age=0 is treated as a delete.
919
- */
920
943
  const MAX_AGE_ZERO_RE = /;\s*Max-Age\s*=\s*0/i;
921
944
 
922
945
  function parseResponseCookies(response: Response): Map<string, string | null> {
@@ -924,7 +947,6 @@ function parseResponseCookies(response: Response): Map<string, string | null> {
924
947
  const setCookies = response.headers.getSetCookie();
925
948
 
926
949
  for (const header of setCookies) {
927
- // First segment before ';' is the name=value pair
928
950
  const semiIdx = header.indexOf(";");
929
951
  const pair = semiIdx === -1 ? header : header.substring(0, semiIdx);
930
952
  const eqIdx = pair.indexOf("=");
@@ -936,11 +958,9 @@ function parseResponseCookies(response: Response): Map<string, string | null> {
936
958
  name = decodeURIComponent(pair.substring(0, eqIdx).trim());
937
959
  value = decodeURIComponent(pair.substring(eqIdx + 1).trim());
938
960
  } catch {
939
- // Malformed encoding — skip this entry
940
961
  continue;
941
962
  }
942
963
 
943
- // Max-Age=0 means the cookie is being deleted
944
964
  const isDeleted = MAX_AGE_ZERO_RE.test(header);
945
965
  result.set(name, isDeleted ? null : value);
946
966
  }
@@ -948,9 +968,6 @@ function parseResponseCookies(response: Response): Map<string, string | null> {
948
968
  return result;
949
969
  }
950
970
 
951
- /**
952
- * Parse cookies from Cookie header
953
- */
954
971
  function parseCookiesFromHeader(
955
972
  cookieHeader: string | null,
956
973
  ): Record<string, string> {
@@ -966,7 +983,7 @@ function parseCookiesFromHeader(
966
983
  try {
967
984
  cookies[name] = decodeURIComponent(raw);
968
985
  } catch {
969
- // Malformed percent-encoded value (e.g. %zz, %2) - fall back to raw value
986
+ // Malformed percent-encoding: fall back to raw value
970
987
  cookies[name] = raw;
971
988
  }
972
989
  }
@@ -975,9 +992,6 @@ function parseCookiesFromHeader(
975
992
  return cookies;
976
993
  }
977
994
 
978
- /**
979
- * Serialize a cookie for Set-Cookie header
980
- */
981
995
  function serializeCookieValue(
982
996
  name: string,
983
997
  value: string,
@@ -1005,20 +1019,12 @@ export interface CreateUseFunctionOptions<TEnv> {
1005
1019
  getContext: () => RequestContext<TEnv>;
1006
1020
  }
1007
1021
 
1008
- /**
1009
- * Create the use() function for loader and handle composition.
1010
- *
1011
- * This is the unified implementation used by both RequestContext and HandlerContext.
1012
- * - For loaders: executes and memoizes loader functions
1013
- * - For handles: returns a push function to add handle data
1014
- */
1015
1022
  export function createUseFunction<TEnv>(
1016
1023
  options: CreateUseFunctionOptions<TEnv>,
1017
1024
  ): RequestContext["use"] {
1018
1025
  const { handleStore, loaderPromises, getContext } = options;
1019
1026
 
1020
1027
  return ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
1021
- // Handle case: return a push function
1022
1028
  if (isHandle(item)) {
1023
1029
  const handle = item;
1024
1030
  const ctx = getContext();
@@ -1031,30 +1037,24 @@ export function createUseFunction<TEnv>(
1031
1037
  );
1032
1038
  }
1033
1039
 
1034
- // Return a push function bound to this handle and segment
1035
1040
  return (
1036
1041
  dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>),
1037
1042
  ) => {
1038
- // If it's a function, call it immediately to get the promise
1039
1043
  const valueOrPromise =
1040
1044
  typeof dataOrFn === "function"
1041
1045
  ? (dataOrFn as () => Promise<unknown>)()
1042
1046
  : dataOrFn;
1043
1047
 
1044
- // Push directly - promises will be serialized by RSC and streamed
1045
1048
  handleStore.push(handle.$$id, segmentId, valueOrPromise);
1046
1049
  };
1047
1050
  }
1048
1051
 
1049
- // Loader case
1050
1052
  const loader = item as LoaderDefinition<any, any>;
1051
1053
 
1052
- // Return cached promise if already started
1053
1054
  if (loaderPromises.has(loader.$$id)) {
1054
1055
  return loaderPromises.get(loader.$$id);
1055
1056
  }
1056
1057
 
1057
- // Get loader function - either from loader object or fetchable registry
1058
1058
  let loaderFn = loader.fn;
1059
1059
  if (!loaderFn) {
1060
1060
  const fetchable = getFetchableLoader(loader.$$id);
@@ -1071,7 +1071,6 @@ export function createUseFunction<TEnv>(
1071
1071
 
1072
1072
  const ctx = getContext();
1073
1073
 
1074
- // Create loader context with recursive use() support
1075
1074
  const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
1076
1075
  params: ctx.params,
1077
1076
  routeParams: (ctx.params ?? {}) as Record<string, string>,
@@ -1088,7 +1087,6 @@ export function createUseFunction<TEnv>(
1088
1087
  use: (<TDep, TDepParams = any>(
1089
1088
  dep: LoaderDefinition<TDep, TDepParams>,
1090
1089
  ): Promise<TDep> => {
1091
- // Recursive call - will start dep loader if not already started
1092
1090
  return ctx.use(dep);
1093
1091
  }) as LoaderContext["use"],
1094
1092
  method: "GET",
@@ -1112,7 +1110,6 @@ export function createUseFunction<TEnv>(
1112
1110
  doneLoader();
1113
1111
  });
1114
1112
 
1115
- // Memoize for subsequent calls
1116
1113
  loaderPromises.set(loader.$$id, promise);
1117
1114
 
1118
1115
  return promise;
package/src/ssr/index.tsx CHANGED
@@ -71,7 +71,7 @@ export interface SSRRenderOptions {
71
71
  */
72
72
  export interface SSRDependencies<TEnv = unknown> {
73
73
  /**
74
- * createFromReadableStream from @vitejs/plugin-rsc/ssr
74
+ * createFromReadableStream from @rangojs/router/internal/deps/ssr
75
75
  */
76
76
  createFromReadableStream: <T>(
77
77
  stream: ReadableStream<Uint8Array>,
@@ -86,7 +86,7 @@ export interface SSRDependencies<TEnv = unknown> {
86
86
  ) => Promise<ReactDOMReadableStream>;
87
87
 
88
88
  /**
89
- * injectRSCPayload from rsc-html-stream/server
89
+ * injectRSCPayload from @rangojs/router/internal/deps/html-stream-server
90
90
  */
91
91
  injectRSCPayload: (
92
92
  rscStream: ReadableStream<Uint8Array>,
@@ -218,10 +218,10 @@ function createSsrEventController(opts: {
218
218
  *
219
219
  * @example
220
220
  * ```tsx
221
- * import { createSSRHandler } from "rsc-router/ssr";
222
- * import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
221
+ * import { createSSRHandler } from "@rangojs/router/ssr";
222
+ * import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
223
223
  * import { renderToReadableStream } from "react-dom/server.edge";
224
- * import { injectRSCPayload } from "rsc-html-stream/server";
224
+ * import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
225
225
  *
226
226
  * export const renderHTML = createSSRHandler({
227
227
  * createFromReadableStream,
@@ -263,6 +263,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
263
263
  let payload: Promise<RscPayload> | undefined;
264
264
  let handlesPromise: Promise<HandleData> | undefined;
265
265
  let ssrContextValue: NavigationStoreContextValue | undefined;
266
+ let rootPromise: Promise<React.ReactNode> | undefined;
266
267
  function SsrRoot() {
267
268
  payload ??= createFromReadableStream<RscPayload>(rscStream1);
268
269
  const resolved = React.use(payload);
@@ -296,17 +297,16 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
296
297
  };
297
298
 
298
299
  // Build content tree from segments.
299
- // Order must match NavigationProvider: NavigationStoreContext > ThemeProvider > content
300
- const reconstructedRoot = renderSegments(
301
- resolved.metadata?.segments ?? [],
302
- {
300
+ // Order must match NavigationProvider: NavigationStoreContext > NonceContext > ThemeProvider > content
301
+ // Memoize like payload/handles above: renderSegments is async, so
302
+ // React.use() on a fresh promise suspends and replays SsrRoot, which
303
+ // would re-run the entire segment-tree build on every initial render.
304
+ rootPromise ??= Promise.resolve(
305
+ renderSegments(resolved.metadata?.segments ?? [], {
303
306
  rootLayout: resolved.metadata?.rootLayout,
304
- },
307
+ }),
305
308
  );
306
- let content: React.ReactNode =
307
- reconstructedRoot instanceof Promise
308
- ? React.use(reconstructedRoot)
309
- : reconstructedRoot;
309
+ let content: React.ReactNode = React.use(rootPromise);
310
310
 
311
311
  // Wrap content with ThemeProvider if theme is enabled
312
312
  if (themeConfig) {
@@ -35,8 +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
-
39
- // -- Types ------------------------------------------------------------------
38
+ import { isUnderTestRunner } from "./runtime-env.js";
40
39
 
41
40
  export interface StaticHandlerOptions {
42
41
  /**
@@ -61,7 +60,9 @@ export interface StaticHandlerDefinition<
61
60
  use?: () => UseItems<HandlerUseItem>;
62
61
  }
63
62
 
64
- // -- Function ---------------------------------------------------------------
63
+ // Process-stable fallback id counter (mirrors createHandle/createLoader/Prerender).
64
+ // Only assigned in bare unit tests where the Vite plugin did not inject an id.
65
+ let runtimeStaticIdCounter = 0;
65
66
 
66
67
  export function Static<TParams extends Record<string, any> = {}>(
67
68
  handler: (ctx: StaticBuildContext) => ReactNode | Promise<ReactNode>,
@@ -69,8 +70,6 @@ export function Static<TParams extends Record<string, any> = {}>(
69
70
  __injectedId?: string,
70
71
  ): StaticHandlerDefinition<TParams>;
71
72
 
72
- // -- Implementation ---------------------------------------------------------
73
-
74
73
  export function Static<TParams extends Record<string, any>>(
75
74
  handler: Function,
76
75
  optionsOrId?: StaticHandlerOptions | string,
@@ -94,12 +93,15 @@ export function Static<TParams extends Record<string, any>>(
94
93
  id = maybeId ?? "";
95
94
  }
96
95
 
97
- if (!id) {
96
+ if (!id && !isUnderTestRunner()) {
98
97
  throw new Error(
99
- "[rango] Static: missing $$id. " +
100
- "Ensure the exposeInternalIds Vite plugin is configured.",
98
+ "[rango] Static: missing $$id. Use `export const X = Static(...)` and " +
99
+ "ensure the exposeInternalIds Vite plugin is configured.",
101
100
  );
102
101
  }
102
+ if (!id) {
103
+ id = `__rango_runtime_static_${runtimeStaticIdCounter++}`;
104
+ }
103
105
 
104
106
  return {
105
107
  __brand: "staticHandler" as const,
@@ -109,11 +111,6 @@ export function Static<TParams extends Record<string, any>>(
109
111
  };
110
112
  }
111
113
 
112
- // -- Type guard -------------------------------------------------------------
113
-
114
- /**
115
- * Type guard to check if a value is a StaticHandlerDefinition.
116
- */
117
114
  export function isStaticHandler(
118
115
  value: unknown,
119
116
  ): value is StaticHandlerDefinition {
@@ -0,0 +1,119 @@
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
+ export function parseCacheHeader(
41
+ headerValue: string | null | undefined,
42
+ ): Record<string, string> {
43
+ const result: Record<string, string> = {};
44
+ if (!headerValue) return result;
45
+ for (const rawEntry of headerValue.split(",")) {
46
+ const entry = rawEntry.trim();
47
+ if (entry.length === 0) continue;
48
+ const eq = entry.indexOf("=");
49
+ if (eq === -1) continue;
50
+ const id = entry.slice(0, eq).trim();
51
+ const status = entry.slice(eq + 1).trim();
52
+ if (id.length === 0 || status.length === 0) continue;
53
+ result[id] = status;
54
+ }
55
+ return result;
56
+ }
57
+
58
+ function getHeaders(target: CacheStatusTarget): Headers {
59
+ return target.headers;
60
+ }
61
+
62
+ export function assertCacheStatus(
63
+ target: CacheStatusTarget,
64
+ segment: string,
65
+ expected: ExpectedCacheStatus,
66
+ ): void {
67
+ const headerValue = getHeaders(target).get(CACHE_HEADER);
68
+ if (headerValue === null) {
69
+ throw new Error(
70
+ `assertCacheStatus: response has no ${CACHE_HEADER} header. ` +
71
+ `Enable the debug cache signal via createRouter({ debugCacheSignal: true }) ` +
72
+ `or RANGO_TEST_SIGNALS=1.`,
73
+ );
74
+ }
75
+ const map = parseCacheHeader(headerValue);
76
+ const actual = map[segment];
77
+ if (actual === undefined) {
78
+ const known = Object.keys(map);
79
+ throw new Error(
80
+ `assertCacheStatus: segment "${segment}" not found in ${CACHE_HEADER} ` +
81
+ `("${headerValue}"). Known segments: ${
82
+ known.length > 0 ? known.join(", ") : "(none)"
83
+ }.`,
84
+ );
85
+ }
86
+ if (actual !== expected) {
87
+ throw new Error(
88
+ `assertCacheStatus: segment "${segment}" expected "${expected}" but got "${actual}".`,
89
+ );
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A telemetry sink paired with the array it records events into.
95
+ */
96
+ export interface CacheSink {
97
+ /** Wire into `createRouter({ telemetry: sink })`. */
98
+ sink: TelemetrySink;
99
+ /** All telemetry events captured so far, in emit order. */
100
+ events: TelemetryEvent[];
101
+ }
102
+
103
+ export function createCacheSink(): CacheSink {
104
+ const events: TelemetryEvent[] = [];
105
+ const sink: TelemetrySink = {
106
+ emit(event: TelemetryEvent): void {
107
+ events.push(event);
108
+ },
109
+ };
110
+ return { sink, events };
111
+ }
112
+
113
+ export function filterCacheDecisions(
114
+ events: readonly TelemetryEvent[],
115
+ ): CacheDecisionEvent[] {
116
+ return events.filter(
117
+ (e): e is CacheDecisionEvent => e.type === "cache.decision",
118
+ );
119
+ }