@rangojs/router 0.17.0 → 0.19.0

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 (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -5,9 +5,11 @@
5
5
  * `@vercel/functions`). It is the production store analogue to CFCacheStore, but
6
6
  * far smaller: Vercel's Runtime Cache already IS a distributed, tag-aware cache
7
7
  * (regional storage, global tag-expire within ~300ms), so none of CFCacheStore's
8
- * L1/L2 tiering, KV tag-marker comparison, marker memoization, or per-tier
9
- * timeout budgets are needed here - the platform does that work. What this store
10
- * adds on top of the raw primitive is the parts Vercel does NOT give us:
8
+ * L1/L2 tiering or per-tier timeout budgets are needed here - the platform does
9
+ * that work. Tag markers (`tm`, see isTagsInvalidatedSince) gate shell reads
10
+ * and writes and every cache write that started before an invalidation
11
+ * (#977); only PPR shell reads memoize them. What this
12
+ * store adds on top of the raw primitive is the parts Vercel does NOT give us:
11
13
  *
12
14
  * 1. Stale-while-revalidate. `getCache` has no stale-but-serve: a TTL'd entry
13
15
  * simply becomes a miss. We store our own {staleAt, expiresAt} envelope and
@@ -40,16 +42,23 @@ import type {
40
42
  CacheItemOptions,
41
43
  ShellCacheEntry,
42
44
  ShellSnapshotRecord,
45
+ ShellDocumentRead,
46
+ ShellDocumentReadOptions,
47
+ ShellReadStats,
43
48
  CacheReadError,
44
49
  } from "../types.js";
45
50
  import { CACHE_READ_ERROR } from "../types.js";
46
- import type { RequestContext } from "../../server/request-context.js";
51
+ import {
52
+ _getRequestContext,
53
+ type RequestContext,
54
+ } from "../../server/request-context.js";
47
55
  import { isPerClientSignalHeader } from "../../browser/cookie-name.js";
48
56
  import {
49
57
  resolveTtl,
50
58
  resolveSwrWindow,
51
59
  computeExpiration,
52
60
  DEFAULT_FUNCTION_TTL,
61
+ staleShellRecaptureDue,
53
62
  } from "../cache-policy.js";
54
63
  import { reportCacheError, reportingAsync } from "../cache-error.js";
55
64
  import type { CacheErrorCategory } from "../cache-error.js";
@@ -61,7 +70,37 @@ import type { CacheErrorCategory } from "../cache-error.js";
61
70
  // byte-identical to a per-byte encoder (chunk size does not affect base64), so
62
71
  // this is a robustness fix, not a format change. Do NOT reintroduce a local
63
72
  // spread-based encoder or raise the chunk here; cf-base64.ts is import-pure.
64
- import { bufferToBase64, base64ToBuffer } from "../cf/cf-base64.js";
73
+ import {
74
+ bufferToBase64,
75
+ base64ByteLength,
76
+ base64ToBuffer,
77
+ base64ToBytes,
78
+ } from "../cf/cf-base64.js";
79
+ import {
80
+ gateMarkerRead,
81
+ maskRequestTags,
82
+ maskedForRequest,
83
+ } from "../request-tag-mask.js";
84
+ import {
85
+ ShellMemo,
86
+ freshReadsWindowMs,
87
+ isShellFresh,
88
+ resolveShellMemoOptions,
89
+ shellHasAnyTag,
90
+ type ResolvedShellMemoOptions,
91
+ type StoreMemoOptions,
92
+ } from "../shell-memo.js";
93
+ import {
94
+ TagMarkerMemo,
95
+ TagNameHints,
96
+ hintedTags,
97
+ freshReadsRequired,
98
+ recordMarkerRow,
99
+ DEFAULT_VERCEL_MARKER_FRESH_MS,
100
+ DEFAULT_VERCEL_MARKER_MAX_STALE_MS,
101
+ type MarkerFetch,
102
+ type MarkerMemoOutcome,
103
+ } from "../isolate-tag-memo.js";
65
104
 
66
105
  /**
67
106
  * Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
@@ -133,8 +172,9 @@ type CacheFamily = "s" | "i" | "r" | "h" | "tm";
133
172
 
134
173
  /**
135
174
  * TTL for tag-invalidation marker entries ("tm" family), written by
136
- * invalidateTags for the build-shell read-through's isTagsInvalidatedSince
137
- * gate. The platform's expireTag() DELETES tagged entries (no queryable
175
+ * invalidateTags for isTagsInvalidatedSince (the build-shell read-through,
176
+ * the shell write gate, and the write gate of cache executions, #977).
177
+ * The platform's expireTag() DELETES tagged entries (no queryable
138
178
  * history), so the markers are rango's own record of "tag X was invalidated
139
179
  * at T". One year: runtime tagged-shell retention is capped to this lifetime,
140
180
  * while buildVersion retires build shells on the next deploy. An expired marker
@@ -150,6 +190,8 @@ interface VercelSegmentEnvelope {
150
190
  s: number;
151
191
  /** expiresAt (ms since epoch). */
152
192
  e: number;
193
+ /** Write time of a tagged entry (ms), for the request tag mask. */
194
+ ta?: number;
153
195
  }
154
196
 
155
197
  /** Stored envelope for a "use cache" function result (getItem/setItem). */
@@ -164,6 +206,8 @@ interface VercelItemEnvelope {
164
206
  e: number;
165
207
  /** Tags, surfaced on read so a hit still contributes to the document tag set. */
166
208
  t?: string[];
209
+ /** Write time of a tagged entry (ms), for the request tag mask. */
210
+ ta?: number;
167
211
  }
168
212
 
169
213
  /** Stored envelope for a full Response (getResponse/putResponse). */
