@rangojs/router 0.18.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 (244) hide show
  1. package/dist/types/browser/event-controller.d.ts +3 -3
  2. package/dist/types/browser/history-state.d.ts +2 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  4. package/dist/types/browser/partial-update.d.ts +16 -1
  5. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  6. package/dist/types/browser/react/Link.d.ts +19 -9
  7. package/dist/types/browser/react/context.d.ts +2 -2
  8. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  9. package/dist/types/browser/react/location-state.d.ts +1 -1
  10. package/dist/types/browser/transition-when.d.ts +80 -0
  11. package/dist/types/browser/types.d.ts +104 -9
  12. package/dist/types/build/generate-manifest.d.ts +8 -0
  13. package/dist/types/cache/background-task.d.ts +24 -2
  14. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +44 -13
  16. package/dist/types/cache/cache-policy.d.ts +1 -1
  17. package/dist/types/cache/cache-runtime.d.ts +7 -0
  18. package/dist/types/cache/cache-scope.d.ts +117 -35
  19. package/dist/types/cache/cache-tag.d.ts +24 -4
  20. package/dist/types/cache/cf/cf-cache-store.d.ts +55 -44
  21. package/dist/types/cache/cf/cf-shell-frame.d.ts +1 -1
  22. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +0 -15
  23. package/dist/types/cache/document-cache.d.ts +10 -0
  24. package/dist/types/cache/handle-snapshot.d.ts +40 -8
  25. package/dist/types/cache/invalidation-order.d.ts +23 -0
  26. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +88 -141
  28. package/dist/types/cache/tag-invalidation.d.ts +38 -14
  29. package/dist/types/cache/types.d.ts +68 -61
  30. package/dist/types/cache/vercel/vercel-cache-store.d.ts +23 -16
  31. package/dist/types/client-urls/navigation.d.ts +28 -2
  32. package/dist/types/client-urls/server-projection.d.ts +5 -2
  33. package/dist/types/client-urls/types.d.ts +5 -5
  34. package/dist/types/client.d.ts +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.rsc.d.ts +1 -1
  37. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  38. package/dist/types/route-name.d.ts +7 -0
  39. package/dist/types/router/error-handling.d.ts +1 -1
  40. package/dist/types/router/intercept-resolution.d.ts +2 -0
  41. package/dist/types/router/match-context.d.ts +5 -0
  42. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  43. package/dist/types/router/router-options.d.ts +4 -1
  44. package/dist/types/router/segment-resolution/loader-cache.d.ts +18 -1
  45. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  46. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +14 -40
  47. package/dist/types/router/segment-resolution/mask-nested.d.ts +11 -3
  48. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  49. package/dist/types/router/transition-when.d.ts +24 -10
  50. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  51. package/dist/types/rsc/full-payload.d.ts +14 -7
  52. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  53. package/dist/types/rsc/shell-build-manifest.d.ts +4 -3
  54. package/dist/types/rsc/shell-capture-constants.d.ts +77 -20
  55. package/dist/types/rsc/shell-capture.d.ts +78 -81
  56. package/dist/types/rsc/shell-serve.d.ts +19 -48
  57. package/dist/types/rsc/types.d.ts +2 -0
  58. package/dist/types/segment-system.d.ts +7 -0
  59. package/dist/types/server/context.d.ts +89 -61
  60. package/dist/types/server/cookie-store.d.ts +30 -18
  61. package/dist/types/server/handle-store.d.ts +52 -1
  62. package/dist/types/server/request-context.d.ts +66 -45
  63. package/dist/types/server/request-headers.d.ts +44 -0
  64. package/dist/types/server.d.ts +1 -0
  65. package/dist/types/testing/e2e/index.d.ts +1 -1
  66. package/dist/types/testing/index.d.ts +3 -2
  67. package/dist/types/testing/internal/action-id.d.ts +7 -0
  68. package/dist/types/testing/location-state-key.d.ts +24 -0
  69. package/dist/types/testing/render-route.d.ts +17 -1
  70. package/dist/types/testing/run-transition-when.d.ts +44 -69
  71. package/dist/types/testing/serve-shell-request.d.ts +7 -6
  72. package/dist/types/testing/shell-status.d.ts +34 -22
  73. package/dist/types/transition-when-ref.d.ts +79 -0
  74. package/dist/types/types/cache-types.d.ts +15 -3
  75. package/dist/types/types/index.d.ts +1 -1
  76. package/dist/types/types/segments.d.ts +86 -71
  77. package/dist/types/urls/path-helper-types.d.ts +3 -4
  78. package/dist/types/urls/pattern-types.d.ts +3 -4
  79. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  80. package/dist/vite/index.js +960 -97
  81. package/package.json +1 -1
  82. package/skills/cache-guide/SKILL.md +76 -49
  83. package/skills/caching/SKILL.md +167 -69
  84. package/skills/client-urls/SKILL.md +1 -1
  85. package/skills/comparison/references/framework-comparison.md +8 -5
  86. package/skills/document-cache/SKILL.md +24 -0
  87. package/skills/hooks/SKILL.md +3 -1
  88. package/skills/hooks/navigation.md +23 -2
  89. package/skills/hooks/state.md +98 -6
  90. package/skills/intercept/SKILL.md +29 -12
  91. package/skills/loader/SKILL.md +52 -21
  92. package/skills/middleware/SKILL.md +18 -0
  93. package/skills/migrate-nextjs/SKILL.md +3 -3
  94. package/skills/observability/SKILL.md +6 -5
  95. package/skills/parallel/SKILL.md +1 -1
  96. package/skills/ppr/SKILL.md +129 -77
  97. package/skills/prerender/SKILL.md +3 -3
  98. package/skills/rango/SKILL.md +25 -6
  99. package/skills/route/SKILL.md +2 -0
  100. package/skills/testing/SKILL.md +27 -25
  101. package/skills/testing/cache-prerender.md +9 -9
  102. package/skills/testing/client-components.md +45 -3
  103. package/skills/testing/loader.md +3 -2
  104. package/skills/testing/middleware.md +1 -0
  105. package/skills/testing/predicates.md +49 -49
  106. package/skills/testing/render-handler.md +1 -0
  107. package/skills/testing/serve-shell-request.md +14 -0
  108. package/skills/testing/server-actions.md +9 -3
  109. package/skills/theme/SKILL.md +8 -4
  110. package/skills/typesafety/env-and-bindings.md +8 -0
  111. package/skills/use-cache/SKILL.md +64 -19
  112. package/skills/view-transitions/SKILL.md +150 -29
  113. package/src/__augment-tests__/augmented.check.ts +23 -0
  114. package/src/browser/event-controller.ts +4 -4
  115. package/src/browser/history-state.ts +52 -1
  116. package/src/browser/link-interceptor.ts +4 -0
  117. package/src/browser/navigation-bridge.ts +77 -18
  118. package/src/browser/navigation-client.ts +4 -3
  119. package/src/browser/navigation-store.ts +43 -1
  120. package/src/browser/navigation-transaction.ts +31 -11
  121. package/src/browser/partial-update.ts +146 -35
  122. package/src/browser/prefetch/cache.ts +6 -6
  123. package/src/browser/prefetch/fetch.ts +2 -2
  124. package/src/browser/react/Link.tsx +38 -33
  125. package/src/browser/react/NavigationProvider.tsx +2 -2
  126. package/src/browser/react/context.ts +2 -2
  127. package/src/browser/react/location-state-shared.ts +241 -49
  128. package/src/browser/react/location-state.ts +5 -2
  129. package/src/browser/rsc-router.tsx +4 -0
  130. package/src/browser/server-action-bridge.ts +84 -15
  131. package/src/browser/transition-when.ts +205 -0
  132. package/src/browser/types.ts +124 -10
  133. package/src/build/generate-manifest.ts +77 -1
  134. package/src/cache/background-task.ts +55 -2
  135. package/src/cache/cache-exec-scope.ts +48 -1
  136. package/src/cache/cache-key-utils.ts +56 -16
  137. package/src/cache/cache-policy.ts +9 -4
  138. package/src/cache/cache-runtime.ts +197 -144
  139. package/src/cache/cache-scope.ts +304 -112
  140. package/src/cache/cache-tag.ts +105 -13
  141. package/src/cache/cf/cf-cache-store.ts +152 -76
  142. package/src/cache/cf/cf-shell-frame.ts +2 -2
  143. package/src/cache/cf/cf-tag-marker-memo.ts +11 -100
  144. package/src/cache/document-cache.ts +53 -7
  145. package/src/cache/handle-snapshot.ts +52 -9
  146. package/src/cache/invalidation-order.ts +84 -0
  147. package/src/cache/request-tag-mask.ts +147 -0
  148. package/src/cache/shell-snapshot.ts +130 -348
  149. package/src/cache/tag-invalidation.ts +57 -51
  150. package/src/cache/types.ts +71 -67
  151. package/src/cache/vercel/vercel-cache-store.ts +61 -58
  152. package/src/client-urls/client-root.tsx +8 -4
  153. package/src/client-urls/client-urls.ts +8 -6
  154. package/src/client-urls/navigation.ts +55 -4
  155. package/src/client-urls/server-projection.ts +31 -10
  156. package/src/client-urls/types.ts +12 -5
  157. package/src/client.tsx +2 -0
  158. package/src/host/cookie-handler.ts +2 -1
  159. package/src/index.rsc.ts +3 -0
  160. package/src/index.ts +3 -0
  161. package/src/loader-redirect.tsx +7 -3
  162. package/src/prerender/build-shell-capture.ts +155 -146
  163. package/src/route-definition/dsl-helpers.ts +2 -0
  164. package/src/route-definition/helpers-types.ts +11 -11
  165. package/src/route-name.ts +12 -0
  166. package/src/router/content-negotiation.ts +4 -1
  167. package/src/router/error-handling.ts +5 -2
  168. package/src/router/handler-context.ts +10 -7
  169. package/src/router/intercept-resolution.ts +11 -1
  170. package/src/router/loader-resolution.ts +28 -3
  171. package/src/router/logging.ts +5 -3
  172. package/src/router/match-api.ts +62 -34
  173. package/src/router/match-context.ts +5 -0
  174. package/src/router/match-handlers.ts +9 -29
  175. package/src/router/match-middleware/background-revalidation.ts +3 -5
  176. package/src/router/match-middleware/cache-lookup.ts +35 -19
  177. package/src/router/match-middleware/cache-store.ts +15 -9
  178. package/src/router/match-result.ts +1 -0
  179. package/src/router/middleware.ts +2 -5
  180. package/src/router/navigation-snapshot.ts +6 -5
  181. package/src/router/prerender-match.ts +4 -0
  182. package/src/router/request-classification.ts +7 -4
  183. package/src/router/router-options.ts +4 -1
  184. package/src/router/segment-resolution/fresh.ts +0 -4
  185. package/src/router/segment-resolution/loader-cache.ts +150 -25
  186. package/src/router/segment-resolution/loader-mask.ts +3 -4
  187. package/src/router/segment-resolution/loader-snapshot.ts +15 -78
  188. package/src/router/segment-resolution/mask-nested.ts +47 -22
  189. package/src/router/segment-resolution/revalidation.ts +0 -3
  190. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  191. package/src/router/telemetry.ts +6 -3
  192. package/src/router/transition-when.ts +34 -52
  193. package/src/router.ts +2 -1
  194. package/src/rsc/attach-transition-when.ts +47 -0
  195. package/src/rsc/full-payload.ts +30 -10
  196. package/src/rsc/handler.ts +12 -10
  197. package/src/rsc/loader-fetch.ts +2 -1
  198. package/src/rsc/manifest-init.ts +4 -0
  199. package/src/rsc/origin-guard.ts +8 -5
  200. package/src/rsc/progressive-enhancement.ts +7 -30
  201. package/src/rsc/response-cache-serve.ts +68 -80
  202. package/src/rsc/response-route-handler.ts +2 -2
  203. package/src/rsc/rsc-rendering.ts +206 -252
  204. package/src/rsc/server-action.ts +8 -20
  205. package/src/rsc/shell-build-manifest.ts +22 -20
  206. package/src/rsc/shell-capture-constants.ts +136 -20
  207. package/src/rsc/shell-capture.ts +352 -589
  208. package/src/rsc/shell-serve.ts +73 -109
  209. package/src/rsc/ssr-setup.ts +3 -2
  210. package/src/rsc/types.ts +2 -0
  211. package/src/segment-system.tsx +40 -7
  212. package/src/server/context.ts +189 -124
  213. package/src/server/cookie-store.ts +153 -127
  214. package/src/server/handle-store.ts +245 -27
  215. package/src/server/request-context.ts +123 -84
  216. package/src/server/request-headers.ts +134 -0
  217. package/src/server.ts +4 -0
  218. package/src/testing/dispatch.ts +4 -7
  219. package/src/testing/e2e/index.ts +1 -0
  220. package/src/testing/index.ts +5 -0
  221. package/src/testing/internal/action-id.ts +25 -0
  222. package/src/testing/internal/context.ts +1 -1
  223. package/src/testing/location-state-key.ts +45 -0
  224. package/src/testing/render-handler.ts +1 -1
  225. package/src/testing/render-route.tsx +109 -16
  226. package/src/testing/run-client-revalidate.ts +3 -19
  227. package/src/testing/run-loader.ts +5 -1
  228. package/src/testing/run-transition-when.ts +109 -172
  229. package/src/testing/serve-shell-request.ts +10 -7
  230. package/src/testing/shell-status.ts +60 -56
  231. package/src/transition-when-ref.ts +220 -0
  232. package/src/types/cache-types.ts +15 -3
  233. package/src/types/index.ts +3 -0
  234. package/src/types/segments.ts +94 -91
  235. package/src/urls/path-helper-types.ts +3 -4
  236. package/src/urls/pattern-types.ts +3 -4
  237. package/src/vite/discovery/discover-routers.ts +3 -0
  238. package/src/vite/discovery/shell-prerender-phase.ts +0 -2
  239. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  240. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  241. package/src/vite/rango.ts +2 -0
  242. package/src/vite/router-discovery.ts +75 -5
  243. package/dist/types/rsc/transition-gate.d.ts +0 -23
  244. package/src/rsc/transition-gate.ts +0 -86
