@rangojs/router 0.16.0 → 0.17.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 (245) hide show
  1. package/README.md +254 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  6. package/dist/types/browser/rsc-router.d.ts +1 -1
  7. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  8. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  9. package/dist/types/cache/cache-scope.d.ts +34 -5
  10. package/dist/types/cache/handle-capture.d.ts +41 -3
  11. package/dist/types/cache/handle-snapshot.d.ts +53 -13
  12. package/dist/types/cache/segment-codec.d.ts +10 -3
  13. package/dist/types/cache/taint.d.ts +1 -1
  14. package/dist/types/cache/types.d.ts +15 -3
  15. package/dist/types/client.d.ts +4 -4
  16. package/dist/types/client.rsc.d.ts +4 -5
  17. package/dist/types/components/DefaultDocument.d.ts +5 -2
  18. package/dist/types/context-var.d.ts +6 -5
  19. package/dist/types/escape-script.d.ts +1 -1
  20. package/dist/types/handles/MetaTags.d.ts +5 -4
  21. package/dist/types/handles/Scripts.d.ts +8 -7
  22. package/dist/types/handles/meta.d.ts +2 -2
  23. package/dist/types/handles/script.d.ts +17 -16
  24. package/dist/types/href-client.d.ts +1 -1
  25. package/dist/types/html.d.ts +12 -0
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/index.rsc.d.ts +3 -2
  28. package/dist/types/loader-store.d.ts +7 -4
  29. package/dist/types/reverse.d.ts +36 -4
  30. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  31. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +1 -1
  34. package/dist/types/router/debug-manifest.d.ts +3 -2
  35. package/dist/types/router/error-handling.d.ts +20 -0
  36. package/dist/types/router/handler-context.d.ts +6 -0
  37. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  38. package/dist/types/router/middleware-types.d.ts +3 -2
  39. package/dist/types/router/router-context.d.ts +3 -1
  40. package/dist/types/router/router-interfaces.d.ts +24 -2
  41. package/dist/types/router/router-options.d.ts +8 -5
  42. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  43. package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
  44. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  45. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
  46. package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
  47. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  48. package/dist/types/router/segment-wrappers.d.ts +1 -1
  49. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  50. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  51. package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
  52. package/dist/types/rsc/shell-capture.d.ts +5 -4
  53. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  54. package/dist/types/rsc/types.d.ts +10 -0
  55. package/dist/types/server/context.d.ts +33 -3
  56. package/dist/types/server/handle-store.d.ts +26 -2
  57. package/dist/types/server/request-context.d.ts +24 -21
  58. package/dist/types/ssr/index.d.ts +15 -0
  59. package/dist/types/static-handler.d.ts +4 -1
  60. package/dist/types/testing/generated-routes.d.ts +2 -2
  61. package/dist/types/testing/internal/context.d.ts +1 -1
  62. package/dist/types/testing/render-route.d.ts +32 -6
  63. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  64. package/dist/types/testing/vitest.d.ts +35 -1
  65. package/dist/types/theme/ThemeScript.d.ts +3 -3
  66. package/dist/types/types/cache-types.d.ts +14 -10
  67. package/dist/types/types/handler-context.d.ts +14 -0
  68. package/dist/types/types/loader-types.d.ts +8 -6
  69. package/dist/types/urls/response-types.d.ts +3 -12
  70. package/dist/types/use-loader.d.ts +12 -3
  71. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  72. package/dist/types/vite/rango.d.ts +1 -1
  73. package/dist/vite/index.js +1381 -1346
  74. package/package.json +2 -2
  75. package/skills/api-client/SKILL.md +22 -10
  76. package/skills/breadcrumbs/SKILL.md +65 -22
  77. package/skills/bundle-analysis/SKILL.md +22 -22
  78. package/skills/cache-guide/SKILL.md +207 -97
  79. package/skills/caching/SKILL.md +295 -93
  80. package/skills/catalog.json +16 -16
  81. package/skills/client-urls/SKILL.md +53 -31
  82. package/skills/cloudflare/SKILL.md +72 -20
  83. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  84. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  85. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  86. package/skills/comparison/SKILL.md +5 -0
  87. package/skills/comparison/references/framework-comparison.md +39 -33
  88. package/skills/composability/SKILL.md +38 -29
  89. package/skills/css/SKILL.md +10 -13
  90. package/skills/debug-manifest/SKILL.md +74 -31
  91. package/skills/defer-hydration/SKILL.md +2 -1
  92. package/skills/deployment-caching/SKILL.md +6 -1
  93. package/skills/document-cache/SKILL.md +92 -28
  94. package/skills/fonts/SKILL.md +16 -8
  95. package/skills/handler-use/SKILL.md +53 -17
  96. package/skills/hooks/SKILL.md +28 -20
  97. package/skills/hooks/data.md +23 -3
  98. package/skills/hooks/handle-and-actions.md +31 -16
  99. package/skills/hooks/navigation.md +39 -10
  100. package/skills/hooks/outlets.md +13 -6
  101. package/skills/hooks/state.md +37 -15
  102. package/skills/hooks/urls.md +7 -5
  103. package/skills/host-router/SKILL.md +64 -12
  104. package/skills/i18n/SKILL.md +60 -34
  105. package/skills/intercept/SKILL.md +161 -26
  106. package/skills/layout/SKILL.md +106 -50
  107. package/skills/links/SKILL.md +51 -19
  108. package/skills/loader/SKILL.md +242 -106
  109. package/skills/middleware/SKILL.md +119 -23
  110. package/skills/migrate-nextjs/SKILL.md +100 -45
  111. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  112. package/skills/migrate-react-router/SKILL.md +49 -38
  113. package/skills/migrate-react-router/component-migration.md +25 -6
  114. package/skills/migrate-react-router/data-and-actions.md +18 -5
  115. package/skills/migrate-react-router/route-mapping.md +12 -6
  116. package/skills/mime-routes/SKILL.md +12 -7
  117. package/skills/observability/SKILL.md +28 -14
  118. package/skills/parallel/SKILL.md +75 -39
  119. package/skills/ppr/SKILL.md +345 -150
  120. package/skills/prerender/SKILL.md +189 -100
  121. package/skills/rango/SKILL.md +65 -34
  122. package/skills/react-compiler/SKILL.md +6 -5
  123. package/skills/response-routes/SKILL.md +133 -25
  124. package/skills/route/SKILL.md +165 -60
  125. package/skills/router-setup/SKILL.md +418 -240
  126. package/skills/scripts/SKILL.md +31 -20
  127. package/skills/server-actions/SKILL.md +220 -59
  128. package/skills/shell-manifest/SKILL.md +12 -7
  129. package/skills/streams-and-websockets/SKILL.md +32 -14
  130. package/skills/tailwind/SKILL.md +37 -33
  131. package/skills/testing/SKILL.md +68 -32
  132. package/skills/testing/cache-prerender.md +55 -29
  133. package/skills/testing/client-components.md +59 -6
  134. package/skills/testing/e2e-parity.md +12 -2
  135. package/skills/testing/flight.md +19 -11
  136. package/skills/testing/handles.md +8 -5
  137. package/skills/testing/loader.md +8 -5
  138. package/skills/testing/middleware.md +8 -3
  139. package/skills/testing/predicates.md +120 -0
  140. package/skills/testing/render-handler.md +70 -22
  141. package/skills/testing/response-routes.md +11 -4
  142. package/skills/testing/reverse-and-types.md +20 -14
  143. package/skills/testing/server-actions.md +12 -3
  144. package/skills/testing/server-tree.md +9 -0
  145. package/skills/testing/setup.md +9 -5
  146. package/skills/theme/SKILL.md +106 -29
  147. package/skills/typesafety/SKILL.md +20 -9
  148. package/skills/typesafety/env-and-bindings.md +126 -47
  149. package/skills/typesafety/generated-files-and-cli.md +26 -14
  150. package/skills/typesafety/params-and-search.md +44 -19
  151. package/skills/typesafety/route-types.md +50 -14
  152. package/skills/use-cache/SKILL.md +200 -82
  153. package/skills/vercel/SKILL.md +21 -11
  154. package/skills/view-transitions/SKILL.md +41 -20
  155. package/src/__augment-tests__/augmented.check.ts +26 -1
  156. package/src/browser/index.ts +2 -2
  157. package/src/browser/navigation-bridge.ts +3 -4
  158. package/src/browser/navigation-transaction.ts +22 -2
  159. package/src/browser/partial-update.ts +35 -25
  160. package/src/browser/react/ScrollRestoration.tsx +6 -6
  161. package/src/browser/rsc-router.tsx +1 -1
  162. package/src/browser/scroll-restoration.ts +53 -6
  163. package/src/cache/cache-exec-scope.ts +37 -4
  164. package/src/cache/cache-key-utils.ts +1 -6
  165. package/src/cache/cache-runtime.ts +230 -61
  166. package/src/cache/cache-scope.ts +76 -12
  167. package/src/cache/document-cache.ts +15 -0
  168. package/src/cache/handle-capture.ts +90 -10
  169. package/src/cache/handle-snapshot.ts +132 -28
  170. package/src/cache/segment-codec.ts +26 -7
  171. package/src/cache/tag-invalidation.ts +4 -3
  172. package/src/cache/taint.ts +1 -1
  173. package/src/cache/types.ts +16 -3
  174. package/src/client.rsc.tsx +10 -9
  175. package/src/client.tsx +5 -4
  176. package/src/components/DefaultDocument.tsx +7 -2
  177. package/src/context-var.ts +6 -5
  178. package/src/escape-script.ts +1 -1
  179. package/src/handles/MetaTags.tsx +13 -11
  180. package/src/handles/Scripts.tsx +11 -10
  181. package/src/handles/meta.ts +2 -2
  182. package/src/handles/script.ts +17 -16
  183. package/src/href-client.ts +1 -1
  184. package/src/html.ts +12 -0
  185. package/src/index.rsc.ts +5 -0
  186. package/src/index.ts +1 -0
  187. package/src/loader-store.ts +7 -4
  188. package/src/prerender/build-shell-capture.ts +8 -11
  189. package/src/reverse.ts +44 -4
  190. package/src/route-definition/dsl-helpers.ts +117 -30
  191. package/src/route-definition/helpers-types.ts +33 -9
  192. package/src/route-definition/resolve-handler-use.ts +121 -6
  193. package/src/route-types.ts +0 -3
  194. package/src/router/debug-manifest.ts +10 -2
  195. package/src/router/error-handling.ts +51 -15
  196. package/src/router/handler-context.ts +34 -25
  197. package/src/router/intercept-resolution.ts +125 -39
  198. package/src/router/loader-resolution.ts +56 -11
  199. package/src/router/match-api.ts +12 -17
  200. package/src/router/match-middleware/background-revalidation.ts +161 -96
  201. package/src/router/match-middleware/cache-lookup.ts +93 -10
  202. package/src/router/match-middleware/cache-store.ts +17 -90
  203. package/src/router/middleware-types.ts +3 -5
  204. package/src/router/middleware.ts +52 -9
  205. package/src/router/prerender-match.ts +53 -19
  206. package/src/router/route-snapshot.ts +11 -1
  207. package/src/router/router-context.ts +2 -0
  208. package/src/router/router-interfaces.ts +25 -2
  209. package/src/router/router-options.ts +8 -5
  210. package/src/router/segment-resolution/fresh.ts +44 -12
  211. package/src/router/segment-resolution/loader-cache.ts +205 -45
  212. package/src/router/segment-resolution/loader-mask.ts +12 -22
  213. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  214. package/src/router/segment-resolution/mask-nested.ts +7 -7
  215. package/src/router/segment-resolution/revalidation.ts +34 -6
  216. package/src/router/segment-wrappers.ts +4 -0
  217. package/src/rsc/render-pipeline.ts +3 -0
  218. package/src/rsc/response-route-handler.ts +11 -3
  219. package/src/rsc/rsc-rendering.ts +8 -45
  220. package/src/rsc/shell-capture-constants.ts +13 -0
  221. package/src/rsc/shell-capture.ts +111 -90
  222. package/src/rsc/ssr-setup.ts +14 -8
  223. package/src/rsc/types.ts +11 -0
  224. package/src/server/context.ts +88 -11
  225. package/src/server/cookie-store.ts +5 -4
  226. package/src/server/handle-store.ts +232 -7
  227. package/src/server/request-context.ts +35 -30
  228. package/src/ssr/index.tsx +40 -7
  229. package/src/static-handler.ts +4 -1
  230. package/src/testing/flight-runtime.d.ts +21 -0
  231. package/src/testing/generated-routes.ts +2 -2
  232. package/src/testing/internal/context.ts +1 -1
  233. package/src/testing/internal/flight-client-globals.ts +7 -5
  234. package/src/testing/render-route.tsx +85 -24
  235. package/src/testing/run-loader.ts +5 -1
  236. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  237. package/src/testing/vitest.ts +62 -1
  238. package/src/theme/ThemeScript.tsx +3 -3
  239. package/src/types/cache-types.ts +14 -10
  240. package/src/types/handler-context.ts +14 -0
  241. package/src/types/loader-types.ts +8 -6
  242. package/src/urls/response-types.ts +3 -23
  243. package/src/use-loader.tsx +12 -3
  244. package/src/vite/plugins/use-cache-transform.ts +118 -74
  245. package/src/vite/rango.ts +7 -3
