@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
@@ -10,10 +10,9 @@ Caches the rendered HTML **shell** of a page route (React `prerender` prelude
10
10
  bytes plus `postponed` state) and, on a later request, flushes those bytes after
11
11
  route classification and the complete middleware chain, but before downstream
12
12
  tail rendering. It then resumes React's HTML renderer for just the live holes.
13
- The browser sees one ordinary streamed document; loaders stay fresh on every
14
- request. Rango calls this the second render axis ("axis 2"): the default path
15
- ("axis 1", a normal full render) is untouched, and every ineligible request
16
- falls open to it.
13
+ The browser sees one ordinary streamed document; live loaders (no `ssr: false`)
14
+ stay fresh on every request. A normal render (no shell) is untouched, and every
15
+ request the shell cannot serve falls open to it.
17
16
 
18
17
  Use it when a page has a stable layout that should reach the browser instantly
19
18
  (the first byte comes from cache) while specific regions stay per-request:
@@ -24,8 +23,9 @@ doctrine").
24
23
  Compare `/document-cache`, which freezes the WHOLE response including loader
25
24
  output, and the `cache()` DSL (`/caching`), which caches Flight segments but
26
25
  still renders HTML on every request. Shell caching is for pages that mix a
27
- stable shell with live data: the shell is shared per host+URL, the holes are
28
- per request.
26
+ stable shell with live data: the shell is shared per host+URL (and per request
27
+ partition, when the route's `cache({ key })` or the store's `keyGenerator`
28
+ partitions its record), the holes are per request.
29
29
 
30
30
  This is in-function PPR on every deployment. The worker/function serves the
31
31
  prelude; it is not a CDN static file. See `/deployment-caching` before combining
@@ -36,9 +36,9 @@ PPR with HTTP shared-cache headers.
36
36
  - You want the WHOLE response frozen, loader output included — see
37
37
  `/document-cache`.
38
38
  - You want build-time Flight segment payloads from `Static()`/`Prerender()` —
39
- see `/prerender`. A `Prerender` page may also declare `ppr`; then the build
40
- step called "producer B" bakes the HTML shell at build time while loaders stay
41
- live.
39
+ see `/prerender`. A `Prerender` page may also declare `ppr`; then the
40
+ build-time shell capture bakes the HTML shell while loaders without
41
+ `ssr: false` stay live.
42
42
  - You want cached segments with live loaders but no cached HTML — that is the
43
43
  `cache()` DSL: see `/caching`.
44
44
  - You are unsure which cache layer you need — start at `/cache-guide`.
@@ -51,7 +51,9 @@ only prerequisite is an app-level `createRouter({ cache })` store that
51
51
  implements the shell family (`getShell`/`putShell`): `MemorySegmentCacheStore`
52
52
  (dev/tests), `CFCacheStore` (Cache API L1 + optional KV L2), or
53
53
  `VercelCacheStore` (Vercel Runtime Cache). A ppr route on a store without the
54
- family stays on axis 1 with a once-per-key warning.
54
+ family renders normally (no shell) with a once-per-key warning. A custom store
55
+ returns each `ShellCacheEntry` field as `putShell` received it, including
56
+ `buildVersion` and `snapshot` (both required; the snapshot can be empty).
55
57
 
56
58
  ```typescript
57
59
  import { createRouter, urls } from "@rangojs/router";
@@ -82,70 +84,155 @@ export const router = createRouter<AppBindings>({
82
84
  });
83
85
  ```
84
86
 
85
- That is ONE of two hole mechanisms — the loader one. Do not conclude PPR
86
- requires a loader:
87
+ The live price above is a hole because it is LOADER data read under a
88
+ boundary. Everything the handler layer produces is shell material, the way it
89
+ is under `cache()`:
87
90
 
88
- ### The same opt-in with NO loader and NO loading() — promise holes
91
+ ### Handler output always bakes, promises included
89
92
 
90
- A route (or its layouts) whose live regions are pending promises under
91
- `<Suspense>` PPRs with no `loader()` and no `loading()` anywhere. Hand the
92
- un-awaited promise over as a prop; the consumer suspends under its OWN
93
- boundary; the boundary postpones at capture and becomes the hole:
93
+ A promise a handler passes to a component under `<Suspense>` is NOT a hole. The
94
+ capture waits for it, bakes the settled value into the prelude, and serves that
95
+ value for the shell's lifetime:
94
96
 
95
97
  ```typescript
96
- // Handler: kick off the fetch, do NOT await it.
98
+ // Handler: the fetch is not awaited here, but the capture awaits it.
97
99
  function ProductPage(ctx: HandlerContext) {
98
- const reviews = fetchReviews(ctx.params.id); // Promise<Review[]> — pending
100
+ const reviews = fetchReviews(ctx.params.id); // Promise<Review[]>
99
101
  return (
100
102
  <main>
101
- <h1>Product {ctx.params.id}</h1> {/* shell — baked into the prelude */}
102
- <ReviewsSection promise={reviews} /> {/* hole — resumes per request */}
103
+ <h1>Product {ctx.params.id}</h1> {/* shell */}
104
+ <ReviewsSection promise={reviews} /> {/* shell too: the capture waits for
105
+ `reviews`, and every HIT shows
106
+ the capture's reviews */}
103
107
  </main>
104
108
  );
105
109
  }
106
110
  ```
107
111
 
