@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
@@ -17,8 +17,17 @@ import {
17
17
  getRequestContext,
18
18
  _getRequestContext,
19
19
  } from "../server/request-context.js";
20
- import { recordRequestTags } from "./cache-tag.js";
20
+ import {
21
+ getSegmentTags,
22
+ recordRequestTags,
23
+ recordSegmentTags,
24
+ runInSegmentTagScope,
25
+ } from "./cache-tag.js";
21
26
  import { reportCacheError } from "./cache-error.js";
27
+ import {
28
+ predatesInvalidation,
29
+ type ExecutionStart,
30
+ } from "./tag-invalidation.js";
22
31
  // segment-codec is the only module on cache-scope's import graph that eagerly
23
32
  // pulls @vitejs/plugin-rsc (a virtual: module the plain node/vitest runner cannot
24
33
  // resolve). It is imported LAZILY at the two call sites below (deserializeSegments
@@ -30,10 +39,11 @@ import {
30
39
  captureHandles,
31
40
  captureHandleOwners,
32
41
  restoreHandles,
42
+ type OwnedPushDelivery,
33
43
  encodeHandles,
34
44
  decodeHandles,
35
45
  } from "./handle-snapshot.js";
36
- import { cacheKeyBase } from "./cache-key-utils.js";
46
+ import { cacheKeyBase, composeCacheKeys } from "./cache-key-utils.js";
37
47
  import {
38
48
  DEFAULT_ROUTE_TTL,
39
49
  isFiniteNonNegativeSeconds,
@@ -42,13 +52,38 @@ import {
42
52
  resolveTagsOption,
43
53
  } from "./cache-policy.js";
44
54
  import type { RequestContext } from "../server/request-context.js";
55
+ import { runIdentityExempt } from "./cache-exec-scope.js";
45
56
 
46
- export function resolveCacheTags(
47
- config: PartialCacheOptions | false,
48
- ctx: RequestContext | undefined,
49
- ): string[] | undefined {
50
- if (config === false) return undefined;
51
- return resolveTagsOption(config.tags, ctx, "CacheScope");
57
+ /**
58
+ * Narrow the request's route-record window (RequestContext._routeRecordWindow)
59
+ * to a record this request read or wrote: fresh until `freshUntil` (the
60
+ * record's `expiresAt`), then stale for `swr` seconds. A shell capture starts
61
+ * with no window of its own (shell-capture.ts deriveShellCaptureContext sets
62
+ * it undefined), so it narrows only to the records the capture itself read
63
+ * or wrote.
64
+ */
65
+ function noteRouteRecordWindow(
66
+ requestCtx: RequestContext,
67
+ freshUntil: number,
68
+ ttl: number,
69
+ swr: number,
70
+ written: boolean,
71
+ ): void {
72
+ const next = {
73
+ freshUntil,
74
+ staleUntil: freshUntil + swr * 1000,
75
+ ttl,
76
+ swr,
77
+ written,
78
+ };
79
+ const current = requestCtx._routeRecordWindow;
80
+ // The record that runs out first names the config; freshness is the least.
81
+ requestCtx._routeRecordWindow = current
82
+ ? {
83
+ ...(current.staleUntil <= next.staleUntil ? current : next),
84
+ freshUntil: Math.min(current.freshUntil, freshUntil),
85
+ }
86
+ : next;
52
87
  }
53
88
 
54
89
  function debugCacheLog(message: string): void {
@@ -110,7 +145,95 @@ function getDefaultRouteCacheKey(
110
145
  // CacheScope
111
146
  // ============================================================================
112
147
 
113
- const CACHE_HIT_OBSERVERS = new WeakMap<CacheScope, () => void>();
148
+ type CacheKeyFn = NonNullable<PartialCacheOptions["key"]>;
149
+ type CacheConditionFn = NonNullable<PartialCacheOptions["condition"]>;
150
+ type CacheTagsOption = NonNullable<PartialCacheOptions["tags"]>;
151
+
152
+ /** `inherited`, plus `own` when set. */
153
+ function appendOwn<T>(
154
+ inherited: readonly T[] | undefined,
155
+ own: T | undefined,
156
+ ): readonly T[] {
157
+ if (own === undefined) return inherited ?? [];
158
+ return inherited ? [...inherited, own] : [own];
159
+ }
160
+
161
+ /**
162
+ * A configured `key()` cannot run without a request context. Falling back to
163
+ * the default key would store the key()'s partitioned output under the broad
164
+ * key and serve it across partitions, so key resolution fails instead and
165
+ * every caller degrades to an uncached render (as for a throwing `key()`).
166
+ */
167
+ function keyWithoutContextError(): Error {
168
+ return new Error(
169
+ "[CacheScope] a cache() key() needs a request context; rendering uncached",
170
+ );
171
+ }
172
+
173
+ /** keyGenerators already warned about returning an empty key. */
174
+ const EMPTY_KEY_WARNED = new WeakSet<object>();
175
+
176
+ /**
177
+ * A keyGenerator result as a partition part: null when it is the default key.
178
+ * An empty result rejects (the request renders uncached, as for a throwing
179
+ * keyGenerator): positionalParts writes a default result as the empty part,
180
+ * so an empty result would name the partition of a store that returned the
181
+ * default key. Warned once per keyGenerator, naming its store.
182
+ */
183
+ function generatedPart(
184
+ store: SegmentCacheStore | null,
185
+ generated: Promise<string>,
186
+ defaultKey: string,
187
+ ): Promise<string | null> {
188
+ return generated.then((key) => {
189
+ if (key === defaultKey) return null;
190
+ if (key !== "") return key;
191
+ const name = store?.constructor?.name ?? "store";
192
+ const keyGenerator = store?.keyGenerator;
193
+ if (keyGenerator && !EMPTY_KEY_WARNED.has(keyGenerator)) {
194
+ EMPTY_KEY_WARNED.add(keyGenerator);
195
+ console.warn(
196
+ `[CacheScope] ${name}'s keyGenerator returned an empty key; rendering uncached. Return the default key to leave a request unpartitioned.`,
197
+ );
198
+ }
199
+ throw new Error(
200
+ `[CacheScope] ${name}'s keyGenerator returned an empty key; rendering uncached`,
201
+ );
202
+ });
203
+ }
204
+
205
+ /**
206
+ * keyGenerator parts by position: none when every one returned the default
207
+ * key (the key stays as it was), else all of them, an empty part for each
208
+ * default. Dropping only the defaults lost the positions: with a locale
209
+ * store that returns the default key for `en` and a region store that does
210
+ * for `us`, `{ en, de }` and `{ de, us }` both kept one part `…|de` and
211
+ * shared a record and a shell.
212
+ */
213
+ function positionalParts(parts: readonly (string | null)[]): string[] {
214
+ return parts.every((part) => part === null)
215
+ ? []
216
+ : parts.map((part) => part ?? "");
217
+ }
218
+
219
+ /**
220
+ * One key resolution per request, resolver (`by`: a `key()` or a store's
221
+ * keyGenerator) and `slot`, held on the request context (`_resolvedCacheKeys`,
222
+ * see CacheScope.resolveKeyFrom).
223
+ */
224
+ function memoizedKey(
225
+ requestCtx: RequestContext,
226
+ by: unknown,
227
+ slot: string,
228
+ resolve: () => Promise<string>,
229
+ ): Promise<string> {
230
+ const memo = (requestCtx._resolvedCacheKeys ??= new Map());
231
+ let resolved = memo.get(by);
232
+ if (!resolved) memo.set(by, (resolved = new Map()));
233
+ let key = resolved.get(slot);
234
+ if (!key) resolved.set(slot, (key = resolve()));
235
+ return key;
236
+ }
114
237
 
115
238
  /**
116
239
  * Discriminated outcome of a route cache lookup — see
@@ -145,6 +268,41 @@ export class CacheScope {
145
268
  readonly parent: CacheScope | null;
146
269
  /** Explicit store from cache() options, if specified */
147
270
  private readonly explicitStore: SegmentCacheStore | undefined;
271
+ /**
272
+ * @internal The `key()` functions that partition this scope's records,
273
+ * outermost first: every cache() on the parent chain that sets one, this
274
+ * scope's last (#970). `cache(false)` adds none and does not cut the chain,
275
+ * so a cache() re-enabled under it stays in the partition. Before, the
276
+ * innermost config alone decided the key: an inner cache() without `key()`
277
+ * wrote under the default key and an inner `key()` dropped the outer one,
278
+ * so a request in one partition HIT another partition's inner records.
279
+ */
280
+ readonly keyFns: readonly CacheKeyFn[];
281
+ /** Whether this scope's own config sets `key()` (the last of keyFns). */
282
+ private readonly hasOwnKey: boolean;
283
+ /**
284
+ * The `condition()` predicates gating this scope, outermost first: every
285
+ * cache() on the parent chain that sets one, this scope's last (#974). A
286
+ * read or write needs all of them to allow it (conditionAllows). Before,
287
+ * only the innermost config's predicate ran, so an outer condition()
288
+ * refusing a request still let a nested cache() read and write.
289
+ */
290
+ private readonly conditions: readonly CacheConditionFn[];
291
+ /**
292
+ * The `tags` options of this scope and every cache() on the parent chain,
293
+ * outermost first (#974): a nested record carries them all (resolveTags),
294
+ * so `updateTag(outerTag)` evicts it and the shells built from it.
295
+ */
296
+ private readonly tagOptions: readonly CacheTagsOption[];
297
+ /**
298
+ * The enclosing enabled scopes without a `key()` of their own whose
299
+ * `store` option differs from this scope's, outermost first: a store
300
+ * keyGenerator of theirs partitions this scope's records too (#974,
301
+ * resolvePartitionParts). On the same store the keyGenerator already
302
+ * applies through this scope's own default key, or its own `key()`
303
+ * overrides it (#970).
304
+ */
305
+ private readonly storeScopes: readonly CacheScope[];
148
306
 
149
307
  constructor(
150
308
  config: PartialCacheOptions | false,
@@ -158,11 +316,46 @@ export class CacheScope {
158
316
  * a ppr route).
159
317
  */
160
318
  readonly boundary?: string,
319
+ /**
320
+ * Awaited on every lookup HIT: the implicit doc scope's marker `onHit`
321
+ * (createShellImplicitDocScope), which arms a partial replay's bake-lane
322
+ * loader pins before the route's loaders resolve.
323
+ */
324
+ private readonly onHit?: () => void | Promise<void>,
325
+ /**
326
+ * The record key of the implicit doc scope of a document HIT tail
327
+ * (createShellImplicitDocScope): the shell entry's own `docKey`, used as
328
+ * is. Not a `key()`, which would be namespaced (#975) and miss the
329
+ * record.
330
+ */
331
+ private readonly fixedKey?: string,
161
332
  ) {
162
333
  this.config = config;
163
334
  this.parent = parent;
335
+ const own = config === false ? undefined : config;
164
336
  // Extract and store explicit store reference
165
- this.explicitStore = config !== false ? config.store : undefined;
337
+ this.explicitStore = own?.store;
338
+ // Only a function counts: a conditional `key: flag ? fn : null` from
339
+ // untyped code is no key(), as resolveCacheKey always treated it.
340
+ const ownKey = typeof own?.key === "function" ? own.key : undefined;
341
+ this.hasOwnKey = ownKey !== undefined;
342
+ this.keyFns = appendOwn(parent?.keyFns, ownKey);
343
+ this.conditions = appendOwn(
344
+ parent?.conditions,
345
+ own?.condition || undefined,
346
+ );
347
+ this.tagOptions = appendOwn(parent?.tagOptions, own?.tags || undefined);
348
+ const storeScopes: CacheScope[] = [];
349
+ for (let scope = parent; scope; scope = scope.parent) {
350
+ if (
351
+ scope.enabled &&
352
+ !scope.hasOwnKey &&
353
+ scope.explicitStore !== this.explicitStore
354
+ ) {
355
+ storeScopes.unshift(scope);
356
+ }
357
+ }
358
+ this.storeScopes = storeScopes;
166
359
  }
167
360
 
168
361
  /**
@@ -242,6 +435,15 @@ export class CacheScope {
242
435
  * 2. App-level store from request context
243
436
  */
244
437
  getStore(): SegmentCacheStore | null {
438
+ // The implicit doc scope's store is a per-request overlay
439
+ // (createShellImplicitDocScope: SeededShellStore on a HIT tail or a
440
+ // partial replay, SnapshotOnlySegmentStore at capture), never a tag
441
+ // invalidation target. Registered like a cache({ store }), every HIT
442
+ // left one in the handler's explicit-store registry, and each
443
+ // updateTag()/revalidateTag() then warned about it.
444
+ if (this.isShellImplicitDocScope && this.explicitStore) {
445
+ return this.explicitStore;
446
+ }
245
447
  return resolveCacheStore(this.explicitStore);
246
448
  }
247
449
 
@@ -249,19 +451,200 @@ export class CacheScope {
249
451
  * Resolve the cache key using the shared 3-tier priority.
250
452
  * @internal
251
453
  */
252
- private async resolveKey(
454
+ private resolveKey(
253
455
  pathname: string,
254
456
  params: Record<string, string>,
255
457
  isIntercept?: boolean,
256
458
  ): Promise<string> {
257
- const defaultKey = getDefaultRouteCacheKey(
258
- pathname,
259
- params,
260
- isIntercept,
261
- this.defaultKeyPrefix,
459
+ return this.resolveKeyFrom(
460
+ getDefaultRouteCacheKey(
461
+ pathname,
462
+ params,
463
+ isIntercept,
464
+ this.defaultKeyPrefix,
465
+ ),
466
+ );
467
+ }
468
+
469
+ /**
470
+ * A shell captured from a route record this request read or wrote must not
471
+ * outlive it (noteRouteRecordWindow). The implicit doc scope's own record
472
+ * is the shell's, so it notes nothing.
473
+ */
474
+ private noteRecordWindow(
475
+ requestCtx: RequestContext,
476
+ freshUntil: number,
477
+ written: boolean,
478
+ ): void {
479
+ if (!this.isShellImplicitDocScope) {
480
+ noteRouteRecordWindow(
481
+ requestCtx,
482
+ freshUntil,
483
+ this.ttl,
484
+ this.swr ?? 0,
485
+ written,
486
+ );
487
+ }
488
+ }
489
+
490
+ /**
491
+ * @internal This scope's record key for `defaultKey`, resolved once per
492
+ * request: a ppr route's shell partition (resolvePartition) and the record
493
+ * lookup share the `key()` runs, and a rejection is shared too.
494
+ *
495
+ * With partition parts on the chain (resolvePartitionParts: the `key()`
496
+ * results, and the keyGenerator results of enclosing scopes on another
497
+ * store), they are composed by composeCacheKeys, which namespaces every
498
+ * `key()` result (#975), so a nested cache() keys its records within the
499
+ * enclosing partition (#970, #974). A scope whose own config sets no
500
+ * `key()` appends its own default key (resolveDefaultKey). Reusing the
501
+ * outer `key()` result alone would give every route under the inner
502
+ * cache() one record: a `key()` replaces the whole default key, so an
503
+ * outer key that names no route (`tier:gold`) would make `/a` HIT `/b`'s
504
+ * record. A scope with its own `key()` adds no default key: a `key()` is a
505
+ * full override, and its store's keyGenerator does not run. With no
506
+ * partition part it is resolveDefaultKey alone. With a `key()` on the
507
+ * chain but no request context it rejects (keyWithoutContextError).
508
+ * `partitionedPrefix` prefixes a key built from partition parts only (the
509
+ * response family's `response:`).
510
+ *
511
+ * Each `key()` result lives on the request context (`_resolvedCacheKeys`),
512
+ * by function: it runs once per request whatever the default key
513
+ * (document, partial or `doc` shell prefix) and however many scopes share
514
+ * it (sibling routes under one keyed cache()). A shell capture, whose
515
+ * context is `Object.create` of the request's (shell-capture.ts), reads the
516
+ * foreground's results through the prototype chain and never runs `key()`
517
+ * itself: a `key()` calling `cookies()` would trip the capture guard there,
518
+ * and a capture must store the partition its request resolved.
519
+ */
520
+ resolveKeyFrom(defaultKey: string, partitionedPrefix = ""): Promise<string> {
521
+ if (this.fixedKey !== undefined) return Promise.resolve(this.fixedKey);
522
+ const requestCtx = _getRequestContext();
523
+ if (!requestCtx || !this.mayPartition) {
524
+ return this.keyFns.length > 0
525
+ ? Promise.reject(keyWithoutContextError())
526
+ : this.resolveDefaultKey(requestCtx, defaultKey);
527
+ }
528
+ return Promise.all([
529
+ this.resolvePartitionParts(requestCtx, defaultKey),
530
+ this.hasOwnKey
531
+ ? undefined
532
+ : this.resolveDefaultKey(requestCtx, defaultKey),
533
+ ]).then(([[keys, storeParts], ownDefault]) => {
534
+ const generated = positionalParts(storeParts);
535
+ if (keys.length === 0 && generated.length === 0) return ownDefault!;
536
+ if (ownDefault !== undefined) generated.push(ownDefault);
537
+ return partitionedPrefix + composeCacheKeys(keys, generated);
538
+ });
539
+ }
540
+
541
+ /**
542
+ * @internal The request partition of a ppr route's shell under this scope
543
+ * (resolveShellPartition): its partition parts (resolvePartitionParts),
544
+ * composed like the record key but without the default key, which the
545
+ * URL-based shell key already carries. A scope without its own `key()`
546
+ * whose store has a keyGenerator adds that result when it differs from the
547
+ * `doc` default key, as an unkeyed scope's partition always did: the
548
+ * record is split by it, so the shell must be too. Undefined
549
+ * (synchronously, with no work) when nothing can partition; null when
550
+ * nothing does.
551
+ */
552
+ resolvePartition(
553
+ pathname: string,
554
+ params: Record<string, string> | undefined,
555
+ ): Promise<string | null> | undefined {
556
+ const keyGenerator = this.hasOwnKey
557
+ ? undefined
558
+ : this.getStore()?.keyGenerator;
559
+ if (!this.mayPartition && !keyGenerator) return undefined;
560
+ const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
561
+ const requestCtx = _getRequestContext();
562
+ if (!requestCtx) {
563
+ return this.keyFns.length > 0
564
+ ? Promise.reject(keyWithoutContextError())
565
+ : Promise.resolve(null);
566
+ }
567
+ return Promise.all([
568
+ this.resolvePartitionParts(requestCtx, defaultKey),
569
+ keyGenerator
570
+ ? generatedPart(
571
+ this.getStore(),
572
+ this.resolveDefaultKey(requestCtx, defaultKey),
573
+ defaultKey,
574
+ )
575
+ : null,
576
+ ]).then(([[keys, storeParts], ownGenerated]) => {
577
+ const generated = positionalParts(
578
+ keyGenerator ? [...storeParts, ownGenerated] : storeParts,
579
+ );
580
+ return keys.length === 0 && generated.length === 0
581
+ ? null
582
+ : composeCacheKeys(keys, generated);
583
+ });
584
+ }
585
+
586
+ /** Whether a partition part can apply: a `key()` or a store scope above. */
587
+ private get mayPartition(): boolean {
588
+ return this.keyFns.length > 0 || this.storeScopes.length > 0;
589
+ }
590
+
591
+ /**
592
+ * The parts partitioning this scope's records, outermost first: the
593
+ * chain's `key()` results (each once per request, memoizedKey) and the
594
+ * keyGenerator result of each enclosing scope on another store (once per
595
+ * keyGenerator, #974), null where it is `defaultKey`. The callers keep those
596
+ * positions (positionalParts): a result equal to the default key
597
+ * partitions nothing only when every one does (#970).
598
+ */
599
+ private resolvePartitionParts(
600
+ requestCtx: RequestContext,
601
+ defaultKey: string,
602
+ ): Promise<[string[], (string | null)[]]> {
603
+ const keys = this.keyFns.map((keyFn) =>
604
+ memoizedKey(requestCtx, keyFn, "", () =>
605
+ resolveCacheKey(keyFn, null, defaultKey),
606
+ ),
262
607
  );
263
- const keyFn = this.config !== false ? this.config.key : undefined;
264
- return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
608
+ const generated: Promise<string | null>[] = [];
609
+ if (this.storeScopes.length > 0) {
610
+ // By keyGenerator, not by store: a capture wraps the app store
611
+ // (shell-capture.ts RecordingShellStore) but not an explicit one, and
612
+ // the wrapper hands back the inner store's keyGenerator. By store, a
613
+ // chain naming the app store explicitly added its keyGenerator a second
614
+ // time in the capture, and the capture's record key was not the
615
+ // request's.
616
+ const seen = new Set([this.getStore()?.keyGenerator]);
617
+ for (const scope of this.storeScopes) {
618
+ const store = scope.getStore();
619
+ const keyGenerator = store?.keyGenerator;
620
+ if (!keyGenerator || seen.has(keyGenerator)) continue;
621
+ seen.add(keyGenerator);
622
+ generated.push(
623
+ generatedPart(
624
+ store,
625
+ scope.resolveDefaultKey(requestCtx, defaultKey),
626
+ defaultKey,
627
+ ),
628
+ );
629
+ }
630
+ }
631
+ return Promise.all([Promise.all(keys), Promise.all(generated)]);
632
+ }
633
+
634
+ /**
635
+ * This scope's store keyGenerator result for `defaultKey` (once per request
636
+ * and default key), else `defaultKey` (resolveCacheKey).
637
+ */
638
+ private resolveDefaultKey(
639
+ requestCtx: RequestContext | undefined,
640
+ defaultKey: string,
641
+ ): Promise<string> {
642
+ const store = this.getStore();
643
+ const keyGenerator = store?.keyGenerator;
644
+ const resolve = () => resolveCacheKey(undefined, store, defaultKey);
645
+ return requestCtx && keyGenerator
646
+ ? memoizedKey(requestCtx, keyGenerator, defaultKey, resolve)
647
+ : resolve();
265
648
  }
266
649
 
267
650
  /**
@@ -292,11 +675,11 @@ export class CacheScope {
292
675
  }
293
676
 
294
677
  /**
295
- * Evaluate the cache `condition` predicate. Returns false (skip the cache
296
- * operation) when the predicate returns false or throws; returns true when
297
- * there is no condition or no request context to evaluate it against.
298
- */
299
- /**
678
+ * Evaluate the cache `condition` predicates of this scope and every
679
+ * enclosing cache() (`conditions`, #974), outermost first. Returns false
680
+ * (skip the cache operation) when one returns false or throws; returns
681
+ * true when there is none or no request context to evaluate them against.
682
+ *
300
683
  * One WRITE decision per (scope, request), memoized on the request context.
301
684
  * A capture render has TWO writers consulting the same predicate — the
302
685
  * explicit tier's cacheRoute and the snapshot-only doc record gate
@@ -309,28 +692,37 @@ export class CacheScope {
309
692
  */
310
693
  private readonly writeConditionMemo = new WeakMap<RequestContext, boolean>();
311
694
 
695
+ /**
696
+ * Evaluate the cache `condition` predicate. Returns false (skip the cache
697
+ * operation) when the predicate returns false or throws; returns true when
698
+ * there is no condition or no request context to evaluate it against. A
699
+ * write decision is memoized (writeConditionMemo).
700
+ */
312
701
  private conditionAllows(op: "read" | "write"): boolean {
313
- if (this.config === false || !this.config.condition) return true;
314
- const requestCtx = getRequestContext();
702
+ if (this.conditions.length === 0) return true;
703
+ const requestCtx = _getRequestContext();
315
704
  if (!requestCtx) return true;
316
705
  if (op === "write") {
317
706
  const memoized = this.writeConditionMemo.get(requestCtx);
318
707
  if (memoized !== undefined) return memoized;
319
708
  }
320
- let allowed: boolean;
321
- try {
322
- allowed = !!this.config.condition(requestCtx);
323
- if (!allowed) {
324
- debugCacheLog(
325
- `[CacheScope] condition returned false, skipping cache ${op}`,
709
+ let allowed = true;
710
+ for (const condition of this.conditions) {
711
+ try {
712
+ allowed = !!runIdentityExempt(() => condition(requestCtx));
713
+ if (!allowed) {
714
+ debugCacheLog(
715
+ `[CacheScope] condition returned false, skipping cache ${op}`,
716
+ );
717
+ }
718
+ } catch (error) {
719
+ console.error(
720
+ `[CacheScope] condition function threw, skipping cache ${op}:`,
721
+ error,
326
722
  );
723
+ allowed = false;
327
724
  }
328
- } catch (error) {
329
- console.error(
330
- `[CacheScope] condition function threw, skipping cache ${op}:`,
331
- error,
332
- );
333
- allowed = false;
725
+ if (!allowed) break;
334
726
  }
335
727
  if (op === "write") this.writeConditionMemo.set(requestCtx, allowed);
336
728
  return allowed;
@@ -343,14 +735,15 @@ export class CacheScope {
343
735
  * @param pathname - URL pathname for cache key generation
344
736
  * @param params - Route params for cache key generation
345
737
  * @param isIntercept - Whether this is an intercept navigation (uses different cache key)
346
- * @param claimLoaderPushes - The handler context's _claimLoaderPushes, for
347
- * restoreHandles' loader-owned values
738
+ * @param ownedPushes - How restoreHandles delivers the record's
739
+ * loader-owned values (withCacheLookup: the handler context's
740
+ * _claimLoaderPushes, and on a document HIT tail the owners it restores)
348
741
  */
349
742
  async lookupRoute(
350
743
  pathname: string,
351
744
  params: Record<string, string>,
352
745
  isIntercept?: boolean,
353
- claimLoaderPushes?: (loaderId: string) => boolean,
746
+ ownedPushes?: OwnedPushDelivery,
354
747
  ): Promise<{
355
748
  segments: ResolvedSegment[];
356
749
  shouldRevalidate: boolean;
@@ -359,7 +752,7 @@ export class CacheScope {
359
752
  pathname,
360
753
  params,
361
754
  isIntercept,
362
- claimLoaderPushes,
755
+ ownedPushes,
363
756
  );
364
757
  return outcome.status === "hit" ? outcome.result : null;
365
758
  }
@@ -386,7 +779,7 @@ export class CacheScope {
386
779
  pathname: string,
387
780
  params: Record<string, string>,
388
781
  isIntercept?: boolean,
389
- claimLoaderPushes?: (loaderId: string) => boolean,
782
+ ownedPushes?: OwnedPushDelivery,
390
783
  ): Promise<CacheRouteLookupOutcome> {
391
784
  if (!this.enabled) return { status: "bypass" };
392
785
  if (!this.conditionAllows("read")) return { status: "bypass" };
@@ -431,7 +824,8 @@ export class CacheScope {
431
824
  // expand them (segment-fragments.ts). Read off the ambient context at the
432
825
  // same point the cache key was resolved (getDefaultRouteCacheKey), so the
433
826
  // flag shares fate with the key: a disrupted ALS already missed the
434
- // seeded record and degraded to the full tail.
827
+ // seeded record, which a document tail turns into the degrade
828
+ // (ShellRecordUnavailableError).
435
829
  let segments: ResolvedSegment[];
436
830
  const ambientContext = _getRequestContext();
437
831
  try {
@@ -467,8 +861,14 @@ export class CacheScope {
467
861
  // A hit serves content that was tagged at write time, so the document
468
862
  // tag union must include this entry's tags for updateTag()/revalidateTag()
469
863
  // to invalidate any full-page entry built on top of it. The write path
470
- // records via cacheRoute (resolveCacheTags); the hit path records here.
864
+ // records via cacheRoute (resolveTags); the hit path records here,
865
+ // onto the replayed segments too, for a record written from this replay
866
+ // (a shell capture's doc record).
471
867
  recordRequestTags(cached.tags);
868
+ recordSegmentTags(
869
+ segments.map((s) => s.id),
870
+ cached.tags,
871
+ );
472
872
 
473
873
  // Replay handle data. An empty string means the route pushed no handles —
474
874
  // skip the decode entirely (the common case). Otherwise decode the
@@ -488,7 +888,7 @@ export class CacheScope {
488
888
  handlesRecord,
489
889
  handleStore,
490
890
  cached.handleOwners,
491
- claimLoaderPushes,
891
+ ownedPushes,
492
892
  );
493
893
  }
494
894
  }
@@ -502,7 +902,10 @@ export class CacheScope {
502
902
  );
503
903
  }
504
904
 
505
- CACHE_HIT_OBSERVERS.get(this)?.();
905
+ if (ambientContext)
906
+ this.noteRecordWindow(ambientContext, cached.expiresAt, false);
907
+
908
+ await this.onHit?.();
506
909
  return { status: "hit", result: { segments, shouldRevalidate } };
507
910
  } catch (error) {
508
911
  // Covers a store.get() failure AND a throwing consumer key()/keyGenerator
@@ -535,7 +938,26 @@ export class CacheScope {
535
938
  recordTags(requestCtx: RequestContext | undefined): void {
536
939
  if (!this.enabled) return;
537
940
  if (!this.conditionAllows("write")) return;
538
- recordRequestTags(resolveCacheTags(this.config, requestCtx), requestCtx);
941
+ recordRequestTags(this.resolveTags(requestCtx), requestCtx);
942
+ }
943
+
944
+ /**
945
+ * @internal The `tags` of this scope and every enclosing cache()
946
+ * (`tagOptions`, #974), static and function forms, for `ctx`: the tags its
947
+ * records carry. Undefined when none resolve (a disabled scope has none).
948
+ */
949
+ resolveTags(ctx: RequestContext | undefined): string[] | undefined {
950
+ if (!this.enabled || this.tagOptions.length === 0) return undefined;
951
+ if (this.tagOptions.length === 1) {
952
+ return resolveTagsOption(this.tagOptions[0], ctx, "CacheScope");
953
+ }
954
+ const tags = new Set<string>();
955
+ for (const option of this.tagOptions) {
956
+ for (const tag of resolveTagsOption(option, ctx, "CacheScope") ?? []) {
957
+ tags.add(tag);
958
+ }
959
+ }
960
+ return tags.size > 0 ? [...tags] : undefined;
539
961
  }
540
962
 
541
963
  /**
@@ -547,12 +969,17 @@ export class CacheScope {
547
969
  * @param params - Route params for cache key generation
548
970
  * @param segments - All resolved segments to cache
549
971
  * @param isIntercept - Whether this is an intercept navigation (uses different cache key)
972
+ * @param start - Where the render of `segments` started: the record is not
973
+ * written when one of its tags was invalidated since (#977,
974
+ * predatesInvalidation). A shell capture's doc record passes none: it
975
+ * lives only in the shell entry, which putShell gates by its own start.
550
976
  */
551
977
  async cacheRoute(
552
978
  pathname: string,
553
979
  params: Record<string, string>,
554
980
  segments: ResolvedSegment[],
555
981
  isIntercept?: boolean,
982
+ start?: ExecutionStart,
556
983
  ): Promise<void> {
557
984
  if (!this.enabled || segments.length === 0) return;
558
985
  if (!this.conditionAllows("write")) return;
@@ -575,6 +1002,7 @@ export class CacheScope {
575
1002
 
576
1003
  const ttl = this.ttl;
577
1004
  const swr = this.swr;
1005
+ this.noteRecordWindow(requestCtx, Date.now() + ttl * 1000, true);
578
1006
 
579
1007
  // Resolve cache key early (while request context is available)
580
1008
  const key = await this.resolveKey(pathname, params, isIntercept);
@@ -589,7 +1017,7 @@ export class CacheScope {
589
1017
  }
590
1018
 
591
1019
  // Resolve tags early (while request context is available, before waitUntil)
592
- const tags = resolveCacheTags(this.config, requestCtx);
1020
+ const tags = this.resolveTags(requestCtx);
593
1021
  recordRequestTags(tags, requestCtx);
594
1022
 
595
1023
  // Check if this is a partial request (navigation) vs document request
@@ -657,9 +1085,21 @@ export class CacheScope {
657
1085
  const onFlightError = (error: unknown): void => {
658
1086
  flightErrors.push(error);
659
1087
  };
1088
+ // Each segment serializes inside its own tag scope, and the handles
1089
+ // inside one for this write: Flight re-renders their server
1090
+ // components, so their render-called tags land on the record.
1091
+ const handlesOwnerId = `${key}#handles`;
660
1092
  const [serializedSegments, encodedHandles] = await Promise.all([
661
- serializeSegments(nonLoaderSegments, onFlightError),
662
- encodeHandles(handles, onFlightError),
1093
+ Promise.all(
1094
+ nonLoaderSegments.map((segment) =>
1095
+ runInSegmentTagScope(segment.id, () =>
1096
+ serializeSegments([segment], onFlightError),
1097
+ ),
1098
+ ),
1099
+ ).then((parts) => parts.flat()),
1100
+ runInSegmentTagScope(handlesOwnerId, () =>
1101
+ encodeHandles(handles, onFlightError),
1102
+ ),
663
1103
  ]);
664
1104
  // Flight encodes a component that throws (an async server component in
665
1105
  // the tree) or a rejected handle value as an error row and completes
@@ -670,10 +1110,18 @@ export class CacheScope {
670
1110
  segments: serializedSegments,
671
1111
  handles: encodedHandles,
672
1112
  expiresAt: Date.now() + ttl * 1000,
673
- tags,
1113
+ tags: collectRecordTags(requestCtx, tags, [
1114
+ ...nonLoaderSegments.map((s) => s.id),
1115
+ handlesOwnerId,
1116
+ ]),
674
1117
  };
675
1118
  if (handleOwners && encodedHandles) data.handleOwners = handleOwners;
676
1119
 
1120
+ if (start && (await predatesInvalidation(store, data.tags, start))) {
1121
+ debugCacheLog(`[CacheScope] ${key}: invalidated since render`);
1122
+ return;
1123
+ }
1124
+
677
1125
  if (INTERNAL_RANGO_DEBUG) {
678
1126
  debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
679
1127
  }
@@ -699,6 +1147,56 @@ export class CacheScope {
699
1147
  }
700
1148
  }
701
1149
 
1150
+ /**
1151
+ * A route cache() record's tags (#957): its config tags plus every tag its
1152
+ * content recorded under `ownerIds` (cache-tag.ts getSegmentTags — the covered
1153
+ * segments' handlers, the server components their Flight serialization
1154
+ * re-rendered, the loaders those handlers consumed, and the handle values).
1155
+ * A HIT replays all of that output, loading() subtrees included, so every one
1156
+ * of these tags describes it.
1157
+ */
1158
+ function collectRecordTags(
1159
+ requestCtx: RequestContext,
1160
+ configTags: string[] | undefined,
1161
+ ownerIds: string[],
1162
+ ): string[] | undefined {
1163
+ const tags = new Set(configTags);
1164
+ const before = tags.size;
1165
+ for (const id of ownerIds) {
1166
+ for (const tag of getSegmentTags(requestCtx, id)) tags.add(tag);
1167
+ }
1168
+ return tags.size > before ? [...tags] : configTags;
1169
+ }
1170
+
1171
+ /**
1172
+ * @internal The request partition of a ppr route's shell: with a route
1173
+ * cache() scope, its CacheScope.resolvePartition (the chain's `key()`
1174
+ * results, namespaced and composed (#975), plus the keyGenerator results of
1175
+ * enclosing scopes on another store (#974) and of a scope without its own
1176
+ * `key()` (#970)); with none, the app store's keyGenerator result
1177
+ * given the document default key. Undefined (synchronously, with no work)
1178
+ * when neither applies: the shell key stays host + path + filtered search.
1179
+ * Resolves to null when the partition is the default key itself (a
1180
+ * keyGenerator that returns it unchanged): that partitions nothing, so the
1181
+ * shell stays unpartitioned and keeps its build shell. The shell's key, its
1182
+ * read and capture, and partial replay all use it, so a visitor is only
1183
+ * served its own partition. A rejected promise is a failed key resolution:
1184
+ * the caller serves no shell.
1185
+ */
1186
+ export function resolveShellPartition(
1187
+ routeScope: CacheScope | null | undefined,
1188
+ appStore: SegmentCacheStore | null | undefined,
1189
+ pathname: string,
1190
+ params: Record<string, string> | undefined,
1191
+ ): Promise<string | null> | undefined {
1192
+ if (routeScope?.enabled) return routeScope.resolvePartition(pathname, params);
1193
+ if (!appStore?.keyGenerator) return undefined;
1194
+ const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
1195
+ return resolveCacheKey(undefined, appStore, defaultKey).then((resolved) =>
1196
+ resolved === defaultKey ? null : resolved,
1197
+ );
1198
+ }
1199
+
702
1200
  /**
703
1201
  * Create a cache scope from entry's cache config. `shortCode` is the cache()
704
1202
  * entry's: a scope nested in an enabled parent keeps the parent's boundary
@@ -725,41 +1223,47 @@ type ShellImplicitCacheMarker = NonNullable<
725
1223
  * {@link resolveShellImplicitCacheScope} (routes that derived no scope) and
726
1224
  * the explicit-scope composition sites (capture doc record in the cache-store
727
1225
  * middleware, seeded replay fallback in withCacheLookup) so both ends of the
728
- * shell contract resolve the SAME canonical document key.
1226
+ * shell contract resolve the SAME canonical document key. A document HIT
1227
+ * tail's marker carries the entry's own key (`fixedDocKey`), which wins over
1228
+ * key resolution.
729
1229
  */
730
1230
  export function createShellImplicitDocScope(
731
1231
  marker: ShellImplicitCacheMarker,
732
1232
  ): CacheScope {
733
- const implicitScope = new CacheScope(
1233
+ return new CacheScope(
734
1234
  { ttl: marker.ttl, swr: marker.swr, store: marker.store },
735
1235
  null,
736
1236
  marker.keyPrefix,
1237
+ undefined,
1238
+ marker.onHit,
1239
+ marker.fixedDocKey,
737
1240
  );
738
- if (marker.onHit) CACHE_HIT_OBSERVERS.set(implicitScope, marker.onHit);
739
- return implicitScope;
740
1241
  }
741
1242
 
742
1243
  /**
743
- * Shell fast path: when the route tree derived NO cache scope and the current
744
- * request context carries the `_shellImplicitCache` marker (a shell capture,
745
- * an eligible HIT tail, or a normal partial navigation replay), substitute an
746
- * implicit doc-level scope so withCacheLookup/withCacheStore treat the WHOLE
747
- * matched route as a cache() boundary — the shell entry IS a cache() of the
748
- * handler layer, with loaders as the live carve-outs
749
- * (resolveFreshLoadersAndYield).
1244
+ * The doc record scope: when the current request context carries the
1245
+ * `_shellImplicitCache` marker (a shell capture, a document HIT tail, or a
1246
+ * normal partial navigation replay), substitute an implicit doc-level scope
1247
+ * so withCacheLookup/withCacheStore treat the WHOLE matched route as a
1248
+ * cache() boundary — the shell entry IS a cache() of the handler layer, with
1249
+ * loaders as the live carve-outs (resolveFreshLoadersAndYield).
750
1250
  *
751
- * An existing scope — including an explicit cache(false) opt-out — always
752
- * wins HERE: the consumer's cache() semantics (their ttl/swr/store/condition)
753
- * are never overridden, and cache(false) keeps the tail on the full handler
754
- * re-run path. On the navigation-replay serve path the marker still composes
755
- * with an explicit scope downstream (withCacheLookup's seeded fallback after
756
- * an explicit-tier miss) — see `onExplicitHit` on `_shellImplicitCache`.
1251
+ * A document HIT tail (`docTail`) always gets the implicit scope: it replays
1252
+ * the shell's own record and never consults a route-derived scope, whose
1253
+ * opt-outs the serve gate evaluated before the commit (shellServePlan). Every
1254
+ * other marker leaves an existing scope — including an explicit cache(false)
1255
+ * — in place: the consumer's cache() semantics (their ttl/swr/store/condition)
1256
+ * are never overridden. On the navigation-replay serve path the marker still
1257
+ * composes with an explicit scope downstream (withCacheLookup's seeded
1258
+ * fallback after an explicit-tier miss) — see `onExplicitHit` on
1259
+ * `_shellImplicitCache`; during a capture, recordShellCaptureDocRecord
1260
+ * (cache-store.ts) writes the record for such a scope.
757
1261
  */
758
1262
  export function resolveShellImplicitCacheScope(
759
1263
  scope: CacheScope | null,
760
1264
  ): CacheScope | null {
761
- if (scope) return scope;
762
1265
  const marker = getRequestContext()?._shellImplicitCache;
1266
+ if (scope && !marker?.docTail) return scope;
763
1267
  if (!marker) return null;
764
1268
  return createShellImplicitDocScope(marker);
765
1269
  }