@@ -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; loaders stay fresh on every
14
+ request. Rango calls this the second render axis ("axis 2"): the default path
15
+ ("axis 1", a normal full render) is untouched, and every ineligible request
16
+ falls open to it.
17
+
18
+ Use it when a page has a stable layout that should reach the browser instantly
19
+ (the first byte comes from cache) while specific regions stay per-request:
20
+ prices, a basket, recommendations. Opt in per page route with the `ppr` path
21
+ option; the holes are decided by the shape of your tree (see "The hole
22
+ doctrine").
16
23
 
17
24
  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.
25
+ output, and the `cache()` DSL (`/caching`), which caches Flight segments but
26
+ still renders HTML on every request. Shell caching is for pages that mix a
27
+ stable shell with live data: the shell is shared per host+URL, the holes are
28
+ per request.
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 build
40
+ step called "producer B" bakes the HTML shell at build time while loaders stay
41
+ 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 stays on axis 1 with a once-per-key warning.
43
55
 
44
56
  ```typescript
45
57
  import { createRouter, urls } from "@rangojs/router";
@@ -61,14 +73,13 @@ 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
85
  That is ONE of two hole mechanisms — the loader one. Do not conclude PPR
@@ -121,9 +132,8 @@ At capture the pending fetch cannot win the task-quantized quiet window, so
121
132
  the boundary postpones — fallback in the frozen prelude, value resumed fresh
122
133
  on every HIT. This is the PHYSICS class from the hole doctrine below, and it
123
134
  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.
135
+ PPR) works with zero restructuring. The same works for a promise hole in a
136
+ LAYOUT with no loader registration at all.
127
137
 
128
138
  A route WITHOUT the `ppr` option is pure axis 1: no store read, no capture, no
129
139
  logs, zero cost. `ppr` is per page route — declaring it on a layout is not
@@ -139,7 +149,7 @@ outermost (final bytes):
139
149
  | ----------------------- | ---------------------------------------- | -------------------------------------------------- | -------------------------------------- |
140
150
  | 1. Function values | `"use cache"` | a function's return value | everything around the call |
141
151
  | 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 |
152
+ | 3. Segments (Flight) | `cache()` route / build-time `Prerender` | serialized rendered segments + replayed handles | loaders, HTML render |
143
153
  | 4. **HTML shell (PPR)** | `ppr` path option | rendered prelude bytes + React postponed state | the holes, hydration payload |
144
154
  | 5. Whole response | `/document-cache` | final response bytes, headers included | nothing — all-or-nothing |
145
155
 
@@ -177,6 +187,11 @@ auth middleware anywhere (global or route DSL) and it guards PPR for free.
177
187
 
178
188
  ### Soft navigation caches and reuses the handler layer
179
189
 
190
+ In short: client-side (soft) navigations to a `ppr` route reuse the handler
191
+ segments captured with the shell, while loaders stay live. Nothing needs
192
+ configuring; the rest of this section explains the `x-rango-ppr-replay` header
193
+ for when you are debugging replay behavior.
194
+
180
195
  Ordinary partial RSC navigations to a `ppr` URL use the same handler-layer cache
181
196
  contract even when no document request has captured an HTML shell yet. When a
182
197
  shell snapshot exists, the server replays its canonical document segment record.
@@ -321,7 +336,7 @@ the write — a write before it still throws.
321
336
  ```ts
322
337
  function catalogPage(ctx) {
323
338
  ctx.dynamic(); // declare live -> refuses capture AND clears the header latch
324
- ctx.headers.set("x-rango-sfra-proxy", "catalog");
339
+ ctx.headers.set("x-catalog-mode", "live");
325
340
  return <Catalog />;
326
341
  }
327
342
  ```
@@ -381,9 +396,9 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
381
396
  slowest bake source (a top-level pushed handle promise or a bake-lane loader
382
397
  container) held the capture gate — in dev, past 2s, the source is also named
383
398
  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="…"`.
399
+ `cache()` the work / drop the loader's `ssr: false`). In dev, with
400
+ `debugPerformance` on, the last capture outcome for a key also rides the
401
+ next document GET's `Server-Timing` as `ppr-capture;desc="…"`.
387
402
  - For deployed Cloudflare tier diagnostics, build with
388
403
  `INTERNAL_RANGO_DEBUG=1` and run `wrangler tail`. `[CFCacheStore][shell]`
389
404
  JSON events distinguish L1 hit/miss, KV fallback/promotion, tier writes, and
@@ -427,25 +442,105 @@ Do not invent a HIT Response in unit tests. Full recipe: `/testing` skill →
427
442
 
428
443
  Holes are **render-defined**, decided by the shape of the tree, on three rules:
429
444
 
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 |
445
+ | Class | What makes the hole | At capture | At serve |
446
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------- |
447
+ | **STRUCTURAL** | the ENTIRE segment subtree under a `loading()` registration, when a LIVE-lane loader read suspends to it | live loaders masked; the LoaderBoundary postpones; the fallback bakes in as route structure | loaders run fresh; resume fills it |
448
+ | **PHYSICS** | any promise NESTED in handed-over data still pending at capture, under the consumer's own `<Suspense>` — handler props, handle containers (`push({ x: promise })`), loader-carried | real I/O cannot win the task-quantized quiet window; the boundary postpones | the promise settles and streams in |
449
+ | **SHELL** | awaited handler data, TOP-LEVEL `push(promise)` (awaited before SSR), resolved promises, replayed `cache()` segments, the settled non-promise data of BAKE-lane (`ssr: false`) loaders | baked into the prelude | served from the frozen prelude |
435
450
 
436
451
  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.
440
-
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).
452
+ nested inside your data is never baked; the container settles.** A
453
+ `loader(Def, { ssr: false })` is the BAKE lane (see the lane rule below): its
454
+ settled non-promise data is shell material, exactly like awaited handler data
455
+ and top-level handle pushes.
456
+
457
+ ### The loader lane rule
458
+
459
+ **Only `loader(Def, { ssr: false })` bakes into the shell, and only the parts
460
+ of its returned data that are NOT promises.** The loader runs at capture; its
461
+ settled non-promise values are frozen into the shell (snapshot-pinned per
462
+ shell). Any promise inside the returned data is masked at capture and stays a
463
+ live hole, streamed fresh on every request at the consumer's own `<Suspense>`,
464
+ however fast it settles. The shape of the return value declares what is live:
465
+ a plain value bakes, a promise stays live.
466
+
467
+ - **Any depth, plain containers only.** The mask walks plain objects
468
+ (prototype `Object.prototype` or `null`) and arrays at any depth —
469
+ `{ items: [{ stock: fetchStock(sku) }] }` keeps every `stock` live. A `Map`,
470
+ `Set`, class instance, or any other non-plain object is a LEAF: a promise
471
+ inside it is NOT masked and does NOT become a live hole. Keep live promises
472
+ in plain objects and arrays.
473
+ - **The consumer's `<Suspense>` is the hole.** A client component reads the
474
+ live property with `use(data.price)` inside its own `<Suspense>`; that
475
+ boundary postpones at capture and resumes with the fresh value.
476
+ - **Handle containers follow the same shape rule.** The capture applies the
477
+ same mask to pushed handle containers, so `ctx.use(Handle)({ ..., x: promise })`
478
+ keeps `x` live (see "Handles: nesting = liveness").
479
+
480
+ **Every loader without `ssr: false` is entirely live**, whether or not it has
481
+ `loading()`: masked at capture with a never-resolving promise, postponed at its
482
+ boundary, and streamed fresh on every request. It needs a boundary —
483
+ `loading()` or an inline `<Suspense>` above the reader — or the shell is
484
+ refused: a masked read with no boundary above it root-postpones, the `<body>`
485
+ sanity gate refuses the capture, and the route stays an eternal MISS (warned
486
+ once per key). A route whose loaders are ALL `ssr: false` needs no
487
+ `loading()`: nothing masks, and the shell captures complete.
488
+
489
+ ```typescript
490
+ export const ProductLoader = createLoader(async (ctx) => {
491
+ const product = await getProduct(ctx.params.id);
492
+ return {
493
+ name: product.name, // plain value: bakes
494
+ description: product.description, // plain value: bakes
495
+ price: fetchPrice(ctx.params.id), // promise: masked at capture, live hole
496
+ };
497
+ });
498
+
499
+ path("/products/:id", ProductPage, { name: "product", ppr: true }, () => [
500
+ loader(ProductLoader, { ssr: false }), // BAKE lane
501
+ loader(StockLoader), // LIVE lane: ProductPage reads it under an inline <Suspense>
502
+ ]);
503
+ ```
504
+
505
+ ```tsx
506
+ // ProductDetails.tsx — the consumer of the live nested property.
507
+ "use client";
508
+
509
+ import { Suspense, use } from "react";
510
+ import { useLoader } from "@rangojs/router/client";
511
+
512
+ function Price({ price }: { price: Promise<number> }) {
513
+ return <PriceTag amount={use(price)} />;
514
+ }
515
+
516
+ export function ProductDetails() {
517
+ const { data } = useLoader(ProductLoader);
518
+ return (
519
+ <article>
520
+ <h1>{data.name}</h1> {/* baked */}
521
+ <p>{data.description}</p> {/* baked */}
522
+ <Suspense fallback={<PriceSkeleton />}>
523
+ <Price price={data.price} /> {/* hole — fresh on every request */}
524
+ </Suspense>
525
+ </article>
526
+ );
527
+ }
528
+ ```
529
+
530
+ `name` and `description` are frozen into the shell; `price` is a hole under
531
+ the consumer's `<Suspense>`, fresh on every request. `StockLoader` is masked at
532
+ capture and postpones at the inline `<Suspense>` around its reader. A
533
+ `loading()` on this entry would also be a valid boundary, but its fallback
534
+ wraps the entry's whole subtree: a live read with no inline `<Suspense>` of its
535
+ own takes the entire entry — baked `name`/`description` included — into that
536
+ hole.
537
+
538
+ **`loading()` is NOT the lane switch and NOT the gate for holes** — it is one
539
+ of the two boundary kinds a live loader postpones at. A pending promise under
540
+ any plain `<Suspense>` postpones and is a hole at whatever level it suspends —
541
+ the PHYSICS row needs no `loading()` anywhere (the e2e fixture's physics and
542
+ nested-handle holes sit in a layout with none). A route without a loader PPRs
543
+ on pure promise/Suspense holes.
449
544
 