108
- ```tsx
109
- // ReviewsSection.tsx — the consumer owns its Suspense boundary.
110
- "use client";
111
-
112
- import { Suspense, use } from "react";
112
+ The same holds for an async server component (with or without a `<Suspense>`
113
+ above it), a promise nested in a handle the handler pushes
114
+ (`ctx.use(Handle)({ x: promise })`), and a loader the handler awaits
115
+ (`await ctx.use(Loader)`). The capture waits for all of it, bounded by ONE
116
+ deadline, `ppr.captureTimeout` (15s by default); handler output that has not
117
+ settled by then stores no shell (the once-per-key warning names the cause: "did
118
+ not settle within ppr.captureTimeout"). A shell HIT never runs a handler: it
119
+ replays the handler layer the capture recorded.
120
+
121
+ To keep a region fresh per request, load it in a loader WITHOUT `ssr: false`
122
+ and read it with `useLoader` under `loading()` or an inline `<Suspense>` (a live
123
+ hole, see "The hole doctrine"); inside a `loader(Def, { ssr: false })`, return
124
+ the live part as a nested promise.
125
+
126
+ A route WITHOUT the `ppr` option renders normally (no shell): no store read, no
127
+ capture, no logs, zero cost. `ppr` is per page route — declaring it on a layout
128
+ is not supported (subtree inheritance is a possible follow-up).
129
+
130
+ ### Migration from the old promise-hole model
131
+
132
+ Earlier versions treated a pending handler promise under `<Suspense>` as a hole
133
+ and re-ran handlers on some HITs. That is gone:
134
+
135
+ Everything a handler produces is shell material, as it is under `cache()`: a
136
+ promise it passes to a component under `<Suspense>`, an async server component,
137
+ a nested promise in a handle it pushes, and a loader it awaits are all awaited
138
+ at capture (bounded by `ppr.captureTimeout`) and served frozen for the shell's
139
+ lifetime. A HIT never runs a handler. To keep a value fresh per request, load
140
+ it in a loader without `ssr: false` and read it with `useLoader` under
141
+ `loading()` or an inline `<Suspense>`; inside an `ssr: false` loader, return
142
+ that part as a nested promise. Request-scoped reads the capture now waits for
143
+ refuse it: `cookies()`, `headers()`, `ctx.request.headers`, a
144
+ `{ cache: false }` variable, and
145
+ `ctx.dynamic()` inside a handler promise, an async component, a handle push, or
146
+ a loader a handler awaits. A normal `ctx.get()` value is not guarded: shell
147
+ material is shared per host+URL and request partition. `cache(false)`, or a
148
+ `condition()` that returns false, on a ppr route now renders the request like a
149
+ cache miss (no shell).
150
+
151
+ ### Request-partitioned shells
152
+
153
+ A route whose `cache()` record is partitioned by the request partitions its
154
+ shell the same way: the route's `cache({ key })`, or the store's
155
+ `keyGenerator`, decides which shell a request reads, captures and replays.
156
+ Each partition captures its own on its first request and never serves
157
+ another's:
113
158
 
114
- function Inner({ promise }: { promise: Promise<Review[]> }) {
115
- return <ReviewList reviews={use(promise)} />;
116
- }
117
- export function ReviewsSection({ promise }: { promise: Promise<Review[]> }) {
118
- return (
119
- <Suspense fallback={<ReviewsSkeleton />}>
120
- <Inner promise={promise} />
121
- </Suspense>
122
- );
123
- }
159
+ ```tsx
160
+ cache(
161
+ { ttl: 300, key: (ctx) => `tier:${ctx.request.headers.get("x-tier")}` },
162
+ () => [path("/pricing", PricingPage, { ppr: true })],
163
+ );
164
+ // gold and silver visitors each get their own /pricing shell.
124
165
  ```
125
166
 
126
- ```typescript
127
- path("/products/:id", ProductPage, { name: "product", ppr: true });
128
- // No use() list at all: no loader, no loading, still a shell + live hole.
129
- ```
167
+ The partition follows nested scopes (issue #970): a `ppr` route under a
168
+ `cache()` nested in a keyed one is partitioned by the outer `key()` when the
169
+ inner boundary has none, and by both composed (outermost first, joined by
170
+ `|`) when it has its own. Each `key()` result is namespaced as `key:` plus
171
+ its URI encoding (issue #975), exactly as in the record key, so the shell of
172
+ `key: () => "tier:gold"` is `<host><path>:shell|key%3Atier%253Agold`. The
173
+ route's record adds the inner boundary's own default key when it has no
174
+ `key`; the shell partition does not, since the shell key already carries the
175
+ URL (a store `keyGenerator`'s result is kept when it adds anything, an
176
+ enclosing `cache({ store })`'s on another store included, issue #974). An
177
+ enclosing `condition()` that refuses the request means no shell, as the
178
+ route's own does. See `/caching`, "Keys nest" and "Conditions and tags
179
+ inherit".
130
180
 
131
- At capture the pending fetch cannot win the task-quantized quiet window, so
132
- the boundary postpones — fallback in the frozen prelude, value resumed fresh
133
- on every HIT. This is the PHYSICS class from the hole doctrine below, and it
134
- is exactly how an existing Suspense-shaped tree (e.g. migrated from Next.js
135
- PPR) works with zero restructuring. The same works for a promise hole in a
136
- LAYOUT with no loader registration at all.
181
+ ```tsx
182
+ cache({ ttl: 300, key: (ctx) => `tier:${tierOf(ctx)}` }, () => [
183
+ layout(TierLayout, () => [
184
+ cache({ ttl: 60 }, () => [path("/pricing", PricingPage, { ppr: true })]),
185
+ ]),
186
+ ]);
187
+ // gold and silver still get their own /pricing shell and record.
188
+ ```
137
189
 
138
- A route WITHOUT the `ppr` option is pure axis 1: no store read, no capture, no
139
- logs, zero cost. `ppr` is per page route — declaring it on a layout is not
140
- supported (subtree inheritance is a possible follow-up).
190
+ This is the sanctioned way to vary shell material by request: the capture
191
+ guards still refuse `cookies()`/`headers()`/`ctx.request.headers` in handler
192
+ work, while the key function, a store `keyGenerator`, a `condition()` and a `tags()` function read
193
+ what they need (`cookies()` and `ctx.request.headers` included, at capture
194
+ too). To render the partition value, copy it in middleware with `ctx.set()`
195
+ and read `ctx.get()` in the handler (see `/middleware`). A `key()` runs once per
196
+ request: the shell read, the record lookup and the capture share its result
197
+ (each `key()` of a nested chain once).
198
+ One that throws serves no shell (like the record path renders uncached); a
199
+ store `keyGenerator` that returns the default key unchanged partitions
200
+ nothing. A partitioned route never serves a build-time shell (the build
201
+ captured one partition): each partition captures at runtime, and when the
202
+ route has a build shell a once-per-route warning says so. A `keyGenerator`
203
+ that returns the default key unchanged keeps the build shell.
204
+
205
+ Keep partition values to a small, known set: normalize what the key reads
206
+ (a tier, a locale) to the values you serve before returning it. Every
207
+ distinct value is a full capture and a stored shell, so a key that returns
208
+ raw header or cookie text lets any client mint new shells.
209
+
210
+ Query strings multiply shells the same way: the shell key includes the sorted
211
+ search, so a PDP reached as `/products/1?color=red&size=m` and
212
+ `?color=blue&size=m` has one shell per distinct query, and every appended
213
+ tracking param (`utm_*`, a click id) mints another. The router's
214
+ `cache.searchParams` option (`createRouter({ cache: { store, searchParams } })`)
215
+ drops params from the key, shell included
216
+ (`{ exclude: TRACKING_SEARCH_PARAMS }`, or an `{ include: [...] }` allowlist;
217
+ see `/caching`, "Search param key filtering"). Exclude only a param the shell
218
+ does not render from: otherwise the first variant captured is served for every
219
+ value of it.
220
+
221
+ In tests, `serveShellRequest` reports the key the serve path resolved
222
+ (`result.key`, partition included); `shellCacheKey(url, searchParams,
223
+ partition)` builds the same key from the `key()` result, namespacing it as
224
+ production does. Under nested keyed scopes, pass the `key()` results as an
225
+ array, outermost first (`shellCacheKey(url, undefined, ["tier:gold",
226
+ "v:a"])`); for a store `keyGenerator` partition pass
227
+ `{ keys, generated }` (`/testing`, `cache-prerender.md`).
141
228
 
142
229
  ## Where PPR sits: the cache onion
143
230
 
144
- Rango's caches layer like an onion — each ring stores a progressively more
231
+ Rango's caches layer like an onion — each layer stores a progressively more
145
232
  "cooked" representation of the same page. From innermost (raw values) to
146
233
  outermost (final bytes):
147
234
 
148
- | Ring | Primitive | What is stored | What stays live on a hit |
235
+ | Layer | Primitive | What is stored | What stays live on a hit |
149
236
  | ----------------------- | ---------------------------------------- | -------------------------------------------------- | -------------------------------------- |
150
237
  | 1. Function values | `"use cache"` | a function's return value | everything around the call |
151
238
  | 2. Loader values | `loader(Fn, () => [cache({...})])` | one loader's result (opt-in; loaders default live) | all other loaders, handlers, rendering |
@@ -153,13 +240,15 @@ outermost (final bytes):
153
240
  | 4. **HTML shell (PPR)** | `ppr` path option | rendered prelude bytes + React postponed state | the holes, hydration payload |
154
241
  | 5. Whole response | `/document-cache` | final response bytes, headers included | nothing — all-or-nothing |
155
242
 
156
- PPR is ORTHOGONAL to `cache()` (ring 3): a ppr route may be uncached (its
157
- handlers run fresh on every serve and during capture), fully `cache()`d (its
158
- segments replay), or mixed. One useful cache() property to know: the segment
159
- codec **deep-settles promises at the ring-3 write**, so nothing inside a
160
- `cache()` boundary can stay live — that is a cache() fact, not a ppr one.
243
+ PPR is ORTHOGONAL to `cache()` (the segments layer): a ppr route may be
244
+ uncached (its handlers run on a MISS and during capture), fully `cache()`d
245
+ (the capture replays its segments), or mixed. Either way a HIT runs no
246
+ handler: the shell entry is itself a `cache()` of the handler layer, recorded
247
+ by the capture and replayed on every HIT. That is why handler promises cannot stay live under ppr:
248
+ the segment codec **deep-settles promises** when it writes a segment record,
249
+ under `cache()` and under the shell alike.
161
250
 
162
- Invalidation crosses rings: `updateTag()`/`revalidateTag()` reach segment,
251
+ Invalidation crosses layers: `updateTag()`/`revalidateTag()` reach segment,
163
252
  shell, loader, and item entries in the same store, and shell entries
164
253
  additionally self-invalidate on `React.version` change.
165
254
 
@@ -173,54 +262,99 @@ On a document GET to a ppr route the router runs:
173
262
  them: any rejection/redirect/401 wins before a single shell byte, on MISS
174
263
  and on a warmed HIT alike;
175
264
  3. **shell lookup** — `getShell(key)` on the app store (key =
176
- host+pathname+sorted search);
265
+ host+pathname+sorted search). `CFCacheStore` stores the entry prelude-first
266
+ and the serve path reads only its head and prelude here, with the tag-marker
267
+ read running alongside; the capture snapshot is read off the commit path.
268
+ `CFCacheStore` and `VercelCacheStore` keep the last fresh read of each shell
269
+ in a memo for `memo.shellMs` (default 2 s): a warm isolate's next HITs skip
270
+ the store read, but never the tag-marker check, which reads its markers
271
+ through a per-isolate stale-while-revalidate memo (`memo.markerFreshMs`,
272
+ `memo.markerMaxStaleMs`). The user whose request ran `updateTag()` /
273
+ `revalidateTag()` gets a fresh-reads cookie that skips both memos; past
274
+ them each store's own consistency applies (`VercelCacheStore`: fresh in
275
+ any region, `expireTag` is global; `CFCacheStore` with KV: fresh in the
276
+ colo that ran it, other colos once KV propagates the marker and
277
+ `tagCacheTtl` expires; KV-less purge mode: once the purge reaches the colo).
278
+ Other users see the invalidation once the marker memo refreshes (KV-less
279
+ purge mode and another Vercel region: once the shell window passes; see the
280
+ caching skill);
177
281
  4. **HIT** — the composed response is committed immediately: the stored prelude
178
- bytes flush first, while segment resolution, the fresh Flight render (the
179
- full hydration payload — there is no Flight-side resume), and the fizz
180
- `resume` of just the holes run BEHIND them inside the response stream;
181
- 5. **MISS** — plain axis-1 serve, tagged `x-rango-shell: MISS`, plus a
282
+ bytes flush first (in 32 KB chunks), while segment resolution, the Flight
283
+ render (the full hydration payload — there is no Flight-side resume), and
284
+ the fizz `resume` of just the holes run BEHIND them inside the response
285
+ stream, starting a macrotask after the commit so they cannot delay the
286
+ prelude's write. Segment resolution replays the handler layer from the
287
+ shell's own recorded segments — NO handler runs on a HIT — and runs the
288
+ live loaders fresh (a promise-free bake-lane loader is served from the
289
+ shell, see "On a shell HIT");
290
+ 5. **MISS** — a normal render (no shell), tagged `x-rango-shell: MISS`, plus a
182
291
  background capture (stampede-guarded, retry-in-place, exponential backoff).
183
292
 
184
293
  `x-rango-shell: HIT | MISS` is the observability header. Because the commit
185
294
  point is after the chain, an unauthorized request NEVER sees shell bytes — put
186
295
  auth middleware anywhere (global or route DSL) and it guards PPR for free.
187
296
 
297
+ A route whose own `cache()` scope refuses THIS request — `cache(false)`, or a
298
+ `condition()` that returns false — skips steps 3-5: the document renders like a
299
+ cache miss (a normal render, no shell), with no `x-rango-shell` header and no
300
+ capture scheduled.
301
+
302
+ If a HIT cannot read the shell's recorded segments, it never falls back to
303
+ running handlers behind the committed prelude: it ends the response with a
304
+ small script that reloads the page once with a `_rsc_shell=miss` query marker,
305
+ and a request carrying the marker renders like a cache miss (no shell, no
306
+ capture), so the reload cannot repeat. The router drops the marker from the
307
+ request before anything reads it (middleware, handlers, loaders, cache keys
308
+ and `useSearchParams` see the clean URL), and the browser drops it from the
309
+ address bar before the page hydrates. When the stored value is corrupt it
310
+ also replaces the entry with a placeholder the next request treats as a MISS
311
+ and schedules a recapture that heals the key; a snapshot read that was only
312
+ slow leaves the entry alone.
313
+
188
314
  ### Soft navigation caches and reuses the handler layer
189
315
 
190
316
  In short: client-side (soft) navigations to a `ppr` route reuse the handler
191
- segments captured with the shell, while loaders stay live. Nothing needs
192
- configuring; the rest of this section explains the `x-rango-ppr-replay` header
193
- for when you are debugging replay behavior.
317
+ segments captured with the shell, while loaders without `ssr: false` stay live.
318
+ Nothing needs configuring; the rest of this section explains the
319
+ `x-rango-ppr-replay` header for when you are debugging replay behavior.
194
320
 
195
321
  Ordinary partial RSC navigations to a `ppr` URL use the same handler-layer cache
196
322
  contract even when no document request has captured an HTML shell yet. When a
197
- shell snapshot exists, the server replays its canonical document segment record.
323
+ shell snapshot exists, the server replays the page's recorded segments from it.
198
324
  On a cold partial request, normal matching renders the response and schedules a
199
325
  background navigation-only shell capture; later navigations and prefetches
200
326
  replay its eligible snapshot. In both cases `matchPartial()`:
201
327
 
202
328
  - preserves client-owned shared layouts by segment id;
203
329
  - returns only new or revalidating destination segments;
204
- - runs DSL loaders fresh with their normal `loading()` streaming behavior;
330
+ - runs live DSL loaders fresh with their normal `loading()` streaming
331
+ behavior, and serves bake-lane loaders from the pins of a shell captured by a
332
+ document request; a snapshot captured by a navigation alone carries no pins,
333
+ and its replay runs them fresh (see below);
205
334
  - keeps the existing prefetch key, source scope, and in-flight lock unchanged.
206
335
 
207
336
  This is deliberately invisible to the browser: the response is the same
208
- `RscPayload` shape as any other partial navigation. Captured item/response values
209
- and loader-container pins are NOT replayed on this path, so loader reads stay
210
- live.
337
+ `RscPayload` shape as any other partial navigation. A shell snapshot holds no
338
+ `"use cache"` or loader `cache()` values. Bake-lane loader pins are, when the replayed
339
+ snapshot is a shell a document request captured, as on a document HIT: a
340
+ promise-free `ssr: false` loader is served from its pin without running
341
+ (unless the capture marked it to run: see "On a shell HIT"), and one whose
342
+ return holds promises runs with its baked parts overlaid. A snapshot captured
343
+ by a navigation alone carries no pins, and its replay runs them fresh. Loaders
344
+ without `ssr: false` stay live.
211
345
 
212
346
  A route's own `cache()` scope — including one inherited from an ancestor, the
213
347
  common app-wide storefront shape — COMPOSES with replay instead of disabling
214
348
  it. The explicit tier stays authoritative: its lookup runs first with its
215
349
  normal store, key, TTL, SWR, tags, and condition, and when it supplies the
216
350
  match the response reports `BYPASS; reason=explicit-cache-hit` (never a false
217
- replay `HIT`). Only when the explicit tier misses does the shell snapshot's
218
- canonical doc segment record supply the match and report `HIT`. To make that
219
- possible, a capture of such a route records the doc segment record into the
220
- shell snapshot IN ADDITION to the scope's normal store write; the record rides
351
+ replay `HIT`). Only when the explicit tier misses do the page's recorded
352
+ segments in the shell snapshot supply the match and report `HIT`. To make that
353
+ possible, a capture of such a route records the page's segments into the
354
+ shell snapshot IN ADDITION to the scope's normal store write; that record rides
221
355
  only inside the shell entry, never the real store. Two opt-outs stay absolute:
222
356
  `cache(false)` and a `condition()` returning false mean "do not serve this
223
- request's segments from any cache" — no doc record is captured for them and
357
+ request's segments from any cache" — no segments are recorded for them and
224
358
  the seeded fallback never rescues a refused read. `cache(false)` is static, so
225
359
  replay reports `BYPASS; reason=cache-disabled` before performing a single
226
360
  shell read; a `condition()` refusal is request-time state, decided at the
@@ -228,15 +362,23 @@ lookup itself and reported as the same `cache-disabled` post-match (the gate
228
362
  must not pre-decide a predicate that could flap between the two evaluations).
229
363
  An errored explicit read — a throwing `key()`, or a built-in store's swallowed
230
364
  backend failure (`CACHE_READ_ERROR`) — also never falls back: it renders
231
- uncached, exactly as without ppr.
232
-
233
- `transition({ when })` is evaluated from the
234
- matched manifest before route handlers on every PPR match, so it can vary by
235
- URL/params/action or middleware context without disabling replay; handler-set
236
- context is unavailable by design. Intercepts, handler-live holes, an active
237
- nonce, and an absent/corrupt segment snapshot fall open to the ordinary partial
238
- path when encountered by the shell capture. A transition already replayed from
239
- an explicit cache tier remains frozen by that tier's normal semantics.
365
+ uncached, exactly as without ppr. (Document requests treat the same two
366
+ opt-outs as a cache miss before any shell read: see the serve pipeline above.)
367
+
368
+ `transition({ when })` is a browser predicate: the server never evaluates
369
+ it, so a shell, a replay and a cache entry never hold a decision. The route
370
+ carries it as a client reference (attached right before Flight on every
371
+ match, never stored), and the browser decides each navigation from
372
+ `{ kind, from, to }`. Location state middleware sets reaches `to.state` on a
373
+ replay `HIT` (middleware runs on every HIT); a handler's does not (a HIT runs
374
+ no handler). The decision gates only how the browser commits; it never makes
375
+ a replay send a segment. A replay `HIT`
376
+ makes the same segment decision as the live partial path: a segment the client
377
+ holds that the navigation does not re-send (`revalidate()` false, or a layout
378
+ the default keeps) is omitted from the response, so the client keeps its tree
379
+ (a list the client built up, "Load more" style, stays as it is). Intercepts, an active nonce, and an
380
+ absent/corrupt segment snapshot fall open to the ordinary partial path when
381
+ encountered by the shell capture.
240
382
 
241
383
  Two more decisions are made before any shell-store read, so probes and
242
384
  prerendered routes never spend passive `getShell` I/O:
@@ -247,8 +389,8 @@ prerendered routes never spend passive `getShell` I/O:
247
389
  with this in mind — such probes are not cache misses.
248
390
  - A `Prerender()` route's partial is served from the build-time prerender
249
391
  store inside matching (a better-than-HIT outcome); it reports
250
- `BYPASS; reason=prerender-store`. Its captures never record a doc segment
251
- record (the prerender store short-circuits the cache write), so replay
392
+ `BYPASS; reason=prerender-store`. Its captures never record the page's
393
+ segments (the prerender store short-circuits the cache write), so replay
252
394
  seeding would be impossible anyway.
253
395
 
254
396
  Fresh and stale-within-SWR runtime shells replay. The stale read is passive: it
@@ -291,17 +433,17 @@ consumer-invisible.
291
433
 
292
434
  The bounded bypass tokens, grouped by when they are decided:
293
435
 
294
- | Token | Decided | Meaning |
295
- | --------------------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
296
- | `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay |
297
- | `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path`/`Referer`; a partial match is impossible |
298
- | `prerender-store` | pre-read or match | `Prerender()` route served by its baked artifact (pre-read probe of the normal variant; reclassified post-match when the store actually served, either variant) |
299
- | `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture |
300
- | `cache-disabled` | pre-read or match | `cache(false)` (pre-read, static) or `condition()` false (decided at the lookup); consumer opt-out is absolute |
301
- | `read-error`, `no-entry`, `invalid-version`, `corrupt-entry`, `stale-build-entry` | shell read | no usable shell entry (`no-entry`/`invalid-version`/`corrupt-entry`/`stale-build-entry` schedule the navigation-only heal capture) |
302
- | `handler-live-holes`, `transition-when`, `no-segment-snapshot` | eligibility | entry exists but its snapshot cannot replay (no canonical doc record, or handler/transition liveness); on a route with an enabled `cache()` scope, `no-segment-snapshot` heals when the lookup did not refuse (a `condition()` false-at-capture entry becomes replayable) |
303
- | `explicit-cache-hit` | match | the route's own `cache()` tier supplied the match |
304
- | `snapshot-miss` | match | an eligible snapshot was seeded but matching did not consume it |
436
+ | Token | Decided | Meaning |
437
+ | ----------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
438
+ | `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay |
439
+ | `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path`/`Referer`; a partial match is impossible |
440
+ | `prerender-store` | pre-read or match | `Prerender()` route served by its baked artifact (pre-read probe of the normal variant; reclassified post-match when the store actually served, either variant) |
441
+ | `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture |
442
+ | `cache-disabled` | pre-read or match | `cache(false)` (pre-read, static) or `condition()` false (decided at the lookup); consumer opt-out is absolute |
443
+ | `read-error`, `no-entry`, `invalid-version`, `corrupt-entry` | shell read | no usable shell entry (`no-entry`/`invalid-version`/`corrupt-entry` schedule the navigation-only heal capture) |
444
+ | `no-segment-snapshot` | eligibility | entry exists but its snapshot has no recorded segments for the page; on a route with an enabled `cache()` scope, it heals when the lookup did not refuse (a `condition()` false-at-capture entry becomes replayable) |
445
+ | `explicit-cache-hit` | match | the route's own `cache()` tier supplied the match |
446
+ | `snapshot-miss` | match | an eligible snapshot was seeded but matching did not consume it |
305
447
 
306
448
  ### Capture-generation invalidation
307
449
 
@@ -313,11 +455,14 @@ out of render code if you want the shell to persist.
313
455
 
314
456
  ### Opting out per request with `ctx.dynamic()`
315
457
 
316
- Middleware and handlers can call `ctx.dynamic()` to force this request back to
317
- axis 1. In middleware it runs before the PPR commit point, so the router skips
318
- shell lookup, HIT serving, and MISS capture for that request. In handlers it is
458
+ Middleware and handlers can call `ctx.dynamic()` to force this request onto a
459
+ normal render (no shell). In middleware it runs before the PPR commit point, so
460
+ the router skips shell lookup, HIT serving, and MISS capture for that request.
461
+ In handlers it is
319
462
  too late to prevent a MISS render from already happening, but it still prevents
320
- the follow-up shell capture.
463
+ the follow-up shell capture. A `ctx.dynamic()` call that only happens during the
464
+ capture itself (for example inside a handler promise, after an await) refuses
465
+ that capture: a dynamic render has no shell.
321
466
 
322
467
  During `Prerender` + `ppr` build-shell capture, middleware is replayed with
323
468
  `ctx.build === true`, `ctx.waitUntil()` inert, and the same `ctx.dynamic()`
@@ -354,9 +499,10 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
354
499
  - Runtime-captured route: first document GET is `MISS`, plus a background
355
500
  capture; a later request becomes a `HIT`.
356
501
  - `Prerender + ppr` route: the shell is produced during `vite build`, so the
357
- first production document request can already be a `HIT`. In dev, producer B
358
- runs on demand and can return a `HIT`; if capture outlasts the bounded
359
- foreground wait, the request falls open to `MISS` and runtime capture.
502
+ first production document request can already be a `HIT`. In dev, the
503
+ build-time shell capture runs on demand and can return a `HIT`; if capture
504
+ outlasts the bounded foreground wait, the request falls open to `MISS` and
505
+ runtime capture.
360
506
  - Production (workerd/node): the SECOND request is a `HIT`.
361
507
  - Dev: expect a few extra MISSes — cold module transforms abort the capture
362
508
  window (per-attempt breadcrumbs: start the server with
@@ -367,8 +513,9 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
367
513
  your baked shell, with hole fallbacks in place), then
368
514
  `<div hidden id="S:0">…` segments as the holes resume, per request.
369
515
  - A ppr-declared route that CANNOT be honored (missing shell store family,
370
- per-request nonce) serves plain axis 1 with NO header and warns once per
371
- key — no header + a declared `ppr` means look for that warning.
516
+ per-request nonce) serves a normal render (no shell) with NO header and
517
+ warns once per key — no header + a declared `ppr` means look for that
518
+ warning.
372
519
  - On Cloudflare, `CFCacheStore` reads PPR shells from the per-colo Cache API,
373
520
  falls through to KV on a miss, and promotes the KV hit back into that colo.
374
521
  WITHOUT a KV namespace the family runs L1-only (edge-only ppr): every colo
@@ -392,23 +539,27 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
392
539
  captures outrank queued navigation-only snapshots, but never interrupt the
393
540
  active capture — the skip event and the `rango.background` span carry
394
541
  `queuePriority`/`queueAhead` (class and backlog at enqueue) so a parked
395
- capture diagnoses itself. A stored attempt reports `bakeWaitMs`: how long the
396
- slowest bake source (a top-level pushed handle promise or a bake-lane loader
397
- container) held the capture gate — in dev, past 2s, the source is also named
398
- once per key in a console warning with the remedies (nest the promise /
399
- `cache()` the work / drop the loader's `ssr: false`). In dev, with
542
+ capture diagnoses itself. A stored attempt reports `recordSettleMs`
543
+ (`record=` in the log line): how long the capture waited for the handler
544
+ layer to settle (promises it passes or pushes, async server components,
545
+ loaders it awaits, top-level pushed handle promises and bake-lane loader
546
+ containers) and its segment record to encode; and `entryBytes` (`entry=`):
547
+ the stored entry's size. In dev, past 2s, the settle is also
548
+ named once per key in a console warning with the remedies (`cache()` the
549
+ work / move it into a live loader / return it as a nested promise or drop
550
+ `ssr: false` in a bake-lane loader). In dev, with
400
551
  `debugPerformance` on, the last capture outcome for a key also rides the
401
552
  next document GET's `Server-Timing` as `ppr-capture;desc="…"`.
402
553
  - For deployed Cloudflare tier diagnostics, build with
403
554
  `INTERNAL_RANGO_DEBUG=1` and run `wrangler tail`. `[CFCacheStore][shell]`
404
- JSON events distinguish L1 hit/miss, KV fallback/promotion, tier writes, and
405
- marker rejection, with `cf-ray`/colo and read timings. The flag is baked at
555
+ JSON events distinguish memo hits, L1 hit/miss, KV fallback/promotion, tier
556
+ writes, and marker rejection, with `cf-ray`/colo and read timings. The flag is baked at
406
557
  build time; setting only a Worker runtime variable is too late.
407
558
 
408
559
  ### Unit / integration testing (public primitives)
409
560
 
410
561
  Import from `@rangojs/router/testing` (Vitest) or `@rangojs/router/testing/e2e`
411
- (Playwright):
562
+ (Playwright); `serveShellRequest` from `@rangojs/router/testing/flight`:
412
563
 
413
564
  | Helper | Use for |
414
565
  | ------------------------------------------------- | ------------------------------------------------------------ |
@@ -417,6 +568,7 @@ Import from `@rangojs/router/testing` (Vitest) or `@rangojs/router/testing/e2e`
417
568
  | `parsePprReplayStatus(res)` | Read structured replay/bypass status or null |
418
569
  | `shellCacheKey(url)` | Production store key for `store.getShell` / custom stores |
419
570
  | `MemorySegmentCacheStore` + `getShell`/`putShell` | Custom store contract / tag eviction (no faked HIT) |
571
+ | `serveShellRequest(router, url, opts)` | A real MISS → capture → HIT (react-server Vitest) |
420
572
 
421
573
  ```ts
