@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -0,0 +1,622 @@
1
+ ---
2
+ name: ppr
3
+ description: PPR shell caching — opt a page route in with the `ppr` path option; the router serves the cached HTML shell instantly and resumes the live holes. Use when a page should render instantly from a cached shell while specific parts stay live, or asking about partial prerendering in Rango.
4
+ argument-hint: "[setup]"
5
+ ---
6
+
7
+ # PPR Shell Caching
8
+
9
+ Caches the rendered HTML **shell** of a page route (React `prerender` prelude
10
+ bytes plus `postponed` state) and, on a later request, flushes those bytes
11
+ before any render work happens, then resumes fizz for just the live holes. The
12
+ browser sees one ordinary streamed document; loaders stay fresh on every
13
+ request. This is the second render axis — the default axis-1 path is untouched,
14
+ and every ineligible request falls open to it.
15
+
16
+ Compare `/document-cache`, which freezes the WHOLE response including loader
17
+ output. Shell caching is for pages that mix a stable shell with live data: the
18
+ shell is shared per host+URL, the holes are per request.
19
+
20
+ ## Not this skill if…
21
+
22
+ - You want the WHOLE response frozen, loader output included — see
23
+ `/document-cache`.
24
+ - You want routes rendered at build time with `Static()`/`Prerender()` — the
25
+ `ppr` path option captures at runtime; see `/prerender`.
26
+ - You are unsure which cache layer you need — start at `/cache-guide`.
27
+
28
+ ## Setup: one path option, no middleware
29
+
30
+ PPR is a DOCUMENT-level property declared on the page route via the `ppr` path
31
+ option. Serving is **integral to the router** — there is nothing to mount. The
32
+ only prerequisite is an app-level `createRouter({ cache })` store that
33
+ implements the shell family (`getShell`/`putShell`): `MemorySegmentCacheStore`
34
+ (dev/tests), `CFCacheStore` (Cloudflare KV), or `VercelCacheStore` (runtime
35
+ cache). A ppr route on a store without the family stays on axis 1 with a
36
+ once-per-key warning.
37
+
38
+ ```typescript
39
+ import { createRouter, urls } from "@rangojs/router";
40
+ import { CFCacheStore } from "@rangojs/router/cache";
41
+
42
+ export const urlpatterns = urls(({ path, layout, loader, loading }) => [
43
+ layout(ProductShell, () => [
44
+ path(
45
+ "/products/:id",
46
+ PricePage,
47
+ // `ppr` is the whole opt-in AND the policy. `ppr: true` uses the default
48
+ // ttl (300s); an object sets ttl/swr/tags (PartialPrerenderProps).
49
+ { name: "product", ppr: { ttl: 600, swr: 120 } },
50
+ () => [
51
+ loader(LivePriceLoader),
52
+ loading(<PriceSkeleton />), // structural hole: the loader subtree stays live
53
+ ],
54
+ ),
55
+ ]),
56
+ ]);
57
+
58
+ const router = createRouter<AppBindings>({
59
+ document: Document,
60
+ urls: urlpatterns,
61
+ cache: (env, ctx) => ({
62
+ store: new CFCacheStore({ kv: env.CACHE_KV, ctx: ctx! }),
63
+ }),
64
+ });
65
+ export default router;
66
+ ```
67
+
68
+ That is ONE of two hole mechanisms — the loader one. Do not conclude PPR
69
+ requires a loader:
70
+
71
+ ### The same opt-in with NO loader and NO loading() — promise holes
72
+
73
+ A route (or its layouts) whose live regions are pending promises under
74
+ `<Suspense>` PPRs with no `loader()` and no `loading()` anywhere. Hand the
75
+ un-awaited promise over as a prop; the consumer suspends under its OWN
76
+ boundary; the boundary postpones at capture and becomes the hole:
77
+
78
+ ```typescript
79
+ // Handler: kick off the fetch, do NOT await it.
80
+ function ProductPage(ctx: HandlerContext) {
81
+ const reviews = fetchReviews(ctx.params.id); // Promise<Review[]> — pending
82
+ return (
83
+ <main>
84
+ <h1>Product {ctx.params.id}</h1> {/* shell — baked into the prelude */}
85
+ <ReviewsSection promise={reviews} /> {/* hole — resumes per request */}
86
+ </main>
87
+ );
88
+ }
89
+ ```
90
+
91
+ ```tsx
92
+ // ReviewsSection.tsx — the consumer owns its Suspense boundary.
93
+ "use client";
94
+
95
+ import { Suspense, use } from "react";
96
+
97
+ function Inner({ promise }: { promise: Promise<Review[]> }) {
98
+ return <ReviewList reviews={use(promise)} />;
99
+ }
100
+ export function ReviewsSection({ promise }: { promise: Promise<Review[]> }) {
101
+ return (
102
+ <Suspense fallback={<ReviewsSkeleton />}>
103
+ <Inner promise={promise} />
104
+ </Suspense>
105
+ );
106
+ }
107
+ ```
108
+
109
+ ```typescript
110
+ path("/products/:id", ProductPage, { name: "product", ppr: true });
111
+ // No use() list at all: no loader, no loading, still a shell + live hole.
112
+ ```
113
+
114
+ At capture the pending fetch cannot win the task-quantized quiet window, so
115
+ the boundary postpones — fallback in the frozen prelude, value resumed fresh
116
+ on every HIT. This is the PHYSICS class from the hole doctrine below, and it
117
+ is exactly how an existing Suspense-shaped tree (e.g. migrated from Next.js
118
+ PPR) works with zero restructuring. The e2e proof is
119
+ `e2e/test-app/src/components/ShellPhysicsValue.tsx` — a promise hole living in
120
+ a LAYOUT with no loader registration at all.
121
+
122
+ A route WITHOUT the `ppr` option is pure axis 1: no store read, no capture, no
123
+ logs, zero cost. `ppr` is per page route — declaring it on a layout is not
124
+ supported (subtree inheritance is a possible follow-up).
125
+
126
+ ## Where PPR sits: the cache onion
127
+
128
+ Rango's caches layer like an onion — each ring stores a progressively more
129
+ "cooked" representation of the same page. From innermost (raw values) to
130
+ outermost (final bytes):
131
+
132
+ | Ring | Primitive | What is stored | What stays live on a hit |
133
+ | ----------------------- | ---------------------------------------- | -------------------------------------------------- | -------------------------------------- |
134
+ | 1. Function values | `"use cache"` | a function's return value | everything around the call |
135
+ | 2. Loader values | `loader(Fn, () => [cache({...})])` | one loader's result (opt-in; loaders default live) | all other loaders, handlers, rendering |
136
+ | 3. Segments (Flight) | `cache()` route / build-time `prerender` | serialized rendered segments + replayed handles | loaders, HTML render |
137
+ | 4. **HTML shell (PPR)** | `ppr` path option | rendered prelude bytes + React postponed state | the holes, hydration payload |
138
+ | 5. Whole response | `/document-cache` | final response bytes, headers included | nothing — all-or-nothing |
139
+
140
+ PPR is ORTHOGONAL to `cache()` (ring 3): a ppr route may be uncached (its
141
+ handlers run fresh on every serve and during capture), fully `cache()`d (its
142
+ segments replay), or mixed. One useful cache() property to know: the segment
143
+ codec **deep-settles promises at the ring-3 write**, so nothing inside a
144
+ `cache()` boundary can stay live — that is a cache() fact, not a ppr one.
145
+
146
+ Invalidation crosses rings: `updateTag()`/`revalidateTag()` reach segment,
147
+ shell, loader, and item entries in the same store, and shell entries
148
+ additionally self-invalidate on `React.version` change.
149
+
150
+ ## The serve pipeline: commit after ALL middleware
151
+
152
+ On a document GET to a ppr route the router runs:
153
+
154
+ 1. **match** — route identified, `ppr` config read from the matched route;
155
+ 2. **the WHOLE middleware chain** — the global `router.use()` chain AND route
156
+ DSL `middleware()`; both are guards, and the COMMIT POINT is after all of
157
+ them: any rejection/redirect/401 wins before a single shell byte, on MISS
158
+ and on a warmed HIT alike;
159
+ 3. **shell lookup** — `getShell(key)` on the app store (key =
160
+ host+pathname+sorted search);
161
+ 4. **HIT** — the composed response is committed immediately: the stored prelude
162
+ bytes flush first, while segment resolution, the fresh Flight render (the
163
+ full hydration payload — there is no Flight-side resume), and the fizz
164
+ `resume` of just the holes run BEHIND them inside the response stream;
165
+ 5. **MISS** — plain axis-1 serve, tagged `x-rango-shell: MISS`, plus a
166
+ background capture (stampede-guarded, retry-in-place, exponential backoff).
167
+
168
+ `x-rango-shell: HIT | MISS` is the observability header. Because the commit
169
+ point is after the chain, an unauthorized request NEVER sees shell bytes — put
170
+ auth middleware anywhere (global or route DSL) and it guards PPR for free.
171
+
172
+ ## Verifying it works
173
+
174
+ The header exists on DOCUMENT responses only. A bare `curl` (no `Accept`)
175
+ content-negotiates a Flight payload (`text/x-component`) with NO
176
+ `x-rango-shell` header at all — which reads as "PPR is off" but is only the
177
+ wrong request shape:
178
+
179
+ ```
180
+ curl -s -D - -o /dev/null -H "Accept: text/html" https://app.example.com/products/1 | grep -i x-rango-shell
181
+ ```
182
+
183
+ - First document GET: `MISS`, plus a background capture.
184
+ - Production (workerd/node): the SECOND request is a `HIT`.
185
+ - Dev: expect a few extra MISSes — cold module transforms abort the capture
186
+ window (per-attempt breadcrumbs: start the server with
187
+ `INTERNAL_RANGO_DEBUG=1`). This self-heals; only a route that NEVER flips
188
+ has a real hole/eligibility problem (the once-per-key warning tells the two
189
+ apart).
190
+ - A HIT is one ordinary document: the frozen prelude first (view-source shows
191
+ your baked shell, with hole fallbacks in place), then
192
+ `<div hidden id="S:0">…` segments as the holes resume, per request.
193
+ - A ppr-declared route that CANNOT be honored (missing shell store family,
194
+ per-request nonce) serves plain axis 1 with NO header and warns once per
195
+ key — no header + a declared `ppr` means look for that warning.
196
+
197
+ ## The hole doctrine (encode this in your head)
198
+
199
+ Holes are **render-defined**, decided by the shape of the tree, on three rules:
200
+
201
+ | Class | What makes the hole | At capture | At serve |
202
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------- |
203
+ | **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 |
204
+ | **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 |
205
+ | **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 |
206
+
207
+ ONE rule for promises, every lane — handlers, handles, AND loaders: **a promise
208
+ nested inside your data is never baked; the container settles.** A loader
209
+ without `loading()` is the BAKE lane (see below): its settled container is
210
+ shell material, exactly like awaited handler data and top-level handle pushes.
211
+
212
+ **`loading()` is NOT the gate for holes — it is the LANE SELECTOR for loader
213
+ data.** A pending promise under any plain `<Suspense>` postpones and is a hole
214
+ at whatever level it suspends — the PHYSICS row needs no `loading()` anywhere
215
+ (the e2e fixture's physics and nested-handle holes sit in a layout with none).
216
+ A route without a loader PPRs on pure promise/Suspense holes. For loaders,
217
+ `loading()` picks the lane: present = the GUARANTEED live lane (masked at
218
+ capture, fresh every serve, immune to fast resolution); absent = the bake lane
219
+ (the container is shell material, nested promises stay live).
220
+
221
+ The three promise positions, side by side:
222
+
223
+ ```typescript
224
+ async function Handler(ctx: HandlerContext) {
225
+ const push = ctx.use(MyHandle);
226
+
227
+ push(fetchBadge()); // TOP-LEVEL push: awaited pre-SSR → BAKED
228
+ push({ label: "x", stat: fetchStat() }); // NESTED in container → container baked,
229
+ // stat streams → HOLE (consumer Suspends it)
230
+
231
+ const data = await fetchHeader(ctx); // awaited by the handler → BAKED
232
+
233
+ return (
234
+ <section>
235
+ <Header data={data} /> {/* shell */}
236
+ <StatsPanel promise={fetchStats(ctx)} /> {/* un-awaited prop + own
237
+ <Suspense> + use() → HOLE */}
238
+ </section>
239
+ );
240
+ }
241
+ ```
242
+
243
+ ### Choosing the hole mechanism
244
+
245
+ | Your live region is… | Use | Why |
246
+ | ----------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
247
+ | 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 |
248
+ | 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>` |
249
+ | handler-fetched real I/O (db, fetch) | un-awaited promise prop + consumer `<Suspense>` + `use()` | no loader needed; real latency postpones by physics |
250
+ | per-segment metadata consumed elsewhere | handle container with a NESTED promise + consumer `<Suspense>` | container is shell, nested value streams — "nesting = liveness" |
251
+ | 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 |
252
+ | none of the above | nothing | it bakes — that is what the shell is for |
253
+
254
+ The physics caveat in one line: HANDLER-created promise props are holes only
255
+ because the I/O is genuinely pending at capture — if the value can resolve
256
+ near-instantly (memory read, warmed cache), it may bake into the shell; when
257
+ liveness must be guaranteed rather than probable, use a loader. BAKE-LANE
258
+ NESTED promises are exempt from that race: the capture MASKS every thenable
259
+ nested in a bake-lane container regardless of settle timing
260
+ (`maskNestedContainerThenables`, loader-cache.ts), so the consuming boundary
261
+ always postpones as a hole and every HIT streams the FRESH value — the
262
+ promise SHAPE is the liveness declaration, not a bet on latency. (Before the
263
+ mask, a nested promise that settled inside the window pinned its capture-time
264
+ value into the shared shell; found live as a storefront basket — with the
265
+ capturing session's identifiers — served to anonymous visitors.)
266
+
267
+ ### Handles: "nesting = liveness"
268
+
269
+ - `ctx.use(H)(promise)` — a TOP-LEVEL pushed promise is awaited server-side
270
+ before SSR (`resolvedHandleStream`) and BAKED into the shell. The capture
271
+ gate is held open for the same await, so real latency here is safe (bounded
272
+ by the capture's 5s guard).
273
+ - `ctx.use(H)({ x: promise })` — the container passes through verbatim
274
+ (resolution is shallow); the nested promise streams to the consumer, who must
275
+ `<Suspense>` it. Under capture that boundary postpones — a hole — REGARDLESS
276
+ of settle timing: the capture masks nested thenables in pushed handle
277
+ containers (the capture store's push wrap, shell-capture.ts), so even an
278
+ already-resolved nested promise holes instead of baking its value into the
279
+ shared shell. Same shape-is-the-declaration rule as bake-lane loaders.
280
+
281
+ ### Want a hole for already-resolved data?
282
+
283
+ Put it in a loader: `loader(() => Promise.resolve(x))` + `loading()`. Loaders
284
+ are always the live lane — masked at capture, fresh on every serve — no matter
285
+ how fast the value settles.
286
+
287
+ ### The bake lane: loaders without loading() on THEIR entry
288
+
289
+ A loader on an entry with no renderable `loading()` EXECUTES during capture
290
+ (the capture gate holds open for its real latency, bounded by the 5s guard).
291
+ Its settled container bakes into the prelude; every promise nested in it is
292
+ masked at capture (regardless of how fast it settles) and postpones at the
293
+ consumer's own `<Suspense>` — a hole. On every HIT the
294
+ capture snapshot's loader family overlays the recorded container onto the
295
+ fresh run, so the payload matches the frozen prelude byte-for-byte while the
296
+ nested promises run fresh. The return shape is the declaration:
297
+
298
+ ```typescript
299
+ export const StorefrontContextLoader = createLoader(async (ctx) => {
300
+ const config = await loadSiteConfig(ctx.params.locale); // bakes (pinned per shell)
301
+ return {
302
+ config, // shell material
303
+ basket: fetchBasket(ctx), // hole — consumer <Suspense>s it, fresh per request
304
+ };
305
+ });
306
+ ```
307
+
308
+ The lane is decided PER TREE NODE, at the entry that REGISTERS the loaders —
309
+ `loading()` on a CHILD route does not change a parent layout's lane, and
310
+ `loading()` IS valid on layout and parallel entries, not just routes.
311
+
312
+ Three hard edges (each e2e/unit-pinned):
313
+
314
+ - **Identity refuses.** `cookies()`/`headers()` inside a bake-lane loader
315
+ throws during capture and the capture REFUSES (deterministic, once-per-key
316
+ warned) — identity can never bake into the shared shell. Give that loader's
317
+ entry `loading()` (the live lane is exempt) or move the identity-dependent
318
+ part into a nested promise. The guard's scope is EXACTLY those two calls:
319
+ per-user state read from a middleware-provided object (`ctx.get("session")`)
320
+ does NOT refuse — it bakes silently as the capturing user's data (see
321
+ Pitfalls: the session-object bake trap).
322
+ - **A rejecting bake-lane loader refuses.** Error UI never bakes.
323
+ - **Baked containers show CAPTURE-time data** for the shell's lifetime on
324
+ document GETs (client navigations stay fresh — axis 1). That IS the bake
325
+ lane's meaning; if a value must be fresh on every serve, it belongs on the
326
+ live lane (`loading()`) or in a nested promise.
327
+
328
+ ### The layout-with-loaders playbook (the storefront case)
329
+
330
+ The most common real-app shape: an app-wide layout registers per-user loaders
331
+ (session context, basket, wishlist) and the page under it declares `ppr`.
332
+ Those loaders are on the BAKE lane (no `loading()` on the layout), so the page
333
+ captures and HITs — the question is which parts of their data should bake vs
334
+ stay live. Your levers, in order of preference:
335
+
336
+ 1. **Shape the return value.** Shared/config data returns as plain values
337
+ (bakes, pinned per shell); per-request data returns as NESTED promises
338
+ consumed under the widget's own `<Suspense>` (live holes). No `loading()`,
339
+ no restructuring. One wall: a bake-lane loader that reads
340
+ `cookies()`/`headers()` refuses the capture — identity belongs in a nested
341
+ promise or on the live lane.
342
+ 2. **Do NOT put `loading()` on the layout itself** — that flips the WHOLE
343
+ layout to the live lane and the LoaderBoundary fallback wraps the layout's
344
+ ENTIRE subtree: chrome (header, nav, footer) falls out of the shell into
345
+ the skeleton. Technically PPR, practically pointless.
346
+ 3. **Guaranteed-fresh widgets: a parallel slot with its OWN `loading()`.**
347
+ Parallel-owned loaders get their own per-slot boundary (`fresh.ts` tags
348
+ them with the slot's loading; `segment-system.tsx` builds a per-slot
349
+ LoaderBoundary), so the chrome bakes into the shell and each widget is an
350
+ independent, widget-sized hole:
351
+
352
+ ```typescript
353
+ layout(StoreChrome, () => [
354
+ // chrome renders NO loader data itself — it bakes into the shell
355
+ parallel({ "@basket": BasketBadge }, () => [
356
+ loader(BasketLoader),
357
+ loading(<BadgeSkeleton />), // hole the size of a badge, not a page
358
+ ]),
359
+ // Descriptor form when the slot handler needs ctx (annotate it —
360
+ // StaticHandlerDefinition in the union blocks inference there):
361
+ parallel({
362
+ "@wishlist": {
363
+ handler: (ctx: HandlerContext) => (
364
+ <WishlistBadge listUrl={ctx.reverse("wishlist")} />
365
+ ),
366
+ use: () => [loader(WishlistLoader), loading(<BadgeSkeleton />)],
367
+ },
368
+ }),
369
+ path("/", HomePage, { name: "home", ppr: true }),
370
+ ]),
371
+ ```
372
+
373
+ Slot-owned loaders are masked at capture and GUARANTEED fresh per serve —
374
+ use this where the bake lane's physics (a fast resolve bakes) or pinning
375
+ (capture-time data for the shell's lifetime) is not acceptable, at the cost
376
+ of a widget-sized fallback in the shell. The slot handler must hand the
377
+ loader to a CLIENT component (`useLoader` in a `"use client"` component)
378
+ for the freshness guarantee to reach the rendered value: server-side
379
+ `await ctx.use(...)` in the handler is the BAKED lane (the consumption-lane
380
+ rule, `/rango` → Invariants) — it executes at capture with identity reads
381
+ permitted, but the value it renders is a capture-time copy wherever it is
382
+ not shielded by the slot's masked LoaderBoundary.
383
+
384
+ 4. **Shared layout data can also leave the loader lane entirely**: an
385
+ un-awaited handler promise under the consumer's `<Suspense>` (a physics
386
+ hole) or `cache()`/`"use cache"` to bake it with tag-invalidation.
387
+
388
+ The identity rule, stated once: per-user data on a PPR page lives in a NESTED
389
+ promise (a hole, fresh per request) or behind `loading()` with CLIENT-side
390
+ consumption (the live lane). Reading `cookies()`/`headers()` where the value
391
+ would bake as SEGMENT material — handler/render code or a bake-lane loader
392
+ container — refuses the capture by construction. The one exemption is
393
+ handler-INVOKED loader bodies (`await ctx.use(loader)`): they execute at
394
+ capture with identity reads permitted, and the value bakes as a shared
395
+ capture-time copy — mirroring `cache()` semantics (the consumption-lane
396
+ rule; semantic-matrix row PPR3).
397
+
398
+ ## Execution matrix
399
+
400
+ | Phase | MISS (foreground) | Background capture | HIT (foreground) |
401
+ | ---------------- | ---------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------- |
402
+ | Middleware chain | runs (full) | **NOT re-run** — inherits the request's post-middleware context | runs (full) — commit point is after it |
403
+ | `router.match` | runs | re-runs under a derived context | runs (behind the flushed prelude) |
404
+ | Handlers | run | run on UNCACHED segments; `cache()`d segments replay (mixed-chain) | run (same mixed-chain rules as any render) |
405
+ | Loaders | run **fresh** | LIVE lane (`loading()`): MASKED; BAKE lane: execute + snapshot-pin | run **fresh** (bake containers overlaid from the snapshot) |
406
+ | Flight render | full | full | full (hydration needs the whole payload — no Flight resume) |
407
+ | HTML production | full fizz | `prerender` + abort → prelude + postponed | `resume` only the holes — O(paths to holes) |
408
+ | Shell store | schedules a bg capture | `putShell(key, …)` | `getShell(key)`; a stale/SWR hit also schedules a recapture |
409
+ | Prelude bytes | — | — | flushed FIRST, before segment resolution starts |
410
+
411
+ Middleware is not re-run during capture because it already ran for the
412
+ triggering request — the capture's derived context inherits the
413
+ post-middleware state (`ctx` variables included, which is what makes
414
+ middleware-derived shell content photograph correctly). Guarding is
415
+ serve-time: the commit point runs the full chain on EVERY serve.
416
+
417
+ Because handlers on uncached segments EXECUTE during capture — and BAKE-lane
418
+ loaders now do too — the `cookies()`/`headers()` capture guard is load-bearing:
419
+ those reads THROW during a capture render (`assertNotInsideShellCapture`), so
420
+ identity can never leak into a shared shell through them. Live-lane loaders
421
+ (behind `loading()`) are exempt: masked at capture, they never run there.
422
+
423
+ ## allReady: the SEO/bot story
424
+
425
+ `ssr: { resolveStreaming: ... }` returning `"allReady"` (e.g. for bot user
426
+ agents) bypasses PPR entirely — the request gets one complete, fully-buffered
427
+ axis-1 document. Crawlers that dislike streamed shells get a finished page;
428
+ regular users get the streamed shell. No configuration interaction: allReady
429
+ wins.
430
+
431
+ ## Security
432
+
433
+ Shell caching shares one shell per host+URL across all users:
434
+
435
+ **(a) Access control is sound by construction.** The commit point is after ALL
436
+ middleware on every serve. A 401/redirect short-circuit returns before any
437
+ shell byte.
438
+
439
+ **(b) Identity can't leak via cookies/headers.** `cookies()` and `headers()`
440
+ THROW during the background capture render — in handlers AND in bake-lane
441
+ loaders (whose containers would bake). A shell that reads them is
442
+ PPR-ineligible by construction; the live lane (`loading()`) stays exempt.
443
+
444
+ **(c) Residual hazard — middleware-derived per-user state.** A `ctx` variable
445
+ set by an upstream auth middleware and rendered by shell material is
446
+ photographed into the SHARED shell (the capture inherits post-middleware
447
+ state). That is scope fidelity working as designed — for shared values. The
448
+ same hazard reaches BAKE-LANE LOADERS: a loader reading a middleware-provided
449
+ session object (`ctx.get("session")`) never calls `cookies()` itself, so the
450
+ guard cannot see it — whatever it returns as settled container data is
451
+ photographed as the CAPTURING user's state. If the
452
+ value is per-user: shell-cache only public/shared pages, keep per-user content
453
+ in nested pending promises or live-lane (`loading()`) loaders — NOT in a
454
+ bake-lane container — or key per variant at the CDN tier.
455
+
456
+ ## What always stays on axis 1
457
+
458
+ Non-GET, RSC/partial/action/loader fetches, per-request CSP nonce,
459
+ `streamMode: "allReady"`, redirects, 404s, error renders, routes without `ppr`,
460
+ and any store without the shell family. A stored shell is invalidated when
461
+ `React.version` changes (postponed state is build-coupled), so deploys
462
+ self-heal via recapture.
463
+
464
+ The per-request CSP nonce guarantee covers BOTH ways a nonce arrives — the
465
+ `createRouter({ nonce })` provider AND a direct `ctx.set(nonce, value)` token
466
+ write in middleware (the `nonce` token from `@rangojs/router`). Either way the
467
+ nonce ends up rendered into the document (`useNonce()` puts the provider nonce
468
+ on every nonced script/style/meta; a token nonce is rendered by whatever app
469
+ code reads `ctx.get(nonce)`), so a shell shared per host+URL cannot bake it
470
+ without freezing one request's nonce for every visitor (the browser's CSP
471
+ would then reject the frozen nonce for all but the capture request). The serve
472
+ gate reads the token off the post-middleware request variables at the commit
473
+ point (which runs after the whole middleware chain), so a middleware-set nonce
474
+ blocks capture the same as a provider one. Because the route DECLARED `ppr`
475
+ but cannot be honored, it logs a once-per-key worker warning (same
476
+ declared-intent-cannot-be-honored doctrine as the missing-store warning) and
477
+ serves pure axis 1 with no `x-rango-shell` header. An undeclared route stays
478
+ silent.
479
+
480
+ ### The proper way to supply a nonce
481
+
482
+ `createRouter({ nonce })` is the canonical path — supply the nonce THERE, not
483
+ via a token write. The provider value is threaded into the router's own SSR
484
+ machinery: `NonceContext`/`useNonce()`, automatic nonce attributes on
485
+ `<Scripts />` and `<MetaTags />` output, and the inlined Flight payload
486
+ scripts. It ALSO sets the `nonce` token, so `ctx.get(nonce)` works in
487
+ middleware and handlers for the CSP response header. A direct
488
+ `ctx.set(nonce, value)` write in middleware is app-managed only: the router
489
+ resolves its SSR nonce BEFORE middleware runs, so a token-set value is
490
+ readable via `ctx.get(nonce)` and gates PPR (this section), but the router
491
+ will NOT apply it to its own scripts — `useNonce()` stays undefined and the
492
+ Flight payload scripts carry no nonce, which a nonce-only `script-src` policy
493
+ would then block. If you need a per-request nonce, use the provider; reserve
494
+ the token for READING the value.
495
+
496
+ ## Options: PartialPrerenderProps
497
+
498
+ ```typescript
499
+ path("/products/:id", Page, { name: "product", ppr: true }, use);
500
+ path(
501
+ "/products/:id",
502
+ Page,
503
+ { name: "product", ppr: { ttl: 600, swr: 120, tags: ["catalog"] } },
504
+ use,
505
+ );
506
+ ```
507
+
508
+ | Field | Default | Notes |
509
+ | ------ | ------- | -------------------------------------------------------------------------------------------------- |
510
+ | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
511
+ | `swr` | — | stale window: serve the stale shell + background recapture |
512
+ | `tags` | — | operational tags UNIONED with the tags the capture render auto-collects — see "Invalidation" below |
513
+
514
+ The shell store is always the app-level `createRouter({ cache })` store; the
515
+ default key is `${host}${pathname}${sortedSearch}:shell` (host-scoped so
516
+ multi-tenant shells never collide).
517
+
518
+ ## Invalidation: tags vs revalidate()
519
+
520
+ `updateTag()`/`revalidateTag()` is the ONLY lever that changes the frozen shell
521
+ HTML; `revalidate()` is a DATA lever that never touches it.
522
+
523
+ A captured shell auto-carries the UNION of the non-loader tags recorded during
524
+ the capture render — every `cacheTag(...)` that ran as shell material, whether
525
+ from a `"use cache"` function, a `cache()` segment, or a render-callable
526
+ `cacheTag()` in a plain server component (no `"use cache"`/`cache()` in its
527
+ tree). Loader tags never attach (the holes are already live). `ppr.tags` adds
528
+ operational tags the render cannot know (a tenant id, a deploy marker).
529
+
530
+ | Lever | Reaches the frozen shell? | Reaches the holes? |
531
+ | --------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
532
+ | `updateTag` / `revalidateTag` on a SHELL tag | YES — drops the shell → MISS → recapture | n/a (holes are already live) |
533
+ | `updateTag` / `revalidateTag` on a LOADER tag | no — loader tags never attach to a shell | drops that loader's cached value (if it `cache()`s) |
534
+ | `revalidate()` (named revalidation contract) | **no** — re-runs segments/loaders for the PAYLOAD, never HTML | yes — the hole re-renders with fresh data |
535
+
536
+ A server action's automatic invalidation refreshes the CLIENT only — it re-runs
537
+ the holes and streams a fresh payload, but does NOT evict the server shell.
538
+ Shell-baked data stays stale until the shell's TTL unless you tag-invalidate it
539
+ (`updateTag` on a shell tag). Data baked into the shell WITHOUT a tag cannot be
540
+ evicted by tag at all — move always-fresh data under a `loading()` hole.
541
+
542
+ ## Pitfalls
543
+
544
+ - **A bake-lane loader that reads `cookies()`/`headers()`**: the capture is
545
+ REFUSED (deterministic, once-per-key warned) — the route stays on axis 1.
546
+ Move identity into a nested promise or behind `loading()`.
547
+ - **A bake-lane container that must be fresh per document GET**: it is
548
+ snapshot-pinned for the shell's lifetime by design. Use the live lane
549
+ (`loading()`) or a nested promise instead.
550
+ - **A bake-lane loader slower than the capture guard (~5s)**: the capture
551
+ cannot hold for it — eternal MISS with the once-per-key warning.
552
+ - **Per-user value in shell material**: baked into the shared shell —
553
+ deterministically, not by race (handler promises deep-settle at the ring-3
554
+ write on cached chains; awaited/resolved values bake everywhere). Put
555
+ per-user data in a nested pending promise or a live-lane (`loading()`)
556
+ loader — a BAKE-lane loader container bakes just like handler material.
557
+ - **The session-object bake trap (the guard cannot save you here)**: the
558
+ capture guard sees `cookies()`/`headers()` calls ONLY. A bake-lane loader
559
+ reading a middleware-provided session object (`ctx.get("session")`) refuses
560
+ nothing. Per-user data survives ONLY behind a nested promise — the shape is
561
+ the declaration, and it holds for BOTH branches regardless of settle timing
562
+ (nested thenables are masked at capture):
563
+
564
+ ```typescript
565
+ const CartLoader = createLoader(async (ctx) => {
566
+ const basketId = ctx.get("session")!.get("basketId");
567
+ if (!basketId) return { cart: Promise.resolve(null) }; // nested thenable → masked → hole, fresh per HIT
568
+ return { cart: fetchBasket(basketId) }; // nested thenable → masked → hole, fresh per HIT
569
+ });
570
+ ```
571
+
572
+ The remaining trap is returning per-user data as PLAIN container material:
573
+ `return { user: session.user }` bakes it into the shared shell like any
574
+ other settled value — deterministically, not by race. Wrap it in a promise
575
+ (even an already-resolved one) or put the loader on the live lane.
576
+
577
+ - **Theme on a HIT is capture-then-corrected**: the resume tree replays the
578
+ CAPTURE's `initialTheme` (resume requires it to match the frozen prelude);
579
+ the visitor's cookie theme is applied pre-paint by the FOUC script and
580
+ re-synced post-mount by ThemeProvider. Nothing to configure — but a themed
581
+ component in the shell may briefly render the captured theme's markup before
582
+ the post-mount re-sync.
583
+ - **Shell shows CAPTURE-time data for the shell's lifetime**: a `cache()`/`"use
584
+ cache"` value baked into the shell is PINNED at capture (the capture data
585
+ snapshot) and replayed on every HIT, so the shell stays byte-identical to the
586
+ frozen prelude even after that cache entry expires, gets recomputed, or is
587
+ tag-invalidated. This is deliberate — parity beats freshness inside the shell.
588
+ If a shell region needs to be fresh, put it under a hole — `loading()` for
589
+ loader data, or an un-awaited promise under the consumer's `<Suspense>`
590
+ (holes are never pinned) — or make the SHELL itself invalidatable by tagging
591
+ it: call `cacheTag(...)` from the shell-material render code (the render-time
592
+ lever), or add the tag to `ppr.tags` (operational tags the render cannot know —
593
+ a tenant id, a deploy marker). Ring-1/ring-3 tag invalidation does NOT drop the
594
+ shell.
595
+ - **Uncached nondeterminism in the shell is a hydration hazard**: a raw
596
+ `Date.now()` / `Math.random()` / uncached `fetch` rendered directly in shell
597
+ material (outside any cache ring) drifts between capture and hit and the
598
+ snapshot CANNOT pin it — it was never a cache read. It will mismatch the frozen
599
+ prelude and detonate hydration. Wrap it in `cache()`/`"use cache"` (then it is
600
+ pinned) or move it under a hole (`loading()`, or a pending-promise
601
+ `<Suspense>` region).
602
+ - **Stacking with `/document-cache`**: pick one per route — the document cache
603
+ would cache the composite.
604
+ - **Dev + HMR**: works, but edits produce stale shells until TTL/recapture.
605
+ - **Dev cold-start cadence**: expect `MISS -> (in-place retry) -> HIT`. A
606
+ refused capture is negatively cached with an exponential window (1s doubling
607
+ to a 60s cap), so declaring `ppr` on an ineligible route never re-renders it
608
+ on every request.
609
+ - **HIT status is committed at the flush**: a failing hole cannot become a
610
+ 500/redirect after the first shell byte — error UI renders inline via
611
+ Suspense/error boundaries (the same property any streamed SSR page has after
612
+ its shell flushes).
613
+
614
+ ## Related
615
+
616
+ - `/defer-hydration` — keep the full body HTML in the shell while moving a
617
+ heavy subtree's hydration off the initial main-thread task (gated boundary,
618
+ content-as-fallback)
619
+ - `/document-cache` — whole-response edge caching (no live holes)
620
+ - `/caching` and `/cache-guide` — segment/function caching (axis 1 data)
621
+ - `/shell-manifest` — replayed handles as cache metadata read by live loaders
622
+ - Design doc: `docs/design/ppr-shell-resume.md` in the package