450
545
  The three promise positions, side by side:
451
546
 
@@ -471,19 +566,20 @@ async function Handler(ctx: HandlerContext) {
471
566
 
472
567
  ### Choosing the hole mechanism
473
568
 
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 |
569
+ | Your live region is… | Use | Why |
570
+ | ----------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
571
+ | loader data, must be fresh EVERY serve | `loader()` (the live lane) + a boundary: `loading()` or an inline `<Suspense>` | the guaranteed hole — masked at capture, fresh every serve, immune to fast resolution |
572
+ | loader data, shell container + live parts | `loader(Def, { ssr: false })` (the bake lane): `{ static, dynamic: promise }` | plain values bake (snapshot-pinned per shell); nested promises hole at the consumer's `<Suspense>` |
573
+ | handler-fetched real I/O (db, fetch) | un-awaited promise prop + consumer `<Suspense>` + `use()` | no loader needed; real latency postpones by physics |
574
+ | per-segment metadata consumed elsewhere | handle container with a NESTED promise + consumer `<Suspense>` | container is shell, nested value streams — "nesting = liveness" |
575
+ | already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))` (no `ssr: false`) + a boundary | a raw promise that settles inside the quiet window BAKES; only the live lane guarantees live |
576
+ | none of the above | nothing | it bakes — that is what the shell is for |
482
577
 
483
578
  The physics caveat in one line: HANDLER-created promise props are holes only
484
579
  because the I/O is genuinely pending at capture — if the value can resolve
485
580
  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
581
+ liveness must be guaranteed rather than probable, use a live-lane loader (no
582
+ `ssr: false`). BAKE-LANE
487
583
  NESTED promises are exempt from that race: the capture MASKS every thenable
488
584
  nested in a bake-lane container regardless of settle timing
489
585
  (`maskNestedContainerThenables`, loader-cache.ts), so the consuming boundary
@@ -498,31 +594,35 @@ capturing session's identifiers — served to anonymous visitors.)
498
594
  - `ctx.use(H)(promise)` — a TOP-LEVEL pushed promise is awaited server-side
499
595
  before SSR (`resolvedHandleStream`) and BAKED into the shell. The capture
500
596
  gate is held open for the same await, so real latency here is safe (bounded
501
- by the capture's 5s guard).
597
+ by the capture budget, `ppr.captureTimeout`, 15s by default).
502
598
  - `ctx.use(H)({ x: promise })` — the container passes through verbatim
503
599
  (resolution is shallow); the nested promise streams to the consumer, who must
504
600
  `<Suspense>` it. Under capture that boundary postpones — a hole — REGARDLESS
505
601
  of settle timing: the capture masks nested thenables in pushed handle
506
602
  containers (the capture store's push wrap, shell-capture.ts), so even an
507
603
  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.
604
+ shared shell. Same shape-is-the-declaration rule, and the same mask, as
605
+ bake-lane loaders: promises are found at any depth of plain objects and
606
+ arrays; one inside a `Map`, `Set`, or class instance is not masked.
509
607
 
510
608
  ### Want a hole for already-resolved data?
511
609
 
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.
610
+ Put it in a loader without `ssr: false`: `loader(() => Promise.resolve(x))`
611
+ under `loading()` or an inline `<Suspense>`. Live-lane loaders are masked at
612
+ capture and fresh on every serve, no matter how fast the value settles.
515
613
 
516
- ### The bake lane: loaders without loading() on THEIR entry
614
+ ### The bake lane: `loader(Def, { ssr: false })`
517
615
 
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:
616
+ A loader registered with `{ ssr: false }` EXECUTES during capture (the capture
617
+ gate holds open for its real latency, bounded by `ppr.captureTimeout`, 15s by
618
+ default). The flag's document promise — "this loader's data is in the HTML
619
+ before first flush" — maps to the frozen prelude under ppr. Its settled
620
+ non-promise data bakes into the prelude; every promise nested in it is masked
621
+ at capture (regardless of how fast it settles) and postpones at the consumer's
622
+ own `<Suspense>` — a hole. The capture records the container into the shell
623
+ snapshot's loader family so every HIT payload matches the frozen prelude
624
+ byte-for-byte (see "On a shell HIT" below). The return shape is the
625
+ declaration:
526
626
 
527
627
  ```typescript
