@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
@@ -12,6 +12,12 @@ import type {
12
12
  } from "../types";
13
13
  import { invariant, DslContextError } from "../errors";
14
14
  import type { DefaultRouteName } from "../types/global-namespace.js";
15
+ import type { ContextVar } from "../context-var.js";
16
+ import { PPR_LANE_HINT } from "../rsc/shell-capture-constants.js";
17
+ import {
18
+ getCacheExecScope,
19
+ type CacheExecScope,
20
+ } from "../cache/cache-exec-scope.js";
15
21
 
16
22
  // ============================================================================
17
23
  // Performance Metrics Types
@@ -65,6 +71,17 @@ export type EntryPropCommon = {
65
71
  id: string;
66
72
  shortCode: string; // Short identifier for network efficiency (e.g., "L0", "P1", "R2")
67
73
  parent: EntryData | null;
74
+ /**
75
+ * Orphan siblings only (attachOrphanSibling in
76
+ * route-definition/dsl-helpers.ts): the entry whose `layout[]` holds this
77
+ * one. `parent` stays null on an orphan (matchError's matched-id stack in
78
+ * router/match-api.ts starts at the boundary-holding entry, possibly an
79
+ * orphan, and must stop there), so the boundary walkers in
80
+ * router/error-handling.ts follow this instead. A lookup that starts at an
81
+ * orphan (its loaders, an intercept it declares) then reaches the owner's
82
+ * boundaries and the owner's ancestors.
83
+ */
84
+ orphanOwner?: EntryData;
68
85
  /** Cache configuration for this entry (set by cache() DSL) */
69
86
  cache?: EntryCacheConfig;
70
87
  /** URL prefix from include() scope, used for MountContext on client */
@@ -118,6 +135,15 @@ export type LoaderEntry = {
118
135
  * appears on navigation-lane entries.
119
136
  */
120
137
  awaitBeforeFlush?: true;
138
+ /**
139
+ * loader(Def, { ssr: false }) on every DSL evaluation, document and
140
+ * navigation: the loader's PPR bake lane (loader-cache.ts
141
+ * resolveLoaderData). The bake/seed key rides on it, so a client
142
+ * navigation's shell replay pins exactly the loaders a document HIT pins,
143
+ * whatever the entry's loading(). awaitBeforeFlush cannot carry that: a
144
+ * navigation evaluation never has it.
145
+ */
146
+ bake?: true;
121
147
  };
122
148
 
123
149
  /**
@@ -187,9 +213,6 @@ export type InterceptEntry = {
187
213
  routeName: string; // e.g., "card"
188
214
  handler: ReactNode | Handler<any, any, any>;
189
215
  middleware: MiddlewareFn<any, any>[];
190
- revalidate: ShouldRevalidateFn<any, any>[];
191
- errorBoundary: (ReactNode | ErrorBoundaryHandler)[];
192
- notFoundBoundary: (ReactNode | NotFoundBoundaryHandler)[];
193
216
  loader: LoaderEntry[];
194
217
  loading?: ReactNode | false;
195
218
  transition?: TransitionConfig;
@@ -833,27 +856,30 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
833
856
  // Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
834
857
  // of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
835
858
  // invoked via ctx.use). Consulted by isInsideCacheScope() to exempt
836
- // request-scoped reads, by getCurrentLoaderBodyId() for guard-warning
837
- // attribution, and by isInsideHandlerInvokedLoaderBody() for the
838
- // consumption-lane rule (the shell-capture guard exemption). It deliberately
839
- // does NOT affect isInsideLoaderScope(), so rendered()/barrier/deadlock
840
- // gating (which must distinguish DSL from handler-invoked loaders) is
841
- // unchanged.
842
- const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
843
- const loaderBodyScopeALS: AsyncLocalStorage<{
844
- active: true;
845
- loaderId?: string;
846
- handlerInvoked?: boolean;
847
- }> = ((globalThis as any)[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{
859
+ // request-scoped reads, and by getCurrentLoaderBodyId() for guard-warning
860
+ // attribution. It deliberately does NOT affect isInsideLoaderScope(), so
861
+ // rendered()/barrier/deadlock gating (which must distinguish DSL from
862
+ // handler-invoked loaders) is unchanged.
863
+ interface LoaderBodyScope {
848
864
  active: true;
849
865
  loaderId?: string;
850
- handlerInvoked?: boolean;
851
- }>());
866
+ /** The body scope this one was entered from (a ctx.use(Loader) chain). */
867
+ parent?: LoaderBodyScope;
868
+ /** The "use cache" scope the body was entered in (assertNonCacheableReadAllowed). */
869
+ execScope?: CacheExecScope;
870
+ /** The tags this execution records (#964, cache-tag.ts "Recorded-tag sets"). */
871
+ tags?: Set<string>;
872
+ }
873
+ const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
874
+ const loaderBodyScopeALS: AsyncLocalStorage<LoaderBodyScope> = ((
875
+ globalThis as any
876
+ )[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<LoaderBodyScope>());
852
877
 
853
878
  /**
854
879
  * Check if the current execution is inside a cache() DSL boundary.
855
- * Returns false inside loader execution — loaders are always fresh
856
- * (never cached), so non-cacheable reads are safe.
880
+ * Returns false inside loader execution: a route cache() does not store
881
+ * loader values, so non-cacheable reads are safe. A loader bound with its own
882
+ * cache() stores its value; recordLoaderIdentityRead guards that.
857
883
  */
858
884
  export function isInsideCacheScope(): boolean {
859
885
  if (RangoContext.getStore()?.insideCacheScope !== true) return false;
@@ -869,6 +895,234 @@ export function isInsideCacheScope(): boolean {
869
895
  return true;
870
896
  }
871
897
 
898
+ /**
899
+ * Throw if called during the ACTIVE background shell-capture render
900
+ * (`_shellCaptureRun` true on the derived request context built by
901
+ * shell-capture.ts). The captured shell is shared across every user hitting
902
+ * the URL, so a request-scoped read here would bake one user's cookies,
903
+ * headers, or per-request variables into markup served to others — same
904
+ * hazard as the cache scopes above, at the document tier. DSL segment loaders
905
+ * need no exemption: the live lane is masked (never executed) during capture,
906
+ * and the bake lane is exactly what this guard exists for.
907
+ *
908
+ * It covers everything the capture waits for, not only synchronous handler
909
+ * code: a promise the handler passes or pushes, an async server component,
910
+ * and a loader the handler awaits (`await ctx.use(Loader)`) all run under the
911
+ * capture context and all bake. The last one used to be exempt (the
912
+ * consumption-lane rule, #672/#674): while a HIT re-ran handlers, a slot
913
+ * handler's `ctx.use` of an identity loader rendered per visitor on the HIT.
914
+ * Now every HIT replays the capture's copy, so the exemption would bake the
915
+ * capturing request's identity into every visitor's page. The cache()
916
+ * guards above keep it (a cache() HIT is a separate tier).
917
+ *
918
+ * Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
919
+ * is also present during the FOREGROUND render (it means "a capture is wanted"),
920
+ * and the foreground must read cookies/headers normally to serve the real user.
921
+ * Only the derived capture context sets `_shellCaptureRun`.
922
+ *
923
+ * Applies only to the READ surfaces (cookies(), headers(), a { cache: false }
924
+ * variable, and the theme reads through tripShellCaptureGuard) whose values
925
+ * become markup. Response directives
926
+ * (invalidateClientCache(), keepClientCache()) stay callable: during capture
927
+ * they are header effects on a discarded response, and on the live HIT path
928
+ * the full pipeline runs so their headers flow to the client normally.
929
+ *
930
+ * The throw can be caught by the code that made the read, so the guard also
931
+ * flags the capture context; the capture refuses on the flag
932
+ * (shell-capture.ts refuseOnCaptureGuard), nothing is stored, and every
933
+ * request keeps getting the normal axis-1 render.
934
+ */
935
+ export function assertNotInsideShellCapture(
936
+ ctx: unknown,
937
+ fnName: string,
938
+ ): void {
939
+ if (!tripShellCaptureGuard(ctx, `${fnName}()`, REQUEST_READ_CAPTURE_FIX)) {
940
+ return;
941
+ }
942
+ const what =
943
+ fnName === "cookies"
944
+ ? "cookies"
945
+ : fnName === "headers"
946
+ ? "headers"
947
+ : "per-request variables";
948
+ throw new Error(
949
+ `${fnName}() cannot be called while capturing a shared shell ` +
950
+ `(ppr shell capture). The captured shell is served to every user ` +
951
+ `of this URL, so request-scoped data read here would leak one user's ` +
952
+ `${what} to others. Read it ` +
953
+ `inside a loader without ssr: false and consume it with useLoader, e.g. ` +
954
+ `createLoader(async () => getUser(cookies().get("session")?.value)). ` +
955
+ PPR_LANE_HINT,
956
+ );
957
+ }
958
+
959
+ /**
960
+ * The fix the capture refusal warning (shell-capture.ts refuseOnCaptureGuard)
961
+ * gives for a cookies()/headers()/{ cache: false } variable read. Recorded
962
+ * with the trip (tripShellCaptureGuard), so each guarded read carries its own
963
+ * fix; the theme reads (cookie-store.ts assertThemeReadAllowed) record theirs.
964
+ */
965
+ const REQUEST_READ_CAPTURE_FIX: string =
966
+ "Read it in a loader without ssr: false and consume it with useLoader under " +
967
+ "loading() or an inline <Suspense> (a live hole). A promise the handler passes " +
968
+ "or pushes does not help: the capture waits for it.";
969
+
970
+ /**
971
+ * True when `ctx` is the active capture render (assertNotInsideShellCapture
972
+ * above). On true the capture context is flagged with the read (`surface`,
973
+ * e.g. "cookies()", "ctx.theme") and the fix the refusal warning gives, plus
974
+ * the loader body (if any) that made the read, and the caller throws.
975
+ */
976
+ export function tripShellCaptureGuard(
977
+ ctx: unknown,
978
+ surface: string,
979
+ fix: string,
980
+ ): boolean {
981
+ if (
982
+ ctx === null ||
983
+ typeof ctx !== "object" ||
984
+ (ctx as { _shellCaptureRun?: unknown })._shellCaptureRun !== true
985
+ ) {
986
+ return false;
987
+ }
988
+ // Record WHICH loader body (if any) made the read, so the refusal warning
989
+ // can name the real source instead of hardcoding a lane (issue #672).
990
+ const flagged = ctx as {
991
+ _shellCaptureGuardTripped?: { surface: string; fix: string };
992
+ _shellCaptureGuardTrippedLoaderId?: string;
993
+ };
994
+ flagged._shellCaptureGuardTripped = { surface, fix };
995
+ flagged._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
996
+ return true;
997
+ }
998
+
999
+ /**
1000
+ * Read guard for a non-cacheable variable (`createVar({ cache: false })` or a
1001
+ * `ctx.set(..., { cache: false })` write). Callers check isNonCacheable() first
1002
+ * so ordinary reads never reach the scope lookups.
1003
+ *
1004
+ * Throws during a PPR shell capture (`requestCtx` is the ambient request
1005
+ * context; assertNotInsideShellCapture): the shell is shared per host+URL.
1006
+ *
1007
+ * Throws inside a "use cache" body: the key does not include the value, so the
1008
+ * first caller's value would be stored and served to later callers (#925). A
1009
+ * loader body entered INSIDE the cached function is exempt, the same baked-copy
1010
+ * trade isInsideCacheScope() makes for cache(). A cached function called FROM a
1011
+ * loader is not: the loader re-runs, the cached body does not. The body scope
1012
+ * records the exec scope it was entered in, so the exemption holds only while
1013
+ * that is still the innermost one.
1014
+ */
1015
+ export function assertNonCacheableReadAllowed(
1016
+ keyOrVar: string | ContextVar<unknown>,
1017
+ requestCtx?: unknown,
1018
+ ): void {
1019
+ // A shell capture bakes what it reads into a page every visitor gets.
1020
+ assertNotInsideShellCapture(requestCtx, "ctx.get");
1021
+ const execScope = getCacheExecScope();
1022
+ const variable = ` for a non-cacheable variable${typeof keyOrVar === "string" ? ` "${keyOrVar}"` : ""}`;
1023
+ if (
1024
+ execScope !== undefined &&
1025
+ loaderBodyScopeALS.getStore()?.execScope !== execScope
1026
+ ) {
1027
+ throw new Error(
1028
+ `ctx.get()${variable} cannot be called inside a "use cache" function. ` +
1029
+ `The variable was created with { cache: false } or set with { cache: false }, ` +
1030
+ `and the cache key does not include its value, so the first caller's value ` +
1031
+ `would be served to later callers. Read it before calling the cached function ` +
1032
+ `and pass the value in as an argument so it becomes part of the cache key.`,
1033
+ );
1034
+ }
1035
+ if (isInsideCacheScope()) {
1036
+ throw new Error(
1037
+ `ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
1038
+ `The variable was created with { cache: false } or set with { cache: false }, ` +
1039
+ `and its value would be stale on cache hit. Move the read outside the cached scope.`,
1040
+ );
1041
+ }
1042
+ recordLoaderIdentityRead(`ctx.get()${variable}`);
1043
+ }
1044
+
1045
+ /** How the identity error words a read: a function call or a property read. */
1046
+ export type LoaderIdentityReadVerb = "called" | "read";
1047
+
1048
+ /** A request-identity read a loader execution made (recordLoaderIdentityRead). */
1049
+ export interface LoaderIdentityRead {
1050
+ /**
1051
+ * The read as the error names it: "cookies()", "headers()",
1052
+ * `ctx.get() for a non-cacheable variable "x"`, "ctx.theme" or
1053
+ * "getRequestContext().theme".
1054
+ */
1055
+ surface: string;
1056
+ /** "called" for a function, "read" for a property (the theme getters). */
1057
+ verb: LoaderIdentityReadVerb;
1058
+ /** The loader body that made the read. */
1059
+ bodyId: string | undefined;
1060
+ /**
1061
+ * The cached loader whose value carries a read another loader made
1062
+ * (loader-cache.ts identity mark): its readers read it through `via`.
1063
+ */
1064
+ via?: string;
1065
+ }
1066
+
1067
+ /**
1068
+ * Where cache-tag.ts installs its recorder at module init (same key there):
1069
+ * it owns the per-execution recorded sets, and importing it here would be a
1070
+ * cycle.
1071
+ */
1072
+ const IDENTITY_READ_RECORDER_KEY = Symbol.for(
1073
+ "rangojs-router:identity-read-recorder",
1074
+ );
1075
+
1076
+ /**
1077
+ * Record a request-identity read (cookies(), headers(), a non-cacheable
1078
+ * ctx.get()) on the current loader execution (#972).
1079
+ *
1080
+ * isInsideCacheScope() exempts loader bodies because a route cache() never
1081
+ * stores their values. A loader bound with its own cache() does: with no key()
1082
+ * and no store keyGenerator its entry is keyed by loader, host, path and
1083
+ * params only, and an enclosing route cache() key does not partition it
1084
+ * (#974). A cookies() read there stored the first visitor's session and served
1085
+ * it to everyone for the TTL. The read is recorded on the execution, not
1086
+ * thrown at the call: a reader that starts the loader before its binding does
1087
+ * runs it outside any fill, and the binding's MISS then reuses that run. The
1088
+ * fill checks what its execution recorded, through the same links as its tags
1089
+ * (cache-tag.ts recordedIdentityRead), so both orders fail the same way.
1090
+ * Response directives do not record: a key cannot make a skipped body's side
1091
+ * effect reach a HIT.
1092
+ */
1093
+ export function recordLoaderIdentityRead(
1094
+ surface: string,
1095
+ verb: LoaderIdentityReadVerb = "called",
1096
+ ): void {
1097
+ const recorder = (globalThis as Record<symbol, unknown>)[
1098
+ IDENTITY_READ_RECORDER_KEY
1099
+ ] as ((surface: string, verb: LoaderIdentityReadVerb) => void) | undefined;
1100
+ recorder?.(surface, verb);
1101
+ }
1102
+
1103
+ /** The error a loader cache() fill with no declared key fails with (#972). */
1104
+ export function loaderCacheIdentityError(
1105
+ read: LoaderIdentityRead,
1106
+ cachedLoaderId: string,
1107
+ ): Error {
1108
+ const filling = `while filling its own cache() entry with no key()`;
1109
+ const where =
1110
+ read.via !== undefined && read.via !== cachedLoaderId
1111
+ ? `inside loader "${read.bodyId}", which loader "${cachedLoaderId}" reads through another loader ("${read.via}") ${filling}`
1112
+ : read.bodyId !== undefined && read.bodyId !== cachedLoaderId
1113
+ ? `inside loader "${read.bodyId}", which loader "${cachedLoaderId}" reads ${filling}`
1114
+ : `inside loader "${cachedLoaderId}", whose own cache() has no key()`;
1115
+ return new Error(
1116
+ `${read.surface} cannot be ${read.verb} ${where}. ` +
1117
+ `The entry is keyed by loader, host, path and params only, so it is ` +
1118
+ `shared across users: request-scoped data (cookies, headers, ` +
1119
+ `non-cacheable variables) read here would be stored from one request ` +
1120
+ `and served to everyone. Add a key that includes the value, or drop ` +
1121
+ `the loader's cache():\n\n` +
1122
+ ` cache({ ttl: 60, key: (ctx) => \`session:\${cookies().get("session")?.value}\` })`,
1123
+ );
1124
+ }
1125
+
872
1126
  /**
873
1127
  * Check if the current execution is inside a DSL loader scope
874
1128
  * (wrapped by runInsideLoaderScope). Used by rendered() barrier
@@ -897,7 +1151,7 @@ export function latchCachedHeaderScope(
897
1151
  /** True inside ANY loader execution — DSL loader scope or a loader body
898
1152
  * (however invoked). The "loaders always re-run fresh" exemptions key off
899
1153
  * this. */
900
- function isInsideAnyLoaderScope(): boolean {
1154
+ export function isInsideAnyLoaderScope(): boolean {
901
1155
  return (
902
1156
  loaderScopeALS.getStore()?.active === true ||
903
1157
  loaderBodyScopeALS.getStore()?.active === true
@@ -1050,15 +1304,29 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
1050
1304
  export function runInsideLoaderBodyScope<T>(
1051
1305
  fn: () => T,
1052
1306
  loaderId?: string,
1053
- handlerInvoked?: boolean,
1307
+ tags?: Set<string>,
1054
1308
  ): T {
1055
- return loaderBodyScopeALS.run({ active: true, loaderId, handlerInvoked }, fn);
1309
+ return loaderBodyScopeALS.run(
1310
+ {
1311
+ active: true,
1312
+ loaderId,
1313
+ parent: loaderBodyScopeALS.getStore(),
1314
+ execScope: getCacheExecScope(),
1315
+ tags,
1316
+ },
1317
+ fn,
1318
+ );
1319
+ }
1320
+
1321
+ /** The innermost loader body's execution tag set (runInsideLoaderBodyScope). */
1322
+ export function getLoaderBodyTags(): Set<string> | undefined {
1323
+ return loaderBodyScopeALS.getStore()?.tags;
1056
1324
  }
1057
1325
 
1058
1326
  /**
1059
1327
  * The $$id of the loader whose body is currently executing, or undefined
1060
1328
  * outside any loader body. Used by the shell-capture identity guard
1061
- * (cookie-store.ts) so its refusal warning can name the loader that read
1329
+ * (assertNotInsideShellCapture) so its refusal warning can name the loader that read
1062
1330
  * cookies()/headers() instead of blaming a lane it cannot see — the old
1063
1331
  * hardcoded "bake-lane loader" text misled a live-lane debugging session
1064
1332
  * (issue #672, secondary).
@@ -1068,16 +1336,16 @@ export function getCurrentLoaderBodyId(): string | undefined {
1068
1336
  }
1069
1337
 
1070
1338
  /**
1071
- * True while a HANDLER-invoked loader body (`await ctx.use(Loader)` from a
1072
- * handler, not the DSL segment funnel) is executing. The consumption-lane
1073
- * rule keys off this: handler consumption yields a BAKED copy in every shared
1074
- * artifact — cache(), "use cache", and the PPR shell — so the shell-capture
1075
- * identity guard (cookie-store.ts) permits cookies()/headers() here, exactly
1076
- * like the cache-purity guards do. DSL segment loaders (live lane masked at
1077
- * capture, bake lane guarded) never set the flag.
1339
+ * True while `loaderId`'s body, or a loader it awaits via ctx.use at any
1340
+ * depth, is executing. The loader-level cache records these pushes
1341
+ * (loader-cache.ts): a HIT skips the cached body, so a dep that no other
1342
+ * reader runs in that request only reaches the page through the replay.
1078
1343
  */
1079
- export function isInsideHandlerInvokedLoaderBody(): boolean {
1080
- return loaderBodyScopeALS.getStore()?.handlerInvoked === true;
1344
+ export function isInsideLoaderBody(loaderId: string): boolean {
1345
+ for (let s = loaderBodyScopeALS.getStore(); s; s = s.parent) {
1346
+ if (s.loaderId === loaderId) return true;
1347
+ }
1348
+ return false;
1081
1349
  }
1082
1350
 
1083
1351
  // Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
@@ -11,8 +11,9 @@ import type { CookieOptions } from "../router/middleware-types.js";
11
11
  import { getRequestContext, _getRequestContext } from "./request-context.js";
12
12
  import {
13
13
  isInsideCacheScope,
14
- getCurrentLoaderBodyId,
15
- isInsideHandlerInvokedLoaderBody,
14
+ assertNotInsideShellCapture,
15
+ tripShellCaptureGuard,
16
+ recordLoaderIdentityRead,
16
17
  } from "./context.js";
17
18
  import { isInsideCacheExecScope } from "../cache/cache-exec-scope.js";
18
19
 
@@ -112,9 +113,13 @@ type HeadersIterator<T> = IterableIterator<T>;
112
113
  * (Scar: the previous INSIDE_CACHE_EXEC stamp on the shared RequestContext
113
114
  * made exactly that parallel read throw for the cached fetch's whole
114
115
  * execution window — see cache-exec-scope.ts.)
116
+ *
117
+ * The loader cache() identity reads (#972) are recorded by the read methods
118
+ * instead (createCookieStore, the headers() view), so a write alone is not one.
115
119
  */
116
120
  function assertNotInsideCacheContext(fnName: string): void {
117
- if (isInsideCacheExecScope()) {
121
+ const scope = refusingCacheScope();
122
+ if (scope === "use cache") {
118
123
  throw new Error(
119
124
  `${fnName}() cannot be called inside a "use cache" function. ` +
120
125
  `Request-scoped data (cookies, headers) varies per request but is not ` +
@@ -125,7 +130,7 @@ function assertNotInsideCacheContext(fnName: string): void {
125
130
  ` const data = await getCachedData(locale); // locale is now in the cache key`,
126
131
  );
127
132
  }
128
- if (isInsideCacheScope()) {
133
+ if (scope === "cache()") {
129
134
  throw new Error(
130
135
  `${fnName}() cannot be called inside a cache() boundary. ` +
131
136
  `A cache() scope caches everything except loaders, so request-scoped ` +
@@ -138,72 +143,89 @@ function assertNotInsideCacheContext(fnName: string): void {
138
143
  }
139
144
 
140
145
  /**
141
- * Throw if called during the ACTIVE background shell-capture render
142
- * (`_shellCaptureRun` true on the derived request context built by
143
- * shell-capture.ts). The captured shell prelude is shared across every user
144
- * hitting the URL, so a request-scoped read here would bake one user's
145
- * cookies/headers into markup served to others — same hazard as the cache
146
- * scopes above, at the document tier. DSL segment loaders need no exemption:
147
- * the live lane is masked (never executed) during capture, and the bake lane
148
- * is exactly what this guard exists for.
149
- *
150
- * HANDLER-INVOKED loader bodies (`await ctx.use(Loader)` from a handler) are
151
- * EXEMPT — the consumption-lane rule: handler consumption yields a BAKED
152
- * shared copy in every artifact tier, and the cache-purity guards above
153
- * already permit identity reads there (cache()/"use cache" bake the same
154
- * reads today). Guarding only the PPR tier made the same code legal under
155
- * cache() but capture-refusing under ppr (issue #672 / #674). The trade is
156
- * documented: an identity read in a handler-consumed loader bakes the CAPTURE
157
- * request's value into the shared shell; client-side consumption (useLoader)
158
- * is the live lane.
159
- *
160
- * Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
161
- * is also present during the FOREGROUND render (it means "a capture is wanted"),
162
- * and the foreground must read cookies/headers normally to serve the real user.
163
- * Only the derived capture context sets `_shellCaptureRun`.
146
+ * The cache scope that refuses a request-scoped read here, or undefined. One
147
+ * owner of the predicates and their order (assertNotInsideCacheContext above),
148
+ * so cookies()/headers() and the theme reads refuse in the same places.
149
+ */
150
+ function refusingCacheScope(): "use cache" | "cache()" | undefined {
151
+ if (isInsideCacheExecScope()) return "use cache";
152
+ if (isInsideCacheScope()) return "cache()";
153
+ return undefined;
154
+ }
155
+
156
+ /** The fix for a theme read that a cache() boundary or a ppr capture refuses. */
157
+ const THEME_READ_FIX =
158
+ "On ppr and cache() routes, read the theme with useTheme() in a client " +
159
+ "component, or in a live loader (no ssr: false) with cookies().get(<storageKey>). " +
160
+ "The <html> theme class needs neither: the theme script sets it before paint. " +
161
+ "See the /theme skill (node_modules/@rangojs/router/skills/theme/SKILL.md).";
162
+
163
+ /** The public reads of the visitor's theme cookie that assertThemeReadAllowed guards. */
164
+ export type ThemeReadSurface = "ctx.theme" | "getRequestContext().theme";
165
+
166
+ /**
167
+ * Guard for a read of the visitor's theme: the handler and middleware
168
+ * `ctx.theme` (handler-context.ts, middleware.ts) and `getRequestContext().theme`
169
+ * (request-context.ts). The theme is the visitor's theme cookie, so it is an
170
+ * identity read like cookies(): it refuses in the same scopes, in the same
171
+ * order (refusingCacheScope, then tripShellCaptureGuard in context.ts), with
172
+ * the same exemption (a loader body under cache(); at capture there is none).
173
+ * Before #971 each read was a plain cookie read, so a ppr shell, a cache()
174
+ * entry or a "use cache" entry stored the first visitor's theme and served it
175
+ * to every later visitor.
164
176
  *
165
- * Applies only to the READ surfaces (cookies(), headers()) whose values
166
- * become markup. Response directives (invalidateClientCache(),
167
- * keepClientCache()) stay callable: during capture they are header effects on
168
- * a discarded response, and on the live HIT path the full pipeline runs so their
169
- * headers flow to the client normally.
177
+ * A read the guards allow is recorded on the current loader execution, as a
178
+ * cookies() read method records it (#972, recordLoaderIdentityRead).
170
179
  *
171
- * The throw makes such a route PPR-ineligible by construction: the capture
172
- * render errors, nothing is stored, and every request keeps getting the
173
- * normal axis-1 render.
180
+ * `ctx` is the request context at read time, as cookies() reads it. The
181
+ * router's own payload read (payloadInitialTheme, rsc/full-payload.ts) goes
182
+ * through the unguarded `_readTheme()`.
174
183
  */
175
- function assertNotInsideShellCapture(ctx: unknown, fnName: string): void {
176
- if (
177
- ctx !== null &&
178
- typeof ctx === "object" &&
179
- (ctx as { _shellCaptureRun?: unknown })._shellCaptureRun === true
180
- ) {
181
- if (isInsideHandlerInvokedLoaderBody()) return;
182
- // Flag the capture context BEFORE throwing: inside an executing bake-lane
183
- // loader this throw is swallowed by wrapLoaderPromise into per-loader error
184
- // UI, which would bake silently into the shared shell. The capture checks
185
- // the flag after the render and refuses (shell-capture.ts). Also record
186
- // WHICH loader body (if any) made the read, so the refusal warning can
187
- // name the real source instead of hardcoding a lane — the read may come
188
- // from a bake-lane loader OR from handler/render code (issue #672).
189
- (ctx as { _shellCaptureGuardTripped?: string })._shellCaptureGuardTripped =
190
- fnName;
191
- (
192
- ctx as { _shellCaptureGuardTrippedLoaderId?: string }
193
- )._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
184
+ export function assertThemeReadAllowed(
185
+ ctx: unknown,
186
+ surface: ThemeReadSurface,
187
+ ): void {
188
+ const scope = refusingCacheScope();
189
+ if (scope === "use cache") {
194
190
  throw new Error(
195
- `${fnName}() cannot be called while capturing a shared shell ` +
196
- `(shell-cache middleware). The captured shell is served to every user ` +
197
- `of this URL, so request-scoped data read here would leak one user's ` +
198
- `${fnName === "cookies" ? "cookies" : "headers"} to others. Read it ` +
199
- `inside a loader instead — loaders are never captured and always run ` +
200
- `fresh per request:\n\n` +
201
- ` loader("user", () => getUser(cookies().get("session")?.value));`,
191
+ `${surface} cannot be read inside a "use cache" function. The theme ` +
192
+ `comes from the visitor's cookie and is not in the cache key, so the ` +
193
+ `first caller's theme would be served to later callers. Read it ` +
194
+ `before the cached function and pass it in as an argument.`,
195
+ );
196
+ }
197
+ if (scope === "cache()") {
198
+ throw new Error(
199
+ `${surface} cannot be read inside a cache() boundary. The theme comes ` +
200
+ `from the visitor's cookie, so the first visitor's theme would be ` +
201
+ `stored in the shared entry and served to everyone. ${THEME_READ_FIX}`,
202
+ );
203
+ }
204
+ if (tripShellCaptureGuard(ctx, surface, THEME_READ_FIX)) {
205
+ throw new Error(
206
+ `${surface} cannot be read while capturing a shared shell (ppr shell ` +
207
+ `capture). The captured shell is served to every visitor of this URL, ` +
208
+ `so the capturing visitor's theme would reach everyone. ${THEME_READ_FIX}`,
202
209
  );
203
210
  }
211
+ // An identity read like a cookies() read method: a loader cache() fill with
212
+ // no key() refuses an execution that made one (#972).
213
+ recordLoaderIdentityRead(surface, "read");
204
214
  }
205
215
 
206
216
  const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
217
+ // Reading one records the read (#972), like the cookies() read methods: a
218
+ // headers() view taken outside a loader and read inside one still counts.
219
+ const HEADERS_READ_PROPS = new Set<string | symbol>([
220
+ "get",
221
+ "has",
222
+ "entries",
223
+ "keys",
224
+ "values",
225
+ "forEach",
226
+ "getSetCookie",
227
+ Symbol.iterator,
228
+ ]);
207
229
 
208
230
  /**
209
231
  * Get the original request headers (read-only).
@@ -226,6 +248,7 @@ export function headers(): ReadonlyHeaders {
226
248
  assertNotInsideShellCapture(ctx, "headers");
227
249
  return new Proxy(ctx.request.headers, {
228
250
  get(target, prop, receiver) {
251
+ if (HEADERS_READ_PROPS.has(prop)) recordLoaderIdentityRead("headers()");
229
252
  if (typeof prop === "string" && HEADERS_MUTATION_METHODS.has(prop)) {
230
253
  return () => {
231
254
  throw new Error(
@@ -306,11 +329,13 @@ function createCookieStore(ctx: {
306
329
  }): CookieStore {
307
330
  return {
308
331
  get(name: string): Cookie | undefined {
332
+ recordLoaderIdentityRead("cookies()");
309
333
  const value = ctx.cookie(name);
310
334
  return value !== undefined ? { name, value } : undefined;
311
335
  },
312
336
 
313
337
  getAll(name?: string): Cookie[] {
338
+ recordLoaderIdentityRead("cookies()");
314
339
  const all = ctx.cookies();
315
340
  if (name !== undefined) {
316
341
  const value = all[name];
@@ -320,6 +345,7 @@ function createCookieStore(ctx: {
320
345
  },
321
346
 
322
347
  has(name: string): boolean {
348
+ recordLoaderIdentityRead("cookies()");
323
349
  return ctx.cookie(name) !== undefined;
324
350
  },
325
351