422
574
  import { MemorySegmentCacheStore } from "@rangojs/router/cache";
@@ -432,27 +584,49 @@ expect(await store.getShell(key)).not.toBeNull();
432
584
  assertShellStatus({ headers: new Headers(res.headers()) }, "HIT");
433
585
  ```
434
586
 
435
- **Out of unit scope** (stay e2e): live MISS → background capture → HIT,
436
- browser resume of holes, build-time producer B. `dispatch` never runs PPR.
437
- `renderHandler` only exposes `ctx.dynamic()` / `build` for the opt-out path.
438
- Do not invent a HIT Response in unit tests. Full recipe: `/testing` skill →
439
- `cache-prerender.md` (PPR shell section).
587
+ **Out of unit scope** (stay e2e): real HTML (the prelude bytes and the fizz
588
+ resume), browser resume of holes, the build-time shell capture. A MISS →
589
+ capture → HIT runs in a unit test through `serveShellRequest` from
590
+ `@rangojs/router/testing/flight`: the shell as captured is `result.prelude`,
591
+ the live tail is `result.flight`. "A HIT runs no handler and shows the
592
+ capture's handler output" is assertable there: count handler runs or render a
593
+ per-run stamp, then compare the MISS and a later HIT. `dispatch` never runs
594
+ PPR. `renderHandler` only exposes `ctx.dynamic()` / `build` for the opt-out
595
+ path. Do not invent a HIT Response in unit tests. Full recipe: `/testing`
596
+ skill → `serve-shell-request.md` and `cache-prerender.md` (PPR shell section).
440
597
 
441
598
  ## The hole doctrine (encode this in your head)
442
599
 
443
- Holes are **render-defined**, decided by the shape of the tree, on three rules:
600
+ Holes are **render-defined**, decided by the shape of the tree. There are two
601
+ classes, and the handler layer is always on the SHELL side:
602
+
603
+ | Class | What decides it | At capture | At serve (HIT) |
604
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
605
+ | **STRUCTURAL** | a LIVE-lane loader (no `ssr: false`) read under a boundary: `loading()` (the whole segment subtree under it) or an inline `<Suspense>` above the reader | live loaders masked; the boundary postpones; its fallback bakes in as route structure | loaders run fresh; resume fills the hole |
606
+ | **SHELL** | everything the handler layer produces — awaited data, a promise it passes as a prop (even under the consumer's `<Suspense>`), an async server component, handle pushes at any depth, a loader it awaits (`await ctx.use(Loader)`) — plus replayed `cache()` segments and the settled non-promise data of BAKE-lane (`ssr: false`) loaders | awaited (one deadline, `ppr.captureTimeout`) and baked into the prelude and the shell's recorded segments | served from the frozen prelude; no handler runs |
444
607
 
445
- | Class | What makes the hole | At capture | At serve |
446
- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------- |
447
- | **STRUCTURAL** | the ENTIRE segment subtree under a `loading()` registration, when a LIVE-lane loader read suspends to it | live loaders masked; the LoaderBoundary postpones; the fallback bakes in as route structure | loaders run fresh; resume fills it |
448
- | **PHYSICS** | any promise NESTED in handed-over data still pending at capture, under the consumer's own `<Suspense>` — handler props, handle containers (`push({ x: promise })`), loader-carried | real I/O cannot win the task-quantized quiet window; the boundary postpones | the promise settles and streams in |
449
- | **SHELL** | awaited handler data, TOP-LEVEL `push(promise)` (awaited before SSR), resolved promises, replayed `cache()` segments, the settled non-promise data of BAKE-lane (`ssr: false`) loaders | baked into the prelude | served from the frozen prelude |
608
+ One hole lives INSIDE loader data, decided by shape: a promise nested in a
609
+ bake-lane loader's return value, or in a handle container a loader pushes, is
610
+ masked at capture however fast it settles, and postpones at the consumer's own
611
+ `<Suspense>`. Such a loader still runs on every HIT, so that promise streams in
612
+ fresh (a promise-free bake-lane loader does not run on a HIT; see "On a shell
613
+ HIT").
450
614
 
451
- ONE rule for promises, every lane — handlers, handles, AND loaders: **a promise
452
- nested inside your data is never baked; the container settles.** A
615
+ The promise rule, by producer: **a promise a handler produces bakes, at any
616
+ depth; a promise nested in loader data stays live.** A
453
617
  `loader(Def, { ssr: false })` is the BAKE lane (see the lane rule below): its
454
- settled non-promise data is shell material, exactly like awaited handler data
455
- and top-level handle pushes.
618
+ settled non-promise data is shell material, exactly like handler output, while
619
+ its nested promises are holes. A live-lane loader is a hole as a whole.
620
+ `ssr: false` on a loader is the same knob as `loading(fallback, { ssr: false })`:
621
+ the document does not stream a fallback for it but resolves the loader before
622
+ the first flush, so under `ppr` its value lands in the prelude and bakes into
623
+ the shell. Delivery details: `/loader` → "`ssr: false` — Guarantee a Loader in
624
+ the Document".
625
+
626
+ An async server component that never settles inside `ppr.captureTimeout`
627
+ (with or without `<Suspense>` above it) stores no shell; the warning names the
628
+ deadline. Make it cheaper (`cache()`/`"use cache"`), raise the budget, or move
629
+ its data into a live loader read under a boundary.
456
630
 
457
631
  ### The loader lane rule
458
632
 
@@ -462,20 +636,36 @@ settled non-promise values are frozen into the shell (snapshot-pinned per
462
636
  shell). Any promise inside the returned data is masked at capture and stays a
463
637
  live hole, streamed fresh on every request at the consumer's own `<Suspense>`,
464
638
  however fast it settles. The shape of the return value declares what is live:
465
- a plain value bakes, a promise stays live.
466
-
467
- - **Any depth, plain containers only.** The mask walks plain objects
468
- (prototype `Object.prototype` or `null`) and arrays at any depth —
469
- `{ items: [{ stock: fetchStock(sku) }] }` keeps every `stock` live. A `Map`,
470
- `Set`, class instance, or any other non-plain object is a LEAF: a promise
471
- inside it is NOT masked and does NOT become a live hole. Keep live promises
472
- in plain objects and arrays.
639
+ a plain value bakes, a promise stays live. (The lane rule is about loaders the
640
+ route registers. A loader a HANDLER awaits with `ctx.use()` is handler output:
641
+ the handler's copy bakes whatever the flag says, even while the same loader's
642
+ registered read stays a live hole.)
643
+
644
+ - **Any depth, plain containers and JSX props.** The mask walks plain objects
645
+ (prototype `Object.prototype` or `null`), arrays and the props of React
646
+ elements at any depth — `{ items: [{ stock: fetchStock(sku) }] }` keeps
647
+ every `stock` live, and so does `{ reviews: <Reviews data={fetchReviews(id)} /> }`
648
+ for `data`. JSX without promises bakes like any plain value. A promise in
649
+ the props of a host element (`<p>`, `<div>`), `<Suspense>`/`<Fragment>` or a
650
+ client component is the hole and nothing else: the element's other props
651
+ bake and stay pinned on every HIT. That includes client components in JSX
652
+ the loader read back from `cache()` or `"use cache"`, which come back
653
+ wrapped in `lazy`. A server component (a function component, `memo` or
654
+ `forwardRef` around one, or a `lazy` that resolves to one) with a promise
655
+ anywhere in its props is the exception: the whole element is the hole and
656
+ renders from the
657
+ fresh loader run on a HIT, so the props it renders into the shell must come
658
+ out the same on every run. A `Map`, `Set`, class instance, or any other
659
+ non-plain object is a LEAF: a promise inside it is NOT masked and does NOT
660
+ become a live hole. Keep live promises in plain objects, arrays and JSX
661
+ props.
473
662
  - **The consumer's `<Suspense>` is the hole.** A client component reads the
474
663
  live property with `use(data.price)` inside its own `<Suspense>`; that
475
664
  boundary postpones at capture and resumes with the fresh value.
476
- - **Handle containers follow the same shape rule.** The capture applies the
477
- same mask to pushed handle containers, so `ctx.use(Handle)({ ..., x: promise })`
478
- keeps `x` live (see "Handles: nesting = liveness").
665
+ - **Loader handle pushes follow the same shape rule.** The capture applies the
666
+ same mask to handle containers a loader pushes, so
667
+ `ctx.use(Handle)({ ..., x: promise })` inside a loader keeps `x` live. The
668
+ same push from a HANDLER bakes `x` (see "Handles").
479
669
 
480
670
  **Every loader without `ssr: false` is entirely live**, whether or not it has
481
671
  `loading()`: masked at capture with a never-resolving promise, postponed at its
@@ -536,29 +726,30 @@ own takes the entire entry — baked `name`/`description` included — into that
536
726
  hole.
537
727
 
538
728
  **`loading()` is NOT the lane switch and NOT the gate for holes** — it is one
539
- of the two boundary kinds a live loader postpones at. A pending promise under
540
- any plain `<Suspense>` postpones and is a hole at whatever level it suspends —
541
- the PHYSICS row needs no `loading()` anywhere (the e2e fixture's physics and
542
- nested-handle holes sit in a layout with none). A route without a loader PPRs
543
- on pure promise/Suspense holes.
729
+ of the two boundary kinds a live loader postpones at; an inline `<Suspense>`
730
+ above the reader is the other. A route with no loader has no holes: its whole
731
+ page is shell, which is a valid ppr route (the shell is served instantly and
732
+ recaptured on TTL/SWR or tag invalidation).
544
733
 
545
- The three promise positions, side by side:
734
+ The promise positions, side by side:
546
735
 
547
736
  ```typescript