528
628
  export const StorefrontContextLoader = createLoader(async (ctx) => {
@@ -532,20 +632,66 @@ export const StorefrontContextLoader = createLoader(async (ctx) => {
532
632
  basket: fetchBasket(ctx), // hole — consumer <Suspense>s it, fresh per request
533
633
  };
534
634
  });
635
+
636
+ layout(StoreLayout, () => [
637
+ loader(StorefrontContextLoader, { ssr: false }), // the flag selects the bake lane
638
+ path("/", HomePage, { name: "home", ppr: true }),
639
+ ]);
535
640
  ```
536
641
 
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.
642
+ The lane is decided PER LOADER, by its own `ssr: false` flag — `loading()` on
643
+ the same entry, a parent, or a child never changes it. `loading()` only decides
644
+ where a live loader's hole sits, and it IS valid on layout and parallel
645
+ entries, not just routes.
646
+
647
+ #### On a shell HIT
648
+
649
+ What a bake-lane loader does on a document HIT depends on whether its recorded
650
+ container carried holes:
651
+
652
+ - **Hole-free record (the return had no promises): pin-first.** The response
653
+ uses the pinned container from the shell snapshot immediately and does not
654
+ wait for the loader. The loader body STILL runs fresh on every HIT, in the
655
+ background via `executionContext.waitUntil`, only for its side effects and
656
+ cache read-through writes; its result is discarded. The pinned SHAPE wins
657
+ wholesale: a field the fresh run adds mid-TTL is dropped, because the frozen
658
+ prelude has no hole for it. The output changes only when the shell is
659
+ recaptured (TTL/SWR expiry or tag invalidation).
660
+ - **Hole-carrying record (the return had promises).** The loader runs fresh ON
661
+ the critical path — only the body can create the live promises — and the
662
+ recorded baked paths are overlaid onto the fresh result, so the payload
663
+ matches the prelude while the nested promises stream fresh. A fresh
664
+ rejection skips the overlay and goes to the loader's error boundary.
665
+
666
+ A fully baked loader therefore still costs one backend call per HIT, off the
667
+ response's critical path. To avoid it, give the loader its own cache —
668
+ `loader(Def, { ssr: false }, () => [cache({ ttl })])` — so the background run
669
+ reads through the loader cache.
670
+
671
+ **Handle pushes from a bake-lane loader appear once, with the re-run's value.**
672
+ The capture records the loader's settled, thenable-free handle pushes (the
673
+ prelude rendered them), and a HIT that replays the handler layer from the
674
+ record restores them. The loader still re-runs, and its pushes replace the
675
+ restored ones in place, so any handle, deduping or not, shows each value
676
+ once and the live value wins. A re-run push that lands after the document's
677
+ handle snapshot reaches the client after hydration; until then the client
678
+ shows the recorded value, which matches the prelude. Deferred and
679
+ promise-carrying pushes are never recorded; the re-run is their only
680
+ producer. The record keeps only the pushes
681
+ made by the `ssr: false` loader's own body. A loader it awaits with
682
+ `ctx.use()` re-runs with it on a HIT, so that loader's pushes are not recorded
683
+ and appear once, whatever the value type (a string is left out the same way as
684
+ an object). The same holds for pushes the capture replays from the loader's
685
+ own `cache()` entry.
540
686
 
541
687
  Four hard edges (each e2e/unit-pinned):
542
688
 
543
689
  - **Header writes throw (issue #713).** ppr is a document-scoped `cache()`:
544
690
  in any cached scenario ONLY MIDDLEWARE writes response headers. A handler
545
691
  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
692
+ `ctx.setTheme()`, or the request-context `header()`/`setStatus()` throws on
693
+ EVERY render — dev and prod, first render, same guard family as the
694
+ `cache()` boundary guard. Handlers
549
695
  are replayed on HITs (the write would silently differ between MISS and
550
696
  HIT); loaders are live but settle AFTER the response headers flushed with
551
697
  the shell (dead letters). Move the write into route middleware — it runs
@@ -555,39 +701,55 @@ Four hard edges (each e2e/unit-pinned):
555
701
  HITs, so the write is deterministic (see "Opting out per request").
556
702
  - **Identity refuses.** `cookies()`/`headers()` inside a bake-lane loader
557
703
  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:
704
+ warned) — identity can never bake into the shared shell. Drop the loader's
705
+ `ssr: false` flag (the live lane is exempt; give it a boundary) or move the
706
+ identity-dependent part into a separate loader without `ssr: false`. A
707
+ nested promise does not help here: its body still runs during capture and
708
+ trips the guard. The guard's scope is EXACTLY those two calls:
561
709
  per-user state read from a middleware-provided object (`ctx.get("session")`)
562
710
  does NOT refuse — it bakes silently as the capturing user's data (see
563
711
  Pitfalls: the session-object bake trap).
564
- - **A rejecting bake-lane loader refuses.** Error UI never bakes.
712
+ - **A rejecting bake-lane loader refuses.** Error UI never bakes. So does a
713
+ value that fails to encode: a function, a class instance, a promise inside a
714
+ `Map` that rejects, or a server component in the value that throws. The
715
+ capture stores nothing, logs the error, reports `cache-write` and backs the
716
+ key off, the same as a shell component that throws (below). The capture
717
+ encodes the value a second time to pin it for HITs, so a server component
718
+ that throws only on that run refuses it too. A build-time capture skips the
719
+ shell (`SHELL SKIP`) and the route keeps runtime capture.
565
720
  - **Baked containers show CAPTURE-time data** for the shell's lifetime on
566
721
  document GETs. Soft navigations may replay the captured handler segments, but
567
722
  DSL loaders and their item/response reads remain fresh. That IS the bake
568
723
  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.
724
+ live lane (no `ssr: false`) or in a nested promise.
570
725
 
571
726
  ### The layout-with-loaders playbook (the storefront case)
572
727
 
573
728
  The most common real-app shape: an app-wide layout registers per-user loaders
574
729
  (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
730
+ Unflagged, those loaders are on the LIVE lane: masked at capture, so every
731
+ read needs a boundary (`loading()` or an inline `<Suspense>`) or the capture
732
+ refuses and the page stays an eternal MISS. The question is which parts of
733
+ their data should bake vs stay live. Your levers, in order of preference:
734
+
735
+ 1. **Flag the loader and shape the return value.**
736
+ `loader(SessionContextLoader, { ssr: false })` puts it on the bake lane:
737
+ shared/config data returns as plain values (bakes, pinned per shell);
738
+ per-request data returns as NESTED promises consumed under the widget's own
739
+ `<Suspense>` (live holes). No `loading()`, no restructuring. One wall: a
740
+ bake-lane loader that reads `cookies()`/`headers()` refuses the capture —
741
+ identity belongs on the live lane (a separate unflagged loader); a nested
742
+ promise inside the flagged loader still runs during capture and trips the
743
+ guard.
744
+ 2. **Do NOT use `loading()` on the layout itself as the live loaders'
745
+ boundary** — any live read without an inline `<Suspense>` of its own
746
+ suspends to it, and the LoaderBoundary fallback wraps the layout's ENTIRE
747
+ subtree: chrome (header, nav, footer) falls out of the shell into the
748
+ skeleton. Technically PPR, practically pointless.
749
+ 3. **Guaranteed-fresh widgets: live loaders with a widget-sized boundary.**
750
+ An unflagged loader read under an inline `<Suspense>` in the widget is
751
+ enough. Alternatively, a parallel slot with its OWN `loading()`:
752
+ parallel-owned loaders get their own per-slot boundary (`fresh.ts` tags
591
753
  them with the slot's loading; `segment-system.tsx` builds a per-slot
592
754
  LoaderBoundary), so the chrome bakes into the shell and each widget is an
593
755
  independent, widget-sized hole:
@@ -613,8 +775,8 @@ stay live. Your levers, in order of preference:
613
775
  ]),
614
776
  ```
615
777
 
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
778
+ Slot-owned loaders without `ssr: false` are masked at capture and
779
+ GUARANTEED fresh per serve — use this where the bake lane's pinning
618
780
  (capture-time data for the shell's lifetime) is not acceptable, at the cost
619
781
  of a widget-sized fallback in the shell. The slot handler must hand the
620
782
  loader to a CLIENT component (`useLoader` in a `"use client"` component)
@@ -629,14 +791,17 @@ stay live. Your levers, in order of preference:
629
791
  hole) or `cache()`/`"use cache"` to bake it with tag-invalidation.
630
792
 
631
793
  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
794
+ promise (a hole, fresh per request) or in a live-lane loader (no `ssr: false`)
795
+ consumed CLIENT-side under `loading()` or an inline `<Suspense>`. A nested
796
+ promise's body still runs at capture, so it must not call `cookies()`/`headers()`
797
+ itself — identity reads belong in the live-lane loader. Reading
798
+ `cookies()`/`headers()` where the value would bake as SEGMENT material —
799
+ handler/render code or a bake-lane loader container — refuses the capture by
800
+ construction. The one exemption is
636
801
  handler-INVOKED loader bodies (`await ctx.use(loader)`): they execute at
637
802
  capture with identity reads permitted, and the value bakes as a shared
638
803
  capture-time copy — mirroring `cache()` semantics (the consumption-lane
639
- rule; semantic-matrix row PPR3).
804
+ rule, `/rango` → Invariants).
640
805
 
