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

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 (235) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/rango.js +3 -4
  3. package/dist/vite/index.js +315 -68
  4. package/package.json +19 -18
  5. package/skills/breadcrumbs/SKILL.md +60 -0
  6. package/skills/hooks/SKILL.md +2 -2
  7. package/skills/route/SKILL.md +6 -0
  8. package/skills/server-actions/SKILL.md +25 -1
  9. package/skills/testing/SKILL.md +17 -17
  10. package/skills/testing/cache-prerender.md +29 -3
  11. package/skills/testing/flight.md +13 -10
  12. package/skills/testing/render-handler.md +3 -0
  13. package/skills/testing/server-tree.md +1 -1
  14. package/skills/testing/setup.md +1 -1
  15. package/src/__internal.ts +0 -65
  16. package/src/browser/action-coordinator.ts +1 -1
  17. package/src/browser/action-fence.ts +10 -0
  18. package/src/browser/event-controller.ts +1 -83
  19. package/src/browser/navigation-store-handle.ts +3 -4
  20. package/src/browser/navigation-store.ts +0 -39
  21. package/src/browser/navigation-transaction.ts +0 -32
  22. package/src/browser/partial-update.ts +23 -84
  23. package/src/browser/prefetch/cache.ts +6 -45
  24. package/src/browser/prefetch/queue.ts +6 -3
  25. package/src/browser/rango-state.ts +2 -23
  26. package/src/browser/react/Link.tsx +0 -2
  27. package/src/browser/react/NavigationProvider.tsx +2 -1
  28. package/src/browser/react/ScrollRestoration.tsx +10 -6
  29. package/src/browser/react/filter-segment-order.ts +0 -2
  30. package/src/browser/react/index.ts +0 -45
  31. package/src/browser/react/location-state-shared.ts +0 -13
  32. package/src/browser/react/location-state.ts +0 -1
  33. package/src/browser/react/use-action.ts +6 -15
  34. package/src/browser/react/use-handle.ts +0 -5
  35. package/src/browser/react/use-link-status.ts +0 -4
  36. package/src/browser/react/use-navigation.ts +0 -3
  37. package/src/browser/react/use-params.ts +0 -2
  38. package/src/browser/react/use-router.ts +2 -1
  39. package/src/browser/react/use-search-params.ts +0 -5
  40. package/src/browser/react/use-segments.ts +0 -13
  41. package/src/browser/rsc-router.tsx +10 -3
  42. package/src/browser/server-action-bridge.ts +51 -3
  43. package/src/browser/types.ts +23 -5
  44. package/src/browser/validate-redirect-origin.ts +43 -16
  45. package/src/build/index.ts +8 -9
  46. package/src/build/route-trie.ts +46 -11
  47. package/src/build/route-types/param-extraction.ts +6 -3
  48. package/src/build/route-types/router-processing.ts +0 -8
  49. package/src/cache/cache-policy.ts +0 -54
  50. package/src/cache/cache-runtime.ts +48 -24
  51. package/src/cache/cache-scope.ts +0 -27
  52. package/src/cache/cache-tag.ts +0 -37
  53. package/src/cache/cf/cf-cache-store.ts +72 -45
  54. package/src/cache/cf/index.ts +0 -24
  55. package/src/cache/document-cache.ts +10 -36
  56. package/src/cache/handle-snapshot.ts +0 -40
  57. package/src/cache/index.ts +0 -27
  58. package/src/cache/memory-segment-store.ts +0 -52
  59. package/src/cache/profile-registry.ts +6 -30
  60. package/src/cache/read-through-swr.ts +41 -11
  61. package/src/cache/segment-codec.ts +0 -16
  62. package/src/cache/types.ts +0 -98
  63. package/src/client.rsc.tsx +4 -22
  64. package/src/client.tsx +19 -32
  65. package/src/context-var.ts +12 -0
  66. package/src/defer.ts +196 -0
  67. package/src/deps/ssr.ts +0 -1
  68. package/src/handle.ts +2 -12
  69. package/src/handles/MetaTags.tsx +0 -14
  70. package/src/handles/breadcrumbs.ts +16 -5
  71. package/src/handles/meta.ts +0 -39
  72. package/src/host/cookie-handler.ts +0 -36
  73. package/src/host/errors.ts +0 -24
  74. package/src/host/index.ts +6 -0
  75. package/src/host/pattern-matcher.ts +7 -50
  76. package/src/host/router.ts +1 -65
  77. package/src/host/testing.ts +0 -16
  78. package/src/host/types.ts +6 -2
  79. package/src/href-client.ts +0 -4
  80. package/src/index.rsc.ts +27 -2
  81. package/src/index.ts +7 -0
  82. package/src/internal-debug.ts +2 -4
  83. package/src/loader.rsc.ts +4 -15
  84. package/src/loader.ts +3 -9
  85. package/src/network-error-thrower.tsx +1 -6
  86. package/src/outlet-provider.tsx +1 -5
  87. package/src/prerender/param-hash.ts +10 -11
  88. package/src/prerender/store.ts +23 -30
  89. package/src/prerender.ts +34 -0
  90. package/src/redirect-origin.ts +100 -0
  91. package/src/root-error-boundary.tsx +1 -19
  92. package/src/route-content-wrapper.tsx +1 -44
  93. package/src/route-definition/dsl-helpers.ts +7 -19
  94. package/src/route-definition/helpers-types.ts +3 -3
  95. package/src/route-definition/redirect.ts +43 -9
  96. package/src/route-definition/resolve-handler-use.ts +6 -0
  97. package/src/route-map-builder.ts +0 -16
  98. package/src/router/content-negotiation.ts +0 -13
  99. package/src/router/error-handling.ts +12 -16
  100. package/src/router/find-match.ts +4 -31
  101. package/src/router/intercept-resolution.ts +10 -1
  102. package/src/router/lazy-includes.ts +1 -57
  103. package/src/router/loader-resolution.ts +25 -23
  104. package/src/router/logging.ts +0 -6
  105. package/src/router/manifest.ts +1 -25
  106. package/src/router/match-api.ts +0 -20
  107. package/src/router/match-context.ts +0 -22
  108. package/src/router/match-handlers.ts +0 -43
  109. package/src/router/match-middleware/background-revalidation.ts +0 -7
  110. package/src/router/match-middleware/cache-lookup.ts +96 -179
  111. package/src/router/match-middleware/cache-store.ts +0 -31
  112. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  113. package/src/router/match-middleware/segment-resolution.ts +0 -22
  114. package/src/router/match-pipelines.ts +1 -42
  115. package/src/router/match-result.ts +1 -52
  116. package/src/router/metrics.ts +0 -34
  117. package/src/router/middleware-types.ts +0 -116
  118. package/src/router/middleware.ts +77 -60
  119. package/src/router/navigation-snapshot.ts +0 -51
  120. package/src/router/params-util.ts +23 -0
  121. package/src/router/pattern-matching.ts +5 -56
  122. package/src/router/prerender-match.ts +56 -51
  123. package/src/router/request-classification.ts +1 -38
  124. package/src/router/revalidation.ts +14 -62
  125. package/src/router/route-snapshot.ts +0 -1
  126. package/src/router/router-context.ts +0 -27
  127. package/src/router/router-interfaces.ts +10 -0
  128. package/src/router/segment-resolution/fresh.ts +25 -57
  129. package/src/router/segment-resolution/helpers.ts +34 -0
  130. package/src/router/segment-resolution/loader-cache.ts +35 -23
  131. package/src/router/segment-resolution/revalidation.ts +188 -283
  132. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  133. package/src/router/segment-resolution.ts +4 -1
  134. package/src/router/segment-wrappers.ts +0 -3
  135. package/src/router/telemetry-otel.ts +0 -20
  136. package/src/router/telemetry.ts +0 -22
  137. package/src/router/timeout.ts +0 -20
  138. package/src/router/trie-matching.ts +66 -45
  139. package/src/router/types.ts +1 -63
  140. package/src/router/url-params.ts +0 -5
  141. package/src/router.ts +8 -11
  142. package/src/rsc/handler-context.ts +1 -0
  143. package/src/rsc/handler.ts +20 -4
  144. package/src/rsc/helpers.ts +71 -3
  145. package/src/rsc/json-route-result.ts +38 -0
  146. package/src/rsc/origin-guard.ts +9 -15
  147. package/src/rsc/progressive-enhancement.ts +10 -1
  148. package/src/rsc/redirect-guard.ts +99 -0
  149. package/src/rsc/response-route-handler.ts +23 -18
  150. package/src/rsc/rsc-rendering.ts +2 -7
  151. package/src/rsc/runtime-warnings.ts +14 -0
  152. package/src/rsc/server-action.ts +34 -29
  153. package/src/rsc/types.ts +6 -3
  154. package/src/search-params.ts +0 -16
  155. package/src/segment-loader-promise.ts +14 -2
  156. package/src/segment-system.tsx +79 -88
  157. package/src/server/handle-store.ts +7 -24
  158. package/src/server/loader-registry.ts +5 -24
  159. package/src/server/request-context.ts +29 -92
  160. package/src/ssr/index.tsx +14 -14
  161. package/src/static-handler.ts +2 -27
  162. package/src/testing/cache-status.ts +44 -48
  163. package/src/testing/collect-handle.ts +1 -24
  164. package/src/testing/dispatch.ts +43 -6
  165. package/src/testing/e2e/index.ts +1 -22
  166. package/src/testing/e2e/matchers.ts +0 -16
  167. package/src/testing/flight-matchers.ts +0 -13
  168. package/src/testing/flight-normalize.ts +3 -30
  169. package/src/testing/flight.ts +46 -48
  170. package/src/testing/generated-routes.ts +1 -41
  171. package/src/testing/index.ts +1 -21
  172. package/src/testing/internal/context.ts +3 -45
  173. package/src/testing/internal/seed-vars.ts +0 -26
  174. package/src/testing/render-handler.ts +31 -61
  175. package/src/testing/render-route.tsx +75 -103
  176. package/src/testing/run-loader.ts +0 -96
  177. package/src/testing/run-middleware.ts +0 -26
  178. package/src/theme/ThemeProvider.tsx +0 -52
  179. package/src/theme/ThemeScript.tsx +0 -6
  180. package/src/theme/constants.ts +0 -12
  181. package/src/theme/index.ts +0 -7
  182. package/src/theme/theme-context.ts +1 -5
  183. package/src/theme/theme-script.ts +0 -14
  184. package/src/theme/use-theme.ts +0 -3
  185. package/src/types/boundaries.ts +0 -35
  186. package/src/types/error-types.ts +25 -89
  187. package/src/types/global-namespace.ts +4 -14
  188. package/src/types/handler-context.ts +28 -9
  189. package/src/types/index.ts +0 -10
  190. package/src/types/request-scope.ts +0 -19
  191. package/src/types/route-config.ts +6 -50
  192. package/src/types/route-entry.ts +0 -6
  193. package/src/types/segments.ts +0 -13
  194. package/src/urls/include-helper.ts +0 -4
  195. package/src/urls/index.ts +0 -6
  196. package/src/urls/path-helper-types.ts +2 -2
  197. package/src/urls/path-helper.ts +0 -54
  198. package/src/urls/urls-function.ts +0 -13
  199. package/src/use-loader.tsx +0 -186
  200. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  201. package/src/vite/discovery/discover-routers.ts +28 -18
  202. package/src/vite/discovery/prerender-collection.ts +2 -4
  203. package/src/vite/discovery/state.ts +5 -0
  204. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  205. package/src/vite/plugin-types.ts +35 -9
  206. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  207. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  208. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  209. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  210. package/src/vite/plugins/expose-action-id.ts +2 -73
  211. package/src/vite/plugins/expose-id-utils.ts +0 -55
  212. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  213. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  214. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  215. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  216. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  217. package/src/vite/plugins/performance-tracks.ts +0 -3
  218. package/src/vite/plugins/refresh-cmd.ts +1 -1
  219. package/src/vite/plugins/use-cache-transform.ts +21 -46
  220. package/src/vite/plugins/version-injector.ts +0 -20
  221. package/src/vite/plugins/version-plugin.ts +1 -49
  222. package/src/vite/plugins/virtual-entries.ts +0 -15
  223. package/src/vite/rango.ts +2 -108
  224. package/src/vite/router-discovery.ts +9 -1
  225. package/src/vite/utils/ast-handler-extract.ts +0 -16
  226. package/src/vite/utils/bundle-analysis.ts +6 -13
  227. package/src/vite/utils/client-chunks.ts +0 -6
  228. package/src/vite/utils/forward-user-plugins.ts +0 -22
  229. package/src/vite/utils/manifest-utils.ts +0 -4
  230. package/src/vite/utils/package-resolution.ts +1 -73
  231. package/src/vite/utils/prerender-utils.ts +0 -35
  232. package/src/vite/utils/shared-utils.ts +3 -35
  233. package/src/browser/shallow.ts +0 -40
  234. package/src/handles/index.ts +0 -7
  235. package/src/router/middleware-cookies.ts +0 -55