@@ -180,6 +224,8 @@ interface VercelResponseEnvelope {
180
224
  e: number;
181
225
  /** Tags, preserved so a background revalidation re-write keeps them. */
182
226
  t?: string[];
227
+ /** Write time of a tagged entry (ms), for the request tag mask. */
228
+ ta?: number;
183
229
  }
184
230
 
185
231
  /** Stored envelope for a PPR shell entry (getShell/putShell). */
@@ -194,7 +240,7 @@ interface VercelShellEnvelope {
194
240
  /** React.version at capture. */
195
241
  rv: string;
196
242
  /** Build version at capture (ShellCacheEntry.buildVersion). */
197
- bv?: string;
243
+ bv: string;
198
244
  /** Capture-generation start time, used by tag marker checks. */
199
245
  c: number;
200
246
  /** staleAt (ms since epoch). */
@@ -205,8 +251,8 @@ interface VercelShellEnvelope {
205
251
  t?: string[];
206
252
  /** initialTheme the capture render was built with (resume theme fidelity). */
207
253
  i?: string;
208
- /** Capture data snapshot: recorded cache-store hits/writes for HIT parity. */
209
- sn?: ShellSnapshotRecord[];
254
+ /** Capture data snapshot (ShellCacheEntry.snapshot). */
255
+ sn: ShellSnapshotRecord[];
210
256
  /**
211
257
  * ShellCacheEntry.docKey. Must round-trip: navigation-replay eligibility
212
258
  * requires the exact canonical doc segment record named here — dropping the
@@ -214,15 +260,8 @@ interface VercelShellEnvelope {
214
260
  * reports `no-segment-snapshot` after a store round trip.
215
261
  */
216
262
  dk?: string;
217
- /**
218
- * ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
219
- * handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
220
- * here silently fast-pathed handler-live entries after a store round trip —
221
- * their holes only a handler re-run can fill.
222
- */
223
- lh?: boolean;
224
- /** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
225
- tw?: true;
263
+ /** ShellCacheEntry.prunedRecords (diagnostic). */
264
+ pr?: string;
226
265
  /** ShellCacheEntry.navigationOnly; its partial-context prelude is not document-safe. */
227
266
  no?: true;
228
267
  }
@@ -230,6 +269,7 @@ interface VercelShellEnvelope {
230
269
  /** Read-path outcome for the debug sink. */
231
270
  export type VercelCacheReadOutcome =
232
271
  | "miss"
272
+ | "memo-hit"
233
273
  | "fresh"
234
274
  | "stale-revalidate"
235
275
  | "expired"
@@ -271,10 +311,13 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
271
311
  cache: VercelRuntimeCache;
272
312
 
273
313
  /**
274
- * `waitUntil` from `@vercel/functions`. Used only to run the stale-read lock
275
- * write (herd dampening) off the response path - the router already
276
- * backgrounds the actual writes. When omitted, the lock write runs detached
277
- * (fire-and-forget) instead.
314
+ * `waitUntil` from `@vercel/functions`. Keeps two background reads/writes
315
+ * alive past the response: the stale-read lock write (herd dampening; the
316
+ * router already backgrounds the actual writes) and the PPR shell reads'
317
+ * tag-marker memo refreshes (`memo.markerMaxStaleMs`). When omitted, both
318
+ * run detached (fire-and-forget), and the platform may end a refresh when
319
+ * the response ends; the next read past the stale window then reads the
320
+ * marker itself.
278
321
  */
279
322
  waitUntil?: (promise: Promise<unknown>) => void;
280
323
 
@@ -316,12 +359,76 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
316
359
  * receives the structured VercelCacheReadDebugEvent.
317
360
  */
318
361
  debug?: VercelCacheDebug;
362
+
363
+ /**
364
+ * Per-process memos ({@link StoreMemoOptions}), kept per `cache` handle:
365
+ * create the `getCache()` handle once per process, not per request, or the
366
+ * memos never hit. `shellMs` (default 2000) and `shellMaxBytes` (default
367
+ * 16 MiB) size the PPR shell memo; `markerFreshMs` (default 300) and
368
+ * `markerMaxStaleMs` (default 2000) the memo of the tag markers
369
+ * `invalidateTags()` writes, which PPR shell reads use
370
+ * (stale-while-revalidate). The process that invalidates writes the new
371
+ * marker into its memo; another process in the region rejects a memoized
372
+ * shell once its marker memo sees it (up to `markerMaxStaleMs`). The
373
+ * markers are a regional `cache.set` (only `expireTag` is global), so
374
+ * another region serves a shell it memoized before the invalidation until
375
+ * its window passes, where without the memo `expireTag` removes it within
376
+ * about 300 ms; a platform `expireTag` issued outside rango is likewise
377
+ * seen once the window passes. The response of a request that ran
378
+ * `updateTag()` / `revalidateTag()` sets the fresh-reads cookie, and the
379
+ * same user's requests carrying it skip both memos. `{ shellMs: 0 }` turns
380
+ * the shell memo off, `{ markerFreshMs: 0 }` the marker memo.
381
+ */
382
+ memo?: StoreMemoOptions;
383
+ }
384
+
385
+ /** A memoized fresh shell read (shell-memo.ts). */
386
+ interface VercelShellMemoValue {
387
+ entry: ShellCacheEntry;
388
+ /** The prelude decoded by the first document read that needed it. */
389
+ preludeBytes?: Uint8Array;
390
+ tags?: string[];
391
+ createdAt: number;
392
+ staleAt: number;
393
+ expiresAt: number;
394
+ }
395
+
396
+ /**
397
+ * The per-process memos of one runtime-cache handle: memoized shells,
398
+ * tag-marker values, and shell tag-name hints. Kept per handle because the
399
+ * handle carries the namespace, so stores over different namespaces never
400
+ * share them.
401
+ */
402
+ interface VercelHandleMemos {
403
+ shells: ShellMemo<VercelShellMemoValue>;
404
+ markers: TagMarkerMemo;
405
+ hints: TagNameHints;
406
+ }
407
+
408
+ const vercelHandleMemos = new WeakMap<VercelRuntimeCache, VercelHandleMemos>();
409
+
410
+ function handleMemos(cache: VercelRuntimeCache): VercelHandleMemos {
411
+ let memos = vercelHandleMemos.get(cache);
412
+ if (!memos) {
413
+ memos = {
414
+ shells: new ShellMemo<VercelShellMemoValue>(),
415
+ markers: new TagMarkerMemo(),
416
+ hints: new TagNameHints(),
417
+ };
418
+ vercelHandleMemos.set(cache, memos);
419
+ }
420
+ return memos;
319
421
  }
320
422
 
321
423
  function isRecord(value: unknown): value is Record<string, unknown> {
322
424
  return typeof value === "object" && value !== null;
323
425
  }
324
426
 
427
+ /** The envelope's `ta` write stamp, on tagged entries only. */
428
+ function tagStamp(tags: string[]): { ta?: number } {
429
+ return tags.length > 0 ? { ta: Date.now() } : {};
430
+ }
431
+
325
432
  /**
326
433
  * Vercel Runtime Cache-backed segment cache store.
327
434
  *
@@ -355,12 +462,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
355
462
  * import { getCache, waitUntil } from "@vercel/functions";
356
463
  * import { VercelCacheStore } from "@rangojs/router/cache";
357
464
  *
465
+ * // One handle per process: getCache() resolves the platform cache on every
466
+ * // call, and the PPR shell and tag-marker memos are kept per handle.
467
+ * // `process.env` (not `import.meta.env`, which needs a VITE_ prefix and
468
+ * // would be undefined here) so cross-deploy busting via `version` works.
469
+ * const runtimeCache = getCache({
470
+ * namespace: process.env.VERCEL_DEPLOYMENT_ID,
471
+ * });
472
+ *
358
473
  * export const router = createRouter({
359
474
  * cache: () => ({
360
475
  * store: new VercelCacheStore({
361
- * // `process.env` (not `import.meta.env`, which needs a VITE_ prefix and
362
- * // would be undefined here) so cross-deploy busting via `version` works.
363
- * cache: getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID }),
476
+ * cache: runtimeCache,
364
477
  * waitUntil,
365
478
  * defaults: { ttl: 60, swr: 300 },
366
479
  * }),
@@ -382,6 +495,16 @@ export class VercelCacheStore<
382
495
  private readonly waitUntil?: (promise: Promise<unknown>) => void;
383
496
  private readonly version?: string;
384
497
  private readonly maxItemBytes: number;
498
+ private readonly memo: ResolvedShellMemoOptions;
499
+ private readonly handleMemos: VercelHandleMemos;
500
+ /** @internal SegmentCacheStore.freshReadsWindowMs */
501
+ readonly freshReadsWindowMs: number;
502
+ /**
503
+ * @internal SegmentCacheStore.maxShellEntryBytes: the item cap, less the
504
+ * base64 expansion of the prelude the shell envelope stores (the capture
505
+ * measures raw prelude bytes).
506
+ */
507
+ readonly maxShellEntryBytes: number;
385
508
  private readonly name?: string;
386
509
  private readonly debug?: VercelCacheDebug;
387
510
 
@@ -397,6 +520,13 @@ export class VercelCacheStore<
397
520
  this.keyGenerator = options.keyGenerator;
398
521
  this.version = options.version;
399
522
  this.maxItemBytes = options.maxItemBytes ?? VERCEL_MAX_ITEM_BYTES;
523
+ this.maxShellEntryBytes = Math.floor((this.maxItemBytes * 3) / 4);
524
+ this.memo = resolveShellMemoOptions(options.memo, {
525
+ markerFreshMs: DEFAULT_VERCEL_MARKER_FRESH_MS,
526
+ markerMaxStaleMs: DEFAULT_VERCEL_MARKER_MAX_STALE_MS,
527
+ });
528
+ this.handleMemos = handleMemos(options.cache);
529
+ this.freshReadsWindowMs = freshReadsWindowMs(this.memo, true);
400
530
  this.name = options.name;
401
531
  this.debug = options.debug;
402
532
  }