641
806
  ### Designing routes for cheap captures (the cost model)
642
807
 
@@ -658,36 +823,40 @@ Three levers, in preference order:
658
823
  tag-invalidatable), but the capture replays the cached value instead of
659
824
  re-executing the expensive body (mixed-chain). Choose this for shared,
660
825
  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.
826
+ 3. **Drop `ssr: false`** (loaders only): moves the loader to the live lane —
827
+ masked at capture, fresh per serve — at the cost of a fallback in the shell
828
+ (a live loader needs `loading()` or an inline `<Suspense>` above its
829
+ reader).
664
830
 
665
831
  Head material (Meta) pushed by HANDLERS cannot be a hole — the head is shell —
666
832
  so those promises bake by design; make them cheap with lever 2. `bakeWaitMs`
667
833
  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.
834
+ Meta pushed by a LIVE-lane loader is different: those loaders are masked at
835
+ capture, so the push happens at request time and applies client-side
836
+ (`metadata.handlesLate`) — it is never in the cached shell's head, by
837
+ construction. A bake-lane (`ssr: false`) loader executes at capture, so its
838
+ settled pushes are shell material like a handler's.
839
+
840
+ `loader(Def, { ssr: false })` (the document-render await, `/loader`) is the
841
+ bake-lane switch under PPR, not an axis-1-only knob: the capture render awaits
842
+ the flagged loader too, so its settled non-promise data — handle pushes
843
+ included — freezes into the stored shell and recurs as capture cost on every
844
+ capture (levers 1 and 2 apply to it). Outside capture the flag keeps its
845
+ axis-1 meaning: document renders await it before first flush, client
846
+ navigations stream it.
678
847
 
