@rangojs/router 0.21.0 → 0.22.1

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 (244) hide show
  1. package/dist/bin/rango.js +13 -1
  2. package/dist/types/browser/event-controller.d.ts +30 -13
  3. package/dist/types/browser/navigation-store.d.ts +9 -0
  4. package/dist/types/browser/partial-update.d.ts +2 -1
  5. package/dist/types/browser/prefetch/default-strategy.d.ts +16 -20
  6. package/dist/types/browser/react/NavigationProvider.d.ts +14 -1
  7. package/dist/types/browser/react/context.d.ts +14 -0
  8. package/dist/types/browser/rsc-router.d.ts +13 -2
  9. package/dist/types/browser/types.d.ts +10 -0
  10. package/dist/types/build/generate-manifest.d.ts +2 -0
  11. package/dist/types/cache/background-task.d.ts +22 -0
  12. package/dist/types/cache/cache-key-utils.d.ts +50 -5
  13. package/dist/types/cache/cache-policy.d.ts +17 -0
  14. package/dist/types/cache/cache-scope.d.ts +1 -1
  15. package/dist/types/cache/cf/cf-cache-store.d.ts +7 -1
  16. package/dist/types/cache/document-cache.d.ts +8 -5
  17. package/dist/types/cache/handle-snapshot.d.ts +23 -0
  18. package/dist/types/cache/index.d.ts +1 -1
  19. package/dist/types/cache/invalidation-order.d.ts +14 -0
  20. package/dist/types/cache/memory-segment-store.d.ts +33 -1
  21. package/dist/types/cache/shell-snapshot.d.ts +10 -1
  22. package/dist/types/cache/store-scope.d.ts +22 -0
  23. package/dist/types/cache/tag-invalidation.d.ts +7 -6
  24. package/dist/types/cache/types.d.ts +63 -14
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +7 -1
  26. package/dist/types/errors.d.ts +6 -0
  27. package/dist/types/handles/deferred-resolution.d.ts +7 -0
  28. package/dist/types/internal-suspense-audit.d.ts +1 -0
  29. package/dist/types/loader-store.d.ts +4 -8
  30. package/dist/types/outlet-provider.d.ts +2 -1
  31. package/dist/types/prerender/cloudflare.d.ts +49 -0
  32. package/dist/types/prerender/create-prerender-trigger.d.ts +121 -0
  33. package/dist/types/prerender/index.d.ts +12 -0
  34. package/dist/types/prerender/memory-prerender-store.d.ts +29 -0
  35. package/dist/types/prerender/on-demand.d.ts +272 -0
  36. package/dist/types/prerender/producer-guard.d.ts +33 -0
  37. package/dist/types/prerender/shell-manifest-key.d.ts +1 -18
  38. package/dist/types/prerender/warm-request.d.ts +65 -0
  39. package/dist/types/prerender/warm.d.ts +65 -0
  40. package/dist/types/prerender/writable-store.d.ts +170 -0
  41. package/dist/types/prerender.d.ts +27 -0
  42. package/dist/types/reverse.d.ts +1 -1
  43. package/dist/types/route-content-wrapper.d.ts +3 -1
  44. package/dist/types/router/error-handling.d.ts +2 -0
  45. package/dist/types/router/handler-context.d.ts +1 -1
  46. package/dist/types/router/match-middleware/cache-lookup.d.ts +14 -0
  47. package/dist/types/router/pattern-matching.d.ts +2 -0
  48. package/dist/types/router/prefetch-default.d.ts +4 -4
  49. package/dist/types/router/prerender-match.d.ts +23 -1
  50. package/dist/types/router/route-trie-builder.d.ts +8 -1
  51. package/dist/types/router/router-context.d.ts +1 -0
  52. package/dist/types/router/router-interfaces.d.ts +30 -0
  53. package/dist/types/router/router-options.d.ts +24 -0
  54. package/dist/types/router/segment-resolution/fresh.d.ts +7 -0
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +2 -1
  56. package/dist/types/router/segment-resolution/revalidation.d.ts +5 -1
  57. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +43 -0
  58. package/dist/types/router/trie-matching.d.ts +2 -0
  59. package/dist/types/rsc/shell-build-manifest.d.ts +6 -4
  60. package/dist/types/rsc/shell-capture-constants.d.ts +20 -5
  61. package/dist/types/rsc/shell-capture.d.ts +14 -1
  62. package/dist/types/rsc/shell-serve.d.ts +2 -2
  63. package/dist/types/rsc/ssr-module-loader.d.ts +15 -0
  64. package/dist/types/rsc/types.d.ts +8 -0
  65. package/dist/types/segment-boundary-content.d.ts +17 -0
  66. package/dist/types/segment-loader-promise.d.ts +2 -6
  67. package/dist/types/server/build-version-table.d.ts +9 -0
  68. package/dist/types/server/context.d.ts +12 -4
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +63 -2
  71. package/dist/types/suspense-audit-react.d.ts +26 -0
  72. package/dist/types/suspense-audit.d.ts +157 -0
  73. package/dist/types/testing/action-form.d.ts +34 -0
  74. package/dist/types/testing/flight.entry.d.ts +2 -0
  75. package/dist/types/testing/index.d.ts +4 -0
  76. package/dist/types/testing/internal/server-modules.d.ts +8 -0
  77. package/dist/types/testing/serve-shell-request.d.ts +34 -4
  78. package/dist/types/testing/shell-status.d.ts +14 -6
  79. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +9 -6
  80. package/dist/types/thenable-status.d.ts +6 -0
  81. package/dist/types/types/handler-context.d.ts +7 -0
  82. package/dist/types/types/route-entry.d.ts +5 -0
  83. package/dist/types/urls/path-helper.d.ts +18 -0
  84. package/dist/types/vite/discovery/state.d.ts +11 -0
  85. package/dist/types/vite/inject-client-debug.d.ts +6 -25
  86. package/dist/types/vite/utils/bundle-analysis.d.ts +6 -2
  87. package/dist/vite/index.js +180 -497
  88. package/package.json +13 -3
  89. package/skills/cache-guide/SKILL.md +18 -17
  90. package/skills/caching/SKILL.md +40 -4
  91. package/skills/catalog.json +1 -1
  92. package/skills/cloudflare/SKILL.md +131 -0
  93. package/skills/comparison/references/framework-comparison.md +1 -1
  94. package/skills/deployment-caching/SKILL.md +33 -12
  95. package/skills/document-cache/SKILL.md +16 -14
  96. package/skills/hooks/SKILL.md +22 -0
  97. package/skills/hooks/handle-and-actions.md +3 -4
  98. package/skills/hooks/navigation.md +10 -0
  99. package/skills/hooks/state.md +5 -4
  100. package/skills/host-router/SKILL.md +18 -0
  101. package/skills/loader/SKILL.md +6 -4
  102. package/skills/middleware/SKILL.md +1 -1
  103. package/skills/migrate-nextjs/SKILL.md +82 -9
  104. package/skills/ppr/SKILL.md +31 -8
  105. package/skills/prerender/SKILL.md +645 -8
  106. package/skills/rango/SKILL.md +1 -0
  107. package/skills/response-routes/SKILL.md +5 -5
  108. package/skills/route/SKILL.md +29 -0
  109. package/skills/testing/SKILL.md +2 -1
  110. package/skills/testing/cache-prerender.md +18 -17
  111. package/skills/testing/serve-shell-request.md +104 -8
  112. package/skills/use-cache/SKILL.md +9 -5
  113. package/skills/vercel/SKILL.md +2 -0
  114. package/skills/view-transitions/SKILL.md +9 -4
  115. package/src/browser/event-controller.ts +69 -25
  116. package/src/browser/link-interceptor.ts +2 -2
  117. package/src/browser/navigation-bridge.ts +75 -35
  118. package/src/browser/navigation-store.ts +26 -0
  119. package/src/browser/navigation-transaction.ts +9 -2
  120. package/src/browser/network-error-handler.ts +2 -0
  121. package/src/browser/partial-update.ts +48 -12
  122. package/src/browser/prefetch/default-strategy.ts +22 -42
  123. package/src/browser/react/Link.tsx +37 -42
  124. package/src/browser/react/NavigationProvider.tsx +63 -3
  125. package/src/browser/react/context.ts +17 -0
  126. package/src/browser/react/location-state.ts +5 -19
  127. package/src/browser/react/use-action.ts +4 -1
  128. package/src/browser/react/use-handle.ts +4 -20
  129. package/src/browser/react/use-link-status.ts +4 -3
  130. package/src/browser/react/use-navigation.ts +4 -1
  131. package/src/browser/react/use-params.ts +4 -1
  132. package/src/browser/react/use-pathname.ts +4 -1
  133. package/src/browser/react/use-search-params.ts +5 -3
  134. package/src/browser/react/use-segments.ts +36 -54
  135. package/src/browser/rsc-router.tsx +79 -39
  136. package/src/browser/segment-reconciler.ts +3 -3
  137. package/src/browser/server-action-bridge.ts +26 -6
  138. package/src/browser/types.ts +11 -0
  139. package/src/build/generate-manifest.ts +17 -0
  140. package/src/build/merge-full-manifests.ts +5 -0
  141. package/src/build/route-trie.ts +1 -0
  142. package/src/cache/background-task.ts +61 -0
  143. package/src/cache/cache-key-utils.ts +91 -12
  144. package/src/cache/cache-policy.ts +11 -2
  145. package/src/cache/cache-runtime.ts +23 -23
  146. package/src/cache/cache-scope.ts +38 -36
  147. package/src/cache/cf/cf-cache-store.ts +16 -11
  148. package/src/cache/document-cache.ts +35 -14
  149. package/src/cache/handle-snapshot.ts +50 -0
  150. package/src/cache/index.ts +1 -0
  151. package/src/cache/invalidation-order.ts +53 -0
  152. package/src/cache/memory-segment-store.ts +52 -1
  153. package/src/cache/shell-snapshot.ts +17 -0
  154. package/src/cache/store-scope.ts +36 -0
  155. package/src/cache/tag-invalidation.ts +7 -5
  156. package/src/cache/types.ts +69 -14
  157. package/src/cache/vercel/vercel-cache-store.ts +20 -6
  158. package/src/client.tsx +40 -3
  159. package/src/errors.ts +13 -6
  160. package/src/handles/deferred-resolution.ts +7 -2
  161. package/src/internal-suspense-audit.ts +12 -0
  162. package/src/loader-store.ts +4 -9
  163. package/src/outlet-provider.tsx +25 -1
  164. package/src/prerender/cloudflare.ts +130 -0
  165. package/src/prerender/create-prerender-trigger.ts +780 -0
  166. package/src/prerender/index.ts +39 -0
  167. package/src/prerender/memory-prerender-store.ts +115 -0
  168. package/src/prerender/on-demand.ts +339 -0
  169. package/src/prerender/producer-guard.ts +66 -0
  170. package/src/prerender/shell-manifest-key.ts +12 -17
  171. package/src/prerender/warm-request.ts +154 -0
  172. package/src/prerender/warm.ts +193 -0
  173. package/src/prerender/writable-store.ts +278 -0
  174. package/src/prerender.ts +29 -0
  175. package/src/reverse.ts +1 -1
  176. package/src/route-content-wrapper.tsx +107 -13
  177. package/src/router/error-handling.ts +12 -12
  178. package/src/router/find-match.ts +1 -0
  179. package/src/router/handler-context.ts +2 -0
  180. package/src/router/intercept-resolution.ts +15 -8
  181. package/src/router/loader-resolution.ts +2 -10
  182. package/src/router/match-middleware/background-revalidation.ts +2 -0
  183. package/src/router/match-middleware/cache-lookup.ts +230 -38
  184. package/src/router/pattern-matching.ts +14 -0
  185. package/src/router/prefetch-default.ts +4 -4
  186. package/src/router/prerender-match.ts +460 -352
  187. package/src/router/route-trie-builder.ts +9 -0
  188. package/src/router/router-context.ts +1 -0
  189. package/src/router/router-interfaces.ts +33 -0
  190. package/src/router/router-options.ts +30 -0
  191. package/src/router/segment-resolution/fresh.ts +20 -19
  192. package/src/router/segment-resolution/loader-cache.ts +15 -17
  193. package/src/router/segment-resolution/revalidation.ts +34 -24
  194. package/src/router/segment-resolution/streamed-handler-boundary.ts +133 -0
  195. package/src/router/trie-matching.ts +3 -0
  196. package/src/router.ts +107 -1
  197. package/src/rsc/handler.ts +51 -4
  198. package/src/rsc/progressive-enhancement.ts +5 -1
  199. package/src/rsc/response-cache-serve.ts +10 -4
  200. package/src/rsc/rsc-rendering.ts +53 -11
  201. package/src/rsc/shell-build-manifest.ts +10 -8
  202. package/src/rsc/shell-capture-constants.ts +39 -7
  203. package/src/rsc/shell-capture.ts +108 -19
  204. package/src/rsc/shell-serve.ts +12 -6
  205. package/src/rsc/ssr-module-loader.ts +26 -0
  206. package/src/rsc/types.ts +8 -0
  207. package/src/segment-boundary-content.ts +25 -0
  208. package/src/segment-loader-promise.ts +28 -38
  209. package/src/segment-system.tsx +117 -57
  210. package/src/server/build-version-table.ts +15 -0
  211. package/src/server/context.ts +40 -17
  212. package/src/server/cookie-store.ts +5 -0
  213. package/src/server/handle-store.ts +13 -0
  214. package/src/server/loader-registry.ts +6 -1
  215. package/src/server/request-context.ts +87 -1
  216. package/src/ssr/ssr-root.tsx +20 -13
  217. package/src/suspense-audit-react.tsx +98 -0
  218. package/src/suspense-audit.ts +1030 -0
  219. package/src/testing/action-form.ts +66 -0
  220. package/src/testing/flight-runtime.d.ts +27 -0
  221. package/src/testing/flight.entry.ts +3 -0
  222. package/src/testing/index.ts +19 -0
  223. package/src/testing/internal/server-modules.ts +32 -0
  224. package/src/testing/render-route.tsx +25 -12
  225. package/src/testing/serve-shell-request.ts +190 -34
  226. package/src/testing/shell-status.ts +17 -6
  227. package/src/testing/vitest-stubs/plugin-rsc.ts +37 -11
  228. package/src/thenable-status.ts +10 -0
  229. package/src/types/handler-context.ts +7 -0
  230. package/src/types/route-entry.ts +6 -0
  231. package/src/urls/path-helper.ts +69 -9
  232. package/src/use-loader.tsx +57 -30
  233. package/src/vite/discovery/bundle-postprocess.ts +4 -0
  234. package/src/vite/discovery/discover-routers.ts +15 -0
  235. package/src/vite/discovery/shell-prerender-phase.ts +5 -5
  236. package/src/vite/discovery/state.ts +17 -1
  237. package/src/vite/inject-client-debug.ts +42 -20
  238. package/src/vite/plugins/virtual-entries.ts +8 -1
  239. package/src/vite/router-discovery.ts +14 -0
  240. package/src/vite/utils/bundle-analysis.ts +39 -7
  241. package/dist/types/browser/react/subscribe-to-nothing.d.ts +0 -7
  242. package/dist/types/segment-content-promise.d.ts +0 -13
  243. package/src/browser/react/subscribe-to-nothing.ts +0 -7
  244. package/src/segment-content-promise.ts +0 -67