548
737
  async function Handler(ctx: HandlerContext) {
549
738
  const push = ctx.use(MyHandle);
550
739
 
551
- push(fetchBadge()); // TOP-LEVEL push: awaited pre-SSR → BAKED
552
- push({ label: "x", stat: fetchStat() }); // NESTED in container → container baked,
553
- // stat streams → HOLE (consumer Suspends it)
740
+ push(fetchBadge()); // top-level handler push: awaited at capture → BAKED
741
+ push({ label: "x", stat: fetchStat() }); // nested in a handler push: awaited at capture → BAKED
554
742
 
555
- const data = await fetchHeader(ctx); // awaited by the handler → BAKED
743
+ const data = await fetchHeader(ctx); // awaited by the handler → BAKED
556
744
 
557
745
  return (
558
746
  <section>
559
- <Header data={data} /> {/* shell */}
560
- <StatsPanel promise={fetchStats(ctx)} /> {/* un-awaited prop + own
561
- <Suspense> + use() → HOLE */}
747
+ <Header data={data} /> {/* shell */}
748
+ <StatsPanel promise={fetchStats(ctx)} /> {/* un-awaited prop under the consumer's
749
+ <Suspense>: awaited at capture → BAKED */}
750
+ <LiveStats /> {/* "use client": useLoader(StatsLoader)
751
+ under <Suspense>, StatsLoader registered
752
+ without ssr: false → HOLE */}
562
753
  </section>
563
754
  );
564
755
  }
@@ -566,44 +757,47 @@ async function Handler(ctx: HandlerContext) {
566
757
 
567
758
  ### Choosing the hole mechanism
568
759
 
569
- | Your live region is… | Use | Why |
570
- | ----------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
571
- | loader data, must be fresh EVERY serve | `loader()` (the live lane) + a boundary: `loading()` or an inline `<Suspense>` | the guaranteed hole — masked at capture, fresh every serve, immune to fast resolution |
572
- | loader data, shell container + live parts | `loader(Def, { ssr: false })` (the bake lane): `{ static, dynamic: promise }` | plain values bake (snapshot-pinned per shell); nested promises hole at the consumer's `<Suspense>` |
573
- | handler-fetched real I/O (db, fetch) | un-awaited promise prop + consumer `<Suspense>` + `use()` | no loader needed; real latency postpones by physics |
574
- | per-segment metadata consumed elsewhere | handle container with a NESTED promise + consumer `<Suspense>` | container is shell, nested value streams — "nesting = liveness" |
575
- | already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))` (no `ssr: false`) + a boundary | a raw promise that settles inside the quiet window BAKES; only the live lane guarantees live |
576
- | none of the above | nothing | it bakes — that is what the shell is for |
577
-
578
- The physics caveat in one line: HANDLER-created promise props are holes only
579
- because the I/O is genuinely pending at capture — if the value can resolve
580
- near-instantly (memory read, warmed cache), it may bake into the shell; when
581
- liveness must be guaranteed rather than probable, use a live-lane loader (no
582
- `ssr: false`). BAKE-LANE
583
- NESTED promises are exempt from that race: the capture MASKS every thenable
584
- nested in a bake-lane container regardless of settle timing
585
- (`maskNestedContainerThenables`, loader-cache.ts), so the consuming boundary
586
- always postpones as a hole and every HIT streams the FRESH value — the
587
- promise SHAPE is the liveness declaration, not a bet on latency. (Before the
588
- mask, a nested promise that settled inside the window pinned its capture-time
589
- value into the shared shell; found live as a storefront basket — with the
590
- capturing session's identifiers — served to anonymous visitors.)
591
-
592
- ### Handles: "nesting = liveness"
593
-
594
- - `ctx.use(H)(promise)` — a TOP-LEVEL pushed promise is awaited server-side
595
- before SSR (`resolvedHandleStream`) and BAKED into the shell. The capture
596
- gate is held open for the same await, so real latency here is safe (bounded
597
- by the capture budget, `ppr.captureTimeout`, 15s by default).
598
- - `ctx.use(H)({ x: promise })` — the container passes through verbatim
599
- (resolution is shallow); the nested promise streams to the consumer, who must
600
- `<Suspense>` it. Under capture that boundary postpones — a hole — REGARDLESS
601
- of settle timing: the capture masks nested thenables in pushed handle
602
- containers (the capture store's push wrap, shell-capture.ts), so even an
603
- already-resolved nested promise holes instead of baking its value into the
604
- shared shell. Same shape-is-the-declaration rule, and the same mask, as
605
- bake-lane loaders: promises are found at any depth of plain objects and
606
- arrays; one inside a `Map`, `Set`, or class instance is not masked.
760
+ | Your live region is… | Use | Why |
761
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
762
+ | data that must be fresh EVERY serve | `loader()` (the live lane) + `useLoader` under `loading()` or an inline `<Suspense>` | the guaranteed hole — masked at capture, fresh every serve, immune to fast resolution |
763
+ | loader data, shell container + live parts | `loader(Def, { ssr: false })` (the bake lane): `{ static, dynamic: promise }` | plain values bake (snapshot-pinned per shell); nested promises hole at the consumer's `<Suspense>` |
764
+ | handler-fetched data (db, fetch) | nothing to choose: it bakes, whether the handler awaits it or passes the promise down | handler output is shell material; move the fetch into a live-lane loader to make it live |
765
+ | handle metadata that must be live | push it from a loader: a live-lane loader pushes per request; a bake-lane loader's nested promise stays a hole | handler pushes bake at any depth; loader pushes keep "nesting = liveness" |
766
+ | already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))` (no `ssr: false`) + a boundary | live-lane loaders are masked at capture no matter how fast they settle |
767
+ | none of the above | nothing | it bakes — that is what the shell is for |
768
+
769
+ Why a handler promise cannot be a hole: every HIT replays the handler layer the
770
+ capture recorded, and no handler runs, so nothing on the HIT could produce a
771
+ fresh value for it. The capture therefore waits for it and bakes it, exactly as
772
+ `cache()` does. Loader-side holes do not depend on timing either: the capture
773
+ MASKS every promise nested in a bake-lane container regardless of how fast it
774
+ settles (`maskNestedContainerThenables`), so the consuming boundary always
775
+ postpones and every HIT streams the FRESH value — the promise SHAPE is the
776
+ liveness declaration, not a bet on latency. (Before the mask, a nested promise
777
+ that settled inside the capture window pinned its capture-time value into the
778
+ shared shell; found live as a storefront basket — with the capturing session's
779
+ identifiers — served to anonymous visitors.)
780
+
781
+ ### Handles: handler pushes bake; loader pushes keep "nesting = liveness"
782
+
783
+ - `ctx.use(H)(promise)` from a handler — a TOP-LEVEL pushed promise is awaited
784
+ at capture and BAKED into the shell (bounded by `ppr.captureTimeout`, 15s by
785
+ default).
786
+ - `ctx.use(H)({ x: promise })` from a HANDLER — also baked. The capture waits
787
+ for every promise nested in a handler's push, at any depth of plain objects,
788
+ arrays and JSX props, before it records the handles every HIT replays. Keep
789
+ promises in plain objects, arrays and JSX props: one inside a `Map`, `Set`,
790
+ or class instance is not waited for, and if it has not settled when the handles are encoded the
791
+ capture is refused.
792
+ - `ctx.use(H)({ x: promise })` from a LOADER — nesting = liveness. A bake-lane
793
+ (`ssr: false`) loader's container settles and bakes, while the nested promise
794
+ is masked at capture regardless of settle timing and streams fresh to the
795
+ consumer's `<Suspense>` on every HIT (the same mask as bake-lane loader data,
796
+ at any depth of plain objects, arrays and JSX props; a promise inside a
797
+ `Map`, `Set`, or class instance is not masked). A live-lane
798
+ loader never runs at capture unless a handler or an `ssr: false` loader
799
+ awaits it; either
800
+ way its pushes, and those of the loaders it awaits, are live on every HIT.
607
801
 