679
848
  ## Execution matrix
680
849
 
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 |
850
+ | Phase | MISS (foreground) | Background capture | HIT (foreground) |
851
+ | ---------------- | ---------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
852
+ | Middleware chain | runs (full) | **NOT re-run** — inherits the request's post-middleware context | runs (full) — commit point is after it |
853
+ | `router.match` | runs | re-runs under a derived context | runs (behind the flushed prelude) |
854
+ | Handlers | run | run on UNCACHED segments; `cache()`d segments replay (mixed-chain) | run (same mixed-chain rules as any render) |
855
+ | Loaders | run **fresh** | LIVE lane (no `ssr: false`): MASKED; BAKE lane (`ssr: false`): execute + snapshot-pin | run **fresh** (bake containers overlaid from the snapshot) |
856
+ | Flight render | full | full | full (hydration needs the whole payload — no Flight resume) |
857
+ | HTML production | full fizz | `prerender` + abort → prelude + postponed | `resume` only the holes — O(paths to holes) |
858
+ | Shell store | schedules a bg capture | `putShell(key, …)` | `getShell(key)`; a stale/SWR hit also schedules a recapture |
859
+ | Prelude bytes | — | — | flushed FIRST, before segment resolution starts |
691
860
 
692
861
  Middleware is not re-run during capture because it already ran for the
