@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
@@ -6,7 +6,8 @@
6
6
  * getItem()/setItem() methods on SegmentCacheStore.
7
7
  *
8
8
  * Cache key resolution (3-tier, matching CacheScope.resolveKey):
9
- * 1. options.key(requestCtx) — full override
9
+ * 1. options.key(requestCtx) — full override, stored namespaced as
10
+ * loader:{loaderId}:key:{encodeURIComponent(result)} (#1009)
10
11
  * 2. store.keyGenerator(requestCtx, defaultKey) — store-level modification
11
12
  * 3. loader:{loaderId}:{host}{pathname}:{sortedParams} — default
12
13
  *
@@ -17,18 +18,36 @@
17
18
  * On stale hit (SWR): returns stale data, schedules background revalidation.
18
19
  * On miss: executes loader, schedules non-blocking cache write.
19
20
  *
21
+ * Identity (#972): without a key() or a store keyGenerator the entry is
22
+ * shared across users, so a fill whose execution recorded a request-identity
23
+ * read fails and is not stored, whoever started that execution
24
+ * (server/context.ts recordLoaderIdentityRead).
25
+ *
20
26
  * Handle pushes (`ctx.use(Handle)(...)` in the loader body) are a side effect
21
27
  * the HIT must reproduce: the MISS records the pushes of the body and of the
22
28
  * loaders it awaits via ctx.use into the entry's `handles` blob (the
23
29
  * "use cache" capture/encode), and every HIT — stale included — replays
24
30
  * them, each loader's pushes at most once per request (a dependency another
25
31
  * reader already ran keeps its live pushes). See replayLoaderHandles.