608
802
  ### Want a hole for already-resolved data?
609
803
 
@@ -621,8 +815,9 @@ non-promise data bakes into the prelude; every promise nested in it is masked
621
815
  at capture (regardless of how fast it settles) and postpones at the consumer's
622
816
  own `<Suspense>` — a hole. The capture records the container into the shell
623
817
  snapshot's loader family so every HIT payload matches the frozen prelude
624
- byte-for-byte (see "On a shell HIT" below). The return shape is the
625
- declaration:
818
+ outside the holes (see "On a shell HIT" below; a server component holding a
819
+ promise is the one element that is a hole as a whole, see "Any depth" above).
820
+ The return shape is the declaration:
626
821
 
627
822
  ```typescript
628
823
  export const StorefrontContextLoader = createLoader(async (ctx) => {
@@ -646,43 +841,86 @@ entries, not just routes.
646
841
 
647
842
  #### On a shell HIT
648
843
 
649
- What a bake-lane loader does on a document HIT depends on whether its recorded
650
- container carried holes:
651
-
652
- - **Hole-free record (the return had no promises): pin-first.** The response
653
- uses the pinned container from the shell snapshot immediately and does not
654
- wait for the loader. The loader body STILL runs fresh on every HIT, in the
655
- background via `executionContext.waitUntil`, only for its side effects and
656
- cache read-through writes; its result is discarded. The pinned SHAPE wins
657
- wholesale: a field the fresh run adds mid-TTL is dropped, because the frozen
658
- prelude has no hole for it. The output changes only when the shell is
844
+ What a bake-lane loader does on a HIT depends on whether its recorded
845
+ container carried holes. A client navigation that replays a shell captured by
846
+ a document request (`x-rango-ppr-replay: HIT`) does the same, for every
847
+ `ssr: false` loader, including one on an entry with `loading()` (before, a
848
+ navigation ran every loader and served its fresh values). A snapshot captured
849
+ by a navigation alone carries no pins, and its replay runs these loaders fresh.
850
+
851
+ - **Hole-free record (the return had no promises): served from the shell.**
852
+ The response uses the pinned container immediately and the loader body does
853
+ NOT run (unless the capture marked it to run: see the handle pushes note
854
+ below): a HIT is rendered from the shell, like the handlers it replays, so
855
+ the loader costs nothing per HIT. Side effects in its body happen once per
856
+ capture, not per request. The output changes only when the shell is
659
857
  recaptured (TTL/SWR expiry or tag invalidation).
660
- - **Hole-carrying record (the return had promises).** The loader runs fresh ON
661
- the critical path — only the body can create the live promises — and the
662
- recorded baked paths are overlaid onto the fresh result, so the payload
663
- matches the prelude while the nested promises stream fresh. A fresh
664
- rejection skips the overlay and goes to the loader's error boundary.
665
-
666
- A fully baked loader therefore still costs one backend call per HIT, off the
667
- response's critical path. To avoid it, give the loader its own cache —
668
- `loader(Def, { ssr: false }, () => [cache({ ttl })])` — so the background run
669
- reads through the loader cache.
670
-
671
- **Handle pushes from a bake-lane loader appear once, with the re-run's value.**
672
- The capture records the loader's settled, thenable-free handle pushes (the
673
- prelude rendered them), and a HIT that replays the handler layer from the
674
- record restores them. The loader still re-runs, and its pushes replace the
675
- restored ones in place, so any handle, deduping or not, shows each value
676
- once and the live value wins. A re-run push that lands after the document's
677
- handle snapshot reaches the client after hydration; until then the client
678
- shows the recorded value, which matches the prelude. Deferred and
679
- promise-carrying pushes are never recorded; the re-run is their only
680
- producer. The record keeps only the pushes
681
- made by the `ssr: false` loader's own body. A loader it awaits with
682
- `ctx.use()` re-runs with it on a HIT, so that loader's pushes are not recorded
683
- and appear once, whatever the value type (a string is left out the same way as
684
- an object). The same holds for pushes the capture replays from the loader's
685
- own `cache()` entry.
858
+ - **Hole-carrying record (the return had promises).** The loader runs on every
859
+ HIT, ON the critical path — only the body can create the live promises that
860
+ fill the holes — and the recorded baked paths are overlaid onto the fresh
861
+ result, so the payload matches the prelude while the nested promises stream
862
+ fresh. A fresh rejection skips the overlay and goes to the loader's error
863
+ boundary. Give it its own cache,
864
+ `loader(Def, { ssr: false }, () => [cache({ ttl })])`, so that run reads
865
+ through the loader cache.
866
+
867
+ Whether a return "has promises" is decided ONCE, at capture, by the shape of
868
+ the value, never by timing: `maskNestedContainerThenables`
869
+ (`router/segment-resolution/mask-nested.ts`) walks the returned data through
870
+ plain objects, arrays and JSX element props and turns every thenable into a
871
+ hole; the drain stores the result as the record's `holes` bit. A promise that
872
+ resolves in 1 ms is still a hole, so `{ product: await getProduct() }` bakes
873
+ and does not run on a HIT, while `{ product: getProduct() }` is a hole and
874
+ runs on every HIT. A promise inside a `Map`, `Set` or class instance is not
875
+ walked and is not a hole: the capture's record encode waits for it and bakes
876
+ its settled value, and a HIT serves that value without running the loader.
877
+
878
+ The granularity is the WHOLE loader: a bake-lane loader returning
879
+ `{ product, reviews: promise }` runs its entire body on every HIT, the product
880
+ fetch included, even though the captured product overlays its result. Split
881
+ it into a promise-free `ssr: false` loader plus a live loader for the
882
+ per-request part, or give it its own `cache()` so that run reads through it:
883
+
884
+ ```tsx
885
+ loader(ProductLoader, { ssr: false }), // promise-free: baked, not run on a HIT
886
+ loader(ReviewsLoader), // live: the hole, under loading()
887
+ ```
888
+
889
+ **A hole never reads the shell's copy of a value.** The shell stores its
890
+ recorded segments and the `ssr: false` loaders' baked containers, and no
891
+ `"use cache"` or loader `cache()` value. Every cache read on a HIT goes to
892
+ the store: a live loader's, a nested promise's, and the body of an
893
+ `ssr: false` loader that runs on the HIT. So a live loader that reads the
894
+ same `"use cache"` key as an `ssr: false` loader shows the store's current
895
+ value once it refreshes, while the baked container keeps the capture's. If a
896
+ hole needs a stable value, give it its own cache (`"use cache"` with a
897
+ profile, or a loader `cache()`); the shell never provides one.
898
+
899
+ **Handle pushes from a bake-lane loader appear once.** The capture records
900
+ the settled, thenable-free handle pushes of every loader body it runs (the
901
+ prelude rendered them): the `ssr: false` loader's own, those of the loaders
902
+ it awaits with `ctx.use()`, and those its own `cache()` entry replays. Every
903
+ HIT restores them with the handler layer, and they stand: a bake-lane loader
904
+ that does run on the HIT (a hole-carrying one) reads the store, so its settled
905
+ pushes, and those a `"use cache"` hit inside its body replays, are dropped
906
+ instead of replacing the restored ones. Any handle, deduping or not, shows
907
+ each value once, and it is the value the prelude rendered. A loader the route
908
+ also registers on the live lane is a hole: its live run replaces its restored
909
+ pushes, and those of the loaders it awaits, so they show its live value, even
910
+ when a bake-lane loader running on the HIT awaits it. A run that throws or
911
+ makes no push shows none of the capture's once its run ends; a hole slower
912
+ than the handler barrier shows the shell's copy in the first snapshot until
913
+ then. A hole with its own loader `cache()` that hits shows the pushes that
914
+ entry recorded, none if it recorded none, matching its data. A dependency the route does not
915
+ register, which an `ssr: false` loader awaited at capture outside any live
916
+ loader, is on neither lane: its settled pushes stay as the prelude rendered
917
+ them, even where a live loader also awaits it, while its data is fresh. To
918
+ keep its pushes live, declare it as its own `loader()` on the route. A push the capture cannot record (a
919
+ deferred push, or one holding a promise) marks every loader record of the
920
+ page to run: those bodies then still run on each HIT, in the background, and
921
+ that push reaches the page (from the loader's own `cache()` entry when it
922
+ hits) with the run's value; a push that lands after the document's handle snapshot
923
+ reaches the client after hydration.
686
924
 
687
925
  Four hard edges (each e2e/unit-pinned):
688
926
 
@@ -691,24 +929,31 @@ Four hard edges (each e2e/unit-pinned):
691
929
  or loader on a ppr route calling `ctx.headers.set()`, `cookies().set()`,
692
930
  `ctx.setTheme()`, or the request-context `header()`/`setStatus()` throws on
693
931
  EVERY render — dev and prod, first render, same guard family as the
694
- `cache()` boundary guard. Handlers
695
- are replayed on HITs (the write would silently differ between MISS and
696
- HIT); loaders are live but settle AFTER the response headers flushed with
697
- the shell (dead letters). Move the write into route middleware — it runs
932
+ `cache()` boundary guard. Handler output is replayed on HITs and no handler
933
+ runs there (the write would silently differ between MISS and HIT); loaders
934
+ are live but settle AFTER the response headers flushed with the shell (dead
935
+ letters). Move the write into route middleware — it runs
698
936
  on every request, including HITs, and its headers/cookies merge into every
699
937
  response. The one exception: a handler that calls `ctx.dynamic()` FIRST
700
938
  re-permits its own header/cookie write (#735) — a dynamic() render never
701
939
  HITs, so the write is deterministic (see "Opting out per request").
702
- - **Identity refuses.** `cookies()`/`headers()` inside a bake-lane loader
940
+ - **Identity refuses.** `cookies()`/`headers()`/`ctx.request.headers` inside a bake-lane loader
703
941
  throws during capture and the capture REFUSES (deterministic, once-per-key
704
942
  warned) — identity can never bake into the shared shell. Drop the loader's
705
943
  `ssr: false` flag (the live lane is exempt; give it a boundary) or move the
706
944
  identity-dependent part into a separate loader without `ssr: false`. A
707
945
  nested promise does not help here: its body still runs during capture and
708
- trips the guard. The guard's scope is EXACTLY those two calls:
709
- per-user state read from a middleware-provided object (`ctx.get("session")`)
710
- does NOT refuse — it bakes silently as the capturing user's data (see
711
- Pitfalls: the session-object bake trap).
946
+ trips the guard. The same refusal covers everything else the capture waits
947
+ for (a handler, a promise it passes or pushes, an async server component, a
948
+ loader a handler awaits), a `{ cache: false }` variable
949
+ (`createVar({ cache: false })` or `ctx.set(..., { cache: false })`) read
950
+ there, and the visitor's theme (`ctx.theme`, `getRequestContext().theme`,
951
+ #971); see "Security". Read the theme with `useTheme()` in a client
952
+ component, or in a live loader with `cookies().get("theme")`; the shell's
953
+ `initialTheme` is the no-cookie default. Per-user state read from a NORMAL
954
+ middleware-provided variable (`ctx.get("session")`) does NOT refuse — it
955
+ bakes silently as the capturing user's data (see Pitfalls: the
956
+ session-object bake trap).
712
957
  - **A rejecting bake-lane loader refuses.** Error UI never bakes. So does a
713
958
  value that fails to encode: a function, a class instance, a promise inside a
714
959
  `Map` that rejects, or a server component in the value that throws. The
@@ -718,10 +963,11 @@ Four hard edges (each e2e/unit-pinned):
718
963
  that throws only on that run refuses it too. A build-time capture skips the
719
964
  shell (`SHELL SKIP`) and the route keeps runtime capture.
720
965
  - **Baked containers show CAPTURE-time data** for the shell's lifetime on
721
- document GETs. Soft navigations may replay the captured handler segments, but
722
- DSL loaders and their item/response reads remain fresh. That IS the bake
723
- lane's meaning; if a value must be fresh on every serve, it belongs on the
724
- live lane (no `ssr: false`) or in a nested promise.
966
+ document GETs, and on soft navigations that replay a shell captured by a
967
+ document request (`x-rango-ppr-replay: HIT`). Live loaders and their
968
+ `"use cache"` reads remain fresh. That IS the bake lane's meaning; if a value
969
+ must be fresh on every serve, it belongs on the live lane (no `ssr: false`) or
970
+ in a nested promise.
725
971
 
726
972
  ### The layout-with-loaders playbook (the storefront case)
727
973
 
@@ -737,7 +983,8 @@ their data should bake vs stay live. Your levers, in order of preference:
737
983
  shared/config data returns as plain values (bakes, pinned per shell);
738
984
  per-request data returns as NESTED promises consumed under the widget's own
739
985
  `<Suspense>` (live holes). No `loading()`, no restructuring. One wall: a
740
- bake-lane loader that reads `cookies()`/`headers()` refuses the capture —
986
+ bake-lane loader that reads `cookies()`/`headers()`/`ctx.request.headers`
987
+ refuses the capture —
741
988
  identity belongs on the live lane (a separate unflagged loader); a nested
742
989
  promise inside the flagged loader still runs during capture and trips the
743
990
  guard.
@@ -779,46 +1026,52 @@ their data should bake vs stay live. Your levers, in order of preference:
779
1026
  GUARANTEED fresh per serve — use this where the bake lane's pinning
780
1027
  (capture-time data for the shell's lifetime) is not acceptable, at the cost
781
1028
  of a widget-sized fallback in the shell. The slot handler must hand the
782
- loader to a CLIENT component (`useLoader` in a `"use client"` component)
783
- for the freshness guarantee to reach the rendered value: server-side
784
- `await ctx.use(...)` in the handler is the BAKED lane (the consumption-lane
785
- rule, `/rango` → Invariants) — it executes at capture with identity reads
786
- permitted, but the value it renders is a capture-time copy wherever it is
787
- not shielded by the slot's masked LoaderBoundary.
788
-
789
- 4. **Shared layout data can also leave the loader lane entirely**: an
790
- un-awaited handler promise under the consumer's `<Suspense>` (a physics
791
- hole) or `cache()`/`"use cache"` to bake it with tag-invalidation.
792
-
793
- The identity rule, stated once: per-user data on a PPR page lives in a NESTED
794
- promise (a hole, fresh per request) or in a live-lane loader (no `ssr: false`)
795
- consumed CLIENT-side under `loading()` or an inline `<Suspense>`. A nested
796
- promise's body still runs at capture, so it must not call `cookies()`/`headers()`
797
- itself — identity reads belong in the live-lane loader. Reading
798
- `cookies()`/`headers()` where the value would bake as SEGMENT material —
799
- handler/render code or a bake-lane loader container — refuses the capture by
800
- construction. The one exemption is
801
- handler-INVOKED loader bodies (`await ctx.use(loader)`): they execute at
802
- capture with identity reads permitted, and the value bakes as a shared
803
- capture-time copy — mirroring `cache()` semantics (the consumption-lane
804
- rule, `/rango` → Invariants).
1029
+ loader to a CLIENT component (`useLoader` in a `"use client"` component):
1030
+ that client-side read is the hole. A server-side
1031
+ `await ctx.use(WishlistLoader)` in the slot handler is handler output: it
1032
+ runs at capture, its value bakes into the shell for every visitor, and if
1033
+ the loader reads `cookies()`/`headers()` (the usual case for a per-user
1034
+ loader) the capture is REFUSED and the page stays on MISS.
1035
+
1036
+ 4. **Shared layout data does not need a loader at all**: render it from the
1037
+ handler (awaited, passed down as a promise, or pushed — it all bakes) and
1038
+ wrap the expensive part in `cache()`/`"use cache"` so captures stay cheap
1039
+ and the shell is tag-invalidatable.
1040
+
1041
+ The identity rule, stated once: per-user data on a PPR page lives in a
1042
+ live-lane loader (no `ssr: false`) consumed CLIENT-side with `useLoader` under
1043
+ `loading()` or an inline `<Suspense>`, or in a NESTED promise inside a
1044
+ bake-lane loader (a hole, fresh per request). A nested promise's body still
1045
+ runs at capture, so it must not call `cookies()`/`headers()` or read
1046
+ `ctx.request.headers` itself — identity reads belong in the live-lane loader.
1047
+ Reading `cookies()`, `headers()`, `ctx.request.headers`, or a
1048
+ `{ cache: false }` variable anywhere the capture waits for refuses the capture
1049
+ by construction: handler/render code, a promise the handler passes or pushes,
1050
+ an async server component, a bake-lane loader, and a loader a handler awaits
1051
+ (`await ctx.use(Loader)`). There is no exemption for handler-invoked loaders:
1052
+ every HIT replays the capture's copy of their value, so an identity read there
1053
+ would show the capturing request's user to every visitor.
805
1054
 
806
1055
  ### Designing routes for cheap captures (the cost model)
807
1056
 
808
1057
  The hole doctrine has a cost corollary that bites silently: **everything that
809
1058
  bakes is awaited at capture, and that wait recurs on EVERY capture of the
810
- route**. A top-level pushed handle promise that takes 5s and a Meta promise
811
- chained 2s further make every capture occupy the per-isolate serialized queue
812
- for ~7s — while the served response still reads 13ms TTFB, because captures are
813
- detached. You will not see this cost in any request timing; you see it as slow
814
- MISS→HIT flips, `skip-queue-timeout` under prefetch pressure, and (in dev, past
815
- 2s) the bake-cost warning naming the source.
1059
+ route**. That now includes every promise a handler produces: a prop promise, an
1060
+ async server component's data, a nested value in a handle push, a loader the
1061
+ handler awaits. A handler pushing a handle promise that takes 5s and a Meta
1062
+ promise chained 2s further make every capture occupy the per-isolate serialized
1063
+ queue for ~7s — while the served response still reads 13ms TTFB, because
1064
+ captures are detached. You will not see this cost in any request timing; you
1065
+ see it as slow MISS→HIT flips, `skip-queue-timeout` under prefetch pressure,
1066
+ and (in dev, past 2s) the bake-cost warning naming the source.
816
1067
 
817
1068
  Three levers, in preference order:
818
1069
 
819
- 1. **Nest it** (`{ data: promise }` instead of the promise): the value becomes
820
- a hole — per-request, streamed under the consumer's `<Suspense>`, zero
821
- capture cost. Choose this whenever the value may be dynamic anyway.
1070
+ 1. **Make it live**: move the value into a live-lane loader (no `ssr: false`)
1071
+ read with `useLoader` under `loading()` or an inline `<Suspense>` — a hole,
1072
+ per request, zero capture cost. Inside a bake-lane loader, return the slow
1073
+ part as a nested promise instead. Nesting a promise inside a HANDLER's push
1074
+ or props no longer does this: handler output is always awaited.
822
1075
  2. **`cache()` the work inside it**: the value stays baked (shell material,
823
1076
  tag-invalidatable), but the capture replays the cached value instead of
824
1077
  re-executing the expensive body (mixed-chain). Choose this for shared,
@@ -829,8 +1082,9 @@ Three levers, in preference order:
829
1082
  reader).
830
1083
 
831
1084
  Head material (Meta) pushed by HANDLERS cannot be a hole — the head is shell —
832
- so those promises bake by design; make them cheap with lever 2. `bakeWaitMs`
833
- on the capture debug event tells you what each capture actually paid. A
1085
+ so those promises bake by design; make them cheap with lever 2.
1086
+ `recordSettleMs` on the capture debug event tells you what each capture
1087
+ actually paid. A
834
1088
  Meta pushed by a LIVE-lane loader is different: those loaders are masked at
835
1089
  capture, so the push happens at request time and applies client-side
836
1090
  (`metadata.handlesLate`) — it is never in the cached shell's head, by
@@ -838,25 +1092,29 @@ construction. A bake-lane (`ssr: false`) loader executes at capture, so its
838
1092
  settled pushes are shell material like a handler's.
839
1093
 
840
1094
  `loader(Def, { ssr: false })` (the document-render await, `/loader`) is the
841
- bake-lane switch under PPR, not an axis-1-only knob: the capture render awaits
842
- the flagged loader too, so its settled non-promise data — handle pushes
843
- included — freezes into the stored shell and recurs as capture cost on every
844
- capture (levers 1 and 2 apply to it). Outside capture the flag keeps its
845
- axis-1 meaning: document renders await it before first flush, client
846
- navigations stream it.
1095
+ bake-lane switch under PPR, not a knob for normal renders only: the capture
1096
+ render awaits the flagged loader too, so its settled non-promise data — handle
1097
+ pushes included — freezes into the stored shell and recurs as capture cost on
1098
+ every capture (levers 1 and 2 apply to it). On a MISS the flag keeps its
1099
+ normal-render meaning: document renders await it before first flush, client
1100
+ navigations stream it. A shell HIT, and a navigation that replays a shell
1101
+ captured by a document request, serve a promise-free one from the shell
1102
+ without running it (unless the capture marked it to run: see "On a shell
1103
+ HIT"); a snapshot captured by a navigation alone carries no pins, and its
1104
+ replay runs it fresh.
847
1105
 
848
1106
  ## Execution matrix
849
1107
 
850
- | Phase | MISS (foreground) | Background capture | HIT (foreground) |
851
- | ---------------- | ---------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
852
- | Middleware chain | runs (full) | **NOT re-run** — inherits the request's post-middleware context | runs (full) — commit point is after it |
853
- | `router.match` | runs | re-runs under a derived context | runs (behind the flushed prelude) |
854
- | Handlers | run | run on UNCACHED segments; `cache()`d segments replay (mixed-chain) | run (same mixed-chain rules as any render) |
855
- | Loaders | run **fresh** | LIVE lane (no `ssr: false`): MASKED; BAKE lane (`ssr: false`): execute + snapshot-pin | run **fresh** (bake containers overlaid from the snapshot) |
856
- | Flight render | full | full | full (hydration needs the whole payload — no Flight resume) |
857
- | HTML production | full fizz | `prerender` + abort → prelude + postponed | `resume` only the holes — O(paths to holes) |
858
- | Shell store | schedules a bg capture | `putShell(key, …)` | `getShell(key)`; a stale/SWR hit also schedules a recapture |
859
- | Prelude bytes | — | — | flushed FIRST, before segment resolution starts |
1108
+ | Phase | MISS (foreground) | Background capture | HIT (foreground) |
1109
+ | ---------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
1110
+ | Middleware chain | runs (full) | **NOT re-run** — inherits the request's post-middleware context | runs (full) — commit point is after it |
1111
+ | `router.match` | runs | re-runs under a derived context | runs (behind the flushed prelude) |
1112
+ | Handlers | run | run on UNCACHED segments (`cache()`d segments replay); everything they produce — promises, async server components, handle pushes, awaited loaders — settles before the freeze (`ppr.captureTimeout`) | **never run** — the handler layer is replayed from the shell's recorded segments |
1113
+ | Loaders | run **fresh** | LIVE lane (no `ssr: false`): MASKED; BAKE lane (`ssr: false`): execute + snapshot-pin | live: run **fresh**; bake: pinned, runs only if its record has holes |
1114
+ | Flight render | full | full, from the capture's own recorded segments (the same bytes every HIT replays) | full (hydration needs the whole payload — no Flight resume) |
1115
+ | HTML production | full fizz | `prerender` + abort → prelude + postponed | `resume` only the holes — O(paths to holes) |
1116
+ | Shell store | schedules a bg capture | `putShell(key, …)` | `getShell(key)`; a stale/SWR hit also schedules a recapture |
1117
+ | Prelude bytes | — | — | flushed FIRST, before segment resolution starts |
860
1118
 
861
1119
  Middleware is not re-run during capture because it already ran for the
862
1120
  triggering request — the capture's derived context inherits the
@@ -864,52 +1122,71 @@ post-middleware state (`ctx` variables included, which is what makes
864
1122
  middleware-derived shell content photograph correctly). Guarding is
865
1123
  serve-time: the commit point runs the full chain on EVERY serve.
866
1124
 
867
- Because handlers on uncached segments EXECUTE during capture — and BAKE-lane
868
- loaders now do too — the `cookies()`/`headers()` capture guard is load-bearing:
869
- those reads THROW during a capture render (`assertNotInsideShellCapture`), so
870
- identity can never leak into a shared shell through them. Live-lane loaders
871
- (every loader without `ssr: false`) are exempt: masked at capture, they never
872
- run there.
1125
+ Because handlers on uncached segments EXECUTE during capture — along with
1126
+ everything they produce and every BAKE-lane loader — the capture guard is
1127
+ load-bearing: `cookies()`, `headers()`, `ctx.request.headers`,
1128
+ `getRequestContext().cookie()` / `.cookies()`, a theme read, and a
1129
+ `{ cache: false }` variable read THROW during a capture render, and the capture
1130
+ is refused even when the code catches that throw, so identity can never leak
1131
+ into a shared shell through them. Live-lane loaders (every loader without
1132
+ `ssr: false`) are exempt when only a client component reads them: masked at
1133
+ capture, they never run there. One a handler or an `ssr: false` loader awaits
1134
+ runs at capture, and its identity reads refuse it.
873
1135
 
874
1136
  ## allReady: the SEO/bot story
875
1137
 
876
1138
  `ssr: { resolveStreaming: ... }` returning `"allReady"` (e.g. for bot user
877
1139
  agents) bypasses PPR entirely — the request gets one complete, fully-buffered
878
- axis-1 document. Crawlers that dislike streamed shells get a finished page;
879
- regular users get the streamed shell. No configuration interaction: allReady
880
- wins.
1140
+ document (a normal render, no shell). Crawlers that dislike streamed shells
1141
+ get a finished page; regular users get the streamed shell. No configuration
1142
+ interaction: allReady wins.
881
1143
 
882
1144
  ## Security
883
1145
 
884
- Shell caching shares one shell per host+URL across all users:
1146
+ Shell caching shares one shell per host+URL (and request partition, see
1147
+ "Request-partitioned shells") across all users:
885
1148
 
886
1149
  **(a) Access control is sound by construction.** The commit point is after ALL
887
1150
  middleware on every serve. A 401/redirect short-circuit returns before any
888
1151
  shell byte.
889
1152
 
890
- **(b) Identity can't leak via cookies/headers.** `cookies()` and `headers()`
891
- THROW during the background capture render — in handlers AND in bake-lane
892
- loaders (whose containers would bake). A shell that reads them is
893
- PPR-ineligible by construction; the live lane (no `ssr: false`) stays exempt.
894
-
895
- **(c) Residual hazard — middleware-derived per-user state.** A `ctx` variable
896
- set by an upstream auth middleware and rendered by shell material is
897
- photographed into the SHARED shell (the capture inherits post-middleware
898
- state). That is scope fidelity working as designed — for shared values. The
899
- same hazard reaches BAKE-LANE LOADERS: a loader reading a middleware-provided
900
- session object (`ctx.get("session")`) never calls `cookies()` itself, so the
901
- guard cannot see it — whatever it returns as settled container data is
902
- photographed as the CAPTURING user's state. If the
903
- value is per-user: shell-cache only public/shared pages, keep per-user content
904
- in nested pending promises or live-lane loaders (no `ssr: false`) — NOT in
905
- plain bake-lane container data — or key per variant at the CDN tier.
906
-
907
- ## What always stays on axis 1
1153
+ **(b) Request-scoped reads refuse the capture.** During the background capture
1154
+ render, `cookies()`, `headers()`, the raw reads `ctx.request.headers` and
1155
+ `getRequestContext().cookie()` / `.cookies()`, the visitor's theme (`ctx.theme`,
1156
+ `getRequestContext().theme`), and `ctx.get()` of a `{ cache: false }` variable
1157
+ (`createVar({ cache: false })`, or a value written with
1158
+ `ctx.set(..., { cache: false })`) THROW, wherever the capture waits for them:
1159
+ a handler, a promise it passes or pushes, an async server component, a
1160
+ bake-lane loader, and a loader a handler awaits (`await ctx.use(Loader)`). The
1161
+ capture is refused even if your code catches the throw, and the route keeps
1162
+ serving MISSes with a once-per-key warning. `ctx.dynamic()` called there
1163
+ refuses it too. The live lane (no `ssr: false`) stays exempt when only a client
1164
+ component reads it; one a handler or an `ssr: false` loader awaits runs at
1165
+ capture, and its identity reads refuse it.
1166
+
1167
+ **(c) Residual hazard — middleware-derived per-user state.** A NORMAL `ctx`
1168
+ variable (no `cache: false`) set by an upstream auth middleware and rendered by
1169
+ shell material is photographed into the SHARED shell (the capture inherits
1170
+ post-middleware state). The guard cannot see it. That is scope fidelity
1171
+ working as designed — for shared
1172
+ values. It reaches handlers and BAKE-LANE LOADERS alike: a loader reading a
1173
+ middleware-provided session object (`ctx.get("session")`) never calls
1174
+ `cookies()` itself, so whatever it returns as settled container data is
1175
+ photographed as the CAPTURING user's state. If the value is per-user:
1176
+ shell-cache only public/shared pages, keep per-user content in live-lane
1177
+ loaders (no `ssr: false`) or nested promises in bake-lane loader data — NOT in
1178
+ handler output or plain bake-lane container data — mark the variable
1179
+ `{ cache: false }` so a stray shell read refuses the capture, or key per
1180
+ variant at the CDN tier.
1181
+
1182
+ ## What always renders without a shell
908
1183
 
909
1184
  Non-GET, non-partial RSC/action/loader fetches, partial requests without an
910
1185
  eligible captured segment snapshot, per-request CSP nonce, `streamMode:
911
- "allReady"`, redirects, 404s, error renders, routes without `ppr`, and any store
912
- without the shell family. A stored shell is invalidated when
1186
+ "allReady"`, redirects, 404s, error renders, routes without `ppr`, a document
1187
+ request the route's own `cache()` scope refuses (`cache(false)`, or a
1188
+ `condition()` returning false for it), and any store without the shell family.
1189
+ A stored shell is invalidated when
913
1190
  `React.version` changes (postponed state is build-coupled), so deploys
914
1191
  self-heal via recapture.
915
1192
 
@@ -926,8 +1203,8 @@ point (which runs after the whole middleware chain), so a middleware-set nonce
926
1203
  blocks capture the same as a provider one. Because the route DECLARED `ppr`
927
1204
  but cannot be honored, it logs a once-per-key worker warning (same
928
1205
  declared-intent-cannot-be-honored doctrine as the missing-store warning) and
929
- serves pure axis 1 with no `x-rango-shell` header. An undeclared route stays
930
- silent.
1206
+ serves a normal render (no shell) with no `x-rango-shell` header. An
1207
+ undeclared route stays silent.
931
1208
 
932
1209
  ### The proper way to supply a nonce
933
1210
 
@@ -957,13 +1234,38 @@ path(
957
1234
  );
