@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
@@ -6,8 +6,14 @@
6
6
  // and in-flight reads, the PPR shell read's own marker record, and the prefix
7
7
  // and sentinel of the optional per-colo L1 marker cache. The maps are module
8
8
  // singletons keyed by the request-context object, then the store INSTANCE.
9
+ // The request's own invalidations are not here: they are the per-request
10
+ // mask (request-tag-mask.ts), which every marker check consults first.
9
11
 
10
12
  import type { MarkerMemoOutcome } from "../isolate-tag-memo.js";
13
+ import {
14
+ perRequestStoreValue,
15
+ type PerRequestStoreMap,
16
+ } from "../request-tag-mask.js";
11
17
 
12
18
  /**
13
19
  * Cache-API path prefix for the optional per-colo L1 cache of tag-invalidation
@@ -24,32 +30,6 @@ export const TAG_MARKER_CACHE_PREFIX = "__tagmarker__/";
24
30
  */
25
31
  export const TAG_MARKER_ABSENT = "none";
26
32
 
27
- /**
28
- * Request context -> store instance -> T, both levels weakly held. Also
29
- * VercelCacheStore's request tag mask (#973).
30
- */
31
- export type PerRequestStoreMap<T> = WeakMap<object, WeakMap<object, T>>;
32
-
33
- /** The value for (ctx, store), created on first use. */
34
- export function perRequestStoreValue<T>(
35
- map: PerRequestStoreMap<T>,
36
- ctx: object,
37
- store: object,
38
- create: () => T,
39
- ): T {
40
- let byStore = map.get(ctx);
41
- if (!byStore) {
42
- byStore = new WeakMap();
43
- map.set(ctx, byStore);
44
- }
45
- let value = byStore.get(store);
46
- if (value === undefined) {
47
- value = create();
48
- byStore.set(store, value);
49
- }
50
- return value;
51
- }
52
-
53
33
  /**
54
34
  * Per-request memo of tag-invalidation markers (tag -> latest invalidatedAt, or
55
35
  * null when no marker exists). Keyed first by the request context object (so it
@@ -73,92 +53,23 @@ export function perRequestStoreValue<T>(
73
53
  * Without the memo, isGloballyInvalidated() issues a KV read per tag on every
74
54
  * tagged cache read, so a page composed of many segments/items sharing a tag
75
55
  * pays that cost N times. The memo collapses it to one KV read per distinct tag
76
- * per (request, store). invalidateTags() writes the new marker here before its
77
- * first await (maskRequestTags, #973), so the request that ran updateTag() or
78
- * revalidateTag() reads its own writes: the action's own re-render sees its
79
- * invalidation from the memo, without waiting for the KV put that
80
- * revalidateTag() leaves to waitUntil.
56
+ * per (request, store). It holds only values read from L1 or KV: the request's
57
+ * own invalidations are the mask (request-tag-mask.ts), checked before any
58
+ * marker read, so a value in the memo is always safe to publish.
81
59
  *
82
60
  * It does NOT span requests, so a hot single-entry route still pays one KV read
83
61
  * per request; that read hits Cloudflare KV's own edge read cache for hot keys.
84
62
  * A context derived from the request (Object.create(reqCtx): a PPR HIT tail, a
85
- * shell capture) keeps a memo of its own, so its marker reads stay its own; it
86
- * starts from the request's mask (requestMasks) instead of empty, and takes
87
- * every mask the request sets later (requestMemos).
63
+ * shell capture) keeps a memo of its own, so its marker reads stay its own.
88
64
  */
89
65
  const tagMarkerMemo: PerRequestStoreMap<Map<string, number | null>> =
90
66
  new WeakMap();
91
67
 