32
+ *
33
+ * Tags too (#964): the entry stores its cache() tags plus every tag its
34
+ * execution recorded, those of the loader values it read included
35
+ * (cache-tag.ts "Recorded-tag sets"), so updateTag() of any of them drops it.
36
+ * Every HIT records the stored tags through the loader's tag owner
37
+ * (recordLoaderTags), so a route cache() record, PPR shell or document built
38
+ * over the HIT carries them, and a loader that reads this one takes them on.
39
+ * Once the binding started, a loader body's ctx.use of it gets the same
40
+ * dataPromise as a handler read (createLoaderExecutor's useLoader); a stale
41
+ * refresh does not (setupLoaderAccess _runLoaderIsolated).
26
42
  */
27
43
 
28
44
  import {
29
45
  getCurrentLoaderBodyId,
30
46
  isInsideLoaderBody,
47
+ loaderCacheIdentityError,
48
+ type EntryData,
31
49
  type LoaderEntry,
50
+ type LoaderIdentityRead,
32
51
  } from "../../server/context.js";
33
52
  import type { HandlerContext, InternalHandlerContext } from "../../types.js";
34
53
  import type { HandleStore } from "../../server/handle-store.js";
@@ -39,13 +58,17 @@ import {
39
58
  } from "../../cache/handle-capture.js";
40
59
  import { encodeHandles, decodeHandles } from "../../cache/handle-snapshot.js";
41
60
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
61
+ import { runIdentityExempt } from "../../cache/cache-exec-scope.js";
42
62
  import {
43
63
  getRequestContext,
44
64
  _getRequestContext,
45
65
  runWithRequestContext,
46
66
  } from "../../server/request-context.js";
47
67
  import { observePhase, PHASES } from "../instrument.js";
48
- import { sortedRouteParams } from "../../cache/cache-key-utils.js";
68
+ import {
69
+ KEY_PART_PREFIX,
70
+ sortedRouteParams,
71
+ } from "../../cache/cache-key-utils.js";
49
72
  import {
50
73
  resolveTtl,
51
74
  resolveSwrWindow,
@@ -55,11 +78,29 @@ import {
55
78
  DEFAULT_ROUTE_TTL,
56
79
  } from "../../cache/cache-policy.js";
57
80
  import { readThroughItem } from "../../cache/read-through-swr.js";
81
+ import {
82
+ executionStart,
83
+ predatesInvalidation,
84
+ } from "../../cache/tag-invalidation.js";
58
85
  import {
59
86
  maskNestedContainerThenables,
60
87
  overlayLoaderContainer,
61
88
  } from "./loader-snapshot.js";
62
- import { recordRequestTags } from "../../cache/cache-tag.js";
89
+ import {
90
+ SHELL_BAKE_TAG_OWNER,
91
+ captureRecordedTags,
92
+ earliestRecordedStart,
93
+ flattenRecordedTags,
94
+ linkLoaderTags,
95
+ linkLoaderTagsTo,
96
+ linkRecordedTags,
97
+ markIdentityRead,
98
+ markTagSetStart,
99
+ readValueTags,
100
+ recordedIdentityRead,
101
+ recordLoaderTags,
102
+ tagLoaderValue,
103
+ } from "../../cache/cache-tag.js";
63
104
  import {
64
105
  isShellCaptureActive,
65
106
  createMaskedLoaderPromise,
@@ -98,7 +139,30 @@ function getDefaultLoaderCacheKey(
98
139
  }
99
140
 
100
141
  /**
101
- * Resolve cache key using the shared 3-tier priority.
142
+ * A loader's own `key()` result as its entry key (#1009). The result is
143
+ * often request input, and the item family (`getItem`/`setItem`) has no
144
+ * other discriminator, so stored raw it could name another loader's entry
145
+ * or a `"use cache"` entry (`use-cache:<id>:...`, cache-runtime.ts), which
146
+ * shares that family; route records use `get`/`set`. Namespaced, it can't:
147
+ * - it starts with `loader:`, which no `"use cache"` key does;
148
+ * - an encoded result holds no `:`, so the key reads unambiguously from
149
+ * the right (result, `key:`, loader id), and two namespaced keys are
150
+ * equal only for the same loader and result;
151
+ * - a default key `loader:<id>:<host><pathname>...` holds a `/` past its
152
+ * id, and an encoded result holds none, so the two are equal only if one
153
+ * loader's id is literally `<other id>:<host>/<path>...`. Loader ids can
154
+ * hold `:` (a dev id is a root-relative path, absolute `D:/...` on
155
+ * Windows), so this is the guarantee, not "ids hold no `:`".
156
+ * A store keyGenerator result stays the store's own namespace, as on routes
157
+ * (composeCacheKeys).
158
+ */
159
+ function loaderKeyFromResult(loaderId: string, result: string): string {
160
+ return `loader:${loaderId}:${KEY_PART_PREFIX}${encodeURIComponent(result)}`;
161
+ }
162
+
163
+ /**
164
+ * Resolve cache key using the shared 3-tier priority. `declared`: a key() or
165
+ * store keyGenerator produced it, so it can carry request identity (#972).
102
166
  */
103
167
  async function resolveLoaderKey(
104
168
  loaderEntry: LoaderEntry,
@@ -106,7 +170,7 @@ async function resolveLoaderKey(
106
170
  loaderId: string,
107
171
  pathname: string,
108
172
  params: Record<string, string>,
109
- ): Promise<string> {
173
+ ): Promise<{ key: string; declared: boolean }> {
110
174
  const options = loaderEntry.cache!.options;
111
175
  // The host is part of the loader cache identity, matching the route-level
112
176
  // cache (cache-scope getCacheKeyBase: `${host}${pathname}`) and "use cache"
@@ -115,8 +179,16 @@ async function resolveLoaderKey(
115
179
  // loader data to another.
116
180
  const host = getRequestContext()?.url?.host ?? "localhost";
117
181
  const defaultKey = getDefaultLoaderCacheKey(loaderId, host, pathname, params);
118
- if (options === false) return defaultKey;
119
- return resolveCacheKey(options.key, store, defaultKey, "LoaderCache");
182
+ if (options === false) return { key: defaultKey, declared: false };
183
+ const keyFn = options.key;
184
+ return {
185
+ key: await resolveCacheKey(
186
+ keyFn && (async (ctx) => loaderKeyFromResult(loaderId, await keyFn(ctx))),
187
+ store,
188
+ defaultKey,
189
+ ),
190
+ declared: Boolean(keyFn || store.keyGenerator),
191
+ };
120
192
  }
121
193
 
122
194
  /**
@@ -131,6 +203,97 @@ function resolveTags(loaderEntry: LoaderEntry): string[] | undefined {
131
203
  return resolveTagsOption(options.tags, getRequestContext(), "LoaderCache");
132
204
  }
133
205
 
206
+ const bindsLoaderCacheMemo = new WeakMap<EntryData, boolean>();
207
+
208
+ const hasLoaderCache = (loaders: readonly LoaderEntry[] | undefined) =>
209
+ loaders?.some((l) => l.cache !== undefined && l.cache.options !== false) ??
210
+ false;
211
+
212
+ function entryBindsLoaderCache(entry: EntryData): boolean {
213
+ let binds = bindsLoaderCacheMemo.get(entry);
214
+ if (binds === undefined) {
215
+ binds =
216
+ hasLoaderCache(entry.loader) ||
217
+ (entry.intercept?.some((i) => hasLoaderCache(i.loader)) ?? false) ||
218
+ (entry.layout?.some(entryBindsLoaderCache) ?? false) ||
219
+ Object.values(entry.parallel ?? {}).some(
220
+ (p) => p !== undefined && entryBindsLoaderCache(p),
221
+ );
222
+ bindsLoaderCacheMemo.set(entry, binds);
223
+ }
224
+ return binds;
225
+ }
226
+
227
+ /**
228
+ * Whether a request over the matched chain `entries` resolves a loader bound
229
+ * with its own cache(): on a chain entry, or on its parallel slots, orphan
230
+ * layouts or intercepts. The match arms the per-execution loader tag sets on
231
+ * it (cache-tag.ts armLoaderTagSets), before any loader starts, so a
232
+ * dependency the handler or another binding starts first still records.
233
+ * Memoized per entry.
234
+ */
235
+ export function bindsLoaderCache(entries: readonly EntryData[]): boolean {
236
+ return entries.some(entryBindsLoaderCache);
237
+ }
238
+
239
+ const chainLoaderEntriesMemo = new WeakMap<EntryData, readonly LoaderEntry[]>();
240
+
241
+ // The loaders an entry registers, its orphan layouts' and its parallel
242
+ // slots' included.
243
+ function chainLoaderEntries(entry: EntryData): readonly LoaderEntry[] {
244
+ let loaders = chainLoaderEntriesMemo.get(entry);
245
+ if (loaders === undefined) {
246
+ loaders = [
247
+ ...(entry.loader ?? []),
248
+ ...(entry.layout ?? []).flatMap(chainLoaderEntries),
249
+ ...Object.values(entry.parallel ?? {}).flatMap((p) =>
250
+ p ? chainLoaderEntries(p) : [],
251
+ ),
252
+ ];
253
+ chainLoaderEntriesMemo.set(entry, loaders);
254
+ }
255
+ return loaders;
256
+ }
257
+
258
+ /**
259
+ * The ids of the loaders a request over the matched chain `entries` runs on
260
+ * the live lane (no `ssr: false`), on a chain entry, its orphan layouts or
261
+ * its parallel slots. A document HIT tail restores the record's pushes of
262
+ * every other owner as authoritative (withCacheLookup, HandleStore
263
+ * pushRestored); a live-lane loader is a hole, so its live run replaces its
264
+ * recorded pushes instead. Memoized per entry.
265
+ */
266
+ export function liveLaneLoaderIds(
267
+ entries: readonly EntryData[],
268
+ ): ReadonlySet<string> {
269
+ const ids = new Set<string>();
270
+ for (const entry of entries) {
271
+ for (const l of chainLoaderEntries(entry)) {
272
+ if (!l.bake) ids.add(l.loader.$$id);
273
+ }
274
+ }
275
+ return ids;
276
+ }
277
+
278
+ /**
279
+ * The lane of each loader the matched chain `entries` registers: "live" (no
280
+ * `ssr: false`) or "bake". A loader registered on both is "live". A PPR
281
+ * capture credits a push to the first registered loader around it when that
282
+ * one is live (shell-capture.ts deriveShellCaptureContext).
283
+ */
284
+ export function routeLoaderLanes(
285
+ entries: readonly EntryData[],
286
+ ): ReadonlyMap<string, "live" | "bake"> {
287
+ const lanes = new Map<string, "live" | "bake">();
288
+ for (const entry of entries) {
289
+ for (const l of chainLoaderEntries(entry)) {
290
+ if (!l.bake) lanes.set(l.loader.$$id, "live");
291
+ else if (!lanes.has(l.loader.$$id)) lanes.set(l.loader.$$id, "bake");
292
+ }
293
+ }
294
+ return lanes;
295
+ }
296
+
134
297
  function getLoaderStore(
135
298
  loaderEntry: LoaderEntry,
136
299
  ): import("../../cache/types.js").SegmentCacheStore | null {
@@ -158,35 +321,82 @@ function getLoaderStore(
158
321
  * keyed on that body scope (executeLoaderData), which would swallow the
159
322
  * replay; and the store reads the body scope to tell a live push from a
160
323
  * replayed one.
161
- * Outside it, a PPR shell capture classifies replayed pushes as
162
- * loader-scoped and unbaked (shell-capture.ts), so a shell HIT gets them
163
- * from the loader's own re-run, not twice.
324
+ * A PPR shell capture records the replayed pushes under this loader (its
325
+ * push funnel reads the pushReplayed owner, shell-capture.ts), so a shell
326
+ * HIT restores them owned (HandleStore.pushRestored). This replay still
327
+ * runs on that HIT: the store drops its settled values, which the record
328
+ * already restored, and keeps the thenable ones the record could not keep
329
+ * (a deferred push, the reason the loader's record carries `runs`).
164
330
  */
165
331
  async function replayLoaderHandles(
166
- encoded: string,
332
+ encoded: string | undefined,
167
333
  handleStore: HandleStore,
168
334
  segmentId: string,
335
+ cachedLoaderId: string,
169
336
  claim: ((loaderId: string) => boolean) | undefined,
170
337
  ): Promise<void> {
171
- const recorded = await decodeHandles(encoded);
338
+ const recorded = encoded ? await decodeHandles(encoded) : {};
172
339
  if (!recorded) return;
173
340
  const delivers = new Map<string, boolean>();
174
- for (const key in recorded) {
175
- const owner = key.slice(key.indexOf(":") + 1);
341
+ const claims = (owner: string): boolean => {
176
342
  let deliver = delivers.get(owner);
177
343
  if (deliver === undefined) {
178
344
  deliver = claim ? claim(owner) : true;
179
345
  delivers.set(owner, deliver);
180
346
  }
181
- if (!deliver) continue;
182
- for (const [handleName, values] of Object.entries(recorded[key])) {
183
- for (const value of values) {
184
- handleStore.pushReplayed(handleName, segmentId, value, owner);
347
+ return deliver;
348
+ };
349
+ const replay = (): void => {
350
+ for (const key in recorded) {
351
+ const owner = key.slice(key.indexOf(":") + 1);
352
+ if (!claims(owner)) continue;
353
+ for (const [handleName, values] of Object.entries(recorded[key])) {
354
+ for (const value of values) {
355
+ handleStore.pushReplayed(handleName, segmentId, value, owner);
356
+ }
185
357
  }
186
358
  }
359
+ };
360
+ // A hole's entry, pushes or none, takes the place of the shell copies of
361
+ // its pushes (HandleStore.redeliverReplays). Not once the hole delivered
362
+ // in this request: a run a reader started first replaces them itself.
363
+ if (claims(cachedLoaderId)) {
364
+ handleStore.redeliverReplays(cachedLoaderId, replay);
365
+ } else {
366
+ replay();
187
367
  }
188
368
  }
189
369
 
370
+ /**
371
+ * Identity mark (#972): a declared-key entry whose MISS recorded a
372
+ * request-identity read stores that read ahead of its payload. A HIT skips the
373
+ * body, so without the mark an unkeyed loader reading this value on a HIT
374
+ * would store another user's data. The value string is this module's own
375
+ * (serialize/deserialize below), so the mark needs no store support; Flight
376
+ * and JSON payloads never start with "~".
377
+ */
378
+ const IDENTITY_MARK = "~identity:";
379
+
380
+ function markIdentity(
381
+ value: string,
382
+ read: LoaderIdentityRead | undefined,
383
+ ): string {
384
+ return read ? `${IDENTITY_MARK}${JSON.stringify(read)}\n${value}` : value;
385
+ }
386
+
387
+ function unmarkIdentity(value: string): {
388
+ read: LoaderIdentityRead | undefined;
389
+ payload: string;
390
+ } {
391
+ if (!value.startsWith(IDENTITY_MARK))
392
+ return { read: undefined, payload: value };
393
+ const end = value.indexOf("\n");
394
+ return {
395
+ read: JSON.parse(value.slice(IDENTITY_MARK.length, end)),
396
+ payload: value.slice(end + 1),
397
+ };
398
+ }
399
+
190
400
  /**
191
401
  * Record key for the MISS capture: run-length groups by the innermost loader
192
402
  * body, `${seq}:${loaderId}` — the cached loader or a dependency it awaits
@@ -226,18 +436,23 @@ function recordOwnerKey(cachedLoaderId: string): () => string {
226
436
  * never run under a shell capture), so the lane is decided here, per LOADER,
227
437
  * never by the entry's loading():
228
438
  *
229
- * - BAKE lane: `loader(Def, { ssr: false })` (awaitBeforeFlush) with a
230
- * `bakeSegmentKey` from the caller. The loader EXECUTES at capture (the
439
+ * - BAKE lane: `loader(Def, { ssr: false })` (LoaderEntry `bake`: the
440
+ * caller passes a `bakeSegmentKey` for it on document and navigation
441
+ * evaluations alike; a capture, a document evaluation, also has
442
+ * awaitBeforeFlush). The loader EXECUTES at capture (the
231
443
  * flag's "data in the HTML before first flush" maps to the frozen prelude)
232
444
  * and its settled non-promise data bakes into the shell. Promises nested in
233
- * plain objects/arrays are masked (mask-nested.ts) and stay live holes at
234
- * the consumer's own Suspense — per-request material must be promise-shaped
235
- * (the #692 cross-session scar). The masked container registers on
236
- * `_shellCaptureLoaderRecords`: it holds the capture gate (bounded by
237
- * `ppr.captureTimeout`) and pins into the snapshot's loader family, and on a
238
- * HIT the recorded container is overlaid onto the fresh run (below) so the
239
- * payload matches the frozen prelude byte-for-byte. cookies()/headers() in
240
- * its body (nested promise bodies included) trip the capture guard.
445
+ * plain objects, arrays and JSX props are masked (mask-nested.ts) and stay
446
+ * live holes at the consumer's own Suspense — per-request material must be
447
+ * promise-shaped (the #692 cross-session scar). The masked container
448
+ * registers on `_shellCaptureLoaderRecords`: it holds the capture gate
449
+ * (bounded by `ppr.captureTimeout`) and pins into the snapshot's loader
450
+ * family, and on a HIT the recorded container is overlaid onto the fresh
451
+ * run (below) so the payload matches the frozen prelude outside the holes.
452
+ * A server-component element holding a promise is a hole as a whole, so its
453
+ * other props come from the fresh run (loader-snapshot.ts
454
+ * elideLoaderContainer). cookies()/headers() in its body (nested promise
455
+ * bodies included) trip the capture guard.
241
456
  * - LIVE lane: every other loader, whatever its entry's loading(). Never
242
457
  * executes at capture: the slot gets a never-resolving promise
243
458
  * (loader-mask.ts) and postpones at the reader's boundary — loading() or an
@@ -269,6 +484,8 @@ export function resolveLoaderData<TEnv>(
269
484
  );
270
485
  maskedPromise.catch(() => {});
271
486
  reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, maskedPromise);
487
+ // Its data bakes into the shell, so its tags tag the shell.
488
+ linkLoaderTagsTo(SHELL_BAKE_TAG_OWNER, loaderEntry.loader.$$id, reqCtx);
272
489
  return maskedPromise;
273
490
  }
274
491
  return createMaskedLoaderPromise();
@@ -280,31 +497,31 @@ export function resolveLoaderData<TEnv>(
280
497
  const recorded = seed.get(bakeSegmentKey)!;
281
498
  if (!recorded.holes) {
282
499
  // Pin-first (hole-free record): the pinned container is what the
283
- // payload serves either way (recorded paths win wholesale), so
284
- // resolve it immediately instead of gating on the fresh run's
285
- // latency — a slow bake-lane loader body otherwise stalls the HIT
286
- // tail for values that get discarded. The fresh run still executes
287
- // for its side effects and cache read-through writes, lifetime-
288
- // extended so the runtime cannot cancel it when the stream closes
289
- // first; its rejection is swallowed here (the payload already
290
- // matches the prelude, which a fresh error value never could).
500
+ // payload serves (recorded paths win wholesale), resolved
501
+ // immediately. The body does not run: a HIT is rendered from the
502
+ // shell, like the handlers it replays, and the loader's settled
503
+ // pushes (and those of loaders it awaits) are restored from the
504
+ // record. Only a record whose capture saw a push it could not keep
505
+ // (`runs`) still runs the body, in the background, lifetime-extended
506
+ // so the runtime cannot cancel it when the stream closes first; its
507
+ // rejection is swallowed (the payload already matches the prelude,
508
+ // which a fresh error value never could).
291
509
  //
292
510
  // CONTRACT (deliberate divergence from the gated overlay's
293
511
  // fresh-only-keys passthrough): a hole-free pin serves the pinned
294
- // SHAPE wholesale — a key the fresh run adds mid-TTL is dropped.
295
- // It could never render server-side anyway: no hole was postponed
296
- // for it at capture, so the prelude froze the without-that-field
297
- // branch and the resume pass has nothing to fill; passing it
298
- // through only made the hydration payload disagree with the frozen
299
- // prelude (client-side mismatch repair — the divergence class the
300
- // snapshot exists to prevent). A field that is per-request must be
301
- // promise-shaped at capture (masked -> hole marker -> holes: 1 ->
302
- // the gated path below, which preserves fresh-only passthrough) or
303
- // live behind loading(). Anything else is uncached nondeterminism
304
- // in shell material — the documented drift residual.
305
- const fresh = executeLoaderData(loaderEntry, ctx, pathname);
306
- fresh.catch(() => {});
307
- reqCtx?.executionContext?.waitUntil?.(fresh);
512
+ // SHAPE wholesale. A field the body would return now had no hole
513
+ // postponed for it at capture, so the prelude froze the
514
+ // without-that-field branch and the resume pass has nothing to
515
+ // fill. A field that is per-request must be promise-shaped at
516
+ // capture (masked -> hole marker -> holes: 1 -> the gated path
517
+ // below, which preserves fresh-only passthrough) or live behind
518
+ // loading(). Anything else is uncached nondeterminism in shell
519
+ // material — the documented drift residual.
520
+ if (recorded.runs) {
521
+ const fresh = executeLoaderData(loaderEntry, ctx, pathname);
522
+ fresh.catch(() => {});
523
+ reqCtx?.executionContext?.waitUntil?.(fresh);
524
+ }
308
525
  return Promise.resolve(
309
526
  overlayLoaderContainer(undefined, recorded.container),
310
527
  );
@@ -343,9 +560,10 @@ function executeLoaderData<TEnv>(
343
560
 
344
561
  // Evaluate runtime condition if provided
345
562
  const options = cacheConfig.options;
346
- if (options.condition) {
563
+ const condition = options.condition;
564
+ if (condition) {
347
565
  const requestCtx = getRequestContext();
348
- if (requestCtx && !options.condition(requestCtx)) {
566
+ if (requestCtx && !runIdentityExempt(() => condition(requestCtx))) {
349
567
  return ctx.use(loaderEntry.loader);
350
568
  }
351
569
  }
@@ -368,7 +586,13 @@ function executeLoaderData<TEnv>(
368
586
  internal._loaderCacheOriginalUse = originalUse;
369
587
  ctx.use = ((item: any) => {
370
588
  const cached = overrides!.get(item?.$$id);
371
- if (cached) return cached;
589
+ if (cached) {
590
+ // A read is a consumption (#957, #964): see createLoaderExecutor's
591
+ // useLoader.
592
+ linkLoaderTags(item.$$id);
593
+ readValueTags(cached);
594
+ return cached;
595
+ }
372
596
  return originalUse(item);
373
597
  }) as typeof ctx.use;
374
598
  }
@@ -395,7 +619,14 @@ function executeLoaderData<TEnv>(
395
619
  const swrWindow = resolveSwrWindow(options.swr, store.defaults);
396
620
  const swr = swrWindow || undefined;
397
621
  const tags = resolveTags(loaderEntry);
398
- recordRequestTags(tags);
622
+ recordLoaderTags(loaderId, tags);
623
+ // The per-execution loader tag sets were armed before any loader ran: by
624
+ // the match (match-api.ts, bindsLoaderCache), or by runLoader.
625
+ // An execution of this loader outside the binding (a reader started it
626
+ // first, a stale refresh) answers for the cache() tags too.
627
+ if (tags && tags.length > 0) {
628
+ internal._bindLoaderCacheTags?.(loaderId, new Set(tags));
629
+ }
399
630
 
400
631
  // Handle pushes: the store and the owning segment are read synchronously
401
632
  // at kickoff, as createLoaderExecutor does for the body's own pushes.
@@ -406,10 +637,16 @@ function executeLoaderData<TEnv>(
406
637
  // concurrently. A dependency can still run on a HIT for another reader
407
638
  // (memoized per request); the replay's claim keeps its pushes to one copy.
408
639
  const isOwnBodyPush = () => isInsideLoaderBody(loaderId);
640
+ // What a reader of this value takes on (tagLoaderValue below,
641
+ // readValueTags): the cache() tags, plus the entry's tags on a HIT or the
642
+ // execution's on a MISS. A HIT's value is as old as its lookup: a cached
643
+ // reader gates its own write from here at the latest (#977).
644
+ const valueTags = new Set(tags);
645
+ markTagSetStart(valueTags, executionStart());
409
646
 
410
647
  const dataPromise = (async () => {
411
648
  const codec = await getCodec();
412
- const key = await resolveLoaderKey(
649
+ const { key, declared } = await resolveLoaderKey(
413
650
  loaderEntry,
414
651
  store,
415
652
  loaderId,
@@ -428,15 +665,23 @@ function executeLoaderData<TEnv>(
428
665
 
429
666
  // One readThroughItem call runs at most one execution — the foreground
430
667
  // MISS or the background stale revalidation (flagged by wrapBackground)
431
- // — so a single capture slot serves the setItem that follows it.
668
+ // — so one handle capture and one tag set serve the setItem after it.
669
+ // The entry's tags: its cache() tags plus what the execution recorded.
432
670
  let capture: HandleCapture | undefined;
671
+ const bodyTags = new Set(tags);
433
672
  let revalidating = false;
434
673
  let hitHandles: string | undefined;
674
+ let cachedRead = false;
435
675
  const onCachedRead = (label: string, cached: CacheItemResult) => {
676
+ cachedRead = true;
436
677
  hitHandles = cached.handles;
678
+ const { read } = unmarkIdentity(cached.value);
679
+ if (read) markIdentityRead(valueTags, read);
680
+ recordLoaderTags(loaderId, cached.tags);
681
+ for (const tag of cached.tags ?? []) valueTags.add(tag);
437
682
  debugLoaderCacheLog(`[LoaderCache] ${label}: ${key}`);
438
683
  };
439
- const execute = async (): Promise<any> => {
684
+ const runBody = async (): Promise<any> => {
440
685
  // A stale-hit revalidation runs on its own executor: it diverts its
441
686
  // pushes (below), so sharing the page's memoized run of a dependency
442
687
  // would take that dependency's pushes off the page.
@@ -457,6 +702,26 @@ function executeLoaderData<TEnv>(
457
702
  c.stop();
458
703
  }
459
704
  };
705
+ // #972: the execution's identity reads, and those of the loader values it
706
+ // read, are on the bodyTags links whoever started them. With an undeclared
707
+ // key one fails the fill (the MISS or the stale refresh); one that settles
708
+ // after the value (a nested promise, a pending handle push) skips the write
709
+ // instead. With a declared key it is stored as the entry's identity mark
710
+ // for the HITs, as this loader's readers see it (through this loader when
711
+ // another made it), and a foreground execution marks the value it serves.
712
+ // A stale refresh does not: the page is served the stale entry, which its
713
+ // own mark (or its lack) describes.
714
+ const identityRead = (): LoaderIdentityRead | undefined => {
715
+ const read = recordedIdentityRead(bodyTags);
716
+ if (!read) return undefined;
717
+ if (!declared) throw loaderCacheIdentityError(read, loaderId);
718
+ const seen =
719
+ read.bodyId === undefined || read.bodyId === loaderId
720
+ ? { ...read, bodyId: loaderId, via: undefined }
721
+ : { ...read, via: loaderId };
722
+ if (!revalidating) markIdentityRead(valueTags, seen);
723
+ return seen;
724
+ };
460
725
 
461
726
  // Flight encodes a rejected promise in the value or a handle push as an
462
727
  // error row and completes normally. setItem runs after serialize and
@@ -473,15 +738,37 @@ function executeLoaderData<TEnv>(
473
738
  // pushes awaited up to its timeout, the whole blob dropped on a timeout
474
739
  // or a thrown encode). Encoded here, inside the deferred write, so a
475
740
  // MISS response never waits on it.
476
- setItem: async (k, v, o) => {
477
- const handles = capture
478
- ? await encodeHandles(capture.data, onFlightError)
479
- : "";
480
- if (flightErrors.length > 0) throw flightErrors[0];
481
- await store.setItem!(k, v, handles ? { ...o, handles } : o);
482
- },
741
+ setItem: (k, v, o) =>
742
+ captureRecordedTags(bodyTags, async () => {
743
+ const handles = capture
744
+ ? await encodeHandles(capture.data, onFlightError)
745
+ : "";
746
+ if (flightErrors.length > 0) throw flightErrors[0];
747
+ // After the handle encode: it settles pending pushes, which can read.
748
+ const read = identityRead();
749
+ const entryTags = [...flattenRecordedTags(bodyTags)];
750
+ // One of the entry's tags was invalidated after the value's oldest
751
+ // part started (#977): this execution, or the run of this loader or
752
+ // of a dependency another reader started earlier. Written now, a
753
+ // value that may predate it would outlive it. Skip the write; the
754
+ // next read runs the body.
755
+ const start = earliestRecordedStart(bodyTags);
756
+ if (start && (await predatesInvalidation(store, entryTags, start))) {
757
+ return;
758
+ }
759
+ await store.setItem!(k, markIdentity(v, read), {
760
+ ...o,
761
+ tags: entryTags.length > 0 ? entryTags : undefined,
762
+ ...(handles ? { handles } : {}),
763
+ });
764
+ }),
483
765
  key,
484
- execute,
766
+ execute: async () => {
767
+ markTagSetStart(bodyTags, executionStart());
768
+ const value = await captureRecordedTags(bodyTags, runBody);
769
+ identityRead();
770
+ return value;
771
+ },
485
772
  // The rango.background span (kind=loader-revalidation) wraps the WHOLE
486
773
  // stale revalidation — the re-execution AND the serialize/setItem write
487
774
  // (read-through-swr routes the full task through wrapBackground) — so
@@ -495,22 +782,30 @@ function executeLoaderData<TEnv>(
495
782
  observePhase(PHASES.background("loader-revalidation"), run),
496
783
  );
497
784
  },
498
- serialize: (d) => codec.serializeResult(d, onFlightError),
499
- deserialize: (v) => codec.deserializeResult(v),
785
+ serialize: (d) =>
786
+ captureRecordedTags(bodyTags, () =>
787
+ codec.serializeResult(d, onFlightError),
788
+ ),
789
+ deserialize: (v) => codec.deserializeResult(unmarkIdentity(v).payload),
500
790
  storeOptions: { ttl, swr, tags },
501
791
  onHit: (cached) => onCachedRead("HIT", cached),
502
792
  onStale: (cached) => onCachedRead("STALE", cached),
503
- onMiss: () => debugLoaderCacheLog(`[LoaderCache] MISS: ${key}`),
793
+ onMiss: () => {
794
+ linkRecordedTags(valueTags, bodyTags);
795
+ debugLoaderCacheLog(`[LoaderCache] MISS: ${key}`);
796
+ },
504
797
  onCached: () => debugLoaderCacheLog(`[LoaderCache] Cached: ${key}`),
505
798
  host: requestCtxForExecute,
506
799
  });
507
800
 
508
- // An entry written before handles were recorded has none: no replay.
509
- if (hitHandles && handleStore && owningSegmentId) {
801
+ // An entry without handles (none recorded, or dropped by an encode
802
+ // timeout) replays none, and still stands for a hole's shell copies.
803
+ if (cachedRead && handleStore && owningSegmentId) {
510
804
  await replayLoaderHandles(
511
805
  hitHandles,
512
806
  handleStore,
513
807
  owningSegmentId,
808
+ loaderId,
514
809
  internal._claimLoaderPushes,
515
810
  );
516
811
  }
@@ -522,6 +817,7 @@ function executeLoaderData<TEnv>(
522
817
  // push after full drain throws LateHandlePushError.
523
818
  handleStore?.trackAuxiliary(dataPromise);
524
819
 
820
+ tagLoaderValue(dataPromise, valueTags);
525
821
  overrides.set(loaderId, dataPromise);
526
822
 
527
823
  return dataPromise;
@@ -8,10 +8,9 @@
8
8
  * fresh into it.
9
9
  *
10
10
  * Capture mode is signalled by `requestCtx._shellCaptureRun`, set to true ONLY on
11
- * the derived request context of the background capture task (shell-capture.ts) —
12
- * NOT by the foreground render, whose `_shellCapture` descriptor merely means "a
13
- * capture is wanted" and must not change behavior. This module is the single home
14
- * for the mask so every loader execution site gates the same way (loader-cache.ts
11
+ * the derived request context of the background capture task (shell-capture.ts);
12
+ * the foreground render never sets it. This module is the single home for the
13
+ * mask so every loader execution site gates the same way (loader-cache.ts
15
14
  * `resolveLoaderData`, fresh.ts `resolveLoaders`).
16
15
  */
17
16