@@ -30,22 +30,27 @@
30
30
  * Invariant: the mask only turns the invalidating request's hits into misses.
31
31
  * If the durable write then fails, that request paid extra misses, never a
32
32
  * stale read, and other requests read the durable state. Both also record
33
- * the tags on the request (markInvalidated), so a "use cache" execution or
34
- * stale refresh this request started before one of its own tags was
35
- * invalidated is neither joined nor written (cache-runtime.ts). Its own tags
36
- * only: a nested "use cache" function's tags do not reach the enclosing
37
- * entry (#980), for this gate as for eviction. Not covered (#977): such an
38
- * execution in another request, or a loader cache() write, still lands, and
39
- * the store accepts it (docs/design/caching.md "Read-your-own-writes").
33
+ * the tags in the isolate's invalidation order (markInvalidated), so a cache
34
+ * execution that started before one of its tags was invalidated, in any
35
+ * request, is neither joined nor written (#973, #977): see
36
+ * predatesInvalidation, and docs/design/caching.md "Read-your-own-writes".
40
37
  */
41
38
 
42
- import {
43
- _getRequestContext,
44
- type RequestContext,
45
- } from "../server/request-context.js";
39
+ import { _getRequestContext } from "../server/request-context.js";
46
40
  import { reportingAsync } from "./cache-error.js";
47
41
  import { normalizeTags } from "./cache-tag.js";
48
42
  import type { SegmentCacheStore } from "./types.js";
43
+ import {
44
+ invalidatedSince,
45
+ markInvalidated,
46
+ type ExecutionStart,
47
+ } from "./invalidation-order.js";
48
+
49
+ export {
50
+ executionStart,
51
+ invalidatedSince,
52
+ type ExecutionStart,
53
+ } from "./invalidation-order.js";
49
54
 
50
55
  /**
51
56
  * Collect every store that may hold entries tagged for this request's handler:
@@ -139,48 +144,49 @@ function markFreshReads(stores: SegmentCacheStore[]): void {
139
144
  }
140
145
 
141
146
  /**
142
- * The isolate's order of tag invalidations and "use cache" execution starts
143
- * (#973). A counter, not Date.now(): Workers advance the clock only on I/O,
144
- * so an execution started right after an invalidation would share its
145
- * millisecond and read as older.
146
- */
147
- let cacheSeq = 0;
148
-
149
- /** The current position in that order; an execution records it at start. */
150
- export function currentCacheSeq(): number {
151
- return cacheSeq;
152
- }
153
-
154
- /**
155
- * Whether the request invalidated any of `tags` after `since` (a
156
- * currentCacheSeq() value an execution recorded when it started): its value
157
- * may predate the invalidation, so the request neither joins nor writes it.
147
+ * The write gate of every cache execution (#977): whether one of `tags` was
148
+ * invalidated after the execution that produced the value started.
149
+ *
150
+ * Every built-in store stamps an entry when it is written (CFCacheStore's
151
+ * `taggedAt`, VercelCacheStore's `ta`) or checks nothing on write
152
+ * (MemorySegmentCacheStore), so a value read before an invalidation and
153
+ * written after it would be served as newer than the invalidation until it
154
+ * expires. Two answers, either one skips the write:
155
+ *
156
+ * - this isolate's order (invalidatedSince), which sees every request's
157
+ * updateTag()/revalidateTag() here, the calling one included;
158
+ * - the store's markers (isTagsInvalidatedSince), for another isolate's
159
+ * invalidation, as far as the store can tell: KV markers on CFCacheStore
160
+ * (KV-less, only this request's own), `tm` markers on VercelCacheStore,
161
+ * the process markers on MemorySegmentCacheStore. Asked about the
162
+ * milliseconds after the start's: this isolate's own invalidation that
163
+ * preceded the start usually shares its millisecond (Workers freeze
164
+ * Date.now() between I/O), and `>=` would skip every write started right
165
+ * after it. Another isolate's invalidation in the start's own millisecond
166
+ * is not caught. Asked fail-closed: a marker the store cannot read
167
+ * counts as an invalidation.
168
+ *
169
+ * The order is read again once the store answers: its read can be in flight
170
+ * while this isolate invalidates (an action awaits a "use cache" miss, whose
171
+ * write waits on the read, then runs updateTag()), and it then answers with
172
+ * the markers from before. markInvalidated is synchronous, so this second
173
+ * look sees every invalidation made before the answer.
174
+ *
175
+ * Invariant: a skipped write only costs a later miss, never a stale read.
176
+ * The caller still returns the value it computed.
158
177
  */
159
- export function invalidatedSince(
160
- ctx: RequestContext<any, any> | undefined,
178
+ export async function predatesInvalidation(
179
+ store: SegmentCacheStore,
161
180
  tags: readonly string[] | undefined,
162
- since: number,
163
- ): boolean {
164
- const record = (ctx?._requestRoot ?? ctx)?._tagInvalidations;
165
- if (!record || !tags) return false;
166
- for (const tag of tags) {
167
- const at = record.get(tag);
168
- if (at !== undefined && at > since) return true;
169
- }
170
- return false;
171
- }
172
-
173
- /**
174
- * Record the invalidation on the request (RequestContext._tagInvalidations,
175
- * on the root so derived contexts share it), before any store sees it.
176
- */
177
- function markInvalidated(tags: readonly string[]): void {
178
- const ctx = _getRequestContext();
179
- if (!ctx) return;
180
- const root = ctx._requestRoot ?? ctx;
181
- const at = ++cacheSeq;
182
- const record = (root._tagInvalidations ??= new Map());
183
- for (const tag of tags) record.set(tag, at);
181
+ start: ExecutionStart,
182
+ ): Promise<boolean> {
183
+ if (!tags || tags.length === 0) return false;
184
+ if (invalidatedSince(tags, start.seq)) return true;
185
+ if (!store.isTagsInvalidatedSince) return false;
186
+ const marked = await store.isTagsInvalidatedSince([...tags], start.at + 1, {
187
+ failClosed: true,
188
+ });
189
+ return marked || invalidatedSince(tags, start.seq);
184
190
  }
185
191
 
186
192
  async function invalidateAcross(
@@ -75,6 +75,12 @@ export interface SegmentCacheStore<TEnv = unknown> {
75
75
  * 2. Store-level `keyGenerator` (modifies default key)
76
76
  * 3. Default key generation (prefix:pathname:params)
77
77
  *
78
+ * Return `defaultKey` unchanged to leave a request unpartitioned. An empty
79
+ * string is not a key: where the result partitions a nested cache() on
80
+ * another store, or a ppr shell, it fails key resolution, so the request
81
+ * renders uncached (as when the keyGenerator throws), and the router warns
82
+ * once naming the store.
83
+ *
78
84
  * @example Using headers for cache segmentation
79
85
  * ```typescript
80
86
  * keyGenerator: (ctx, defaultKey) => {
@@ -327,12 +333,27 @@ export interface SegmentCacheStore<TEnv = unknown> {
327
333
  * freshness). Consulted by build-shell read-through and runtime shell stores:
328
334
  * "was it evicted" is answered by tag markers against the entry's createdAt,
329
335
  * including when invalidation races a capture that has not been written yet.
336
+ * Also the store's half of every cache write's gate (#977,
337
+ * tag-invalidation.ts predatesInvalidation): a "use cache", loader cache(),
338
+ * route cache() or document-cache write whose execution started before an
339
+ * invalidation another isolate made is skipped when this answers true, so
340
+ * it must reflect invalidations made after this request started reading
341
+ * (not a value memoized earlier in the request).
330
342
  * Optional: without it, TAGGED build entries are not served (untagged ones
331
- * are unaffected — they are evictable only by deploy/buildVersion anyway).
343
+ * are unaffected — they are evictable only by deploy/buildVersion anyway),
344
+ * and writes are gated by this isolate's invalidations only.
332
345
  * Fail open to `false` on marker-read errors: a transient store fault must
333
346
  * degrade to "still valid", the same posture as the envelope tag checks.
334
- */
335
- isTagsInvalidatedSince?(tags: string[], sinceMs: number): Promise<boolean>;
347
+ * The write gate passes `{ failClosed: true }` instead: a marker it cannot
348
+ * read (an error, a timed-out read) answers `true`, since a skipped write
349
+ * only costs a later miss. A store that ignores the option still works;
350
+ * its fault then lets that write through.
351
+ */
352
+ isTagsInvalidatedSince?(
353
+ tags: string[],
354
+ sinceMs: number,
355
+ options?: { failClosed?: boolean },
356
+ ): Promise<boolean>;
336
357
  }
337
358
 
338
359
  /**
@@ -399,11 +420,10 @@ export interface ShellCacheEntry {
399
420
  * on dev RSC-module edits). The second read-time gate: a persistent shared
400
421
  * store (KV/runtime-cache) survives deploys, and an app-code change that
401
422
  * keeps the same React version would otherwise leave a stale-build
402
- * prelude+postponed live under the same key. Optional only for entries
403
- * stored before the field existed — those are treated as a miss and the
404
- * recapture re-stamps them (pre-release, no compat shim).
423
+ * prelude+postponed live under the same key. A custom store returns it as
424
+ * putShell received it.
405
425
  */
406
- buildVersion?: string;
426
+ buildVersion: string;
407
427
  /**
408
428
  * The initialTheme the CAPTURE render was built with: the no-cookie default
409
429
  * (payloadInitialTheme, rsc/full-payload.ts), never the capturing visitor's
@@ -418,22 +438,20 @@ export interface ShellCacheEntry {
418
438
  */
419
439
  initialTheme?: string;
420
440
  /**
421
- * The CAPTURE DATA SNAPSHOT: every cache-store read-hit and write the capture
422
- * render performed, in stored/serialized form. Replaying these on a HIT (via
423
- * the SeededShellStore overlay, for the tail render only) reproduces the
424
- * shell's cached content byte-identically, so the freshly rendered hydration
425
- * payload matches the frozen prelude even after the underlying cache entries
426
- * have drifted (expired, been recomputed, or been tag-invalidated).
427
- *
428
- * Optional: an entry captured before this field existed simply has no
429
- * snapshot and keeps the pre-snapshot behavior (the tail reads live, so any
430
- * shell-baked cached value that drifted mismatches the prelude). Recapture
431
- * heals it. See docs/design/ppr-shell-resume.md ("the capture data snapshot").
441
+ * The CAPTURE DATA SNAPSHOT, in stored/serialized form: the doc segment
442
+ * record every HIT replays the handler layer from (`docKey`) and the
443
+ * bake-lane loader pins (pruned to what a HIT reads, issue #941). Replaying
444
+ * them on a HIT keeps the freshly rendered hydration payload equal to the
445
+ * frozen prelude after the underlying cache entries have drifted; every
446
+ * other read on a HIT, a hole's included, reads the store. Empty when the capture
447
+ * recorded nothing (a prerender-served capture without bake-lane loaders,
448
+ * a tombstone). A custom store returns it as putShell received it. See
449
+ * docs/design/ppr-shell-resume.md ("the capture data snapshot").
432
450
  */
433
- snapshot?: ShellSnapshotRecord[];
451
+ snapshot: ShellSnapshotRecord[];
434
452
  /**
435
453
  * Records the capture dropped from `snapshot` because no reader of this
436
- * entry consumes them, by family (`item:4/response:1`). Diagnostic only: the
454
+ * entry consumes them, by family (`loader:1`). Diagnostic only: the
437
455
  * HIT tail timing reports it next to the kept records. See
438
456
  * docs/design/shell-entry-layout.md ("Record only what a HIT reads").
439
457
  */
@@ -459,6 +477,13 @@ export interface ShellCacheEntry {
459
477
  createdAt: number;
460
478
  }
461
479
 
480
+ /**
481
+ * @internal A shell entry without its prelude and snapshot: what a
482
+ * prelude-first read (SegmentCacheStore.readShellDocument) resolves with
483
+ * before the snapshot bytes arrive.
484
+ */
485
+ export type ShellEntryHead = Omit<ShellCacheEntry, "prelude" | "snapshot">;
486
+
462
487
  /**
463
488
  * A shell entry whose document half is present — what the document HIT path
464
489
  * (serveShellHit / lookupBuildShell) consumes. hasIntactShellPayload
@@ -493,7 +518,7 @@ export type ShellSnapshotFailure = "unavailable" | "corrupt";
493
518
  */
494
519
  export interface ShellDocumentRead {
495
520
  /** The entry without its prelude and snapshot (delivered separately). */
496
- entry: ShellCacheEntry;
521
+ entry: ShellEntryHead;
497
522
  /** Raw prelude bytes (empty for a navigationOnly entry). */
498
523
  prelude: Uint8Array;
499
524
  shouldRevalidate?: boolean;
@@ -580,14 +605,13 @@ export interface ShellReadStats {
580
605
  }
581
606
 
582
607
  /**
583
- * The families a shell snapshot pins. The item/segment/response families are
584
- * cache-store reads/writes (recorded by RecordingShellStore); the loader family
585
- * pins the settled CONTAINER of a bake-lane loader (lane rule: see
586
- * resolveLoaderData, loader-cache.ts). Excludes the shell family itself
587
- * (getShell/putShell) — the snapshot rides INSIDE a shell entry, so recording
588
- * it would be self-referential.
608
+ * The families a shell snapshot pins: the doc segment record (recorded by
609
+ * RecordingShellStore) and the settled CONTAINER of each bake-lane loader
610
+ * (lane rule: see resolveLoaderData, loader-cache.ts). No cache read is
611
+ * pinned: a HIT's holes, and a bake-lane loader body that runs on the HIT,
612
+ * read the store.
589
613
  */
590
- export type ShellSnapshotFamily = "item" | "segment" | "response" | "loader";
614
+ export type ShellSnapshotFamily = "segment" | "loader";
591
615
 
592
616
  /**
593
617
  * The stored form of a loader-family snapshot value: the bake-lane loader's
@@ -604,57 +628,35 @@ export interface ShellSnapshotLoaderValue {
604
628
  * container carries hole markers, so a HIT must gate the overlay on the
605
629
  * fresh run (only the loader body can mint the live nested promises);
606
630
  * 0 = fully pinned, so a HIT resolves the payload promise immediately from
607
- * the pin and does not run the loader body (unless `runs`). Absent on
608
- * pre-bit snapshots, which keep the gated path.
631
+ * the pin and does not run the loader body (unless `runs`).
609
632
  */
610
- holes?: 0 | 1;
633
+ holes: 0 | 1;
611
634
  /**
612
635
  * 1 when the capture saw a loader push it could not record (a deferred
613
636
  * push, one with masked nested promises, or one made outside any loader
614
637
  * body): a HIT then still runs the loader body in the background
615
638
  * (pin-first) so those pushes reach the page. 0: a hole-free record is
616
- * served from the pin alone and its body does not run on a HIT. Absent (a
617
- * snapshot written before the bit, which lacks the loader-owned pushes)
618
- * reads as 1, so the body supplies them.
639
+ * served from the pin alone and its body does not run on a HIT.
640
+ *
641
+ * Both bits are always written. A record stored before they existed (v0.17)
642
+ * reads each missing bit as 1 (buildShellLoaderSeed): its snapshot lacks
643
+ * the loader-owned pushes, so the body supplies them.
619
644
  */
620
- runs?: 0 | 1;
621
- }
622
-
623
- /** A serialized cached Response for the response family of a shell snapshot. */
624
- export interface ShellSnapshotResponseValue {
625
- status: number;
626
- /** Client-facing header pairs (per-client signal headers excluded at record). */
627
- headers: [string, string][];
628
- /** base64-encoded response body (binary-safe, JSON-serializable). */
629
- body: string;
630
- }
631
-
632
- /** The stored form of an item-family (use cache / loader cache) snapshot value. */
633
- export interface ShellSnapshotItemValue {
634
- /** RSC-serialized return value. */
635
- value: string;
636
- /** RSC-encoded handle data, if any. */
637
- handles?: string;
638
- /** The entry's cache tags. */
639
- tags?: string[];
645
+ runs: 0 | 1;
640
646
  }
641
647
 
642
648
  /**
643
- * One recorded cache-store read-hit or write from the capture render. `value`
644
- * carries the entry in its stored/serialized shape so it round-trips through a
645
- * JSON-serializing store (KV, CF, Vercel) with the rest of the ShellCacheEntry:
646
- * - `item` -> {@link ShellSnapshotItemValue}
649
+ * One snapshot record: the doc segment record or a bake-lane loader pin.
650
+ * `value` carries it in its stored/serialized shape so it round-trips
651
+ * through a JSON-serializing store (KV, CF, Vercel) with the rest of the
652
+ * ShellCacheEntry:
647
653
  * - `segment` -> {@link CachedEntryData} (already JSON-able)
648
- * - `response`-> {@link ShellSnapshotResponseValue}
654
+ * - `loader` -> {@link ShellSnapshotLoaderValue}
649
655
  */
650
656
  export interface ShellSnapshotRecord {
651
657
  family: ShellSnapshotFamily;
652
658
  key: string;
653
- value:
654
- | ShellSnapshotItemValue
655
- | CachedEntryData
656
- | ShellSnapshotResponseValue
657
- | ShellSnapshotLoaderValue;
659
+ value: CachedEntryData | ShellSnapshotLoaderValue;
658
660
  }
659
661
 
660
662
  /**
@@ -759,8 +761,10 @@ export type SegmentHandleData = Record<string, unknown[]>;
759
761
  * index (null: not owned). Only a PPR shell capture writes owners: the
760
762
  * settled pushes of the loader bodies it ran (an `ssr: false` loader's own,
761
763
  * the loaders it awaits, and its own cache() replays), which the record keeps
762
- * because the prelude rendered them. restoreHandles replays an owned value
763
- * through HandleStore.pushReplayed, so a loader that does run on the HIT
764
- * replaces it with its live push.
764
+ * because the prelude rendered them. A document HIT restores a bake-lane
765
+ * owner's value through HandleStore.pushRestored, and it stands against a run
766
+ * of its loader there; a live-lane owner's (a hole), and every owner's on
767
+ * any other replay of the record (a client navigation), go through
768
+ * pushReplayed, so a run of the loader replaces it with its live push.
765
769
  */
766
770
  export type HandleOwners = Record<string, Record<string, (string | null)[]>>;
@@ -6,8 +6,9 @@
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
8
  * L1/L2 tiering or per-tier timeout budgets are needed here - the platform does
9
- * that work. Tag markers exist only for the shell family (`tm`, see
10
- * isTagsInvalidatedSince), and only PPR shell reads memoize them. What this
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
11
12
  * store adds on top of the raw primitive is the parts Vercel does NOT give us:
12
13
  *
13
14
  * 1. Stale-while-revalidate. `getCache` has no stale-but-serve: a TTL'd entry
@@ -76,9 +77,10 @@ import {
76
77
  base64ToBytes,
77
78
  } from "../cf/cf-base64.js";
78
79
  import {
79
- perRequestStoreValue,
80
- type PerRequestStoreMap,
81
- } from "../cf/cf-tag-marker-memo.js";
80
+ gateMarkerRead,
81
+ maskRequestTags,
82
+ maskedForRequest,
83
+ } from "../request-tag-mask.js";
82
84
  import {
83
85
  ShellMemo,
84
86
  freshReadsWindowMs,
@@ -170,8 +172,9 @@ type CacheFamily = "s" | "i" | "r" | "h" | "tm";
170
172
 
171
173
  /**
172
174
  * TTL for tag-invalidation marker entries ("tm" family), written by
173
- * invalidateTags for the build-shell read-through's isTagsInvalidatedSince
174
- * 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
175
178
  * history), so the markers are rango's own record of "tag X was invalidated
176
179
  * at T". One year: runtime tagged-shell retention is capped to this lifetime,
177
180
  * while buildVersion retires build shells on the next deploy. An expired marker
@@ -237,7 +240,7 @@ interface VercelShellEnvelope {
237
240
  /** React.version at capture. */
238
241
  rv: string;
239
242
  /** Build version at capture (ShellCacheEntry.buildVersion). */
240
- bv?: string;
243
+ bv: string;
241
244
  /** Capture-generation start time, used by tag marker checks. */
242
245
  c: number;
243
246
  /** staleAt (ms since epoch). */
@@ -248,8 +251,8 @@ interface VercelShellEnvelope {
248
251
  t?: string[];
249
252
  /** initialTheme the capture render was built with (resume theme fidelity). */
250
253
  i?: string;
251
- /** Capture data snapshot: recorded cache-store hits/writes for HIT parity. */
252
- sn?: ShellSnapshotRecord[];
254
+ /** Capture data snapshot (ShellCacheEntry.snapshot). */
255
+ sn: ShellSnapshotRecord[];
253
256
  /**
254
257
  * ShellCacheEntry.docKey. Must round-trip: navigation-replay eligibility
255
258
  * requires the exact canonical doc segment record named here — dropping the
@@ -417,18 +420,6 @@ function handleMemos(cache: VercelRuntimeCache): VercelHandleMemos {
417
420
  return memos;
418
421
  }
419
422
 
420
- /**
421
- * The tags a request invalidated through a store, with their invalidatedAt:
422
- * request root context (RequestContext._requestRoot, so contexts derived
423
- * from the request share it) -> store -> tag -> ms (#973). invalidateTags()
424
- * writes it before its first await, and every read in that request treats an entry
425
- * carrying one of the tags, written at or before that time, as invalidated.
426
- * The platform's expireTag() and the tm marker writes are what other
427
- * requests see, and revalidateTag() does not wait for them.
428
- */
429
- const requestInvalidations: PerRequestStoreMap<Map<string, number>> =
430
- new WeakMap();
431
-
432
423
  function isRecord(value: unknown): value is Record<string, unknown> {
433
424
  return typeof value === "object" && value !== null;
434
425
  }
@@ -1217,8 +1208,10 @@ export class VercelCacheStore<
1217
1208
  * fresh values are used as is, stale ones are used and refreshed in the
1218
1209
  * background, older ones block on the runtime-cache read. A request
1219
1210
  * carrying the fresh-reads cookie skips the memo. A failed read rejects and
1220
- * is never memoized. The shell write gate and build shells
1221
- * (isTagsInvalidatedSince) read the runtime cache every time.
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.
1222
1215
  */
1223
1216
  private async readTagMarker(
1224
1217
  tag: string,
@@ -1314,16 +1307,38 @@ export class VercelCacheStore<
1314
1307
  // --- Tags ---
1315
1308
 
1316
1309
  /**
1317
- * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince).
1318
- * expireTag() keeps no queryable history, so invalidateTags() writes "tm"
1319
- * markers for runtime capture races and immutable build shells. Marker >=
1320
- * 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.
1321
1318
  */
1322
1319
  async isTagsInvalidatedSince(
1323
1320
  tags: string[],
1324
1321
  sinceMs: number,
1322
+ options?: { failClosed?: boolean },
1325
1323
  ): Promise<boolean> {
1326
- return this.tagsInvalidatedSince(tags, sinceMs, false);
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
+ );
1327
1342
  }
1328
1343
 
1329
1344
  private async tagsInvalidatedSince(
@@ -1332,6 +1347,8 @@ export class VercelCacheStore<
1332
1347
  isolateMemo: boolean,
1333
1348
  prefetched?: Map<string, Promise<number | null>>,
1334
1349
  outcomes?: Map<string, MarkerMemoOutcome>,
1350
+ /** What a failed marker read answers: true for the write gate (#977). */
1351
+ failClosed = false,
1335
1352
  ): Promise<boolean> {
1336
1353
  if (this.maskedForRequest(tags, sinceMs)) return true;
1337
1354
  try {
@@ -1342,6 +1359,9 @@ export class VercelCacheStore<
1342
1359
  this.readTagMarker(tag, isolateMemo, outcomes),
1343
1360
  ),
1344
1361
  );
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;
1345
1365
  for (const at of markers) {
1346
1366
  if (at !== null && at >= sinceMs) return true;
1347
1367
  }
@@ -1352,7 +1372,7 @@ export class VercelCacheStore<
1352
1372
  "cache-read",
1353
1373
  "[VercelCacheStore] tag invalidation check",
1354
1374
  );
1355
- return false;
1375
+ return failClosed;
1356
1376
  }
1357
1377
  }
1358
1378
 
@@ -1408,42 +1428,24 @@ export class VercelCacheStore<
1408
1428
 
1409
1429
  /**
1410
1430
  * The request-local half of invalidateTags(), run before its first await
1411
- * (#973): see requestInvalidations. revalidateTag() waits for nothing
1412
- * after it.
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.
1413
1434
  */
1414
1435
  private maskTagsForRequest(tags: string[], at: number): void {
1415
1436
  const ctx = _getRequestContext();
1416
- if (!ctx) return;
1417
- const mask = perRequestStoreValue(
1418
- requestInvalidations,
1419
- ctx._requestRoot ?? ctx,
1420
- this,
1421
- () => new Map<string, number>(),
1422
- );
1423
- for (const tag of tags) mask.set(tag, at);
1437
+ if (ctx) maskRequestTags(ctx, this, tags, at);
1424
1438
  }
1425
1439
 
1426
1440
  /**
1427
- * Whether this request invalidated one of `tags` through this store at or
1428
- * after `taggedAt`. `>=`, as the marker check: an entry written in the
1429
- * invalidation's millisecond may hold a value computed before it, so it
1430
- * misses (a false miss, never a stale read). An entry without a stamp was
1431
- * written before the stamp existed and counts as older.
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.
1432
1443
  */
1433
1444
  private maskedForRequest(
1434
1445
  tags: string[] | undefined,
1435
1446
  taggedAt: number | undefined,
1436
1447
  ): boolean {
1437
- if (!tags?.length) return false;
1438
- const ctx = _getRequestContext();
1439
- const mask =
1440
- ctx && requestInvalidations.get(ctx._requestRoot ?? ctx)?.get(this);
1441
- if (!mask) return false;
1442
- for (const tag of tags) {
1443
- const at = mask.get(tag);
1444
- if (at !== undefined && at >= (taggedAt ?? 0)) return true;
1445
- }
1446
- return false;
1448
+ return maskedForRequest(_getRequestContext(), this, tags, taggedAt ?? 0);
1447
1449
  }
1448
1450
 
1449
1451
  /** A data read's mask check: reports the miss and returns true when masked. */
@@ -1700,7 +1702,7 @@ export class VercelCacheStore<
1700
1702
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1701
1703
  if (!isRecord(raw)) return null;
1702
1704
  const { p, po, rv, bv, c, s, e, t, i, sn, dk, pr, no } = raw;
1703
- if (typeof rv !== "string") return null;
1705
+ if (typeof rv !== "string" || typeof bv !== "string") return null;
1704
1706
  // Document half: required unless navigationOnly (`no`), which stores
1705
1707
  // neither field. Tolerate a legacy navigationOnly envelope that still
1706
1708
  // carries them.
@@ -1716,13 +1718,14 @@ export class VercelCacheStore<
1716
1718
  ...(typeof p === "string" ? { p } : {}),
1717
1719
  ...(po !== undefined ? { po: po as string | null } : {}),
1718
1720
  rv,
1719
- bv: typeof bv === "string" ? bv : undefined,
1721
+ bv,
1720
1722
  c,
1721
1723
  s,
1722
1724
  e,
1723
1725
  t: Array.isArray(t) ? (t as string[]) : undefined,
1724
1726
  i: typeof i === "string" ? i : undefined,
1725
- 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[]) : [],
1726
1729
  dk: typeof dk === "string" ? dk : undefined,
1727
1730
  pr: typeof pr === "string" ? pr : undefined,
1728
1731
  no: no === true ? true : undefined,
@@ -125,8 +125,9 @@ export function ClientUrlsRoot({
125
125
  const mount = useMount();
126
126
  const [intent, setIntent] = useState<ClientUrlNavigationIntent | null>(null);
127
127
  useEffect(
128
- () => registerClientUrlGroup(definition, mount, namePrefix, setIntent),
129
- [definition, mount, namePrefix],
128
+ () =>
129
+ registerClientUrlGroup(definition, mount, namePrefix, setIntent, routeId),
130
+ [definition, mount, namePrefix, routeId],
130
131
  );
131
132
 
132
133
  // Optimistic destination (design: docs/design/client-urls-optimistic-destination.md).
@@ -136,9 +137,12 @@ export function ClientUrlsRoot({
136
137
  // own keeps the previous content visible (React's transition hold — the
137
138
  // pre-existing contract for routes without loading()), one with loading()
138
139
  // or inline <Suspense> at its reads presents immediately. Same-route intents
139
- // never swap: held data + transition() own that case.
140
+ // never swap: held data + transition() own that case. A navigation its
141
+ // transition({ when }) gated off at the swap presents urgently: no hold,
142
+ // no view transition, the destination's loading() streams.
140
143
  const pending = intent !== null;
141
- const presented = useDeferredValue(intent);
144
+ const deferred = useDeferredValue(intent);
145
+ const presented = intent?.transitionGatedOff ? intent : deferred;
142
146
  const optimisticRoute =
143
147
  presented && presented.routeId !== routeId
144
148
  ? findRoute(definition, presented.routeId)
@@ -29,6 +29,7 @@ const TRANSITION_CONFIG_KEYS = new Set<PropertyKey>([
29
29
  "default",
30
30
  "name",
31
31
  "viewTransition",
32
+ "when",
32
33
  ]);
33
34
 
34
35
  const PPR_NUMBER_KEYS = new Set<PropertyKey>([
@@ -431,12 +432,6 @@ function createHelpers(): ClientUrlHelpers {
431
432
  );
432
433
  }
433
434
  for (const key of Reflect.ownKeys(config)) {
434
- if (key === "when") {
435
- throw new Error(
436
- "clientUrls() transition() does not support `when` — the gate is a " +
437
- "server-executed predicate; declare it with a server-tree transition()",
438
- );
439
- }
440
435
  if (!TRANSITION_CONFIG_KEYS.has(key)) {
441
436
  throw new Error(
442
437
  `clientUrls() transition() option ${JSON.stringify(String(key))} is not supported`,
@@ -446,6 +441,13 @@ function createHelpers(): ClientUrlHelpers {
446
441
  if (config.name !== undefined && typeof config.name !== "string") {
447
442
  throw new Error("clientUrls() transition() name must be a string");
448
443
  }
444
+ if (config.when !== undefined && typeof config.when !== "function") {
445
+ throw new TypeError(
446
+ process.env.NODE_ENV !== "production"
447
+ ? `clientUrls() transition() when must be a function (it runs in the browser), got ${config.when === null ? "null" : typeof config.when}`
448
+ : "clientUrls() transition() when must be a function",
449
+ );
450
+ }
449
451
  if (
450
452
  config.viewTransition !== undefined &&
451
453
  config.viewTransition !== "auto" &&