@@ -13,6 +13,15 @@ import type { RequestContext } from "../server/request-context.js";
13
13
  import { normalizeTags } from "./cache-tag.js";
14
14
  import { reportCacheError } from "./cache-error.js";
15
15
 
16
+ /**
17
+ * How long a follower trusts an in-flight leader before evicting it and
18
+ * running fresh. High enough that a slow-but-healthy upstream never triggers
19
+ * duplicate work (a legitimate cached call taking >15s is already pathological);
20
+ * low enough to bound the blast radius of a wedged leader to seconds, not the
21
+ * isolate lifetime. Aligned with SHELL_CAPTURE_MAX_WAIT_MS.
22
+ */
23
+ export const IN_FLIGHT_LEADER_MAX_WAIT_MS = 15_000;
24
+
16
25
  /**
17
26
  * Default TTL for route-level cache() DSL and loader cache.
18
27
  * Applied when neither the cache options nor the store defaults specify a TTL.
@@ -184,8 +193,8 @@ export function resolveTagsOption<TEnv>(
184
193
  * and on CFCacheStore would also cost a wasted KV marker read per request.
185
194
  * Returns undefined when nothing usable remains, keeping the entry header-free.
186
195
  */
187
- function normalizeTagList(tags: string[]): string[] | undefined {
188
- const out = normalizeTags(tags);
196
+ export function normalizeTagList(tags: string[]): string[] | undefined {
197
+ const out = [...new Set(normalizeTags(tags))];
189
198
  return out.length > 0 ? out : undefined;
190
199
  }
191
200
 
@@ -42,7 +42,11 @@ import {
42
42
  } from "./handle-snapshot.js";
43
43
  import { startHandleCapture, useCacheRecordKey } from "./handle-capture.js";
44
44
  import { isHandle } from "../handle.js";
45
- import { cacheKeyBase, sortedSearchString } from "./cache-key-utils.js";
45
+ import {
46
+ requestArgKey,
47
+ requestKeyBase,
48
+ sortedSearchString,
49
+ } from "./cache-key-utils.js";
46
50
  import { encodeKV } from "../encode-kv.js";
47
51
  import { runBackground } from "./background-task.js";
48
52
  import { observePhase, PHASES } from "../router/instrument.js";
@@ -60,6 +64,8 @@ import {
60
64
  type CacheExecScope,
61
65
  } from "./cache-exec-scope.js";
62
66
  import { reportCacheError } from "./cache-error.js";
67
+ import { IN_FLIGHT_LEADER_MAX_WAIT_MS } from "./cache-policy.js";
68
+ import { isWarmReplace, noteWarmWrite } from "../prerender/warm-request.js";
63
69
  import {
64
70
  executionStart,
65
71
  invalidatedSince,
@@ -314,15 +320,6 @@ interface InFlightExecution {
314
320
 
315
321
  const inFlightExecutions = new Map<string, InFlightExecution>();
316
322
 
317
- /**
318
- * How long a follower trusts an in-flight leader before evicting it and
319
- * running fresh. High enough that a slow-but-healthy upstream never triggers
320
- * duplicate work (a legitimate cached call taking >15s is already pathological);
321
- * low enough to bound the blast radius of a wedged leader to seconds, not the
322
- * isolate lifetime. Aligned with SHELL_CAPTURE_MAX_WAIT_MS.
323
- */
324
- const IN_FLIGHT_LEADER_MAX_WAIT_MS = 15_000;
325
-
326
323
  /** Distinguishes a leader timeout from a leader rejection in raceLeader. */
327
324
  const LEADER_TIMED_OUT = Symbol("leader-timed-out");
328
325
 
@@ -499,9 +496,11 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
499
496
  }
500
497
  if (ctx.params && typeof ctx.params === "object") {
501
498
  // Include host to prevent cross-host cache collisions (same
502
- // pattern as route-level cache-scope.ts key generation).
499
+ // pattern as route-level cache-scope.ts key generation), led by the
500
+ // router part: a ctx is one router's (cache-key-utils.ts, the
501
+ // router rule).
503
502
  if (ctx.url?.host) {
504
- keyArgs.push(ctx.url.host);
503
+ keyArgs.push(requestKeyBase(requestCtx, ctx.url.host, ""));
505
504
  }
506
505
  // Include route name to prevent collisions when the same cached
507
506
  // function is reused across routes with identical pathname/params
@@ -529,15 +528,8 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
529
528
  } else if (arg instanceof Request) {
530
529
  // A raw Request (ctx.request) is request-scoped like ctx: fold in its
531
530
  // URL with the same host-namespacing and search normalization.
532
- const url = new URL(arg.url);
533
531
  keyArgs.push(
534
- cacheKeyBase(
535
- url.host,
536
- url.pathname,
537
- url.searchParams,
538
- undefined,
539
- requestCtx?._searchParamsFilter,
540
- ),
532
+ requestArgKey(new URL(arg.url), requestCtx?._searchParamsFilter),
541
533
  );
542
534
  } else if (arg != null && arg === requestCtx?.env) {
543
535
  // The request's env is left out: constant per deployment, and its
@@ -617,8 +609,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
617
609
  return result;
618
610
  }
619
611
 
620
- // Cache lookup
621
- const cached = await store.getItem(cacheKey);
612
+ // Cache lookup. A router.prerender() warm reads a miss and leads its own
613
+ // execution below: a leader that started before the warm was called may
614
+ // be reading data from before the change the warm publishes, and a
615
+ // follower writes nothing of its own.
616
+ const warmReplace = isWarmReplace(requestCtx);
617
+ const cached = warmReplace ? null : await store.getItem(cacheKey);
622
618
 
623
619
  // Serve a cached entry on the hit path: deserialize the stored value,
624
620
  // replay handle data (gated on tainted args), and surface the entry's tags
@@ -767,7 +763,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
767
763
  ttl: profile.ttl,
768
764
  swr: profile.swr,
769
765
  tags: freshTags.length > 0 ? freshTags : undefined,
766
+ startedAt: start.at,
770
767
  });