693
862
  triggering request — the capture's derived context inherits the
@@ -699,7 +868,8 @@ Because handlers on uncached segments EXECUTE during capture — and BAKE-lane
699
868
  loaders now do too — the `cookies()`/`headers()` capture guard is load-bearing:
700
869
  those reads THROW during a capture render (`assertNotInsideShellCapture`), so
701
870
  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.
871
+ (every loader without `ssr: false`) are exempt: masked at capture, they never
872
+ run there.
703
873
 
704
874
  ## allReady: the SEO/bot story
705
875
 
@@ -720,7 +890,7 @@ shell byte.
720
890
  **(b) Identity can't leak via cookies/headers.** `cookies()` and `headers()`
721
891
  THROW during the background capture render — in handlers AND in bake-lane
722
892
  loaders (whose containers would bake). A shell that reads them is
723
- PPR-ineligible by construction; the live lane (`loading()`) stays exempt.
893
+ PPR-ineligible by construction; the live lane (no `ssr: false`) stays exempt.
724
894
 
725
895
  **(c) Residual hazard — middleware-derived per-user state.** A `ctx` variable
726
896
  set by an upstream auth middleware and rendered by shell material is
@@ -731,8 +901,8 @@ session object (`ctx.get("session")`) never calls `cookies()` itself, so the
731
901
  guard cannot see it — whatever it returns as settled container data is
732
902
  photographed as the CAPTURING user's state. If the
733
903
  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.
904
+ in nested pending promises or live-lane loaders (no `ssr: false`) — NOT in
905
+ plain bake-lane container data — or key per variant at the CDN tier.
736
906
 
737
907
  ## What always stays on axis 1
738
908
 
@@ -764,7 +934,7 @@ silent.
764
934
  `createRouter({ nonce })` is the canonical path — supply the nonce THERE, not
765
935
  via a token write. The provider value is threaded into the router's own SSR
766
936
  machinery: `NonceContext`/`useNonce()`, automatic nonce attributes on
767
- `<Scripts />` and `<MetaTags />` output, and the inlined Flight payload
937
+ `<Html.Scripts />` and `<Html.Meta />` output, and the inlined Flight payload
768
938
  scripts. It ALSO sets the `nonce` token, so `ctx.get(nonce)` works in
769
939
  middleware and handlers for the CSP response header. A direct
770
940
  `ctx.set(nonce, value)` write in middleware is app-managed only: the router
@@ -814,37 +984,43 @@ A captured shell auto-carries the UNION of the non-loader tags recorded during
814
984
  the capture render — every `cacheTag(...)` that ran as shell material, whether
815
985
  from a `"use cache"` function, a `cache()` segment, or a render-callable
816
986
  `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
987
+ tree), plus the tags of cached loaders on the BAKE lane (they execute during
988
+ capture). Live-lane loader tags (every loader without `ssr: false`) never
989
+ attach — those loaders are masked at capture, and the holes are already live. `ppr.tags` adds
818
990
  operational tags the render cannot know (a tenant id, a deploy marker).
819
991
 
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 |
992
+ | Lever | Reaches the frozen shell? | Reaches the holes? |
993
+ | ------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
994
+ | `updateTag` / `revalidateTag` on a SHELL tag | YES — drops the shell → MISS → recapture | n/a (holes are already live) |
995
+ | `updateTag` / `revalidateTag` on a live-lane LOADER tag | no — live-lane loader tags never attach to a shell | drops that loader's cached value (if it `cache()`s) |
996
+ | `revalidate()` (named revalidation contract) | **no** — re-runs segments/loaders for the PAYLOAD, never HTML | yes — the hole re-renders with fresh data |
825
997
 
826
998
  A server action's automatic invalidation refreshes the CLIENT only — it re-runs
827
999
  the holes and streams a fresh payload, but does NOT evict the server shell.
828
1000
  Shell-baked data stays stale until the shell's TTL unless you tag-invalidate it
829
1001
  (`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.