@@ -40,6 +40,7 @@ import {
40
40
  type HandleData,
41
41
  } from "./handle-store.js";
42
42
  import { isHandle } from "../handle.js";
43
+ import { withDefer } from "../defer.js";
43
44
  import { track, type MetricsStore } from "./context.js";
44
45
  import { getFetchableLoader } from "./fetchable-loader-store.js";
45
46
  import type { SegmentCacheStore } from "../cache/types.js";
@@ -476,6 +477,7 @@ export function _getRequestContext<TEnv = DefaultEnv>():
476
477
  export function setRequestContextParams(
477
478
  params: Record<string, string>,
478
479
  routeName?: string,
480
+ routeMap?: Record<string, string>,
479
481
  ): void {
480
482
  const ctx = requestContextStorage.getStore();
481
483
  if (ctx) {
@@ -488,9 +490,13 @@ export function setRequestContextParams(
488
490
  : undefined
489
491
  ) as DefaultRouteName | undefined;
490
492
  }
491
- // Update reverse with scoped resolution now that route is known
493
+ // Update reverse with scoped resolution now that route is known. Production
494
+ // omits routeMap and uses the global map (routes are registered globally);
495
+ // the testing primitives (renderToFlightString/renderServerTree) pass a
496
+ // scoped routeMap so `ctx.reverse` is not order-dependent on whatever router
497
+ // registered last.
492
498
  ctx.reverse = createReverseFunction(
493
- getGlobalRouteMap(),
499
+ routeMap ?? getGlobalRouteMap(),
494
500
  routeName,
495
501
  params,
496
502
  routeName ? isRouteRootScoped(routeName) : undefined,
@@ -594,12 +600,9 @@ export function createRequestContext<TEnv>(
594
600
  version: stateVersion,
595
601
  } = options;
596
602
  const cookieHeader = request.headers.get("Cookie");
597
- // One Set-Cookie per request no matter how many invalidateClientCache() calls.
598
603
  let rangoStateRotated = false;
599
604
  let parsedCookies: Record<string, string> | null = null;
600
605
 
601
- // Create stub response for collecting headers/cookies.
602
- // All cookie/header mutations go here; cookie reads derive from it.
603
606
  let stubResponse = initialResponse
604
607
  ? new Response(null, {
605
608
  status: initialResponse.status,
@@ -608,11 +611,9 @@ export function createRequestContext<TEnv>(
608
611
  })
609
612
  : new Response(null, { status: 200 });
610
613
 
611
- // Create handle store and loader memoization for this request
612
614
  const handleStore = createHandleStore();
613
615
  const loaderPromises = new Map<string, Promise<any>>();
614
616
 
615
- // Lazy parse cookies from the original Cookie header
616
617
  const getParsedCookies = (): Record<string, string> => {
617
618
  if (!parsedCookies) {
618
619
  parsedCookies = parseCookiesFromHeader(cookieHeader);
@@ -620,7 +621,6 @@ export function createRequestContext<TEnv>(
620
621
  return parsedCookies;
621
622
  };
622
623
 
623
- // Cached response cookie mutations — invalidated on setCookie/deleteCookie/setTheme
624
624
  let responseCookieCache: Map<string, string | null> | null = null;
625
625
  const getResponseCookies = (): Map<string, string | null> => {
626
626
  if (!responseCookieCache) {
@@ -632,8 +632,6 @@ export function createRequestContext<TEnv>(
632
632
  responseCookieCache = null;
633
633
  };
634
634
 
635
- // Guard: throw if a response-level side effect is called inside a cache() scope.
636
- // Uses ALS to detect the scope (set during segment resolution).
637
635
  function assertNotInsideCacheScopeALS(methodName: string): void {
638
636
  if (isInsideCacheScope()) {
639
637
  throw new Error(
@@ -644,8 +642,7 @@ export function createRequestContext<TEnv>(
644
642
  }
645
643
  }
646
644
 
647
- // Effective cookie read: response stub Set-Cookie wins, then original header.
648
- // The stub IS the source of truth for same-request mutations.
645
+ // Response stub Set-Cookie wins, then original header (source of truth for mutations).
649
646
  const effectiveCookie = (name: string): string | undefined => {
650
647
  const mutations = getResponseCookies();
651
648
  if (mutations.has(name)) {
@@ -655,14 +652,11 @@ export function createRequestContext<TEnv>(
655
652
  return getParsedCookies()[name];
656
653
  };
657
654
 
658
- // Theme helpers (only used when themeConfig is provided)
659
655
  const getTheme = (): Theme | undefined => {
660
656
  if (!themeConfig) return undefined;
661
657
 
662
- // Use overlay-aware read so setTheme() in the same request is reflected
663
658
  const stored = effectiveCookie(themeConfig.storageKey);
664
659
  if (stored) {
665
- // Validate stored value
666
660
  if (stored === "system" && themeConfig.enableSystem) {
667
661
  return "system";
668
662
  }
@@ -676,7 +670,6 @@ export function createRequestContext<TEnv>(
676
670
  const setTheme = (theme: Theme): void => {
677
671
  if (!themeConfig) return;
678
672
 
679
- // Validate theme value
680
673
  if (theme !== "system" && !themeConfig.themes.includes(theme)) {
681
674
  console.warn(
682
675
  `[Theme] Invalid theme value: "${theme}". Valid values: system, ${themeConfig.themes.join(", ")}`,
@@ -684,7 +677,6 @@ export function createRequestContext<TEnv>(
684
677
  return;
685
678
  }
686
679
 
687
- // Write to stub — effectiveCookie() will pick it up on next read
688
680
  stubResponse.headers.append(
689
681
  "Set-Cookie",
690
682
  serializeCookieValue(themeConfig.storageKey, theme, {
@@ -696,10 +688,8 @@ export function createRequestContext<TEnv>(
696
688
  invalidateResponseCookieCache();
697
689
  };
698
690
 
699
- // Strip internal _rsc* params so userland sees a clean URL.
700
691
  const cleanUrl = stripInternalParams(url);
701
692
 
702
- // Build the context object first (without use), then add use
703
693
  const ctx: RequestContext<TEnv> = {
704
694
  env,
705
695
  request,
@@ -840,7 +830,6 @@ export function createRequestContext<TEnv>(
840
830
  });
841
831
  },
842
832
 
843
- // Placeholder - will be replaced below
844
833
  use: null as any,
845
834
 
846
835
  method: request.method,
@@ -869,7 +858,6 @@ export function createRequestContext<TEnv>(
869
858
  this._onResponseCallbacks.push(callback);
870
859
  },
871
860
 
872
- // Theme properties (only set when themeConfig is provided)
873
861
  get theme() {
874
862
  return themeConfig ? getTheme() : undefined;
875
863
  },
@@ -893,19 +881,17 @@ export function createRequestContext<TEnv>(
893
881
  _reportedErrors: new WeakSet<object>(),
894
882
  _metricsStore: undefined,
895
883
 
896
- // Render barrier: deferred promise resolved after non-loader segments settle.
897
- _renderBarrier: null as any, // set below
898
- _resolveRenderBarrier: null as any, // set below
884
+ _renderBarrier: null as any,
885
+ _resolveRenderBarrier: null as any,
899
886
  _renderBarrierSegmentOrder: undefined,
900
887
 
901
888
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
902
889
  };
903
890
 
904
- // Lazy render barrier: only allocate the Promise when a loader actually
905
- // calls rendered(). Requests that don't use rendered() pay zero cost.
891
+ // Lazy allocation: only create Promise when a loader calls rendered().
906
892
  let barrierResolved = false;
907
893
  let resolveBarrier: (() => void) | undefined;
908
- ctx._renderBarrier = null as any; // lazy — created on first access
894
+ ctx._renderBarrier = null as any;
909
895
  ctx._resolveRenderBarrier = (
910
896
  segments: Array<{ type: string; id: string }>,
911
897
  ) => {
@@ -916,9 +902,6 @@ export function createRequestContext<TEnv>(
916
902
  .map((s) => s.id);
917
903
  ctx._renderBarrierSegmentOrder = segOrder;
918
904
 
919
- // Closing the guard window means no handler can still form a deadlock cycle
920
- // with a rendered() loader: drop the dependency-tracking state and mark it
921
- // closed. WHEN this runs is the only streaming/non-streaming difference.
922
905
  const closeGuard = () => {
923
906
  ctx._renderBarrierWaiters = undefined;
924
907
  ctx._handlerLoaderDeps = undefined;
@@ -926,20 +909,8 @@ export function createRequestContext<TEnv>(
926
909
  };
927
910
 
928
911
  if (ctx._treeHasStreaming) {
929
- // Streaming: rendered() keeps waiting on handleStore.settled past this
930
- // point, and loading() handlers are still in flight. The eager snapshot
931
- // here would be incomplete, so leave it unset — rendered() builds and
932
- // caches the complete one after settled. Keep the guard window OPEN so a
933
- // handler that resumes and awaits a still-waiting rendered() loader is
934
- // still caught; close it once settled (every tracked handler has finished
935
- // then, so none can await a loader anymore). settled resolves after
936
- // rendered() seals; if no loader used rendered(), nothing seals and the
937
- // (empty) guard state is simply GC'd at request end.
938
912
  handleStore.settled.then(closeGuard);
939
913
  } else {
940
- // Non-streaming: all handlers have settled by now. Build and cache the
941
- // snapshot so loader ctx.use(handle) calls don't rebuild it, and close the
942
- // guard window immediately.
943
914
  ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
944
915
  handleStore,
945
916
  segOrder,
@@ -950,9 +921,6 @@ export function createRequestContext<TEnv>(
950
921
  };
951
922
  Object.defineProperty(ctx, "_renderBarrier", {
952
923
  get() {
953
- // Barrier already resolved (cache/prerender hit) or first lazy access.
954
- // Either way, replace the getter with a concrete value to avoid
955
- // repeated Promise.resolve() allocations on subsequent reads.
956
924
  const p = barrierResolved
957
925
  ? Promise.resolve()
958
926
  : new Promise<void>((resolve) => {
@@ -968,24 +936,16 @@ export function createRequestContext<TEnv>(
968
936
  configurable: true,
969
937
  });
970
938
 
971
- // Now create use() with access to ctx
972
939
  ctx.use = createUseFunction({
973
940
  handleStore,
974
941
  loaderPromises,
975
942
  getContext: () => ctx,
976
943
  });
977
944
 
978
- // Brand with taint symbol so "use cache" excludes ctx from cache keys
979
945
  (ctx as any)[NOCACHE_SYMBOL] = true;
980
946
  return ctx;
981
947
  }
982
948
 
983
- /**
984
- * Parse Set-Cookie headers from a response into effective cookie state.
985
- * Returns a map of cookie name -> value (string) or name -> null (deleted).
986
- * Last-write-wins: later Set-Cookie entries for the same name overwrite earlier ones.
987
- * Max-Age=0 is treated as a delete.
988
- */
989
949
  const MAX_AGE_ZERO_RE = /;\s*Max-Age\s*=\s*0/i;
990
950
 
991
951
  function parseResponseCookies(response: Response): Map<string, string | null> {
@@ -993,7 +953,6 @@ function parseResponseCookies(response: Response): Map<string, string | null> {
993
953
  const setCookies = response.headers.getSetCookie();
994
954
 
995
955
  for (const header of setCookies) {
996
- // First segment before ';' is the name=value pair
997
956
  const semiIdx = header.indexOf(";");
998
957
  const pair = semiIdx === -1 ? header : header.substring(0, semiIdx);
999
958
  const eqIdx = pair.indexOf("=");
@@ -1005,11 +964,9 @@ function parseResponseCookies(response: Response): Map<string, string | null> {
1005
964
  name = decodeURIComponent(pair.substring(0, eqIdx).trim());
1006
965
  value = decodeURIComponent(pair.substring(eqIdx + 1).trim());
1007
966
  } catch {
1008
- // Malformed encoding — skip this entry
1009
967
  continue;
1010
968
  }
1011
969
 
1012
- // Max-Age=0 means the cookie is being deleted
1013
970
  const isDeleted = MAX_AGE_ZERO_RE.test(header);
1014
971
  result.set(name, isDeleted ? null : value);
1015
972
  }
@@ -1017,10 +974,10 @@ function parseResponseCookies(response: Response): Map<string, string | null> {
1017
974
  return result;
1018
975
  }
1019
976
 
1020
- /**
1021
- * Parse cookies from Cookie header
1022
- */
1023
- function parseCookiesFromHeader(
977
+ // Exported for unit tests; the canonical cookie parse/serialize lives here
978
+ // (a duplicate copy in middleware-cookies.ts was removed). Not part of the
979
+ // public export surface.
980
+ export function parseCookiesFromHeader(
1024
981
  cookieHeader: string | null,
1025
982
  ): Record<string, string> {
1026
983
  if (!cookieHeader) return {};
@@ -1035,7 +992,7 @@ function parseCookiesFromHeader(
1035
992
  try {
1036
993
  cookies[name] = decodeURIComponent(raw);
1037
994
  } catch {
1038
- // Malformed percent-encoded value (e.g. %zz, %2) - fall back to raw value
995
+ // Malformed percent-encoding: fall back to raw value
1039
996
  cookies[name] = raw;
1040
997
  }
1041
998
  }
@@ -1044,10 +1001,7 @@ function parseCookiesFromHeader(
1044
1001
  return cookies;
1045
1002
  }
1046
1003
 
1047
- /**
1048
- * Serialize a cookie for Set-Cookie header
1049
- */
1050
- function serializeCookieValue(
1004
+ export function serializeCookieValue(
1051
1005
  name: string,
1052
1006
  value: string,
1053
1007
  options: CookieOptions = {},
@@ -1074,20 +1028,12 @@ export interface CreateUseFunctionOptions<TEnv> {
1074
1028
  getContext: () => RequestContext<TEnv>;
1075
1029
  }
1076
1030
 
1077
- /**
1078
- * Create the use() function for loader and handle composition.
1079
- *
1080
- * This is the unified implementation used by both RequestContext and HandlerContext.
1081
- * - For loaders: executes and memoizes loader functions
1082
- * - For handles: returns a push function to add handle data
1083
- */
1084
1031
  export function createUseFunction<TEnv>(
1085
1032
  options: CreateUseFunctionOptions<TEnv>,
1086
1033
  ): RequestContext["use"] {
1087
1034
  const { handleStore, loaderPromises, getContext } = options;
1088
1035
 
1089
1036
  return ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
1090
- // Handle case: return a push function
1091
1037
  if (isHandle(item)) {
1092
1038
  const handle = item;
1093
1039
  const ctx = getContext();
@@ -1100,30 +1046,24 @@ export function createUseFunction<TEnv>(
1100
1046
  );
1101
1047
  }
1102
1048
 
1103
- // Return a push function bound to this handle and segment
1104
- return (
1105
- dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>),
1106
- ) => {
1107
- // If it's a function, call it immediately to get the promise
1108
- const valueOrPromise =
1109
- typeof dataOrFn === "function"
1110
- ? (dataOrFn as () => Promise<unknown>)()
1111
- : dataOrFn;
1112
-
1113
- // Push directly - promises will be serialized by RSC and streamed
1114
- handleStore.push(handle.$$id, segmentId, valueOrPromise);
1115
- };
1049
+ return withDefer(
1050
+ (dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>)) => {
1051
+ const valueOrPromise =
1052
+ typeof dataOrFn === "function"
1053
+ ? (dataOrFn as () => Promise<unknown>)()
1054
+ : dataOrFn;
1055
+
1056
+ handleStore.push(handle.$$id, segmentId, valueOrPromise);
1057
+ },
1058
+ );
1116
1059
  }
1117
1060
 
1118
- // Loader case
1119
1061
  const loader = item as LoaderDefinition<any, any>;
1120
1062
 
1121
- // Return cached promise if already started
1122
1063
  if (loaderPromises.has(loader.$$id)) {
1123
1064
  return loaderPromises.get(loader.$$id);
1124
1065
  }
1125
1066
 
1126
- // Get loader function - either from loader object or fetchable registry
1127
1067
  let loaderFn = loader.fn;
1128
1068
  if (!loaderFn) {
1129
1069
  const fetchable = getFetchableLoader(loader.$$id);
@@ -1140,7 +1080,6 @@ export function createUseFunction<TEnv>(
1140
1080
 
1141
1081
  const ctx = getContext();
1142
1082
 
1143
- // Create loader context with recursive use() support
1144
1083
  const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
1145
1084
  params: ctx.params,
1146
1085
  routeParams: (ctx.params ?? {}) as Record<string, string>,
@@ -1157,7 +1096,6 @@ export function createUseFunction<TEnv>(
1157
1096
  use: (<TDep, TDepParams = any>(
1158
1097
  dep: LoaderDefinition<TDep, TDepParams>,
1159
1098
  ): Promise<TDep> => {
1160
- // Recursive call - will start dep loader if not already started
1161
1099
  return ctx.use(dep);
1162
1100
  }) as LoaderContext["use"],
1163
1101
  method: "GET",
@@ -1181,7 +1119,6 @@ export function createUseFunction<TEnv>(
1181
1119
  doneLoader();
1182
1120
  });
1183
1121
 
1184
- // Memoize for subsequent calls
1185
1122
  loaderPromises.set(loader.$$id, promise);
1186
1123
 
1187
1124
  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) {
@@ -37,8 +37,6 @@ import type { UseItems, HandlerUseItem } from "./route-types.js";
37
37
  import { isCachedFunction } from "./cache/taint.js";
38
38
  import { isUnderTestRunner } from "./runtime-env.js";
39
39
 
40
- // -- Types ------------------------------------------------------------------
41
-
42
40
  export interface StaticHandlerOptions {
43
41
  /**
44
42
  * Keep handler in server bundle for live fallback (default: false).
@@ -62,11 +60,8 @@ export interface StaticHandlerDefinition<
62
60
  use?: () => UseItems<HandlerUseItem>;
63
61
  }
64
62
 
65
- // -- Function ---------------------------------------------------------------
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).
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.
70
65
  let runtimeStaticIdCounter = 0;
71
66
 
72
67
  export function Static<TParams extends Record<string, any> = {}>(
@@ -75,8 +70,6 @@ export function Static<TParams extends Record<string, any> = {}>(
75
70
  __injectedId?: string,
76
71
  ): StaticHandlerDefinition<TParams>;
77
72
 
78
- // -- Implementation ---------------------------------------------------------
79
-
80
73
  export function Static<TParams extends Record<string, any>>(
81
74
  handler: Function,
82
75
  optionsOrId?: StaticHandlerOptions | string,
@@ -100,25 +93,12 @@ export function Static<TParams extends Record<string, any>>(
100
93
  id = maybeId ?? "";
101
94
  }
102
95
 
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
96
  if (!id && !isUnderTestRunner()) {
111
97
  throw new Error(
112
98
  "[rango] Static: missing $$id. Use `export const X = Static(...)` and " +
113
99
  "ensure the exposeInternalIds Vite plugin is configured.",
114
100
  );
115
101
  }
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
102
  if (!id) {
123
103
  id = `__rango_runtime_static_${runtimeStaticIdCounter++}`;
124
104
  }
@@ -131,11 +111,6 @@ export function Static<TParams extends Record<string, any>>(
131
111
  };
132
112
  }
133
113
 
134
- // -- Type guard -------------------------------------------------------------
135
-
136
- /**
137
- * Type guard to check if a value is a StaticHandlerDefinition.
138
- */
139
114
  export function isStaticHandler(
140
115
  value: unknown,
141
116
  ): value is StaticHandlerDefinition {
@@ -12,7 +12,14 @@
12
12
  * 2. Telemetry path — `createCacheSink` returns a `{ sink, events }` pair the
13
13
  * consumer wires via `createRouter({ telemetry: sink })`. This has ZERO
14
14
  * production surface: no header, just structured `cache.decision` events
15
- * (which carry the same coarse `segments` cache signal).
15
+ * (which carry the same coarse `segments` cache signal). Assert with
16
+ * `assertCacheDecision(events, routeKey, expected)` (the one-call counterpart
17
+ * of `assertCacheStatus`) or filter raw via `filterCacheDecisions`.
18
+ *
19
+ * Both paths report the SAME coarse route-level signal — pick by TRANSPORT, not
20
+ * by meaning: the header is the only signal a black-box Playwright `Response`
21
+ * carries (needs the debug gate ON); the sink is the only zero-production-surface
22
+ * option and the only one exposing per-segment `shouldRevalidate`.
16
23
  *
17
24
  * v1 cache status is COARSE (route-level): the router reports a single entry
18
25
  * keyed by the route key (the route NAME), not per individual segment.
@@ -37,18 +44,6 @@ export type ExpectedCacheStatus = CacheSegmentStatus;
37
44
  /** A target carrying response headers (a Response or a `{ headers }` object). */
38
45
  export type CacheStatusTarget = Response | { headers: Headers };
39
46
 
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
47
  export function parseCacheHeader(
53
48
  headerValue: string | null | undefined,
54
49
  ): Record<string, string> {
@@ -71,25 +66,6 @@ function getHeaders(target: CacheStatusTarget): Headers {
71
66
  return target.headers;
72
67
  }
73
68
 
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
69
  export function assertCacheStatus(
94
70
  target: CacheStatusTarget,
95
71
  segment: string,
@@ -131,19 +107,6 @@ export interface CacheSink {
131
107
  events: TelemetryEvent[];
132
108
  }
133
109
 
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
110
  export function createCacheSink(): CacheSink {
148
111
  const events: TelemetryEvent[] = [];
149
112
  const sink: TelemetrySink = {
@@ -154,9 +117,6 @@ export function createCacheSink(): CacheSink {
154
117
  return { sink, events };
155
118
  }
156
119
 
157
- /**
158
- * Filter captured telemetry events down to `cache.decision` events.
159
- */
160
120
  export function filterCacheDecisions(
161
121
  events: readonly TelemetryEvent[],
162
122
  ): CacheDecisionEvent[] {
@@ -164,3 +124,39 @@ export function filterCacheDecisions(
164
124
  (e): e is CacheDecisionEvent => e.type === "cache.decision",
165
125
  );
166
126
  }
127
+
128
+ /**
129
+ * Telemetry-path counterpart of {@link assertCacheStatus}: assert a captured
130
+ * `cache.decision` event reported `expected` for the segment keyed by `routeKey`
131
+ * (the route NAME, the same coarse key the header path uses). Throws an
132
+ * actionable error when no matching segment was captured, or on a mismatch.
133
+ *
134
+ * Pairs with {@link createCacheSink}: wire `createRouter({ telemetry: sink })`,
135
+ * drive an RSC request, then assert against the recorded `events`. This is the
136
+ * zero-production-surface path (no header to enable). NOTE: `events` accumulates
137
+ * across requests, so the FIRST matching segment wins — slice or recreate the
138
+ * sink between requests for the same `routeKey`.
139
+ */
140
+ export function assertCacheDecision(
141
+ events: readonly TelemetryEvent[],
142
+ routeKey: string,
143
+ expected: ExpectedCacheStatus,
144
+ ): void {
145
+ const segments = filterCacheDecisions(events).flatMap(
146
+ (d) => d.segments ?? [],
147
+ );
148
+ const seg = segments.find((s) => s.id === routeKey);
149
+ if (seg === undefined) {
150
+ const known = segments.map((s) => s.id);
151
+ throw new Error(
152
+ `assertCacheDecision: no cache.decision segment for routeKey "${routeKey}". ` +
153
+ `Seen: ${known.length > 0 ? known.join(", ") : "(none)"}. Wire ` +
154
+ `createRouter({ telemetry: createCacheSink().sink }) and drive an RSC request.`,
155
+ );
156
+ }
157
+ if (seg.cacheStatus !== expected) {
158
+ throw new Error(
159
+ `assertCacheDecision: routeKey "${routeKey}" expected "${expected}" but got "${seg.cacheStatus}".`,
160
+ );
161
+ }
162
+ }
@@ -17,27 +17,6 @@
17
17
 
18
18
  import { getCollectFn, type Handle } from "../handle.js";
19
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
20
  export function collectHandle<TData, TAccumulated>(
42
21
  handle: Handle<TData, TAccumulated>,
43
22
  segments: ReadonlyArray<ReadonlyArray<TData>>,
@@ -55,9 +34,7 @@ export function collectHandle<TData, TAccumulated>(
55
34
  return segments.flat() as unknown as TAccumulated;
56
35
  }
57
36
 
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.
37
+ // Drop empty arrays matching production behavior (segment count/indices).
61
38
  const nonEmpty = segments.filter((seg) => seg.length > 0) as TData[][];
62
39
  return collectFn(nonEmpty);
63
40
  }