768
+ noteWarmWrite(requestCtx, "item");
771
769
  }
772
770
  },
773
771
  ),
@@ -806,7 +804,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
806
804
  // against its OWN handle store (gated on ITS hasTaintedArgs), and records
807
805
  // tags into its OWN request — no deserialized result is shared across
808
806
  // requests. The store write stays exactly once (the leader's).
809
- let existing = inFlightExecutions.get(cacheKey);
807
+ let existing = warmReplace ? undefined : inFlightExecutions.get(cacheKey);
810
808
  while (existing) {
811
809
  const remainingMs =
812
810
  IN_FLIGHT_LEADER_MAX_WAIT_MS - (Date.now() - existing.registeredAt);
@@ -1022,7 +1020,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
1022
1020
  ttl: profile.ttl,
1023
1021
  swr: profile.swr,
1024
1022
  tags: tags.length > 0 ? tags : undefined,
1023
+ startedAt: start.at,
1025
1024
  });
1025
+ noteWarmWrite(requestCtx, "item");
1026
1026
  } catch (writeError) {
1027
1027
  requestCtx?._reportBackgroundError?.(writeError, "cache-write");
1028
1028
  }
@@ -38,12 +38,11 @@ import {
38
38
  import {
39
39
  captureHandles,
40
40
  captureHandleOwners,
41
- restoreHandles,
41
+ restoreRecordHandles,
42
42
  type OwnedPushDelivery,
43
43
  encodeHandles,
44
- decodeHandles,
45
44
  } from "./handle-snapshot.js";
46
- import { cacheKeyBase, composeCacheKeys } from "./cache-key-utils.js";
45
+ import { composeCacheKeys, requestKeyBase } from "./cache-key-utils.js";
47
46
  import {
48
47
  DEFAULT_ROUTE_TTL,
49
48
  isFiniteNonNegativeSeconds,
@@ -53,6 +52,7 @@ import {
53
52
  } from "./cache-policy.js";
54
53
  import type { RequestContext } from "../server/request-context.js";
55
54
  import { runIdentityExempt } from "./cache-exec-scope.js";
55
+ import { isWarmReplace, noteWarmWrite } from "../prerender/warm-request.js";
56
56
 
57
57
  /**
58
58
  * Narrow the request's route-record window (RequestContext._routeRecordWindow)
@@ -138,7 +138,7 @@ function getDefaultRouteCacheKey(
138
138
  ? "intercept"
139
139
  : (prefixOverride ?? (isPartial ? "partial" : "doc"));
140
140
 
141
- return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params, ctx?._searchParamsFilter)}`;
141
+ return `${prefix}:${requestKeyBase(ctx, host, pathname, searchParams, params, ctx?._searchParamsFilter)}`;
142
142
  }
143
143
 
144
144
  // ============================================================================
@@ -786,6 +786,12 @@ export class CacheScope {
786
786
  const store = this.getStore();
787
787
  if (!store) return { status: "bypass" };
788
788
 
789
+ // A router.prerender() warm renders as on a cold cache: the opt-outs
790
+ // above still answer `bypass`, and the miss lets the write path replace
791
+ // the record under the key it resolves (cacheRoute).
792
+ const ambientContext = _getRequestContext();
793
+ if (isWarmReplace(ambientContext)) return { status: "miss" };
794
+
789
795
  // Resolve cache key INSIDE the try so a throwing consumer key() (or a
790
796
  // store.keyGenerator) degrades to a cache miss (return null -> render
791
797
  // uncached) instead of crashing the foreground render. resolveCacheKey
@@ -826,7 +832,6 @@ export class CacheScope {
826
832
  // seeded record, which a document tail turns into the degrade
827
833
  // (ShellRecordUnavailableError).
828
834
  let segments: ResolvedSegment[];
829
- const ambientContext = _getRequestContext();
830
835
  try {
831
836
  const codec = await import("./segment-codec.js");
832
837
  segments = ambientContext?._shellFragmentPayload
@@ -876,32 +881,17 @@ export class CacheScope {
876
881
  await this.onHit?.();
877
882
 
878
883
  const handleStore = _getRequestContext()?._handleStore;
879
- // With or without a handles blob: a pinned loader whose capture pushed
880
- // nothing has no copy in the record, and its run on this request must
881
- // still add no settled push (HandleStore.setRecordAuthority).
882
- const owned = handleStore ? ownedPushes?.() : undefined;
883
- if (owned) handleStore?.setRecordAuthority(owned);
884
-
885
- // Replay handle data. An empty string means the route pushed no handles —
886
- // skip the decode entirely (the common case). Otherwise decode the
887
- // Flight-encoded blob; a decode failure skips handle restore but keeps the
888
- // valid cached segments.
889
- if (handleStore && cached.handles) {
890
- const handlesRecord = await decodeHandles(cached.handles);
891
- if (handlesRecord) {
892
- if (this.boundary !== undefined) {
893
- const kept = new Set(segments.map((s) => s.id));
894
- for (const id of Object.keys(handlesRecord)) {
895
- if (!kept.has(id)) delete handlesRecord[id];
896
- }
897
- }
898
- restoreHandles(
899
- handlesRecord,
900
- handleStore,
901
- cached.handleOwners,
902
- owned,
903
- );
904
- }
884
+ // An empty handles string means the route pushed none (the common
885
+ // case); a decode failure skips the restore but keeps the segments.
886
+ if (handleStore) {
887
+ await restoreRecordHandles(
888
+ handleStore,
889
+ cached,
890
+ ownedPushes?.(),
891
+ this.boundary !== undefined
892
+ ? new Set(segments.map((s) => s.id))
893
+ : undefined,
894
+ );
905
895
  }
906
896
 
907
897
  if (INTERNAL_RANGO_DEBUG) {
@@ -1115,15 +1105,25 @@ export class CacheScope {
1115
1105
  // the tree) or a rejected handle value as an error row and completes
1116
1106
  // normally; stored, every HIT would render that error until expiry.
1117
1107
  if (flightErrors.length > 0) throw flightErrors[0];
1108
+ // A streamed handler recovered into its boundary fallback resolves, so
1109
+ // Flight reports nothing; refuse like a Flight error row.
1110
+ const recovered = requestCtx._recoveredHandlerErrors;
1111
+ if (recovered?.size) {
1112
+ const failed = nonLoaderSegments.find((s) => recovered.has(s.id));
1113
+ if (failed) throw recovered.get(failed.id);
1114
+ }
1118
1115
 
1116
+ const recordTags = collectRecordTags(requestCtx, tags, [
1117
+ ...nonLoaderSegments.map((s) => s.id),
1118
+ handlesOwnerId,
1119
+ ]);
1119
1120
  const data: CachedEntryData = {
1120
1121
  segments: serializedSegments,
1121
1122
  handles: encodedHandles,
1122
1123
  expiresAt: Date.now() + ttl * 1000,
1123
- tags: collectRecordTags(requestCtx, tags, [
1124
- ...nonLoaderSegments.map((s) => s.id),
1125
- handlesOwnerId,
1126
- ]),
1124
+ tags: recordTags,
1125
+ // The stamp is the render's start, not the write's (#1068).
1126
+ ...(start && recordTags?.length ? { taggedAt: start.at } : {}),
1127
1127
  };
1128
1128
  if (handleOwners && encodedHandles) data.handleOwners = handleOwners;
1129
1129
 
@@ -1137,6 +1137,8 @@ export class CacheScope {
1137
1137
  }
1138
1138
 
1139
1139
  await store.set(key, data, ttl, swr);
1140
+ // The implicit doc scope's record lives in the shell entry only.
1141
+ if (!this.isShellImplicitDocScope) noteWarmWrite(requestCtx, "record");
1140
1142
 
1141
1143
  if (INTERNAL_RANGO_DEBUG) {
1142
1144
  const segmentTypes = nonLoaderSegments.map((s) =>
@@ -1185,7 +1187,7 @@ function collectRecordTags(
1185
1187
  * enclosing scopes on another store (#974) and of a scope without its own
1186
1188
  * `key()` (#970)); with none, the app store's keyGenerator result
1187
1189
  * given the document default key. Undefined (synchronously, with no work)
1188
- * when neither applies: the shell key stays host + path + filtered search.
1190
+ * when neither applies: the shell key stays unpartitioned (buildShellKey).
1189
1191
  * Resolves to null when the partition is the default key itself (a
1190
1192
  * keyGenerator that returns it unchanged): that partitions nothing, so the
1191
1193
  * shell stays unpartitioned and keeps its build shell. The shell's key, its
@@ -27,6 +27,7 @@ declare global {
27
27
 
28
28
  import { requestHeaders } from "../../server/request-headers.js";
29
29
  import type {
30
+ CacheStoreScope,
30
31
  SegmentCacheStore,
31
32
  CachedEntryData,
32
33
  CacheDefaults,
@@ -118,6 +119,7 @@ import {
118
119
  DEFAULT_CF_MARKER_MAX_STALE_MS,
119
120
  } from "../isolate-tag-memo.js";
120
121
  import { createCloudflareZonePurge } from "./cf-zone-purge.js";
122
+ import { responseStartedAt, taggedStamp } from "../invalidation-order.js";
121
123
 
122
124
  // ============================================================================
123
125
  // Constants
@@ -519,6 +521,12 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
519
521
  private readonly kv?: KVNamespace;
520
522
  /** True when constructed without KV: no durable tag history (see ctor). */
521
523
  readonly tagHistoryInert?: boolean;
524
+ /**
525
+ * SegmentCacheStore.scope: with KV every colo's L1 miss reads the same L2
526
+ * entry ("global"); without it entries live in one colo's Cache API
527
+ * ("local"), purge mode included (a purge evicts, it stores nothing).
528
+ */
529
+ readonly scope: CacheStoreScope;
522
530
  private readonly onRevalidateTag?: (tags: string[]) => Promise<void>;
523
531
  private readonly tagPurge?: (cacheTags: string[]) => Promise<void>;
524
532
  private readonly tagInvalidationTtl?: number;
@@ -604,6 +612,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
604
612
  // evict it (purge cannot delete a build asset), so the manifest gate
605
613
  // declines on this flag (shell-build-manifest.ts).
606
614
  this.tagHistoryInert = options.kv ? undefined : true;
615
+ this.scope = options.kv ? "global" : "local";
607
616
  this.onRevalidateTag = options.onRevalidateTag;
608
617
  // tagPurge accepts a ready purge function or a credentials object; the
609
618
  // object form is normalized through the built-in zone purge client, which
@@ -1389,13 +1398,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1389
1398
  const totalTtl = ttl + swrWindow;
1390
1399
  const staleAt = Date.now() + ttl * 1000;
1391
1400
 
1392
- // Stamp the tag timestamp at write time and carry it (with the tags)
1393
- // into both the L1 body and the KV envelope so reads can run the
1394
- // invalidation check.
1395
- const taggedAt =
1396
- Array.isArray(data.tags) && data.tags.length > 0
1397
- ? Date.now()
1398
- : undefined;
1401
+ // Stamp the tag timestamp and carry it (with the tags) into both the
1402
+ // L1 body and the KV envelope so reads can run the invalidation check.
1403
+ // It is the render's start when the writer passed it (#1068), else the
1404
+ // write time: a marker written while the render ran then rejects it.
1405
+ const taggedAt = taggedStamp(data.tags, data.taggedAt);
1399
1406
  const dataToStore: CachedEntryData = taggedAt
1400
1407
  ? { ...data, taggedAt }
1401
1408
  : data;
@@ -1640,8 +1647,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1640
1647
  const swrWindow = resolveSwrWindow(swr, this.defaults);
1641
1648
  const totalTtl = ttl + swrWindow;
1642
1649
  const staleAt = Date.now() + ttl * 1000;
1643
- const taggedAt =
1644
- Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1650
+ const taggedAt = taggedStamp(tags, responseStartedAt(response));
1645
1651
 
1646
1652
  // Clone body for potential KV write before consuming it for L1
1647
1653
  const [l1Body, kvBody] = this.kv
@@ -1941,8 +1947,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1941
1947
  const staleAt = Date.now() + ttl * 1000;
1942
1948
 
1943
1949
  const tags = options?.tags;
1944
- const taggedAt =
1945
- Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1950
+ const taggedAt = taggedStamp(tags, options?.startedAt);
1946
1951
 
1947
1952
  // Serialize value/handles once; L1 body and KV envelope.v/h share the
1948
1953
  // escaped strings so a large RSC payload is not re-escaped for L2.
@@ -22,10 +22,19 @@ import {
22
22
  type RequestContext,
23
23
  } from "../server/request-context.js";
24
24
  import { mayNeedSSR } from "../rsc/ssr-setup.js";
25
- import { cacheKeyBase } from "./cache-key-utils.js";
25
+ import { requestKeyBase } from "./cache-key-utils.js";
26
26
  import { runBackground } from "./background-task.js";
27
+ import {
28
+ isWarmReplace,
29
+ noteWarmDocument,
30
+ noteWarmWrite,
31
+ } from "../prerender/warm-request.js";
27
32
  import { reportCacheError } from "./cache-error.js";
28
- import { executionStart, predatesInvalidation } from "./tag-invalidation.js";
33
+ import {
34
+ executionStart,
35
+ markResponseStart,
36
+ predatesInvalidation,
37
+ } from "./tag-invalidation.js";
29
38
  import { observePhase, PHASES } from "../router/instrument.js";
30
39
  import {
31
40
  SEGMENT_FRAGMENT_CAPABILITY_HEADER,
@@ -239,11 +248,14 @@ export interface DocumentCacheOptions<TEnv = any> {
239
248
  /**
240
249
  * Custom cache key generator.
241
250
  *
242
- * Replaces the default `host + pathname + search` key entirely. On a
243
- * multi-domain deployment served by one function you MUST include `url.host`
244
- * (or an equivalent tenant discriminator) yourself — the default key is
245
- * host-namespaced, but a custom generator's output is used verbatim, so
246
- * omitting host bleeds one hostname's cached response to another.
251
+ * Replaces the default `router id + host + pathname + search` key entirely.
252
+ * On a multi-domain deployment served by one function you MUST include
253
+ * `url.host` (or an equivalent tenant discriminator) yourself — the default
254
+ * key is host-namespaced, but a custom generator's output is used verbatim,
255
+ * so omitting host bleeds one hostname's cached response to another. The
256
+ * same goes for the router: two routers that share a cache store and can
257
+ * serve one host and path (a `hostOverride` cookie) need a generator that
258
+ * tells them apart.
247
259
  */
248
260
  keyGenerator?: (url: URL) => string;
249
261
 
@@ -347,7 +359,8 @@ export function createDocumentCacheMiddleware<TEnv = any>(
347
359
  return next();
348
360
  }
349
361
 
350
- // The write gate's start (#977): the request's, not this middleware's. A
362
+ // The write gate's start (#977) and the entry's stamp (#1068): the
363
+ // request's, not this middleware's, also for a stale refresh. A
351
364
  // middleware ahead of this one can read tagged data the document bakes,
352
365
  // and a stale refresh re-runs the handler over what they set.
353
366
  const start = requestCtx._requestStart ?? executionStart();
@@ -392,7 +405,8 @@ export function createDocumentCacheMiddleware<TEnv = any>(
392
405
  // existing keys and double any host they already include).
393
406
  const cacheKey = keyGenerator
394
407
  ? keyGenerator(url) + segmentHash + fragmentSuffix + typeSuffix
395
- : cacheKeyBase(
408
+ : requestKeyBase(
409
+ requestCtx,
396
410
  url.host,
397
411
  url.pathname,
398
412
  url.searchParams,
@@ -406,9 +420,11 @@ export function createDocumentCacheMiddleware<TEnv = any>(
406
420
  // Recovery must reach CacheScope's server decoder so it can evict the bad
407
421
  // segment. Treat it as a miss, then let the ordinary write path replace
408
422
  // the corrupt fragment-capable response with the valid fallback bytes.
409
- const cached = isFragmentRecovery
410
- ? null
411
- : await store.getResponse(cacheKey);
423
+ // A router.prerender() warm is a miss too: it renders and replaces.
424
+ const cached =
425
+ isFragmentRecovery || isWarmReplace(requestCtx)
426
+ ? null
427
+ : await store.getResponse(cacheKey);
412
428
  // Every path past the lookup either returns a fresh HIT, which renders
413
429
  // nothing, or renders a response this cache may store.
414
430
  requestCtx._documentCacheRender = true;
@@ -462,11 +478,13 @@ export function createDocumentCacheMiddleware<TEnv = any>(
462
478
  if (await predatesInvalidation(store, tags, start)) return;
463
479
  await store.putResponse!(
464
480
  cacheKey,
465
- new Response(body, fresh),
481
+ markResponseStart(new Response(body, fresh), start),
466
482
  directives.sMaxAge!,
467
483
  directives.staleWhileRevalidate,
468
484
  tags,
469
485
  );
486
+ noteWarmWrite(requestCtx, "response");
487
+ noteWarmDocument(requestCtx, "stored");
470
488
  log(
471
489
  `[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`,
472
490
  );
@@ -531,11 +549,13 @@ export function createDocumentCacheMiddleware<TEnv = any>(
531
549
  if (await predatesInvalidation(store, tags, start)) return;
532
550
  await store.putResponse!(
533
551
  cacheKey,
534
- new Response(body, originalResponse),
552
+ markResponseStart(new Response(body, originalResponse), start),
535
553
  directives.sMaxAge!,
536
554
  directives.staleWhileRevalidate,
537
555
  tags,
538
556
  );
557
+ noteWarmWrite(requestCtx, "response");
558
+ noteWarmDocument(requestCtx, "stored");
539
559
  } catch (error) {
540
560
  // Detached waitUntil task — pass the captured requestCtx so onError
541
561
  // fires even though the ALS context is gone (see the revalidation
@@ -556,6 +576,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
556
576
  }
557
577
 
558
578
  // No cache headers - pass through
579
+ noteWarmDocument(requestCtx, "not-cacheable");
559
580
  return originalResponse;
560
581
  } catch (error) {
561
582
  reportCacheError(error, "cache-read", "[DocumentCache] middleware");
@@ -134,6 +134,31 @@ export function captureHandleOwners(
134
134
  return owners;
135
135
  }
136
136
 
137
+ /**
138
+ * captureHandles and captureHandleOwners cut down to the handle arrays that
139
+ * hold an owned value, each whole (its unowned values too, in place), so
140
+ * restoreHandles rebuilds exactly those arrays over a store that already
141
+ * holds the rest. A prerender-served shell capture stores this: the
142
+ * prerender store restores the handler pushes on a HIT, and the loader
143
+ * pushes have no other record (shell-capture.ts captureAndStoreShell).
144
+ */
145
+ export function captureOwnedHandles(handleStore: HandleStore): {
146
+ handles: HandleRecord;
147
+ owners?: HandleOwners;
148
+ } {
149
+ const handles: HandleRecord = {};
150
+ let owners: HandleOwners | undefined;
151
+ for (const id of handleStore.getOwnedSegmentIds()) {
152
+ const segOwners = handleStore.getRecordOwners(id);
153
+ if (!segOwners) continue;
154
+ const kept = handleStore.getDataForSegment(id, true);
155
+ handles[id] = {};
156
+ for (const name in segOwners) handles[id][name] = kept[name] ?? [];
157
+ (owners ??= {})[id] = segOwners;
158
+ }
159
+ return { handles, owners };
160
+ }
161
+
137
162
  /**
138
163
  * What the record being restored is to each loader's handle pushes on this
139
164
  * request (HandleStore RecordAuthority). Built by loaderPins (loader-cache.ts)
@@ -190,6 +215,31 @@ export function restoreHandles(
190
215
  }
191
216
  }
192
217
 
218
+ /**
219
+ * Serve a doc record's handles: set its authority over the loaders' pushes
220
+ * (with or without a handles blob: a pinned loader whose capture pushed
221
+ * nothing has no copy, and its run must still add no settled push), then
222
+ * decode and restoreHandles. `keepIds` limits the restore to the segments a
223
+ * boundary-scoped scope serves. A decode failure skips the restore.
224
+ */
225
+ export async function restoreRecordHandles(
226
+ handleStore: HandleStore,
227
+ record: { handles?: string; handleOwners?: HandleOwners },
228
+ owned: OwnedPushDelivery | undefined,
229
+ keepIds?: ReadonlySet<string>,
230
+ ): Promise<void> {
231
+ if (owned) handleStore.setRecordAuthority(owned);
232
+ if (!record.handles) return;
233
+ const decoded = await decodeHandles(record.handles);
234
+ if (!decoded) return;
235
+ if (keepIds) {
236
+ for (const id of Object.keys(decoded)) {
237
+ if (!keepIds.has(id)) delete decoded[id];
238
+ }
239
+ }
240
+ restoreHandles(decoded, handleStore, record.handleOwners, owned);
241
+ }
242
+
193
243
  /**
194
244
  * Append recorded handle pushes to the store, in recorded order, via push()
195
245
  * so active captures and loader-scope tagging see them like live pushes. For
@@ -1,5 +1,6 @@
1
1
  export type {
2
2
  SegmentCacheStore,
3
+ CacheStoreScope,
3
4
  CachedEntryData,
4
5
  CacheGetResult,
5
6
  CacheItemResult,
@@ -43,6 +43,59 @@ export interface ExecutionStart {
43
43
  readonly at: number;
44
44
  }
45
45
 
46
+ /**
47
+ * The `taggedAt` / `ta` stamp a store writes on a tagged data entry: the start
48
+ * of the execution that produced the value (`startedAt`), so a marker written
49
+ * while it ran rejects the entry on read (#1068). Write time when the writer
50
+ * knows no start. Never later than now: a start from a skewed clock must not
51
+ * stamp the future, which would make the entry look newer than every marker.
52
+ */
53
+ export function entryStamp(startedAt: number | undefined): number {
54
+ const now = Date.now();
55
+ // 0 is "no start", not the epoch: no marker is older than it, so such an
56
+ // entry would never be rejected.
57
+ return typeof startedAt === "number" &&
58
+ Number.isFinite(startedAt) &&
59
+ startedAt > 0
60
+ ? Math.min(startedAt, now)
61
+ : now;
62
+ }
63
+
64
+ /** The stamp of a data entry: `entryStamp` for a tagged entry, none otherwise. */
65
+ export function taggedStamp(
66
+ tags: readonly string[] | undefined,
67
+ startedAt: number | undefined,
68
+ ): number | undefined {
69
+ return Array.isArray(tags) && tags.length > 0
70
+ ? entryStamp(startedAt)
71
+ : undefined;
72
+ }
73
+
74
+ /**
75
+ * putResponse has no options object to carry the start and takes no new
76
+ * positional parameter, so the writer marks the Response it hands over.
77
+ * On globalThis like the order: a second evaluated copy of this module that
78
+ * wrote the mark must be read by the store's copy, else the stamp silently
79
+ * falls back to write time.
80
+ */
81
+ const responseStarts: WeakMap<Response, number> = ((globalThis as any)[
82
+ Symbol.for("rangojs-router:response-execution-start")
83
+ ] ??= new WeakMap());
84
+
85
+ /** Mark the execution start behind a Response handed to putResponse. */
86
+ export function markResponseStart(
87
+ response: Response,
88
+ start: ExecutionStart,
89
+ ): Response {
90
+ responseStarts.set(response, start.at);
91
+ return response;
92
+ }
93
+
94
+ /** The start a writer marked on `response`, if any. */
95
+ export function responseStartedAt(response: Response): number | undefined {
96
+ return responseStarts.get(response);
97
+ }
98
+
46
99
  /** Record an execution's start, before it reads its data. */
47
100
  export function executionStart(): ExecutionStart {
48
101
  return { seq: order.seq, at: Date.now() };