@@ -447,6 +577,9 @@ export class VercelCacheStore<
447
577
  });
448
578
  return null;
449
579
  }
580
+ if (this.maskedMiss("get", key, { t: env.d.tags, ta: env.ta }, readMs)) {
581
+ return null;
582
+ }
450
583
 
451
584
  const isStale = env.s > 0 && now > env.s;
452
585
  const shouldRevalidate = isStale
@@ -483,7 +616,12 @@ export class VercelCacheStore<
483
616
  const d: CachedEntryData = data.tags
484
617
  ? { ...data, tags: safeTags.length > 0 ? safeTags : undefined }
485
618
  : data;
486
- const env: VercelSegmentEnvelope = { d, s: staleAt, e: expiresAt };
619
+ const env: VercelSegmentEnvelope = {
620
+ d,
621
+ s: staleAt,
622
+ e: expiresAt,
623
+ ...tagStamp(safeTags),
624
+ };
487
625
  await this.write(
488
626
  this.toStoreKey(key, "s"),
489
627
  env,
@@ -554,6 +692,7 @@ export class VercelCacheStore<
554
692
  });
555
693
  return null;
556
694
  }
695
+ if (this.maskedMiss("getResponse", key, env, readMs)) return null;
557
696
 
558
697
  // Reconstruct the Response BEFORE claiming revalidation. A corrupt body