92
- /**
93
- * The tags each request invalidated through each store, with the
94
- * invalidatedAt its mask carries (#973). Keyed by the request's root context
95
- * (RequestContext._requestRoot) rather than by the context that called, so a
96
- * derived context sees the request's mask and a mask it sets reaches the
97
- * request. Its values are unconfirmed until the KV put lands: a marker read
98
- * that finds one of them publishes nothing to other requests
99
- * (CFCacheStore.fetchTagMarker).
100
- */
101
- const requestMasks: PerRequestStoreMap<Map<string, number>> = new WeakMap();
102
-
103
- /**
104
- * Every memo of a request, whichever context owns it, per (root, store): a
105
- * mask set later reaches the memos that already exist (maskRequestTags), not
106
- * only the caller's. They live as long as the request does.
107
- */
108
- const requestMemos: PerRequestStoreMap<Set<Map<string, number | null>>> =
109
- new WeakMap();
110
-
111
- function requestRoot(ctx: object): object {
112
- return (ctx as { _requestRoot?: object })._requestRoot ?? ctx;
113
- }
114
-
115
68
  export function getTagMarkerMemo(
116
69
  ctx: object,
117
70
  store: object,
118
71
  ): Map<string, number | null> {
119
- return perRequestStoreValue(tagMarkerMemo, ctx, store, () => {
120
- const root = requestRoot(ctx);
121
- const memo = new Map<string, number | null>(
122
- requestMasks.get(root)?.get(store),
123
- );
124
- perRequestStoreValue(requestMemos, root, store, () => new Set()).add(memo);
125
- return memo;
126
- });
127
- }
128
-
129
- /**
130
- * Mask `tags` at `at` for the rest of the request: in the request's mask and
131
- * in every memo the request's contexts hold for `store` (a memo created later
132
- * starts from the mask).
133
- */
134
- export function maskRequestTags(
135
- ctx: object,
136
- store: object,
137
- tags: readonly string[],
138
- at: number,
139
- ): void {
140
- const root = requestRoot(ctx);
141
- const mask = perRequestStoreValue(
142
- requestMasks,
143
- root,
144
- store,
145
- () => new Map<string, number>(),
146
- );
147
- getTagMarkerMemo(ctx, store);
148
- const memos = requestMemos.get(root)?.get(store) ?? [];
149
- for (const tag of tags) {
150
- mask.set(tag, at);
151
- for (const memo of memos) memo.set(tag, at);
152
- }
153
- }
154
-
155
- /** Whether this request masked `tag` through `store` (maskRequestTags). */
156
- export function isRequestMasked(
157
- ctx: object,
158
- store: object,
159
- tag: string,
160
- ): boolean {
161
- return requestMasks.get(requestRoot(ctx))?.get(store)?.has(tag) ?? false;
72
+ return perRequestStoreValue(tagMarkerMemo, ctx, store, () => new Map());
162
73
  }
163
74
 
164
75
  /**
@@ -13,6 +13,7 @@
13
13
  * 4. If miss → run handler, cache if response has cache headers
14
14
  */
15
15
 
16
+ import { requestHeaders } from "../server/request-headers.js";
16
17
  import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
17
18
  import { hasPerClientSignal } from "../browser/cookie-name.js";