958
1235
  ```
959
1236
 
960
- | Field | Default | Notes |
961
- | ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
962
- | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
963
- | `swr` | — | stale window: serve the stale shell + background recapture |
964
- | `tags` | — | operational tags UNIONED with the tags the capture render auto-collects — see "Invalidation" below |
965
- | `maxSnapshotBytes` | 8 MiB | cap on the entry's capture data snapshot; over it the snapshot is skipped (shell still stored, warned once per key) so the entry stays under store limits |
966
- | `captureTimeout` | 15000ms | capture settle budget; a timed-out capture is refused rather than storing a partial shell |
1237
+ | Field | Default | Notes |
1238
+ | ------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1239
+ | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
1240
+ | `swr` | — | stale window: serve the stale shell + background recapture |
1241
+ | `tags` | — | operational tags UNIONED with the tags the capture collects — see "Invalidation" below |
1242
+ | `maxSnapshotBytes` | 8 MiB | cap on the loader pins in the entry's snapshot (the bake-lane loader records). The recorded handler layer is exempt. Over the cap the pins are dropped and the shell is stored with its handler layer (warned once per key); bake-lane loaders then read the live store, and drift is repaired client-side |
1243
+ | `captureTimeout` | 15000ms | ONE capture deadline: the handler layer settling (promises it passes or pushes, async server components, loaders it awaits), bake-lane loaders, and the prerender; a capture that misses it stores nothing rather than a partial shell |
1244
+
1245
+ A shell never outlives the route `cache()` entry it was captured from: under
1246
+ a route `cache()`, the shell is fresh no longer than that entry is (at most
1247
+ `ppr.ttl`) and served no longer than that entry can be (at most `ppr.ttl` +
1248
+ `ppr.swr`), so a document HIT and a client navigation (which reads the
1249
+ `cache()` entry first) show the same handler output. An entry already in its
1250
+ `swr` window gives a shell that is stale from the start, served while it
1251
+ recaptures. Each isolate recaptures a stale shell at most once per second
1252
+ (`SHELL_MIN_RECAPTURE_INTERVAL_MS`): a HIT within a second of the shell's
1253
+ capture serves it without starting another. When the entry a capture read
1254
+ runs out before the store (it was near its end), the capture retries once on
1255
+ a fresh match. Nothing is stored when the retry's entry runs out too, when
1256
+ the capture budget leaves no time to retry, or when the capture wrote the
1257
+ entry itself and it ran out before the store (its `ttl` + `swr` is no longer
1258
+ than the rest of the capture): the URL backs off, and a warning names the
1259
+ route and the entry's `ttl`/`swr`, once per route (dev and production). In dev an explicit `ppr.ttl`/`ppr.swr` the entry reduces
1260
+ warns once per route, stating what the shell is stored with; with no `ppr`
1261
+ window set, the cap applies silently.
1262
+
1263
+ Separately from `maxSnapshotBytes`, the whole entry (prelude, postponed state,
1264
+ and snapshot) must fit the store's value limit — 25 MiB by default, Cloudflare
1265
+ KV's; `VercelCacheStore` derives its own from its item cap. A bigger entry
1266
+ refuses the capture (warned once per key) instead of failing the store write:
1267
+ shrink what the shell bakes or move large regions under a live loader's
1268
+ boundary.
967
1269
 
968
1270
  The shell store is always the app-level `createRouter({ cache })` store; the
969
1271
  default key is `${host}${pathname}${sortedSearch}:shell` (host-scoped so
@@ -980,19 +1282,31 @@ output is shared; see `/deployment-caching`.
980
1282
  `updateTag()`/`revalidateTag()` is the ONLY lever that changes the frozen shell
981
1283
  HTML; `revalidate()` is a DATA lever that never touches it.
982
1284
 
983
- A captured shell auto-carries the UNION of the non-loader tags recorded during
984
- the capture render — every `cacheTag(...)` that ran as shell material, whether
985
- from a `"use cache"` function, a `cache()` segment, or a render-callable
986
- `cacheTag()` in a plain server component (no `"use cache"`/`cache()` in its
987
- tree), plus the tags of cached loaders on the BAKE lane (they execute during
988
- capture). Live-lane loader tags (every loader without `ssr: false`) never
989
- attach — those loaders are masked at capture, and the holes are already live. `ppr.tags` adds
1285
+ A captured shell carries the tags of what renders into it: its segment
1286
+ record's tags — every `cacheTag(...)` the handler layer ran as shell material,
1287
+ whether from a `"use cache"` function, a `cache()` segment, or a
1288
+ render-callable `cacheTag()` in a plain server component (no
1289
+ `"use cache"`/`cache()` in its tree) — plus the tags of loaders on the BAKE
1290
+ lane (they execute during capture and their data is in the shell). A tag
1291
+ recorded outside what the shell renders is not on it. A live-lane loader's tags (every loader without `ssr: false`)
1292
+ attach only when a handler or an `ssr: false` loader consumes its value with
1293
+ `ctx.use()`, which bakes it; one read only by the client stays off, since it is masked at capture and
1294
+ its hole is already live. `ppr.tags` adds
990
1295
  operational tags the render cannot know (a tenant id, a deploy marker).
991
1296
 
1297
+ When the route has its own `cache()`, the capture replays that record instead
1298
+ of re-rendering, so nothing it covers runs at capture. The record stores the
1299
+ tags its content recorded when the foreground wrote it and re-records them on
1300
+ the HIT, so the shell carries the same union. That includes a `loading()`
1301
+ slot's handler output (a HIT replays it from the record) and any loader a
1302
+ handler consumes with `ctx.use()`; a loader read only by `useLoader()` under
1303
+ `loading()` stays off. `updateTag()` of such a tag drops the record together
1304
+ with the shell, so the recapture renders fresh.
1305
+
992
1306
  | Lever | Reaches the frozen shell? | Reaches the holes? |
993
1307
  | ------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
994
1308
  | `updateTag` / `revalidateTag` on a SHELL tag | YES — drops the shell → MISS → recapture | n/a (holes are already live) |
995
- | `updateTag` / `revalidateTag` on a live-lane LOADER tag | no — live-lane loader tags never attach to a shell | drops that loader's cached value (if it `cache()`s) |
1309
+ | `updateTag` / `revalidateTag` on a live-lane LOADER tag | only if a handler consumes the loader (`ctx.use()`) | drops that loader's cached value (if it `cache()`s) |
996
1310
  | `revalidate()` (named revalidation contract) | **no** — re-runs segments/loaders for the PAYLOAD, never HTML | yes — the hole re-renders with fresh data |
997
1311
 
998
1312
  A server action's automatic invalidation refreshes the CLIENT only — it re-runs
@@ -1000,33 +1314,50 @@ the holes and streams a fresh payload, but does NOT evict the server shell.
1000
1314
  Shell-baked data stays stale until the shell's TTL unless you tag-invalidate it
1001
1315
  (`updateTag` on a shell tag). Data baked into the shell WITHOUT a tag cannot be
1002
1316
  evicted by tag at all — move always-fresh data into a live-lane loader (no
1003
- `ssr: false`) or a nested promise.
1317
+ `ssr: false`) or a nested promise in bake-lane loader data.
1004
1318
 
1005
1319
  ## Pitfalls
1006
1320
 
1007
- - **A bake-lane loader that reads `cookies()`/`headers()`**: the capture is
1008
- REFUSED (deterministic, once-per-key warned) — the route stays on axis 1.
1009
- Move identity onto the live lane (drop `ssr: false`, or split the read into
1321
+ - **A bake-lane loader that reads `cookies()`/`headers()`/`ctx.request.headers`**: the capture is
1322
+ REFUSED (deterministic, once-per-key warned) — the route keeps serving
1323
+ MISSes. Move identity onto the live lane (drop `ssr: false`, or split the read into
1010
1324
  a separate unflagged loader; give the reader a boundary). A nested promise
1011
1325
  does not help here: its body still runs during capture.
1012
1326
  - **A bake-lane container that must be fresh per document GET**: it is
1013
1327
  snapshot-pinned for the shell's lifetime by design. Use the live lane
1014
1328
  (no `ssr: false`) or a nested promise instead.
1015
- - **A bake-lane loader slower than `ppr.captureTimeout` (15s by default)**: the
1016
- capture is refused rather than storing a partial shell. Increase the route's
1017
- budget only when the deployment can keep the background/build work alive.
1329
+ - **Handler output or a bake-lane loader slower than `ppr.captureTimeout`
1330
+ (15s by default)**: the capture stores nothing rather than a partial shell
1331
+ (the warning says the output "did not settle within ppr.captureTimeout").
1332
+ Make the slow part cheaper (`cache()`/`"use cache"`), move it into a live
1333
+ loader, or increase the route's budget only when the deployment can keep the
1334
+ background/build work alive.
1335
+ - **A pending handler promise expected to be a hole**: it is not. A promise
1336
+ the handler passes under `<Suspense>`, an async server component, or a
1337
+ promise nested in a handler's handle push is awaited at capture and served
1338
+ frozen on every HIT. Move per-request data into a live-lane loader read with
1339
+ `useLoader` under `loading()` or an inline `<Suspense>` (see "Migration from
1340
+ the old promise-hole model").
1341
+ - **`cache(false)` or a false `condition()` on a ppr route**: the document
1342
+ request renders like a cache miss — a normal render, no `x-rango-shell`
1343
+ header, no capture. A `condition()` that is false only for some requests
1344
+ renders those requests normally (no shell) while the others keep serving the
1345
+ shell.
1018
1346
  - **Per-user value in shell material**: baked into the shared shell —
1019
- deterministically, not by race (handler promises deep-settle at the ring-3
1020
- write on cached chains; awaited/resolved values bake everywhere). Put
1021
- per-user data in a nested pending promise or a live-lane loader (no
1022
- `ssr: false`) — plain BAKE-lane loader data bakes just like handler
1347
+ deterministically, not by race (handler output, promises included, is
1348
+ awaited at capture; awaited/resolved values bake everywhere). Put per-user
1349
+ data in a live-lane loader (no `ssr: false`) or a nested promise in
1350
+ bake-lane loader data — plain BAKE-lane loader data bakes just like handler
1023
1351
  material.
1024
1352
  - **The session-object bake trap (the guard cannot save you here)**: the
1025
- capture guard sees `cookies()`/`headers()` calls ONLY. A bake-lane loader
1026
- reading a middleware-provided session object (`ctx.get("session")`) refuses
1027
- nothing. Per-user data survives ONLY behind a nested promise — the shape is
1353
+ capture guard sees `cookies()`, `headers()`, `ctx.request.headers`, the theme
1354
+ and `{ cache: false }` variable reads ONLY. A handler or bake-lane loader
1355
+ reading a NORMAL middleware-provided
1356
+ session object (`ctx.get("session")`) refuses nothing. Inside a bake-lane
1357
+ loader, per-user data survives ONLY behind a nested promise — the shape is
1028
1358
  the declaration, and it holds for BOTH branches regardless of settle timing
1029
- (nested thenables are masked at capture):
1359
+ (nested thenables are masked at capture). In a handler nothing survives: a
1360
+ handler's output bakes, promises included.
1030
1361
 
1031
1362
  ```typescript
