@rangojs/router 0.16.0 → 0.18.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 (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -17,7 +17,12 @@ 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";
22
27
  // segment-codec is the only module on cache-scope's import graph that eagerly
23
28
  // pulls @vitejs/plugin-rsc (a virtual: module the plain node/vitest runner cannot
@@ -28,11 +33,12 @@ import { reportCacheError } from "./cache-error.js";
28
33
  // is unchanged: both methods are async and already awaited the codec.
29
34
  import {
30
35
  captureHandles,
36
+ captureHandleOwners,
31
37
  restoreHandles,
32
38
  encodeHandles,
33
39
  decodeHandles,
34
40
  } from "./handle-snapshot.js";
35
- import { cacheKeyBase } from "./cache-key-utils.js";
41
+ import { cacheKeyBase, composeCacheKeys } from "./cache-key-utils.js";
36
42
  import {
37
43
  DEFAULT_ROUTE_TTL,
38
44
  isFiniteNonNegativeSeconds,
@@ -50,6 +56,38 @@ export function resolveCacheTags(
50
56
  return resolveTagsOption(config.tags, ctx, "CacheScope");
51
57
  }
52
58
 
59
+ /**
60
+ * Narrow the request's route-record window (RequestContext._routeRecordWindow)
61
+ * to a record this request read or wrote: fresh until `freshUntil` (the
62
+ * record's `expiresAt`), then stale for `swr` seconds. A shell capture starts
63
+ * with no window of its own (shell-capture.ts deriveShellCaptureContext sets
64
+ * it undefined), so it narrows only to the records the capture itself read
65
+ * or wrote.
66
+ */
67
+ function noteRouteRecordWindow(
68
+ requestCtx: RequestContext,
69
+ freshUntil: number,
70
+ ttl: number,
71
+ swr: number,
72
+ written: boolean,
73
+ ): void {
74
+ const next = {
75
+ freshUntil,
76
+ staleUntil: freshUntil + swr * 1000,
77
+ ttl,
78
+ swr,
79
+ written,
80
+ };
81
+ const current = requestCtx._routeRecordWindow;
82
+ // The record that runs out first names the config; freshness is the least.
83
+ requestCtx._routeRecordWindow = current
84
+ ? {
85
+ ...(current.staleUntil <= next.staleUntil ? current : next),
86
+ freshUntil: Math.min(current.freshUntil, freshUntil),
87
+ }
88
+ : next;
89
+ }
90
+
53
91
  function debugCacheLog(message: string): void {
54
92
  if (INTERNAL_RANGO_DEBUG) {
55
93
  console.log(message);
@@ -109,7 +147,43 @@ function getDefaultRouteCacheKey(
109
147
  // CacheScope
110
148
  // ============================================================================
111
149
 
112
- const CACHE_HIT_OBSERVERS = new WeakMap<CacheScope, () => void>();
150
+ const CACHE_HIT_OBSERVERS = new WeakMap<
151
+ CacheScope,
152
+ () => void | Promise<void>
153
+ >();
154
+
155
+ type CacheKeyFn = NonNullable<PartialCacheOptions["key"]>;
156
+
157
+ /**
158
+ * A configured `key()` cannot run without a request context. Falling back to
159
+ * the default key would store the key()'s partitioned output under the broad
160
+ * key and serve it across partitions, so key resolution fails instead and
161
+ * every caller degrades to an uncached render (as for a throwing `key()`).
162
+ */
163
+ function keyWithoutContextError(): Error {
164
+ return new Error(
165
+ "[CacheScope] a cache() key() needs a request context; rendering uncached",
166
+ );
167
+ }
168
+
169
+ /**
170
+ * One key resolution per request, resolver (`by`: a `key()` or a store's
171
+ * keyGenerator) and `slot`, held on the request context (`_resolvedCacheKeys`,
172
+ * see CacheScope.resolveKeyFrom).
173
+ */
174
+ function memoizedKey(
175
+ requestCtx: RequestContext,
176
+ by: unknown,
177
+ slot: string,
178
+ resolve: () => Promise<string>,
179
+ ): Promise<string> {
180
+ const memo = (requestCtx._resolvedCacheKeys ??= new Map());
181
+ let resolved = memo.get(by);
182
+ if (!resolved) memo.set(by, (resolved = new Map()));
183
+ let key = resolved.get(slot);
184
+ if (!key) resolved.set(slot, (key = resolve()));
185
+ return key;
186
+ }
113
187
 
114
188
  /**
115
189
  * Discriminated outcome of a route cache lookup — see
@@ -144,16 +218,63 @@ export class CacheScope {
144
218
  readonly parent: CacheScope | null;
145
219
  /** Explicit store from cache() options, if specified */
146
220
  private readonly explicitStore: SegmentCacheStore | undefined;
221
+ /**
222
+ * @internal The `key()` functions that partition this scope's records,
223
+ * outermost first: every cache() on the parent chain that sets one, this
224
+ * scope's last (#970). `cache(false)` adds none and does not cut the chain,
225
+ * so a cache() re-enabled under it stays in the partition. Before, the
226
+ * innermost config alone decided the key: an inner cache() without `key()`
227
+ * wrote under the default key and an inner `key()` dropped the outer one,
228
+ * so a request in one partition HIT another partition's inner records.
229
+ */
230
+ readonly keyFns: readonly CacheKeyFn[];
231
+ /** Whether this scope's own config sets `key()` (the last of keyFns). */
232
+ private readonly hasOwnKey: boolean;
147
233
 
148
234
  constructor(
149
235
  config: PartialCacheOptions | false,
150
236
  parent: CacheScope | null = null,
151
237
  private readonly defaultKeyPrefix?: "doc",
238
+ /**
239
+ * @internal shortCode of the outermost cache() entry of this scope's
240
+ * enabled chain (createCacheScope). Entries above it are live: their
241
+ * segments are not stored and resolve fresh on a hit (withCacheLookup).
242
+ * Undefined covers the whole matched chain (the implicit shell doc scope,
243
+ * a ppr route).
244
+ */
245
+ readonly boundary?: string,
152
246
  ) {
153
247
  this.config = config;
154
248
  this.parent = parent;
155
249
  // Extract and store explicit store reference
156
250
  this.explicitStore = config !== false ? config.store : undefined;
251
+ // Only a function counts: a conditional `key: flag ? fn : null` from
252
+ // untyped code is no key(), as resolveCacheKey always treated it.
253
+ const ownKey =
254
+ config !== false && typeof config.key === "function"
255
+ ? config.key
256
+ : undefined;
257
+ const inherited = parent?.keyFns ?? [];
258
+ this.hasOwnKey = ownKey !== undefined;
259
+ this.keyFns = ownKey ? [...inherited, ownKey] : inherited;
260
+ }
261
+
262
+ /**
263
+ * @internal Whether a segment belongs to this scope's cache entry. ShortCodes are
264
+ * hierarchical (server/context.ts getShortCode: parent shortCode + include
265
+ * scope + type letter + counter), so the boundary's subtree is every id that
266
+ * extends the boundary shortCode at a non-digit (`C1` must not claim `C10`).
267
+ * Intercept slot segments ride the target route's entry wherever the
268
+ * intercept is declared.
269
+ */
270
+ covers(id: string, namespace?: string): boolean {
271
+ const boundary = this.boundary;
272
+ if (boundary === undefined) return true;
273
+ if (id.startsWith(boundary)) {
274
+ const next = id.charCodeAt(boundary.length);
275
+ return !(next >= 48 && next <= 57);
276
+ }
277
+ return namespace?.startsWith("intercept:") === true;
157
278
  }
158
279
 
159
280
  /**
@@ -222,19 +343,151 @@ export class CacheScope {
222
343
  * Resolve the cache key using the shared 3-tier priority.
223
344
  * @internal
224
345
  */
225
- private async resolveKey(
346
+ private resolveKey(
226
347
  pathname: string,
227
348
  params: Record<string, string>,
228
349
  isIntercept?: boolean,
229
350
  ): Promise<string> {
230
- const defaultKey = getDefaultRouteCacheKey(
231
- pathname,
232
- params,
233
- isIntercept,
234
- this.defaultKeyPrefix,
351
+ return this.resolveKeyFrom(
352
+ getDefaultRouteCacheKey(
353
+ pathname,
354
+ params,
355
+ isIntercept,
356
+ this.defaultKeyPrefix,
357
+ ),
358
+ );
359
+ }
360
+
361
+ /**
362
+ * A shell captured from a route record this request read or wrote must not
363
+ * outlive it (noteRouteRecordWindow). The implicit doc scope's own record
364
+ * is the shell's, so it notes nothing.
365
+ */
366
+ private noteRecordWindow(
367
+ requestCtx: RequestContext,
368
+ freshUntil: number,
369
+ written: boolean,
370
+ ): void {
371
+ if (!this.isShellImplicitDocScope) {
372
+ noteRouteRecordWindow(
373
+ requestCtx,
374
+ freshUntil,
375
+ this.ttl,
376
+ this.swr ?? 0,
377
+ written,
378
+ );
379
+ }
380
+ }
381
+
382
+ /**
383
+ * @internal This scope's record key for `defaultKey`, resolved once per
384
+ * request: a ppr route's shell partition (resolvePartition) and the record
385
+ * lookup share the `key()` runs, and a rejection is shared too.
386
+ *
387
+ * With `key()` functions on the chain (keyFns) the parts are their results,
388
+ * outermost first, composed by composeCacheKeys (a single part raw), so a
389
+ * nested cache() keys its records within the enclosing partition (#970).
390
+ * A scope whose own config sets no `key()` appends its own default key
391
+ * (resolveDefaultKey). Reusing the outer `key()` result alone would give
392
+ * every route under the inner cache() one record: a `key()` replaces the
393
+ * whole default key, so an outer key that names no route (`tier:gold`)
394
+ * would make `/a` HIT `/b`'s record. A scope with its own `key()` adds no
395
+ * default key: a `key()` is a full override, and no keyGenerator runs.
396
+ * Without `key()` on the chain it is resolveDefaultKey alone. With one but
397
+ * no request context it rejects (keyWithoutContextError).
398
+ *
399
+ * Each `key()` result lives on the request context (`_resolvedCacheKeys`),
400
+ * by function: it runs once per request whatever the default key
401
+ * (document, partial or `doc` shell prefix) and however many scopes share
402
+ * it (sibling routes under one keyed cache()). A shell capture, whose
403
+ * context is `Object.create` of the request's (shell-capture.ts), reads the
404
+ * foreground's results through the prototype chain and never runs `key()`
405
+ * itself: a `key()` calling `cookies()` would trip the capture guard there,
406
+ * and a capture must store the partition its request resolved.
407
+ */
408
+ resolveKeyFrom(defaultKey: string): Promise<string> {
409
+ const requestCtx = _getRequestContext();
410
+ if (this.keyFns.length === 0) {
411
+ return this.resolveDefaultKey(requestCtx, defaultKey);
412
+ }
413
+ if (!requestCtx) return Promise.reject(keyWithoutContextError());
414
+ const parts = this.resolveKeyResults(requestCtx, defaultKey);
415
+ if (!this.hasOwnKey) {
416
+ parts.push(this.resolveDefaultKey(requestCtx, defaultKey));
417
+ }
418
+ return parts.length === 1
419
+ ? parts[0]
420
+ : Promise.all(parts).then(composeCacheKeys);
421
+ }
422
+
423
+ /**
424
+ * @internal The request partition of a ppr route's shell under this scope
425
+ * (resolveShellPartition): the chain's `key()` results, composed like the
426
+ * record key but without the default key, which the URL-based shell key
427
+ * already carries. A scope without its own `key()` whose store has a
428
+ * keyGenerator adds that result when it differs from the `doc` default
429
+ * key, as an unkeyed scope's partition always did: the record is split by
430
+ * it, so the shell must be too. Undefined (synchronously, with no work)
431
+ * when nothing can partition; null when nothing does.
432
+ */
433
+ resolvePartition(
434
+ pathname: string,
435
+ params: Record<string, string> | undefined,
436
+ ): Promise<string | null> | undefined {
437
+ const keyGenerator = this.hasOwnKey
438
+ ? undefined
439
+ : this.getStore()?.keyGenerator;
440
+ if (this.keyFns.length === 0 && !keyGenerator) return undefined;
441
+ const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
442
+ const requestCtx = _getRequestContext();
443
+ if (!requestCtx) {
444
+ return this.keyFns.length > 0
445
+ ? Promise.reject(keyWithoutContextError())
446
+ : Promise.resolve(null);
447
+ }
448
+ const generated = keyGenerator
449
+ ? this.resolveDefaultKey(requestCtx, defaultKey)
450
+ : undefined;
451
+ return Promise.all([
452
+ Promise.all(this.resolveKeyResults(requestCtx, defaultKey)),
453
+ generated,
454
+ ]).then(([keys, generatedKey]) => {
455
+ const parts =
456
+ generatedKey === undefined || generatedKey === defaultKey
457
+ ? keys
458
+ : [...keys, generatedKey];
459
+ const partition = parts.length === 0 ? null : composeCacheKeys(parts);
460
+ return partition === defaultKey ? null : partition;
461
+ });
462
+ }
463
+
464
+ /** The chain's `key()` results, each once per request (memoizedKey). */
465
+ private resolveKeyResults(
466
+ requestCtx: RequestContext,
467
+ defaultKey: string,
468
+ ): Promise<string>[] {
469
+ return this.keyFns.map((keyFn) =>
470
+ memoizedKey(requestCtx, keyFn, "", () =>
471
+ resolveCacheKey(keyFn, null, defaultKey, "CacheScope"),
472
+ ),
235
473
  );
236
- const keyFn = this.config !== false ? this.config.key : undefined;
237
- return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
474
+ }
475
+
476
+ /**
477
+ * This scope's store keyGenerator result for `defaultKey` (once per request
478
+ * and default key), else `defaultKey` (resolveCacheKey).
479
+ */
480
+ private resolveDefaultKey(
481
+ requestCtx: RequestContext | undefined,
482
+ defaultKey: string,
483
+ ): Promise<string> {
484
+ const store = this.getStore();
485
+ const keyGenerator = store?.keyGenerator;
486
+ const resolve = () =>
487
+ resolveCacheKey(undefined, store, defaultKey, "CacheScope");
488
+ return requestCtx && keyGenerator
489
+ ? memoizedKey(requestCtx, keyGenerator, defaultKey, resolve)
490
+ : resolve();
238
491
  }
239
492
 
240
493
  /**
@@ -316,11 +569,14 @@ export class CacheScope {
316
569
  * @param pathname - URL pathname for cache key generation
317
570
  * @param params - Route params for cache key generation
318
571
  * @param isIntercept - Whether this is an intercept navigation (uses different cache key)
572
+ * @param claimLoaderPushes - The handler context's _claimLoaderPushes, for
573
+ * restoreHandles' loader-owned values
319
574
  */
320
575
  async lookupRoute(
321
576
  pathname: string,
322
577
  params: Record<string, string>,
323
578
  isIntercept?: boolean,
579
+ claimLoaderPushes?: (loaderId: string) => boolean,
324
580
  ): Promise<{
325
581
  segments: ResolvedSegment[];
326
582
  shouldRevalidate: boolean;
@@ -329,6 +585,7 @@ export class CacheScope {
329
585
  pathname,
330
586
  params,
331
587
  isIntercept,
588
+ claimLoaderPushes,
332
589
  );
333
590
  return outcome.status === "hit" ? outcome.result : null;
334
591
  }
@@ -355,6 +612,7 @@ export class CacheScope {
355
612
  pathname: string,
356
613
  params: Record<string, string>,
357
614
  isIntercept?: boolean,
615
+ claimLoaderPushes?: (loaderId: string) => boolean,
358
616
  ): Promise<CacheRouteLookupOutcome> {
359
617
  if (!this.enabled) return { status: "bypass" };
360
618
  if (!this.conditionAllows("read")) return { status: "bypass" };
@@ -399,7 +657,8 @@ export class CacheScope {
399
657
  // expand them (segment-fragments.ts). Read off the ambient context at the
400
658
  // same point the cache key was resolved (getDefaultRouteCacheKey), so the
401
659
  // flag shares fate with the key: a disrupted ALS already missed the
402
- // seeded record and degraded to the full tail.
660
+ // seeded record, which a document tail turns into the degrade
661
+ // (ShellRecordUnavailableError).
403
662
  let segments: ResolvedSegment[];
404
663
  const ambientContext = _getRequestContext();
405
664
  try {
@@ -424,11 +683,25 @@ export class CacheScope {
424
683
  return { status: "miss" };
425
684
  }
426
685
 
686
+ // A record from a whole-chain writer (a previous build on an unversioned
687
+ // store) can hold segments above the boundary. withCacheLookup resolves
688
+ // those fresh, so replaying them too would duplicate them and their
689
+ // handle pushes.
690
+ if (this.boundary !== undefined) {
691
+ segments = segments.filter((s) => this.covers(s.id, s.namespace));
692
+ }
693
+
427
694
  // A hit serves content that was tagged at write time, so the document
428
695
  // tag union must include this entry's tags for updateTag()/revalidateTag()
429
696
  // to invalidate any full-page entry built on top of it. The write path
430
- // records via cacheRoute (resolveCacheTags); the hit path records here.
697
+ // records via cacheRoute (resolveCacheTags); the hit path records here,
698
+ // onto the replayed segments too, for a record written from this replay
699
+ // (a shell capture's doc record).
431
700
  recordRequestTags(cached.tags);
701
+ recordSegmentTags(
702
+ segments.map((s) => s.id),
703
+ cached.tags,
704
+ );
432
705
 
433
706
  // Replay handle data. An empty string means the route pushed no handles —
434
707
  // skip the decode entirely (the common case). Otherwise decode the
@@ -438,7 +711,18 @@ export class CacheScope {
438
711
  if (handleStore && cached.handles) {
439
712
  const handlesRecord = await decodeHandles(cached.handles);
440
713
  if (handlesRecord) {
441
- restoreHandles(handlesRecord, handleStore);
714
+ if (this.boundary !== undefined) {
715
+ const kept = new Set(segments.map((s) => s.id));
716
+ for (const id of Object.keys(handlesRecord)) {
717
+ if (!kept.has(id)) delete handlesRecord[id];
718
+ }
719
+ }
720
+ restoreHandles(
721
+ handlesRecord,
722
+ handleStore,
723
+ cached.handleOwners,
724
+ claimLoaderPushes,
725
+ );
442
726
  }
443
727
  }
444
728
 
@@ -451,7 +735,10 @@ export class CacheScope {
451
735
  );
452
736
  }
453
737
 
454
- CACHE_HIT_OBSERVERS.get(this)?.();
738
+ if (ambientContext)
739
+ this.noteRecordWindow(ambientContext, cached.expiresAt, false);
740
+
741
+ await CACHE_HIT_OBSERVERS.get(this)?.();
455
742
  return { status: "hit", result: { segments, shouldRevalidate } };
456
743
  } catch (error) {
457
744
  // Covers a store.get() failure AND a throwing consumer key()/keyGenerator
@@ -515,12 +802,16 @@ export class CacheScope {
515
802
  if (!handleStore || !requestCtx) return;
516
803
 
517
804
  // Exclude loader segments - loaders are always fresh by default
518
- // Loaders can opt-in to caching with their own cache() config
519
- const nonLoaderSegments = segments.filter((s) => s.type !== "loader");
805
+ // Loaders can opt-in to caching with their own cache() config.
806
+ // Segments above the boundary are live and never stored.
807
+ const nonLoaderSegments = segments.filter(
808
+ (s) => s.type !== "loader" && this.covers(s.id, s.namespace),
809
+ );
520
810
  if (nonLoaderSegments.length === 0) return;
521
811
 
522
812
  const ttl = this.ttl;
523
813
  const swr = this.swr;
814
+ this.noteRecordWindow(requestCtx, Date.now() + ttl * 1000, true);
524
815
 
525
816
  // Resolve cache key early (while request context is available)
526
817
  const key = await this.resolveKey(pathname, params, isIntercept);
@@ -585,11 +876,8 @@ export class CacheScope {
585
876
  }
586
877
 
587
878
  // Collect handle data for non-loader segments only
588
- const handles = captureHandles(
589
- nonLoaderSegments,
590
- handleStore,
591
- requestCtx._shellCaptureLoaderHandleValues,
592
- );
879
+ const handles = captureHandles(nonLoaderSegments, handleStore);
880
+ const handleOwners = captureHandleOwners(nonLoaderSegments, handleStore);
593
881
 
594
882
  try {
595
883
  if (INTERNAL_RANGO_DEBUG) {
@@ -602,17 +890,41 @@ export class CacheScope {
602
890
  // through the codec (not raw into the entry) so Promise/ReactNode handle
603
891
  // values survive a JSON-serializing store — see encodeHandles.
604
892
  const { serializeSegments } = await import("./segment-codec.js");
893
+ const flightErrors: unknown[] = [];
894
+ const onFlightError = (error: unknown): void => {
895
+ flightErrors.push(error);
896
+ };
897
+ // Each segment serializes inside its own tag scope, and the handles
898
+ // inside one for this write: Flight re-renders their server
899
+ // components, so their render-called tags land on the record.
900
+ const handlesOwnerId = `${key}#handles`;
605
901
  const [serializedSegments, encodedHandles] = await Promise.all([
606
- serializeSegments(nonLoaderSegments),
607
- encodeHandles(handles),
902
+ Promise.all(
903
+ nonLoaderSegments.map((segment) =>
904
+ runInSegmentTagScope(segment.id, () =>
905
+ serializeSegments([segment], onFlightError),
906
+ ),
907
+ ),
908
+ ).then((parts) => parts.flat()),
909
+ runInSegmentTagScope(handlesOwnerId, () =>
910
+ encodeHandles(handles, onFlightError),
911
+ ),
608
912
  ]);
913
+ // Flight encodes a component that throws (an async server component in
914
+ // the tree) or a rejected handle value as an error row and completes
915
+ // normally; stored, every HIT would render that error until expiry.
916
+ if (flightErrors.length > 0) throw flightErrors[0];
609
917
 
610
918
  const data: CachedEntryData = {
611
919
  segments: serializedSegments,
612
920
  handles: encodedHandles,
613
921
  expiresAt: Date.now() + ttl * 1000,
614
- tags,
922
+ tags: collectRecordTags(requestCtx, tags, [
923
+ ...nonLoaderSegments.map((s) => s.id),
924
+ handlesOwnerId,
925
+ ]),
615
926
  };
927
+ if (handleOwners && encodedHandles) data.handleOwners = handleOwners;
616
928
 
617
929
  if (INTERNAL_RANGO_DEBUG) {
618
930
  debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
@@ -640,14 +952,67 @@ export class CacheScope {
640
952
  }
641
953
 
642
954
  /**
643
- * Create a cache scope from entry's cache config
955
+ * A route cache() record's tags (#957): its config tags plus every tag its
956
+ * content recorded under `ownerIds` (cache-tag.ts getSegmentTags — the covered
957
+ * segments' handlers, the server components their Flight serialization
958
+ * re-rendered, the loaders those handlers consumed, and the handle values).
959
+ * A HIT replays all of that output, loading() subtrees included, so every one
960
+ * of these tags describes it.
961
+ */
962
+ function collectRecordTags(
963
+ requestCtx: RequestContext,
964
+ configTags: string[] | undefined,
965
+ ownerIds: string[],
966
+ ): string[] | undefined {
967
+ const tags = new Set(configTags);
968
+ const before = tags.size;
969
+ for (const id of ownerIds) {
970
+ for (const tag of getSegmentTags(requestCtx, id)) tags.add(tag);
971
+ }
972
+ return tags.size > before ? [...tags] : configTags;
973
+ }
974
+
975
+ /**
976
+ * @internal The request partition of a ppr route's shell: with a route
977
+ * cache() scope, its CacheScope.resolvePartition (the chain's `key()`
978
+ * results, composed, plus a keyGenerator's contribution for a scope without
979
+ * its own `key()` (#970)); with none, the app store's keyGenerator result
980
+ * given the document default key. Undefined (synchronously, with no work)
981
+ * when neither applies: the shell key stays host + path + filtered search.
982
+ * Resolves to null when the partition is the default key itself (a
983
+ * keyGenerator that returns it unchanged): that partitions nothing, so the
984
+ * shell stays unpartitioned and keeps its build shell. The shell's key, its
985
+ * read and capture, and partial replay all use it, so a visitor is only
986
+ * served its own partition. A rejected promise is a failed key resolution:
987
+ * the caller serves no shell.
988
+ */
989
+ export function resolveShellPartition(
990
+ routeScope: CacheScope | null | undefined,
991
+ appStore: SegmentCacheStore | null | undefined,
992
+ pathname: string,
993
+ params: Record<string, string> | undefined,
994
+ ): Promise<string | null> | undefined {
995
+ if (routeScope?.enabled) return routeScope.resolvePartition(pathname, params);
996
+ if (!appStore?.keyGenerator) return undefined;
997
+ const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
998
+ return resolveCacheKey(undefined, appStore, defaultKey, "CacheScope").then(
999
+ (resolved) => (resolved === defaultKey ? null : resolved),
1000
+ );
1001
+ }
1002
+
1003
+ /**
1004
+ * Create a cache scope from entry's cache config. `shortCode` is the cache()
1005
+ * entry's: a scope nested in an enabled parent keeps the parent's boundary
1006
+ * (one entry covers both), any other opens its own at this entry.
644
1007
  */
645
1008
  export function createCacheScope(
646
1009
  config: { options: PartialCacheOptions | false } | undefined,
647
1010
  parent: CacheScope | null = null,
1011
+ shortCode?: string,
648
1012
  ): CacheScope | null {
649
1013
  if (!config) return parent; // No config, inherit parent
650
- return new CacheScope(config.options, parent);
1014
+ const boundary = parent?.enabled ? parent.boundary : shortCode;
1015
+ return new CacheScope(config.options, parent, undefined, boundary);
651
1016
  }
652
1017
 
653
1018
  type ShellImplicitCacheMarker = NonNullable<
@@ -661,13 +1026,21 @@ type ShellImplicitCacheMarker = NonNullable<
661
1026
  * {@link resolveShellImplicitCacheScope} (routes that derived no scope) and
662
1027
  * the explicit-scope composition sites (capture doc record in the cache-store
663
1028
  * middleware, seeded replay fallback in withCacheLookup) so both ends of the
664
- * shell contract resolve the SAME canonical document key.
1029
+ * shell contract resolve the SAME canonical document key. A document HIT
1030
+ * tail's marker carries the entry's own key (`fixedDocKey`), which wins over
1031
+ * key resolution.
665
1032
  */
666
1033
  export function createShellImplicitDocScope(
667
1034
  marker: ShellImplicitCacheMarker,
668
1035
  ): CacheScope {
1036
+ const fixedDocKey = marker.fixedDocKey;
669
1037
  const implicitScope = new CacheScope(
670
- { ttl: marker.ttl, swr: marker.swr, store: marker.store },
1038
+ {
1039
+ ttl: marker.ttl,
1040
+ swr: marker.swr,
1041
+ store: marker.store,
1042
+ ...(fixedDocKey !== undefined && { key: () => fixedDocKey }),
1043
+ },
671
1044
  null,
672
1045
  marker.keyPrefix,
673
1046
  );
@@ -676,26 +1049,29 @@ export function createShellImplicitDocScope(
676
1049
  }
677
1050
 
678
1051
  /**
679
- * Shell fast path: when the route tree derived NO cache scope and the current
680
- * request context carries the `_shellImplicitCache` marker (a shell capture,
681
- * an eligible HIT tail, or a normal partial navigation replay), substitute an
682
- * implicit doc-level scope so withCacheLookup/withCacheStore treat the WHOLE
683
- * matched route as a cache() boundary — the shell entry IS a cache() of the
684
- * handler layer, with loaders as the live carve-outs
685
- * (resolveFreshLoadersAndYield).
1052
+ * The doc record scope: when the current request context carries the
1053
+ * `_shellImplicitCache` marker (a shell capture, a document HIT tail, or a
1054
+ * normal partial navigation replay), substitute an implicit doc-level scope
1055
+ * so withCacheLookup/withCacheStore treat the WHOLE matched route as a
1056
+ * cache() boundary — the shell entry IS a cache() of the handler layer, with
1057
+ * loaders as the live carve-outs (resolveFreshLoadersAndYield).
686
1058
  *
687
- * An existing scope — including an explicit cache(false) opt-out — always
688
- * wins HERE: the consumer's cache() semantics (their ttl/swr/store/condition)
689
- * are never overridden, and cache(false) keeps the tail on the full handler
690
- * re-run path. On the navigation-replay serve path the marker still composes
691
- * with an explicit scope downstream (withCacheLookup's seeded fallback after
692
- * an explicit-tier miss) — see `onExplicitHit` on `_shellImplicitCache`.
1059
+ * A document HIT tail (`docTail`) always gets the implicit scope: it replays
1060
+ * the shell's own record and never consults a route-derived scope, whose
1061
+ * opt-outs the serve gate evaluated before the commit (shellServePlan). Every
1062
+ * other marker leaves an existing scope — including an explicit cache(false)
1063
+ * — in place: the consumer's cache() semantics (their ttl/swr/store/condition)
1064
+ * are never overridden. On the navigation-replay serve path the marker still
1065
+ * composes with an explicit scope downstream (withCacheLookup's seeded
1066
+ * fallback after an explicit-tier miss) — see `onExplicitHit` on
1067
+ * `_shellImplicitCache`; during a capture, recordShellCaptureDocRecord
1068
+ * (cache-store.ts) writes the record for such a scope.
693
1069
  */
694
1070
  export function resolveShellImplicitCacheScope(
695
1071
  scope: CacheScope | null,
696
1072
  ): CacheScope | null {
697
- if (scope) return scope;
698
1073
  const marker = getRequestContext()?._shellImplicitCache;
1074
+ if (scope && !marker?.docTail) return scope;
699
1075
  if (!marker) return null;
700
1076
  return createShellImplicitDocScope(marker);
701
1077
  }