@rangojs/router 0.16.0 → 0.18.0

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