1032
1363
  const CartLoader = createLoader(async (ctx) => {
@@ -1042,12 +1373,13 @@ evicted by tag at all — move always-fresh data into a live-lane loader (no
1042
1373
  (even an already-resolved one) or put the loader on the live lane (drop
1043
1374
  `ssr: false`).
1044
1375
 
1045
- - **Theme on a HIT is capture-then-corrected**: the resume tree replays the
1046
- CAPTURE's `initialTheme` (resume requires it to match the frozen prelude);
1047
- the visitor's cookie theme is applied pre-paint by the FOUC script and
1048
- re-synced post-mount by ThemeProvider. Nothing to configure — but a themed
1049
- component in the shell may briefly render the captured theme's markup before
1050
- the post-mount re-sync.
1376
+ - **Theme on a HIT is default-then-corrected**: the shell's `initialTheme` is
1377
+ the no-cookie default (`defaultTheme`), whoever captured it, and the resume
1378
+ tree replays it (resume requires it to match the frozen prelude). A visitor
1379
+ with a stored theme gets it pre-paint from the FOUC script and in
1380
+ `useTheme()` after ThemeProvider re-syncs post-mount. Nothing to configure —
1381
+ but a themed component in the shell may briefly render the default theme's
1382
+ markup before the post-mount re-sync.
1051
1383
  - **Shell shows CAPTURE-time data for the shell's lifetime**: a
1052
1384
  `cache()`/`"use cache"` value baked into the shell is PINNED at capture (the
1053
1385
  capture data snapshot) and replayed on every HIT, so the shell stays
@@ -1057,22 +1389,34 @@ evicted by tag at all — move always-fresh data into a live-lane loader (no
1057
1389
  the capture read it also rides the shell, so `updateTag` on that tag drops the
1058
1390
  shell too (next request MISSes and recaptures). An untagged entry has no such
1059
1391
  link. If a shell region needs to be fresh, put it under a hole — a live-lane
1060
- loader (no `ssr: false`) behind `loading()` or an inline `<Suspense>`, or an
1061
- un-awaited promise under the consumer's `<Suspense>`
1062
- (holes are never pinned) — or make the SHELL itself invalidatable by tagging
1392
+ loader (no `ssr: false`) behind `loading()` or an inline `<Suspense>`, or a
1393
+ nested promise in bake-lane loader data (holes are never pinned) — or make
1394
+ the SHELL itself invalidatable by tagging
1063
1395
  it: tag the cached read, call `cacheTag(...)` from the shell-material render
1064
1396
  code (the render-time lever), or add the tag to `ppr.tags` (operational tags
1065
1397
  the render cannot know — a tenant id, a deploy marker). Tags are optional: if
1066
1398
  TTL/SWR is the complete freshness policy, leave the shell untagged. Rango does not warn for that choice; with
1067
1399
  `debugShellCapture` enabled, a stored event reports `untaggedBake: true` when
1068
1400
  bake-lane loader material uses TTL/SWR-only invalidation.
1069
- - **Uncached nondeterminism in the shell is a hydration hazard**: a raw
1070
- `Date.now()` / `Math.random()` / uncached `fetch` rendered directly in shell
1071
- material (outside any cache ring) drifts between capture and hit and the
1072
- snapshot CANNOT pin it — it was never a cache read. It will mismatch the frozen
1073
- prelude and detonate hydration. Wrap it in `cache()`/`"use cache"` (then it is
1074
- pinned) or move it under a hole (`loading()`, or a pending-promise
1075
- `<Suspense>` region).
1401
+ - **A `"use cache"` value the shell and a live hole both read comes from the
1402
+ cache store in the hole**: the shell keeps the capture-time value, and a
1403
+ live-lane loader under `loading()` or an inline `<Suspense>` that reads the
1404
+ same `"use cache"` entry reads it from the store on every HIT. It is still
1405
+ cached under its own profile; once the entry expires or is invalidated and
1406
+ refreshes, the hole shows the refreshed value next to the shell's old one. The
1407
+ shell stores no `"use cache"` value, only its recorded handler output and the
1408
+ `ssr: false` loaders' baked containers. A hole reads the store even when a
1409
+ bake-lane loader read the same entry at capture: the baked container keeps
1410
+ the capture's value, and the hole shows the current entry.
1411
+ - **Uncached nondeterminism in server output is frozen, not drifting**: a raw
1412
+ `Date.now()` / `Math.random()` / uncached `fetch` in a handler or server
1413
+ component renders once per capture, and the prelude and every HIT show that
1414
+ same value for the shell's lifetime (an uncached async server component used
1415
+ to render twice per capture, so the prelude and the HIT payload disagreed).
1416
+ If the value must change per request, move it under a hole (a live-lane
1417
+ loader read under `loading()` or an inline `<Suspense>`). A CLIENT component
1418
+ that renders such a value during SSR still mismatches on hydration, as on
1419
+ any SSR page.
1076
1420
  - **Stacking with `/document-cache` or HTTP CDN caching**: both cache the
1077
1421
  completed composite, including live-hole output, so PPR becomes redundant on
1078
1422
  a hit. A platform CDN also bypasses every Rango middleware. Restrict this to