559
698
  // (e.g. invalid base64 from base64ToBuffer, or bad header entries) would
@@ -629,6 +768,7 @@ export class VercelCacheStore<
629
768
  s: staleAt,
630
769
  e: expiresAt,
631
770
  t: safeTags.length > 0 ? safeTags : undefined,
771
+ ...tagStamp(safeTags),
632
772
  };
633
773
  await this.write(
634
774
  this.toStoreKey(key, "r"),
@@ -686,6 +826,7 @@ export class VercelCacheStore<
686
826
  });
687
827
  return null;
688
828
  }
829
+ if (this.maskedMiss("getItem", key, env, readMs)) return null;
689
830
 
690
831
  const isStale = env.s > 0 && now > env.s;
691
832
  const shouldRevalidate = isStale
@@ -733,6 +874,7 @@ export class VercelCacheStore<
733
874
  s: staleAt,
734
875
  e: expiresAt,
735
876
  t: safeTags.length > 0 ? safeTags : undefined,
877
+ ...tagStamp(safeTags),
736
878
  };
737
879
  await this.write(
738
880
  this.toStoreKey(key, "i"),
@@ -752,8 +894,140 @@ export class VercelCacheStore<
752
894
  key: string,
753
895
  options?: { claimRevalidation?: boolean },
754
896
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
897
+ const read = await this.readShell(key, options, undefined);
898
+ return read
899
+ ? { entry: read.entry, shouldRevalidate: read.shouldRevalidate }
900
+ : null;
901
+ }
902
+
903
+ /**
904
+ * @internal The document serve path's read (SegmentCacheStore
905
+ * .readShellDocument): the same read as getShell, with the prelude decoded
906
+ * here, once per memoized shell rather than once per HIT, and the read's
907
+ * `debugPerformance` stats. The runtime cache returns a parsed value, so
908
+ * there is no prelude-first read and the tag-marker read follows the entry
909
+ * read (`markerSerial`).
910
+ */
911
+ async readShellDocument(
912
+ key: string,
913
+ options?: ShellDocumentReadOptions,
914
+ ): Promise<ShellDocumentRead | null> {
915
+ const stats: ShellReadStats | undefined = _getRequestContext()
916
+ ?._metricsStore
917
+ ? { tier: "store" }
918
+ : undefined;
919
+ const read = await this.readShell(key, undefined, stats, options?.tagHints);
920
+ if (!read) return null;
921
+ const { entry, memoized } = read;
922
+ let prelude: Uint8Array;
923
+ if (entry.prelude === undefined) {
924
+ prelude = new Uint8Array(0);
925
+ } else if (memoized?.preludeBytes) {
926
+ prelude = memoized.preludeBytes;
927
+ } else {
928
+ try {
929
+ prelude = base64ToBytes(entry.prelude);
930
+ } catch (error) {
931
+ const storeKey = this.toStoreKey(key, "h");
932
+ reportCacheError(
933
+ error,
934
+ "cache-corrupt",
935
+ "[VercelCacheStore] getShell: undecodable prelude, evicting",
936
+ );
937
+ this.handleMemos.shells.delete(storeKey);
938
+ void this.safeDelete(storeKey);
939
+ return null;
940
+ }
941
+ if (memoized) memoized.preludeBytes = prelude;
942
+ }
943
+ if (stats) stats.preludeBytes = prelude.length;
944
+ const { prelude: _prelude, snapshot, ...rest } = entry;
945
+ return {
946
+ entry: rest,
947
+ prelude,
948
+ shouldRevalidate: read.shouldRevalidate,
949
+ snapshot: Promise.resolve(snapshot),
950
+ ...(stats && { stats }),
951
+ };
952
+ }
953
+
954
+ /** @internal SegmentCacheStore.dropShellMemo */
955
+ dropShellMemo(key: string): void {
956
+ this.handleMemos.shells.delete(this.toStoreKey(key, "h"));
957
+ }
958
+
959
+ /**
960
+ * getShell's read, shared with readShellDocument: the shell memo first
961
+ * (shell-memo.ts), then the runtime cache. A memoized shell still runs the
962
+ * tag-marker check; `memoized` is the memo slot's value when the read came
963
+ * from, or went into, the memo (readShellDocument keeps the decoded prelude
964
+ * there).
965
+ */
966
+ private async readShell(
967
+ key: string,
968
+ options: { claimRevalidation?: boolean } | undefined,
969
+ stats: ShellReadStats | undefined,
970
+ routeTags?: readonly string[],
971
+ ): Promise<{
972
+ entry: ShellCacheEntry;
973
+ shouldRevalidate: boolean;
974
+ memoized?: VercelShellMemoValue;
975
+ } | null> {
755
976
  const storeKey = this.toStoreKey(key, "h");
977
+ const memo = this.handleMemos.shells;
978
+ // A request after the same user's updateTag() reads past the memos.
979
+ const freshReads = freshReadsRequired(_getRequestContext());
980
+ if (stats && freshReads) stats.freshReads = true;
981
+ const outcomes = stats ? new Map<string, MarkerMemoOutcome>() : undefined;
982
+ const prefetched = this.prefetchShellMarkers(
983
+ storeKey,
984
+ routeTags,
985
+ stats,
986
+ outcomes,
987
+ );
988
+ const memoized = freshReads
989
+ ? undefined
990
+ : memo.get(storeKey, this.memo.shellMs);
991
+ if (memoized) {
992
+ if (isShellFresh(memoized.staleAt, memoized.expiresAt)) {
993
+ if (stats) stats.tier = "memo";
994
+ // The marker check is what decides whether a memoized shell may be
995
+ // served; it runs on every read, memoized or not.
996
+ if (
997
+ await this.shellTagsInvalidated(
998
+ memoized.tags,
999
+ memoized.createdAt,
1000
+ stats,
1001
+ prefetched,
1002
+ outcomes,
1003
+ )
1004
+ ) {
1005
+ memo.delete(storeKey);
1006
+ void this.safeDelete(storeKey);
1007
+ this.emitDebug({ op: "getShell", key, outcome: "miss" });
1008
+ return null;
1009
+ }
1010
+ if (stats) stats.memo = { hit: true, bytes: memo.size };
1011
+ this.emitDebug({
1012
+ op: "getShell",
1013
+ key,
1014
+ outcome: "memo-hit",
1015
+ staleAt: memoized.staleAt,
1016
+ expiresAt: memoized.expiresAt,
1017
+ });
1018
+ return {
1019
+ entry: memoized.entry,
1020
+ shouldRevalidate: false,
1021
+ memoized,
1022
+ };
1023
+ }
1024
+ memo.delete(storeKey);
1025
+ }
1026
+ if (stats && this.memo.shellMs > 0) {
1027
+ stats.memo = { hit: false, bytes: memo.size };
1028
+ }
756
1029
  const started = Date.now();
1030
+ const readStartedAt = stats ? performance.now() : 0;
757
1031
  let raw: unknown;
758
1032
  try {
759
1033
  raw = await this.cache.get(storeKey);
@@ -769,6 +1043,13 @@ export class VercelCacheStore<
769
1043
  return null;
770
1044
  }
771
1045
  const env = this.asShellEnvelope(this.decodeRaw(raw));
1046
+ if (stats) {
1047
+ const readAt = performance.now();
1048
+ stats.matchMs = readAt - readStartedAt;
1049
+ if (stats.markerHintStartedAt !== undefined) {
1050
+ stats.markerLeadMs = readAt - stats.markerHintStartedAt;
1051
+ }
1052
+ }
772
1053
  if (!env) {
773
1054
  reportCacheError(
774
1055
  new Error("malformed shell envelope"),
@@ -794,10 +1075,9 @@ export class VercelCacheStore<
794
1075
  return null;
795
1076
  }
796
1077
 
1078
+ this.handleMemos.hints.remember(storeKey, env.t);
797
1079
  if (
798
- env.t &&
799
- env.t.length > 0 &&
800
- (await this.isTagsInvalidatedSince(env.t, env.c))
1080
+ await this.shellTagsInvalidated(env.t, env.c, stats, prefetched, outcomes)
801
1081
  ) {
802
1082
  void this.safeDelete(storeKey);
803
1083
  this.emitDebug({ op: "getShell", key, outcome: "miss", readMs });
@@ -806,7 +1086,14 @@ export class VercelCacheStore<
806
1086
 
807
1087
  const isStale = env.s > 0 && now > env.s;
808
1088
  let shouldRevalidate = isStale;
809
- if (isStale && options?.claimRevalidation !== false) {
1089
+ // Inside the recapture floor the serve path schedules nothing, so the
1090
+ // lock is left for the first stale read past it: claimed here, it held
1091
+ // the shell REVALIDATION_LOCK_MS without a recapture.
1092
+ if (
1093
+ isStale &&
1094
+ options?.claimRevalidation !== false &&
1095
+ staleShellRecaptureDue({ createdAt: env.c }, now)
1096
+ ) {
810
1097
  shouldRevalidate = await this.claimRevalidation(
811
1098
  storeKey,
812
1099
  env.e,
@@ -822,22 +1109,132 @@ export class VercelCacheStore<
822
1109
  expiresAt: env.e,
823
1110
  readMs,
824
1111
  });
825
- return {
826
- entry: {
827
- prelude: env.p,
828
- postponed: env.po,
829
- reactVersion: env.rv,
830
- buildVersion: env.bv,
831
- initialTheme: env.i,
832
- snapshot: env.sn,
833
- docKey: env.dk,
834
- handlerLiveHoles: env.lh,
835
- transitionWhen: env.tw,
836
- navigationOnly: env.no,
837
- createdAt: env.c,
838
- },
839
- shouldRevalidate,
1112
+ const entry: ShellCacheEntry = {
1113
+ prelude: env.p,
1114
+ postponed: env.po,
1115
+ reactVersion: env.rv,
1116
+ buildVersion: env.bv,
1117
+ initialTheme: env.i,
1118
+ snapshot: env.sn,
1119
+ docKey: env.dk,
1120
+ prunedRecords: env.pr,
1121
+ navigationOnly: env.no,
1122
+ createdAt: env.c,
840
1123
  };
1124
+ if (isStale) return { entry, shouldRevalidate };
1125
+ const value: VercelShellMemoValue = {
1126
+ entry,
1127
+ tags: env.t,
1128
+ createdAt: env.c,
1129
+ staleAt: env.s,
1130
+ expiresAt: env.e,
1131
+ };
1132
+ // Counted: the envelope, plus the decoded prelude readShellDocument
1133
+ // attaches to the slot on first use.
1134
+ memo.set(
1135
+ storeKey,
1136
+ value,
1137
+ (typeof raw === "string" ? raw.length : JSON.stringify(env).length) +
1138
+ (env.p === undefined ? 0 : base64ByteLength(env.p)),
1139
+ this.memo.shellMs,
1140
+ this.memo.shellMaxBytes,
1141
+ );
1142
+ return { entry, shouldRevalidate, memoized: value };
1143
+ }
1144
+
1145
+ /**
1146
+ * The shell's tag-marker check, timed into `stats`. Markers a prefetch
1147
+ * already started (hinted tags) are awaited, not re-read; the check itself
1148
+ * always uses the entry's own tags.
1149
+ */
1150
+ private async shellTagsInvalidated(
1151
+ tags: string[] | undefined,
1152
+ createdAt: number,
1153
+ stats: ShellReadStats | undefined,
1154
+ prefetched: Map<string, Promise<number | null>> | undefined,
1155
+ outcomes: Map<string, MarkerMemoOutcome> | undefined,
1156
+ ): Promise<boolean> {
1157
+ if (stats) {
1158
+ stats.tags = tags?.length ?? 0;
1159
+ stats.markerSerial = true;
1160
+ }
1161
+ if (!tags || tags.length === 0) {
1162
+ if (stats) stats.markerMs = stats.markerWaitMs = 0;
1163
+ return false;
1164
+ }
1165
+ const startedAt = stats ? performance.now() : 0;
1166
+ const invalidated = await this.tagsInvalidatedSince(
1167
+ tags,
1168
+ createdAt,
1169
+ true,
1170
+ prefetched,
1171
+ outcomes,
1172
+ );
1173
+ if (stats) {
1174
+ stats.markerMs = stats.markerWaitMs = performance.now() - startedAt;
1175
+ recordMarkerRow(stats, tags, outcomes);
1176
+ }
1177
+ return invalidated;
1178
+ }
1179
+
1180
+ /**
1181
+ * Start the tm-marker reads of the tags this shell key carried last time
1182
+ * (and the route's static tags) alongside the entry read.
1183
+ */
1184
+ private prefetchShellMarkers(
1185
+ storeKey: string,
1186
+ routeTags: readonly string[] | undefined,
1187
+ stats: ShellReadStats | undefined,
1188
+ outcomes: Map<string, MarkerMemoOutcome> | undefined,
1189
+ ): Map<string, Promise<number | null>> | undefined {
1190
+ const tags = hintedTags(this.handleMemos.hints.get(storeKey), routeTags);
1191
+ if (tags.length === 0) return undefined;
1192
+ if (stats) {
1193
+ stats.markerHinted = tags;
1194
+ stats.markerHintStartedAt = performance.now();
1195
+ }
1196
+ const prefetched = new Map<string, Promise<number | null>>();
1197
+ for (const tag of tags) {
1198
+ const read = this.readTagMarker(tag, true, outcomes);
1199
+ read.catch(() => {});
1200
+ prefetched.set(tag, read);
1201
+ }
1202
+ return prefetched;
1203
+ }
1204
+
1205
+ /**
1206
+ * A tag's latest invalidatedAt (or null). With `isolateMemo` (PPR shell
1207
+ * reads only) through the per-process marker memo (isolate-tag-memo.ts):
1208
+ * fresh values are used as is, stale ones are used and refreshed in the
1209
+ * background, older ones block on the runtime-cache read. A request
1210
+ * carrying the fresh-reads cookie skips the memo. A failed read rejects and
1211
+ * is never memoized. The shell write gate, build shells and the data
1212
+ * write gates (isTagsInvalidatedSince) read the runtime cache each time
1213
+ * they ask; gates of a request asking while a read is in flight share it
1214
+ * (gateMarkerRead), and nothing is kept once it settles.
1215
+ */
1216
+ private async readTagMarker(
1217
+ tag: string,
1218
+ isolateMemo: boolean,
1219
+ outcomes?: Map<string, MarkerMemoOutcome>,
1220
+ ): Promise<number | null> {
1221
+ const key = this.toStoreKey(tag, "tm");
1222
+ const read = async (): Promise<MarkerFetch> => {
1223
+ const raw = await this.cache.get(key);
1224
+ const decoded =
1225
+ raw == null ? null : (this.decodeRaw(raw) as { at?: unknown } | null);
1226
+ const value =
1227
+ decoded && typeof decoded.at === "number" ? decoded.at : null;
1228
+ return { value, memoize: true };
1229
+ };
1230
+ if (!isolateMemo) return (await read()).value;
1231
+ return this.handleMemos.markers.readThrough(key, read, {
1232
+ freshMs: this.memo.markerFreshMs,
1233
+ maxStaleMs: this.memo.markerMaxStaleMs,
1234
+ bypass: freshReadsRequired(_getRequestContext()),
1235
+ keepAlive: this.waitUntil,
1236
+ onOutcome: outcomes ? (outcome) => outcomes.set(tag, outcome) : undefined,
1237
+ });
841
1238
  }
842
1239
 
843
1240
  async putShell(
@@ -847,6 +1244,9 @@ export class VercelCacheStore<
847
1244
  swrSeconds?: number,
848
1245
  tags?: string[],
849
1246
  ): Promise<"stored" | "invalidated" | void> {
1247
+ // This instance serves its own new capture from the next read on.
1248
+ this.handleMemos.shells.delete(this.toStoreKey(key, "h"));
1249
+ this.handleMemos.hints.remember(this.toStoreKey(key, "h"), tags);
850
1250
  try {
851
1251
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
852
1252
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
@@ -885,8 +1285,7 @@ export class VercelCacheStore<
885
1285
  i: entry.initialTheme,
886
1286
  sn: entry.snapshot,
887
1287
  dk: entry.docKey,
888
- lh: entry.handlerLiveHoles,
889
- tw: entry.transitionWhen,
1288
+ pr: entry.prunedRecords,
890
1289
  no: entry.navigationOnly,
891
1290
  };
892
1291
  // write() enforces the 2 MB per-item ceiling (withinSizeLimit): an
@@ -908,24 +1307,62 @@ export class VercelCacheStore<
908
1307
  // --- Tags ---
909
1308
 
910
1309
  /**
911
- * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince).
912
- * expireTag() keeps no queryable history, so invalidateTags() writes "tm"
913
- * markers for runtime capture races and immutable build shells. Marker >=
914
- * generation start wins; read errors fail open like the CF marker check.
1310
+ * SegmentCacheStore.isTagsInvalidatedSince. expireTag() keeps no queryable
1311
+ * history, so invalidateTags() writes "tm" markers for runtime capture
1312
+ * races, immutable build shells and data writes that started before an
1313
+ * invalidation (#977, tag-invalidation.ts predatesInvalidation). Marker >=
1314
+ * generation start wins. Read errors fail open like the CF marker check,
1315
+ * except for the write gate (`failClosed`), which counts them as an
1316
+ * invalidation. Each tag's marker is read through gateMarkerRead
1317
+ * (request-tag-mask.ts): gates asking while a read is in flight share it.
915
1318
  */
916
1319
  async isTagsInvalidatedSince(
917
1320
  tags: string[],
918
1321
  sinceMs: number,
1322
+ options?: { failClosed?: boolean },
919
1323
  ): Promise<boolean> {
1324
+ if (this.maskedForRequest(tags, sinceMs)) return true;
1325
+ const ctx = _getRequestContext();
1326
+ const reads = new Map(
1327
+ tags.map((tag) => [
1328
+ tag,
1329
+ gateMarkerRead(ctx, this, tag, sinceMs, () =>
1330
+ this.readTagMarker(tag, false),
1331
+ ),
1332
+ ]),
1333
+ );
1334
+ return this.tagsInvalidatedSince(
1335
+ tags,
1336
+ sinceMs,
1337
+ false,
1338
+ reads,
1339
+ undefined,
1340
+ options?.failClosed === true,
1341
+ );
1342
+ }
1343
+
1344
+ private async tagsInvalidatedSince(
1345
+ tags: string[],
1346
+ sinceMs: number,
1347
+ isolateMemo: boolean,
1348
+ prefetched?: Map<string, Promise<number | null>>,
1349
+ outcomes?: Map<string, MarkerMemoOutcome>,
1350
+ /** What a failed marker read answers: true for the write gate (#977). */
1351
+ failClosed = false,
1352
+ ): Promise<boolean> {
1353
+ if (this.maskedForRequest(tags, sinceMs)) return true;
920
1354
  try {
921
1355
  const markers = await Promise.all(
922
- tags.map((tag) => this.cache.get(this.toStoreKey(tag, "tm"))),
1356
+ tags.map(
1357
+ (tag) =>
1358
+ prefetched?.get(tag) ??
1359
+ this.readTagMarker(tag, isolateMemo, outcomes),
1360
+ ),
923
1361
  );
924
- for (const raw of markers) {
925
- if (raw == null) continue;
926
- const decoded = this.decodeRaw(raw) as { at?: unknown } | null;
927
- const at =
928
- decoded && typeof decoded.at === "number" ? decoded.at : null;
1362
+ // A read in flight when this request invalidated one of the tags
1363
+ // resolved to the marker before it.
1364
+ if (this.maskedForRequest(tags, sinceMs)) return true;
1365
+ for (const at of markers) {
929
1366
  if (at !== null && at >= sinceMs) return true;
930
1367
  }
931
1368
  return false;
@@ -935,12 +1372,18 @@ export class VercelCacheStore<
935
1372
  "cache-read",
936
1373
  "[VercelCacheStore] tag invalidation check",
937
1374
  );
938
- return false;
1375
+ return failClosed;
939
1376
  }
940
1377
  }
941
1378
 
942
1379
  async invalidateTags(tags: string[]): Promise<void> {
943
1380
  if (!tags || tags.length === 0) return;
1381
+ // Every memo hit reads the tag markers, so a read in flight that memoizes
1382
+ // one of these shells again is rejected on its next hit once the markers
1383
+ // below land (no in-flight record needed, unlike KV-less CFCacheStore).
1384
+ this.handleMemos.shells.deleteWhere((shell) =>
1385
+ shellHasAnyTag(shell.tags, tags),
1386
+ );
944
1387
  // No per-item cap here: an invalidation must reach every requested tag.
945
1388
  const safe = this.validateTags(
946
1389
  tags,
@@ -948,6 +1391,8 @@ export class VercelCacheStore<
948
1391
  "cache-invalidate",
949
1392
  );
950
1393
  if (safe.length === 0) return;
1394
+ const at = Date.now();
1395
+ this.maskTagsForRequest(safe, at);
951
1396
  // Marker writes FIRST, and strict: isTagsInvalidatedSince() is how an
952
1397
  // updateTag() reaches a build-time shell entry (expireTag cannot delete
953
1398
  // what lives in the build manifest), so a failed marker write must reject
@@ -955,13 +1400,14 @@ export class VercelCacheStore<
955
1400
  // the baked shell keeps serving.
956
1401
  await Promise.all(
957
1402
  safe.map((tag) =>
958
- this.cache.set(
959
- this.toStoreKey(tag, "tm"),
960
- JSON.stringify({ at: Date.now() }),
961
- { ttl: TAG_MARKER_TTL_SECONDS },
962
- ),
1403
+ this.cache.set(this.toStoreKey(tag, "tm"), JSON.stringify({ at }), {
1404
+ ttl: TAG_MARKER_TTL_SECONDS,
1405
+ }),
963
1406
  ),
964
1407
  );
1408
+ // Same process: later reads here see the invalidation at once.
1409
+ const { markers } = this.handleMemos;
1410
+ for (const tag of safe) markers.store(this.toStoreKey(tag, "tm"), at);
965
1411
  try {
966
1412
  await this.cache.expireTag(safe);
967
1413
  } catch (error) {
@@ -980,6 +1426,40 @@ export class VercelCacheStore<
980
1426
  }
981
1427
  }
982
1428
 
1429
+ /**
1430
+ * The request-local half of invalidateTags(), run before its first await
1431
+ * (#973): the request masks the tags (request-tag-mask.ts). The platform's
1432
+ * expireTag() and the tm marker writes are what other requests see, and
1433
+ * revalidateTag() does not wait for them.
1434
+ */
1435
+ private maskTagsForRequest(tags: string[], at: number): void {
1436
+ const ctx = _getRequestContext();
1437
+ if (ctx) maskRequestTags(ctx, this, tags, at);
1438
+ }
1439
+
1440
+ /**
1441
+ * Whether this request masked one of `tags` through this store at or after
1442
+ * `taggedAt`. An entry without a stamp counts as older than any mask.
1443
+ */
1444
+ private maskedForRequest(
1445
+ tags: string[] | undefined,
1446
+ taggedAt: number | undefined,
1447
+ ): boolean {
1448
+ return maskedForRequest(_getRequestContext(), this, tags, taggedAt ?? 0);
1449
+ }
1450
+
1451
+ /** A data read's mask check: reports the miss and returns true when masked. */
1452
+ private maskedMiss(
1453
+ op: "get" | "getItem" | "getResponse",
1454
+ key: string,
1455
+ env: { t?: string[]; ta?: number },
1456
+ readMs: number,
1457
+ ): boolean {
1458
+ if (!this.maskedForRequest(env.t, env.ta)) return false;
1459
+ this.emitDebug({ op, key, outcome: "miss", readMs });
1460
+ return true;
1461
+ }
1462
+
983
1463
  // --- Internals ---
984
1464
 
985
1465
  // The `rg:{family}:` prefix keeps the three value tiers in disjoint
@@ -1188,7 +1668,7 @@ export class VercelCacheStore<
1188
1668
 
1189
1669
  private asSegmentEnvelope(raw: unknown): VercelSegmentEnvelope | null {
1190
1670
  if (!isRecord(raw)) return null;
1191
- const { d, s, e } = raw;
1671
+ const { d, s, e, ta } = raw;
1192
1672
  if (
1193
1673
  !isRecord(d) ||
1194
1674
  !Array.isArray((d as Record<string, unknown>).segments)
@@ -1196,12 +1676,17 @@ export class VercelCacheStore<
1196
1676
  return null;
1197
1677
  }
1198
1678
  if (typeof s !== "number" || typeof e !== "number") return null;
1199
- return { d: d as unknown as CachedEntryData, s, e };
1679
+ return {
1680
+ d: d as unknown as CachedEntryData,
1681
+ s,
1682
+ e,
1683
+ ta: typeof ta === "number" ? ta : undefined,
1684
+ };
1200
1685
  }
1201
1686
 
1202
1687
  private asItemEnvelope(raw: unknown): VercelItemEnvelope | null {
1203
1688
  if (!isRecord(raw)) return null;
1204
- const { v, h, s, e, t } = raw;
1689
+ const { v, h, s, e, t, ta } = raw;
1205
1690
  if (typeof v !== "string") return null;
1206
1691
  if (typeof s !== "number" || typeof e !== "number") return null;
1207
1692
  return {
@@ -1209,14 +1694,15 @@ export class VercelCacheStore<
1209
1694
  h: typeof h === "string" ? h : undefined,
1210
1695
  s,
1211
1696
  e,
1697
+ ta: typeof ta === "number" ? ta : undefined,
1212
1698
  t: Array.isArray(t) ? (t as string[]) : undefined,
1213
1699
  };
1214
1700
  }
1215
1701
 
1216
1702
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1217
1703
  if (!isRecord(raw)) return null;
1218
- const { p, po, rv, bv, c, s, e, t, i, sn, dk, lh, tw, no } = raw;
1219
- if (typeof rv !== "string") return null;
1704
+ const { p, po, rv, bv, c, s, e, t, i, sn, dk, pr, no } = raw;
1705
+ if (typeof rv !== "string" || typeof bv !== "string") return null;
1220
1706
  // Document half: required unless navigationOnly (`no`), which stores
1221
1707
  // neither field. Tolerate a legacy navigationOnly envelope that still
1222
1708
  // carries them.
@@ -1232,23 +1718,23 @@ export class VercelCacheStore<
1232
1718
  ...(typeof p === "string" ? { p } : {}),
1233
1719
  ...(po !== undefined ? { po: po as string | null } : {}),
1234
1720
  rv,
1235
- bv: typeof bv === "string" ? bv : undefined,
1721
+ bv,
1236
1722
  c,
1237
1723
  s,
1238
1724
  e,
1239
1725
  t: Array.isArray(t) ? (t as string[]) : undefined,
1240
1726
  i: typeof i === "string" ? i : undefined,
1241
- sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : undefined,
1727
+ // A non-array field reads as no records.
1728
+ sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : [],
1242
1729
  dk: typeof dk === "string" ? dk : undefined,
1243
- lh: lh === true ? true : undefined,
1244
- tw: tw === true ? true : undefined,
1730
+ pr: typeof pr === "string" ? pr : undefined,
1245
1731
  no: no === true ? true : undefined,
1246
1732
  };
1247
1733
  }
1248
1734
 
1249
1735
  private asResponseEnvelope(raw: unknown): VercelResponseEnvelope | null {
1250
1736
  if (!isRecord(raw)) return null;
1251
- const { b, st, hd, s, e, t } = raw;
1737
+ const { b, st, hd, s, e, t, ta } = raw;
1252
1738
  if (typeof b !== "string" || typeof st !== "number") return null;
1253
1739
  if (!Array.isArray(hd)) return null;
1254
1740
  if (typeof s !== "number" || typeof e !== "number") return null;
@@ -1259,6 +1745,7 @@ export class VercelCacheStore<
1259
1745
  s,
1260
1746
  e,
1261
1747
  t: Array.isArray(t) ? (t as string[]) : undefined,
1748
+ ta: typeof ta === "number" ? ta : undefined,
1262
1749
  };
1263
1750
  }
1264
1751
  }