1002
+ evicted by tag at all — move always-fresh data into a live-lane loader (no
1003
+ `ssr: false`) or a nested promise.
831
1004
 
832
1005
  ## Pitfalls
833
1006
 
834
1007
  - **A bake-lane loader that reads `cookies()`/`headers()`**: the capture is
835
1008
  REFUSED (deterministic, once-per-key warned) — the route stays on axis 1.
836
- Move identity into a nested promise or behind `loading()`.
1009
+ Move identity onto the live lane (drop `ssr: false`, or split the read into
1010
+ a separate unflagged loader; give the reader a boundary). A nested promise
1011
+ does not help here: its body still runs during capture.
837
1012
  - **A bake-lane container that must be fresh per document GET**: it is
838
1013
  snapshot-pinned for the shell's lifetime by design. Use the live lane
839
- (`loading()`) or a nested promise instead.
1014
+ (no `ssr: false`) or a nested promise instead.
840
1015
  - **A bake-lane loader slower than `ppr.captureTimeout` (15s by default)**: the
841
1016
  capture is refused rather than storing a partial shell. Increase the route's
842
1017
  budget only when the deployment can keep the background/build work alive.
843
1018
  - **Per-user value in shell material**: baked into the shared shell —
844
1019
  deterministically, not by race (handler promises deep-settle at the ring-3
845
1020
  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.
1021
+ per-user data in a nested pending promise or a live-lane loader (no
1022
+ `ssr: false`) — plain BAKE-lane loader data bakes just like handler
1023
+ material.
848
1024
  - **The session-object bake trap (the guard cannot save you here)**: the
849
1025
  capture guard sees `cookies()`/`headers()` calls ONLY. A bake-lane loader
850
1026
  reading a middleware-provided session object (`ctx.get("session")`) refuses
@@ -863,7 +1039,8 @@ evicted by tag at all — move always-fresh data under a `loading()` hole.
863
1039
  The remaining trap is returning per-user data as PLAIN container material:
864
1040
  `return { user: session.user }` bakes it into the shared shell like any
865
1041
  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.
1042
+ (even an already-resolved one) or put the loader on the live lane (drop
1043
+ `ssr: false`).
867
1044
 
868
1045
  - **Theme on a HIT is capture-then-corrected**: the resume tree replays the
869
1046
  CAPTURE's `initialTheme` (resume requires it to match the frozen prelude);
@@ -871,19 +1048,22 @@ evicted by tag at all — move always-fresh data under a `loading()` hole.
871
1048
  re-synced post-mount by ThemeProvider. Nothing to configure — but a themed
872
1049
  component in the shell may briefly render the captured theme's markup before
873
1050
  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>`
1051
+ - **Shell shows CAPTURE-time data for the shell's lifetime**: a
1052
+ `cache()`/`"use cache"` value baked into the shell is PINNED at capture (the
1053
+ capture data snapshot) and replayed on every HIT, so the shell stays
1054
+ byte-identical to the frozen prelude even after that cache entry expires or
1055
+ is recomputed. This is deliberate — parity beats freshness inside the shell.
1056
+ Tags are the link back: every tag a `cache()`/`"use cache"` entry carried when
1057
+ the capture read it also rides the shell, so `updateTag` on that tag drops the
1058
+ shell too (next request MISSes and recaptures). An untagged entry has no such
1059
+ link. If a shell region needs to be fresh, put it under a hole — a live-lane
1060
+ loader (no `ssr: false`) behind `loading()` or an inline `<Suspense>`, or an
1061
+ un-awaited promise under the consumer's `<Suspense>`
881
1062
  (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
1063
+ it: tag the cached read, call `cacheTag(...)` from the shell-material render
1064
+ code (the render-time lever), or add the tag to `ppr.tags` (operational tags
1065
+ the render cannot know — a tenant id, a deploy marker). Tags are optional: if
1066
+ TTL/SWR is the complete freshness policy, leave the shell untagged. Rango does not warn for that choice; with
887
1067
  `debugShellCapture` enabled, a stored event reports `untaggedBake: true` when
888
1068
  bake-lane loader material uses TTL/SWR-only invalidation.
889
1069
  - **Uncached nondeterminism in the shell is a hydration hazard**: a raw
@@ -906,13 +1086,28 @@ cache"` value baked into the shell is PINNED at capture (the capture data
906
1086
  500/redirect after the first shell byte — error UI renders inline via
907
1087
  Suspense/error boundaries (the same property any streamed SSR page has after
908
1088
  its shell flushes).
1089
+ - **A shell component that throws during capture refuses it**: an async
1090
+ server component (or a client component during SSR) inside a Suspense
1091
+ boundary that throws does not fail the capture render, so its errored
1092
+ boundary would bake into the shared prelude. The capture stores nothing
1093
+ instead, logs `[ShellCache] capture` with the error (it goes to `onError`
1094
+ as `cache-write` unless the router already reported that same error during
1095
+ the render), and backs the key off like any refused capture; the next
1096
+ capture after the window stores normally. The request that triggered the
1097
+ capture is served as before. Only a throw during the capture counts: a
1098
+ component that failed in the live render and succeeds when captured is
1099
+ stored.
909
1100
 
910
1101
  ## Related
911
1102
 
912
1103
  - `/defer-hydration` — keep the full body HTML in the shell while moving a
913
1104
  heavy subtree's hydration off the initial main-thread task (gated boundary,
914
1105
  content-as-fallback)
915
- - `/document-cache` — whole-response edge caching (no live holes)
916
- - `/caching` and `/cache-guide` — segment/function caching (axis 1 data)
1106
+ - `/document-cache` — store-backed whole-response caching (no live holes)
1107
+ - `/caching` — `cache()` segment caching and stores; `/use-cache` — function caching
917
1108
  - `/shell-manifest` — replayed handles as cache metadata read by live loaders
918
- - Design doc: `docs/design/ppr-shell-resume.md` in the package
1109
+ - `/prerender` — `Prerender` + `ppr` bakes the shell at build time
1110
+ - `/cache-guide` — where the shell layer sits among the other cache layers
1111
+ - `/deployment-caching` — why this is in-function PPR, not a CDN shell
1112
+ - Design doc: `docs/design/ppr-shell-resume.md` in the router repository (not
1113
+ shipped in the npm package)