18
19
  import {
@@ -24,6 +25,7 @@ import { mayNeedSSR } from "../rsc/ssr-setup.js";
24
25
  import { cacheKeyBase } from "./cache-key-utils.js";
25
26
  import { runBackground } from "./background-task.js";
26
27
  import { reportCacheError } from "./cache-error.js";
28
+ import { executionStart, predatesInvalidation } from "./tag-invalidation.js";
27
29
  import { observePhase, PHASES } from "../router/instrument.js";
28
30
  import {
29
31
  SEGMENT_FRAGMENT_CAPABILITY_HEADER,
@@ -109,7 +111,10 @@ function parseCacheControl(header: string | null): CacheDirectives | null {
109
111
  /**
110
112
  * Check if response should be cached based on Cache-Control headers
111
113
  */
112
- function shouldCacheResponse(response: Response): CacheDirectives | null {
114
+ function shouldCacheResponse(
115
+ response: Response,
116
+ requestCtx?: Pick<RequestContext, "_payloadVisitorTheme">,
117
+ ): CacheDirectives | null {
113
118
  // Only cache successful responses
114
119
  if (response.status !== 200) {
115
120
  return null;
@@ -125,10 +130,34 @@ function shouldCacheResponse(response: Response): CacheDirectives | null {
125
130
  return null;
126
131
  }
127
132
 
133
+ // The payload carries the visitor's theme (#978): the render ran before the
134
+ // response opted in (a Cache-Control written after next()), so
135
+ // payloadInitialTheme could not render the default.
136
+ if (requestCtx?._payloadVisitorTheme) {
137
+ return null;
138
+ }
139
+
128
140
  const cacheControl = response.headers.get("Cache-Control");
129
141
  return parseCacheControl(cacheControl);
130
142
  }
131
143
 
144
+ /**
145
+ * Whether this cache will store the response the current render produces, as
146
+ * far as the render can tell: the middleware runs it (a MISS or a stale
147
+ * refresh) and the response stub already opts in. payloadInitialTheme
148
+ * (rsc/full-payload.ts) then renders the no-cookie default theme (#978). A
149
+ * Cache-Control written after next() is not on the stub yet; for that render
150
+ * shouldCacheResponse refuses a payload marked `_payloadVisitorTheme`.
151
+ */
152
+ export function documentCacheStoresRender(
153
+ reqCtx: Pick<RequestContext, "_documentCacheRender" | "res">,
154
+ ): boolean {
155
+ return (
156
+ reqCtx._documentCacheRender === true &&
157
+ shouldCacheResponse(reqCtx.res) !== null
158
+ );
159
+ }
160
+
132
161
  /**
133
162
  * Throw the first error the drained render reported through Flight or Fizz
134
163
  * onError. A component that throws after the 200 committed does not fail the
@@ -279,7 +308,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
279
308
  // are not visible on ctx.url in production.
280
309
  const rawUrl = new URL(ctx.request.url);
281
310
  const isFragmentRecovery =
282
- ctx.request.headers.get(SEGMENT_FRAGMENT_RECOVERY_HEADER) === "1";
311
+ requestHeaders(ctx.request).get(SEGMENT_FRAGMENT_RECOVERY_HEADER) === "1";
283
312
 
284
313
  // Only cache GET requests — mutations and other methods must not be cached
285
314
  if (ctx.request.method !== "GET") {
@@ -318,6 +347,11 @@ export function createDocumentCacheMiddleware<TEnv = any>(
318
347
  return next();
319
348
  }
320
349
 
350
+ // The write gate's start (#977): the request's, not this middleware's. A
351
+ // middleware ahead of this one can read tagged data the document bakes,
352
+ // and a stale refresh re-runs the handler over what they set.
353
+ const start = requestCtx._requestStart ?? executionStart();
354
+
321
355
  // Determine request type for cache key differentiation.
322
356
  // Uses rawUrl for _rsc* param checks and mayNeedSSR for Accept-based
323
357
  // detection. Full-document RSC fetches must not share the HTML cache slot.
@@ -343,7 +377,8 @@ export function createDocumentCacheMiddleware<TEnv = any>(
343
377
  // match-time capability gate.
344
378
  const fragmentSuffix =
345
379
  isPartial &&
346
- (ctx.request.headers.get(SEGMENT_FRAGMENT_CAPABILITY_HEADER) === "1" ||
380
+ (requestHeaders(ctx.request).get(SEGMENT_FRAGMENT_CAPABILITY_HEADER) ===
381
+ "1" ||
347
382
  isFragmentRecovery)
348
383
  ? ":fragments"
349
384
  : "";
@@ -374,6 +409,9 @@ export function createDocumentCacheMiddleware<TEnv = any>(
374
409
  const cached = isFragmentRecovery
375
410
  ? null
376
411
  : await store.getResponse(cacheKey);
412
+ // Every path past the lookup either returns a fresh HIT, which renders
413
+ // nothing, or renders a response this cache may store.
414
+ requestCtx._documentCacheRender = true;
377
415
 
378
416
  if (cached && cached.response.status === 200) {
379
417
  if (!cached.shouldRevalidate) {
@@ -409,7 +447,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
409
447
  PHASES.background("document-revalidation"),
410
448
  async () => {
411
449
  const fresh = await next();
412
- const directives = shouldCacheResponse(fresh);
450
+ const directives = shouldCacheResponse(fresh, requestCtx);
413
451
 
414
452
  if (!directives || !fresh.body) return;
415
453
 
@@ -418,12 +456,16 @@ export function createDocumentCacheMiddleware<TEnv = any>(
418
456
  // (same render-complete barrier as the miss path).
419
457
  const body = await new Response(fresh.body).arrayBuffer();
420
458
  throwIfRenderErrored(requestCtx);
459
+ // Not written when one of its tags was invalidated since
460
+ // the render started (#977).
461
+ const tags = collectRequestTags(requestCtx);
462
+ if (await predatesInvalidation(store, tags, start)) return;
421
463
  await store.putResponse!(
422
464
  cacheKey,
423
465
  new Response(body, fresh),
424
466
  directives.sMaxAge!,
425
467
  directives.staleWhileRevalidate,
426
- collectRequestTags(requestCtx),
468
+ tags,
427
469
  );
428
470
  log(
429
471
  `[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`,
@@ -456,7 +498,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
456
498
  const originalResponse = await next();
457
499
 
458
500
  // 3. Cache if response has appropriate headers
459
- const directives = shouldCacheResponse(originalResponse);
501
+ const directives = shouldCacheResponse(originalResponse, requestCtx);
460
502
 
461
503
  if (directives) {
462
504
  log(
@@ -483,12 +525,16 @@ export function createDocumentCacheMiddleware<TEnv = any>(
483
525
  // body and its tag set consistent.
484
526
  const body = await new Response(cacheStream).arrayBuffer();
485
527
  throwIfRenderErrored(requestCtx);
528
+ // Not written when one of its tags was invalidated since the
529
+ // render started (#977).
530
+ const tags = collectRequestTags(requestCtx);
531
+ if (await predatesInvalidation(store, tags, start)) return;
486
532
  await store.putResponse!(
487
533
  cacheKey,
488
534
  new Response(body, originalResponse),
489
535
  directives.sMaxAge!,
490
536
  directives.staleWhileRevalidate,
491
- collectRequestTags(requestCtx),
537
+ tags,
492
538
  );
493
539
  } catch (error) {
494
540
  // Detached waitUntil task — pass the captured requestCtx so onError
@@ -134,6 +134,29 @@ export function captureHandleOwners(
134
134
  return owners;
135
135
  }
136
136
 
137
+ /** How restoreHandles delivers a record's loader-owned values. */
138
+ export interface OwnedPushDelivery {
139
+ /**
140
+ * Asked once per owner delivered through pushReplayed
141
+ * (setupLoaderAccess _claimLoaderPushes); an owner it refuses is skipped.
142
+ */
143
+ claim?: (loaderId: string) => boolean;
144
+ /**
145
+ * A PPR shell's record (capture, document HIT tail, navigation replay): the
146
+ * loaders the route runs on the live lane (liveLaneLoaderIds), marked on
147
+ * the store (HandleStore.markLiveLane). A hole's values are replayed
148
+ * unclaimed, so its own cache() HIT redelivers its entry's pushes in their
149
+ * place (HandleStore.redeliverReplays), and its run ending without a push
150
+ * drops them (HandleStore.settleLoaderRun).
151
+ */
152
+ liveLane?: ReadonlySet<string>;
153
+ /**
154
+ * A document HIT tail: every owner outside `liveLane` goes through
155
+ * pushRestored and stands, unclaimed.
156
+ */
157
+ restore?: boolean;
158
+ }
159
+
137
160
  /**
138
161
  * Restore handle data from a cached snapshot into the handle store.
139
162
  * Used when serving cached segments to replay their handle data: a route
@@ -141,21 +164,32 @@ export function captureHandleOwners(
141
164
  *
142
165
  * A segment with `owners` is replaced with empty arrays and re-pushed in
143
166
  * recorded order, an owned value through pushReplayed: if the owning loader
144
- * runs on the HIT (a live loader, or a promise-carrying `ssr: false` one;
145
- * a promise-free one is served from the shell and does not), its live pushes
146
- * replace the recorded ones in place instead of appending a second copy. `claim`
147
- * (setupLoaderAccess _claimLoaderPushes) is asked once per owner, so the
148
- * loader's own cache() HIT does not replay its pushes a second time
149
- * (loader-cache.ts replayLoaderHandles); an owner it refuses is skipped. A
150
- * record without owners restores as a plain replay.
167
+ * runs (a client navigation replaying a ppr route's cache() record runs its
168
+ * `ssr: false` loaders), its live pushes replace the recorded ones in place
169
+ * instead of appending a second copy. `owned.claim` is asked once per
170
+ * owner, so the loader's own cache() HIT does not replay its pushes a second
171
+ * time (loader-cache.ts replayLoaderHandles). A record without owners
172
+ * restores as a plain replay.
173
+ *
174
+ * `owned.restore`: a document HIT tail restoring the shell's doc record
175
+ * (withCacheLookup). A bake-lane owner's values are the prelude's, so they go
176
+ * through pushRestored and stand: a run of that loader on the HIT (a
177
+ * promise-carrying `ssr: false` loader, one whose record asks for a run)
178
+ * reads the store, and its settled pushes are dropped, not swapped in.
179
+ * Nothing is claimed, so the loader's own cache() HIT still replays the
180
+ * pushes the record could not keep (the thenable ones pushRestored lets
181
+ * through). A loader the route also runs on the live lane is a hole: its
182
+ * values keep pushReplayed, unclaimed, and its live run replaces them
183
+ * (#936), even inside a restored loader's body (`owned.liveLane`).
151
184
  */
152
185
  export function restoreHandles(
153
186
  handles: Record<string, SegmentHandleData>,
154
187
  handleStore: HandleStore,
155
188
  owners?: HandleOwners,
156
- claim?: (loaderId: string) => boolean,
189
+ owned?: OwnedPushDelivery,
157
190
  ): void {
158
191
  let delivers: Map<string, boolean> | undefined;
192
+ if (owned?.liveLane) handleStore.markLiveLane(owned.liveLane);
159
193
  for (const [segId, segHandles] of Object.entries(handles)) {
160
194
  if (Object.keys(segHandles).length === 0) continue;
161
195
  const segOwners = owners?.[segId];
@@ -174,10 +208,19 @@ export function restoreHandles(
174
208
  handleStore.push(handleName, segId, values[i]);
175
209
  continue;
176
210
  }
211
+ const hole = owned?.liveLane?.has(owner) === true;
212
+ if (owned?.restore && !hole) {
213
+ handleStore.pushRestored(handleName, segId, values[i], owner);
214
+ continue;
215
+ }
216
+ if (hole) {
217
+ handleStore.pushReplayed(handleName, segId, values[i], owner);
218
+ continue;
219
+ }
177
220
  delivers ??= new Map();
178
221
  let deliver = delivers.get(owner);
179
222
  if (deliver === undefined) {
180
- deliver = claim ? claim(owner) : true;
223
+ deliver = owned?.claim ? owned.claim(owner) : true;
181
224
  delivers.set(owner, deliver);
182
225
  }
183
226
  if (deliver) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The isolate's order of tag invalidations and cache execution starts
3
+ * (#973, #977): what the write gate in tag-invalidation.ts
4
+ * (predatesInvalidation) and the "use cache" join check read to tell whether
5
+ * an execution's value may predate an invalidation of one of its tags.
6
+ * Imports nothing, so request-context.ts can take each request's start here
7
+ * without a cycle through tag-invalidation.ts.
8
+ *
9
+ * - `seq` (#973): a counter, not Date.now(). Workers advance the clock only
10
+ * on I/O, so an execution started right after an invalidation would share
11
+ * its millisecond and read as older.
12
+ * - `tags` (#977): tag -> the `seq` of its latest invalidation, from every
13
+ * request, oldest first. Bounded: dropping the oldest raises `forgotten`,
14
+ * and an execution that started before a forgotten invalidation counts as
15
+ * invalidated, whatever its tags (a miss, never a stale read).
16
+ *
17
+ * Keyed by tag name alone, not by router or store: two routers in one
18
+ * isolate that share a tag name skip each other's writes started before an
19
+ * invalidation. That costs a miss, never a stale read.
20
+ *
21
+ * On globalThis: a second evaluated copy of this module (a duplicated
22
+ * package, a dev re-evaluation) must read the invalidations the first one
23
+ * recorded, or its writers pass the gate.
24
+ */
25
+ interface IsolateOrder {
26
+ seq: number;
27
+ tags: Map<string, number>;
28
+ forgotten: number;
29
+ }
30
+ const order: IsolateOrder = ((globalThis as any)[
31
+ Symbol.for("rangojs-router:tag-invalidation-order")
32
+ ] ??= { seq: 0, tags: new Map(), forgotten: 0 });
33
+ const ISOLATE_INVALIDATION_HISTORY = 1024;
34
+
35
+ /**
36
+ * Where a cache execution ("use cache", a loader's own cache(), a route
37
+ * cache() render, a request's document) started: its position in this
38
+ * isolate's order, and the store clock for invalidations this isolate did
39
+ * not see.
40
+ */
41
+ export interface ExecutionStart {
42
+ readonly seq: number;
43
+ readonly at: number;
44
+ }
45
+
46
+ /** Record an execution's start, before it reads its data. */
47
+ export function executionStart(): ExecutionStart {
48
+ return { seq: order.seq, at: Date.now() };
49
+ }
50
+
51
+ /**
52
+ * Whether this isolate invalidated any of `tags` after `since` (the `seq` of
53
+ * an ExecutionStart), in any request: the execution's value may predate the
54
+ * invalidation, so it is neither joined nor written (#973, #977).
55
+ */
56
+ export function invalidatedSince(
57
+ tags: readonly string[] | undefined,
58
+ since: number,
59
+ ): boolean {
60
+ if (!tags || tags.length === 0) return false;
61
+ if (since < order.forgotten) return true;
62
+ for (const tag of tags) {
63
+ const at = order.tags.get(tag);
64
+ if (at !== undefined && at > since) return true;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * Record an invalidation of `tags` in this isolate's order
71
+ * (updateTag()/revalidateTag(), before any store sees it).
72
+ */
73
+ export function markInvalidated(tags: readonly string[]): void {
74
+ const at = ++order.seq;
75
+ for (const tag of tags) {
76
+ order.tags.delete(tag);
77
+ order.tags.set(tag, at);
78
+ }
79
+ for (const [tag, seq] of order.tags) {
80
+ if (order.tags.size <= ISOLATE_INVALIDATION_HISTORY) break;
81
+ order.tags.delete(tag);
82
+ order.forgotten = seq;
83
+ }
84
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The per-request tag mask behind read-your-own-writes (#973).
3
+ *
4
+ * updateTag() and revalidateTag() call a store's invalidateTags() inside the
5
+ * request, and a built-in store masks the tags here before its first await:
6
+ * every later read in that request, through any context derived from it,
7
+ * treats an entry carrying one of the tags, written at or before the mask, as
8
+ * invalidated, without waiting for the durable write (the KV marker put, the
9
+ * tag purge, expireTag()) that revalidateTag() leaves to waitUntil. The mask
10
+ * only ever turns this request's hits into misses: if the durable write then
11
+ * fails, the request paid extra misses, never a stale read, and other
12
+ * requests read the durable state. CFCacheStore and VercelCacheStore share it.
13
+ */
14
+
15
+ /** Request context -> store instance -> T, both levels weakly held. */
16
+ export type PerRequestStoreMap<T> = WeakMap<object, WeakMap<object, T>>;
17
+
18
+ /** The value for (ctx, store), created on first use. */
19
+ export function perRequestStoreValue<T>(
20
+ map: PerRequestStoreMap<T>,
21
+ ctx: object,
22
+ store: object,
23
+ create: () => T,
24
+ ): T {
25
+ let byStore = map.get(ctx);
26
+ if (!byStore) {
27
+ byStore = new WeakMap();
28
+ map.set(ctx, byStore);
29
+ }
30
+ let value = byStore.get(store);
31
+ if (value === undefined) {
32
+ value = create();
33
+ byStore.set(store, value);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ /**
39
+ * The request a context belongs to: RequestContext._requestRoot, which
40
+ * createRequestContext defines and a derived context (Object.create: a shell
41
+ * capture, a PPR HIT tail) inherits. A bare test context is its own root.
42
+ */
43
+ export function requestRoot<T extends object>(
44
+ ctx: T & { _requestRoot?: T },
45
+ ): T {
46
+ return ctx._requestRoot ?? ctx;
47
+ }
48
+
49
+ /** Request root -> store -> tag -> the invalidatedAt its mask carries. */
50
+ const requestMasks: PerRequestStoreMap<Map<string, number>> = new WeakMap();
51
+
52
+ /** Mask `tags` at `at` through `store` for the rest of `ctx`'s request. */
53
+ export function maskRequestTags(
54
+ ctx: object,
55
+ store: object,
56
+ tags: readonly string[],
57
+ at: number,
58
+ ): void {
59
+ const mask = perRequestStoreValue(
60
+ requestMasks,
61
+ requestRoot(ctx),
62
+ store,
63
+ () => new Map<string, number>(),
64
+ );
65
+ for (const tag of tags) mask.set(tag, at);
66
+ }
67
+
68
+ /**
69
+ * Whether `ctx`'s request masked one of `tags` through `store` at or after
70
+ * `taggedAt`. `>=`, as the stores' marker checks: an entry written in the
71
+ * invalidation's millisecond may hold a value computed before it, so it
72
+ * misses (a false miss, never a stale read).
73
+ */
74
+ export function maskedForRequest(
75
+ ctx: object | undefined,
76
+ store: object,
77
+ tags: readonly string[] | undefined,
78
+ taggedAt: number,
79
+ ): boolean {
80
+ if (!ctx || !tags?.length) return false;
81
+ const mask = requestMasks.get(requestRoot(ctx))?.get(store);
82
+ if (!mask) return false;
83
+ for (const tag of tags) {
84
+ const at = mask.get(tag);
85
+ if (at !== undefined && at >= taggedAt) return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * A marker read the write gate issued (#977) and has not settled: the
92
+ * store's answer for one tag and the millisecond it was issued in.
93
+ */
94
+ interface GateRead {
95
+ issuedAt: number;
96
+ answer: Promise<unknown>;
97
+ }
98
+
99
+ /** Request root -> store -> tag -> the gate read in flight for it. */
100
+ const gateReads: PerRequestStoreMap<Map<string, GateRead>> = new WeakMap();
101
+
102
+ /**
103
+ * A marker read for a write gate (isTagsInvalidatedSince, from
104
+ * tag-invalidation.ts predatesInvalidation), shared by the gates of a
105
+ * request that ask while it is in flight. A page's writes (its "use cache"
106
+ * misses, its route record, its document) finish together and often carry
107
+ * the same tags, and each read its own marker before: one KV or
108
+ * runtime-cache read per tag per write.
109
+ *
110
+ * A read answers only for invalidations before it was issued. So a gate
111
+ * shares one still in flight, issued at or after its execution started
112
+ * (`sinceMs` is the millisecond after that start), and the read is dropped
113
+ * once it settles: a gate asking later reads again, and a shared read
114
+ * leaves open at most one marker round-trip. Reusing a settled read let a
115
+ * gate asking long after it miss another isolate's invalidation in
116
+ * between (a capture's putShell answered by a "use cache" write's read
117
+ * from the start of the capture), and kept a timed-out read's fail-open
118
+ * answer for the rest of the request. Without a request context `read()`
119
+ * runs unshared.
120
+ */
121
+ export function gateMarkerRead<T>(
122
+ ctx: object | undefined,
123
+ store: object,
124
+ tag: string,
125
+ sinceMs: number,
126
+ read: () => Promise<T>,
127
+ ): Promise<T> {
128
+ if (!ctx) return read();
129
+ const reads = perRequestStoreValue(
130
+ gateReads,
131
+ requestRoot(ctx),
132
+ store,
133
+ () => new Map<string, GateRead>(),
134
+ );
135
+ const inFlight = reads.get(tag);
136
+ if (inFlight && inFlight.issuedAt >= sinceMs - 1) {
137
+ return inFlight.answer as Promise<T>;
138
+ }
139
+ const answer = read();
140
+ const entry: GateRead = { issuedAt: Date.now(), answer };
141
+ reads.set(tag, entry);
142
+ const settled = (): void => {
143
+ if (reads.get(tag) === entry) reads.delete(tag);
144
+ };
145
+ answer.then(settled, settled);
146
+ return answer;
147
+ }