@rangojs/router 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -15,7 +15,7 @@ storefront homepage (shell HIT, production build, M-series laptop): a single
15
15
  was dead for ~11s. Fast paint, frozen page.
16
16
 
17
17
  The obvious fix — wrap the heavy subtree in `<Suspense>` so it hydrates later —
18
- trades the paint away: under shell capture that boundary postpones, and the
18
+ can trade the paint away: when that boundary postpones under shell capture, the
19
19
  frozen prelude ships an empty `<main>`. This recipe gets both: the full body
20
20
  HTML in the prelude AND its hydration off the initial task, released on first
21
21
  idle at retry-lane priority.
@@ -28,7 +28,9 @@ idle at retry-lane priority.
28
28
  | interactive (menu click works) | ~3.5s | ~1.6s | ~1.6s |
29
29
 
30
30
  Measure your own page before and after — the win depends on how much of the
31
- hydration cost lives under the boundary (see "Verifying and measuring").
31
+ hydration cost lives under the boundary (see "Verifying and measuring"). These
32
+ numbers were measured before PPR captures baked handler output; see the next
33
+ section for what that changed.
32
34
 
33
35
  ## The recipe
34
36
 
@@ -102,23 +104,39 @@ inside hydrates after first idle.
102
104
 
103
105
  ## Why `fallback={children}` is load-bearing
104
106
 
105
- This is not a style choice; it is the half of the recipe that makes it
106
- PPR-compatible.
107
-
108
- Shell capture aborts on flight byte-quiet (`src/rsc/shell-capture.ts`,
109
- `FLIGHT_QUIET_HOPS`): once the Flight source has been byte-silent for the
110
- quiet window, the fizz render freezes. A big HTML subtree under _any_
111
- `<Suspense>` boundary cannot finish inside that window, so the boundary always
112
- postpones — boundary placement cannot fix it. Verified both ways: wrapping the
113
- client island from outside AND placing the boundary inside the island both
114
- baked `<!--$?--><template id="B:…">` into `<main>`, i.e. an empty body in the
115
- frozen prelude.
116
-
117
- With the content as the fallback, the unavoidable postpone _becomes the
118
- delivery mechanism_: the shell bakes the fallback, and the fallback IS the
119
- body. In `/ppr` hole-doctrine terms, this is the PHYSICS class exploited
120
- deliberately — you cannot stop the boundary from becoming a hole, so you make
121
- the hole's baked fallback carry the real markup.
107
+ This is not a style choice. The recipe was built when the shell capture
108
+ postponed any big `<Suspense>` subtree: the capture froze on Flight byte-quiet
109
+ (`FLIGHT_QUIET_HOPS` in `src/rsc/shell-capture.ts`) while the handler output
110
+ was still rendering, so a boundary over the page body became a hole no matter
111
+ where it sat. Verified both ways at the time: wrapping the client island from
112
+ outside AND placing the boundary inside the island both baked
113
+ `<!--$?--><template id="B:…">` into `<main>`, i.e. an empty body in the frozen
114
+ prelude. With the content as the fallback, the postpone became the delivery
115
+ mechanism: the shell baked the fallback, and the fallback was the body.
116
+
117
+ PPR captures now wait for the whole handler layer before freezing anything:
118
+ the capture records the page's segments first (async server components
119
+ rendered, every promise in the handler output settled) and renders its Flight
120
+ payload from that record (`settleCaptureRecord` in `src/rsc/shell-capture.ts`).
121
+ A boundary whose content is only handler output is therefore expected to bake
122
+ its content as a completed boundary instead of postponing. What still
123
+ postpones at capture is a boundary over live data: a loader without
124
+ `ssr: false` read with `useLoader` under `loading()` or an inline
125
+ `<Suspense>`, or a promise nested in an `ssr: false` loader's value. Run the
126
+ prelude check in "Verifying and measuring" on your page rather than assuming
127
+ either shape.
128
+
129
+ `fallback={children}` stays the safe choice either way:
130
+
131
+ - If the boundary still postpones (live data inside it), the fallback is what
132
+ the prelude shows, so it must be the content.
133
+ - The sync-update trap below: a client render of the boundary shows the
134
+ fallback. With `fallback={children}` that is a visual no-op; with
135
+ `fallback={null}` it blanks the page.
136
+
137
+ The hydration deferral does not depend on which shape you get: `HydrationGate`
138
+ suspends during hydration, and React keeps the server DOM of the boundary in
139
+ place until the gate releases.
122
140
 
123
141
  ## Why the client gate is free
124
142
 
@@ -164,13 +182,14 @@ release on interaction instead (see Variations).
164
182
 
165
183
  ## Known cost: the body rides twice (measure it, don't guess)
166
184
 
167
- On a shell HIT the gated subtree's HTML is in the response twice — once as the
168
- baked fallback in the prelude, once as the resumed hole content (the resume
169
- re-renders and re-ships it; there is no bake-through). Homepage measurement:
170
- 234KB → 302KB gzipped (+68KB, +29%; raw +1.05MB). It is post-paint bandwidth,
171
- not render-blocking — the visible prelude streams first — but it is real bytes
172
- on every document GET. Weigh it per page; on a small body the recipe may not
173
- pay for itself.
185
+ When the boundary postpones at capture, a shell HIT carries the gated
186
+ subtree's HTML twice — once as the baked fallback in the prelude, once as the
187
+ resumed hole content (the resume re-renders and re-ships it; there is no
188
+ bake-through). Homepage measurement (postponing boundary): 234KB → 302KB
189
+ gzipped (+68KB, +29%; raw +1.05MB). It is post-paint bandwidth, not
190
+ render-blocking — the visible prelude streams first — but it is real bytes on
191
+ every document GET. Weigh it per page; on a small body the recipe may not pay
192
+ for itself. A boundary that bakes its content ships it once.
174
193
 
175
194
  ## Verifying and measuring
176
195
 
@@ -230,7 +249,8 @@ this exact space. The recipe survives that future; a primitive might not.
230
249
 
231
250
  ## Related
232
251
 
233
- - `/ppr` — the shell/hole mechanics this recipe rides on (hole doctrine:
234
- PHYSICS class), and why the capture postpones any big Suspense subtree
235
- - `src/rsc/shell-capture.ts` — the byte-quiet capture window
236
- (`FLIGHT_QUIET_HOPS`) that makes `fallback={children}` mandatory
252
+ - `/ppr` — the shell/hole mechanics this recipe rides on: what bakes into the
253
+ shell, and which boundaries stay holes
254
+ - `src/rsc/shell-capture.ts` — the record-first capture (`settleCaptureRecord`)
255
+ and the byte-quiet window (`FLIGHT_QUIET_HOPS`) that decide what the prelude
256
+ holds
@@ -56,8 +56,11 @@ There are two shell producers:
56
56
  Both producers feed the same in-function serve path. Build-time shells are
57
57
  content-hashed modules in the server bundle, not CDN-served HTML files.
58
58
 
59
- `ppr.ttl`, `ppr.swr`, and `ppr.tags` govern that shell entry. They do **not**
60
- emit HTTP `Cache-Control` and do not configure a platform CDN.
59
+ `ppr.ttl` and `ppr.swr` set the shell entry's lifetime, capped by the route
60
+ `cache()` entry it was captured from (the shorter wins). Its tags are
61
+ `ppr.tags` plus the tags its content recorded. A route `cache({ key })` or a
62
+ store `keyGenerator` stores one shell per partition value. None of this emits
63
+ HTTP `Cache-Control` or configures a platform CDN.
61
64
 
62
65
  ## Platform deployment shapes
63
66
 
@@ -205,7 +205,10 @@ the variant suffixes are still appended.
205
205
  - Responses whose `Cache-Control` also contains `private`, `no-store`, or an
206
206
  unqualified `no-cache` (those win over `s-maxage` in a shared cache)
207
207
  - Responses carrying `Set-Cookie` (a per-client value must not be replayed to
208
- everyone)
208
+ everyone). That includes a response whose request called
209
+ `updateTag()`/`revalidateTag()` on a store that keeps per-isolate memos
210
+ (`CFCacheStore`, `VercelCacheStore`): it carries the fresh-reads cookie
211
+ (`rango-state-fresh` by default), so it is not stored.
209
212
  - Non-200 responses
210
213
  - A 200 whose render reported an error: a component that threw after the
211
214
  response started streaming, such as an async server component whose fetch
@@ -224,7 +227,10 @@ with the request's tag set, so `updateTag()`/`revalidateTag()` evict them:
224
227
 
225
228
  - a render-callable `cacheTag("...")` in any server component on the page;
226
229
  - tags of `cache()` segments, cached loaders, and `"use cache"` functions the
227
- render read.
230
+ render read. A `cache()` segment HIT contributes the tags its content
231
+ recorded when it was written (render-callable calls and `"use cache"` reads
232
+ inside it), so a document stored over the HIT stays evictable by them. A
233
+ cached loader's HIT likewise contributes the tags its body recorded.
228
234
 
229
235
  Tags are snapshotted after the response body has fully streamed, so tags from
230
236
  Suspense-streamed content are included. A `Prerender()` route's build-time
@@ -184,6 +184,8 @@ parallel({
184
184
  });
185
185
  ```
186
186
 
187
+ On a `ppr` route, a slot's `loading()` over handler output does not make a hole. A slot handler that awaits `ctx.use(CartLoader)` is handler output: it runs at capture, its value is baked into the shared shell, and every HIT replays it without running the handler. A `cookies()`/`headers()` read in that loader refuses the capture, so the route stays on MISS. For a per-request slot, read the loader with `useLoader()` in a `"use client"` component under the slot's `loading()` (see `/ppr` → "The layout-with-loaders playbook").
188
+
187
189
  ### Two scopes for explicit `use` at the mount site: shared (broadcast) and slot-local
188
190
 
189
191
  `parallel()` accepts an explicit `use()` callback that **broadcasts** to every slot in the call ([dsl-helpers.ts](../../src/route-definition/dsl-helpers.ts)). That's the right behavior for the items the parallel allow-list permits and that accumulate (`loader`, `revalidate`, `errorBoundary`, `notFoundBoundary`, `transition`) — every slot gets them. (Note: `middleware` is not allowed inside `parallel()`; see the allowed-types table above.)
@@ -34,7 +34,9 @@ Writers: handlers AND loader bodies — `ctx.use(Handle)` returns the push
34
34
  function in each (middleware has no handle APIs). Loader pushes follow a delivery race (settle before
35
35
  the handler barrier → in the SSR'd document; later → applied client-side), and
36
36
  `loader(Def, { ssr: false })` makes them deterministic in the
37
- document. See `/loader` → "Writing Handles from Loaders". `useHandle`
37
+ document. On a `ppr` shell HIT, a loader without `ssr: false` never ran during
38
+ the capture, so its pushes always apply client-side; with `ssr: false` they
39
+ bake into the shell. See `/loader` → "Writing Handles from Loaders". `useHandle`
38
40
  re-renders on every handle update, late loader pushes included.
39
41
 
40
42
  Handles can be passed as props from server to client components:
@@ -214,7 +214,9 @@ it is selected by export conditions:
214
214
  a rotated state `Set-Cookie` for the responding client, which marks its
215
215
  caches stale when it next reads them. Idempotent within a request; throws
216
216
  inside a `cache()` / `"use cache"` boundary; a no-op with a dev warning
217
- outside a request.
217
+ outside a request. On a `ppr` route a shell HIT runs no handler, so a call
218
+ from handler code writes the cookie only on the renders that run it (a
219
+ MISS); call it from middleware, which runs on every request, HITs included.
218
220
 
219
221
  Server actions already invalidate automatically; to suppress that for a no-op
220
222
  action, call `keepClientCache()` inside it (see `/server-actions` → "Client
@@ -7,7 +7,8 @@ argument-hint: "[loader]"
7
7
  # Data Loaders with loader()
8
8
 
9
9
  A loader is a server function created with `createLoader()` and registered on a
10
- route segment with `loader()`. It runs fresh on every request, streams its
10
+ route segment with `loader()`. It runs fresh on every request (on a `ppr` route,
11
+ one registered with `ssr: false` is baked into the shell instead), streams its
11
12
  result to the client, and client components read it with `useLoader()`. This
12
13
  skill covers defining, registering, consuming, revalidating, caching, and
13
14
  client-fetching loaders.
@@ -121,25 +122,37 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
121
122
  > (plain GET only). See the hooks skill ("Scoping refetch with a `key`" and
122
123
  > "Refreshing multiple loaders together").
123
124
  > - `cache({ key })` — a **server** cache identity (storage hit/miss/ttl/swr).
125
+ > On a route `cache()` over a `ppr` route, each key value also gets its own
126
+ > shell; keep the value set small.
124
127
  > - `revalidate()` — which **server** segments/loaders recompute during
125
128
  > navigation and action refreshes.
126
129
 
127
130
  DSL loaders are the **live data layer**: they resolve fresh on every request,
128
131
  even when the route is inside a `cache()` boundary, so `cache()` gives you
129
132
  cached UI + fresh data by default. See "Loaders: The Live Data Layer" below.
133
+ The exception is a `ppr` route's `ssr: false` loader: it bakes into the shell,
134
+ and a shell HIT runs it only when its return carries promises.
130
135
 
131
136
  ### Cache safety
132
137
 
133
- DSL loaders can safely read `createVar({ cache: false })` variables
134
- because they are always resolved fresh. The read guard is bypassed for
135
- loader functions — they never produce stale data. A `"use cache"` function
136
- the loader calls is not a loader body: a non-cacheable read inside it throws,
137
- also through the loader's `ctx` passed in, so read the var in the loader and
138
- pass the value in as an argument. Passing the loader's `ctx` itself is fine:
139
- the call keys by route, params and search, like a handler `ctx` (`/use-cache`).
140
- A fetchable loader called with a request body (`load({ method: "POST", body })`
141
- or form data) is the exception: the body is not in the key, so the call runs
142
- uncached. Pass the body fields the function needs as arguments instead.
138
+ A DSL loader without `ssr: false` can read `createVar({ cache: false })`
139
+ variables: it runs on every request, `cache()` and PPR shell HITs included, so
140
+ the read guard is bypassed for it. On a `ppr` route the capture runs an
141
+ `ssr: false` loader, and a loader a handler awaits, and bakes their values into
142
+ the shell: a `cookies()`, `headers()` or `{ cache: false }` read there refuses
143
+ the capture, so the route keeps rendering without a shell (warned once per key).
144
+ A loader bound with its own `cache()` is the other exception: its value is
145
+ stored and shared, so a miss whose body read `cookies()`, `headers()`, a
146
+ non-cacheable variable or the theme fails unless the binding has a `key()` or a
147
+ store `keyGenerator`, which must then include them (see "Cache Key"). A
148
+ `"use cache"` function the loader calls is not a loader body: a non-cacheable
149
+ read inside it throws, also through the loader's `ctx` passed in, so read the
150
+ var in the loader and pass the value in as an argument. Passing the loader's
151
+ `ctx` itself is fine: the call keys by route, params and search, like a handler
152
+ `ctx` (`/use-cache`). A fetchable loader called with a request body
153
+ (`load({ method: "POST", body })` or form data) is the exception: the body is
154
+ not in the key, so the call runs uncached. Pass the body fields the function
155
+ needs as arguments instead.
143
156
 
144
157
  ### ctx.use(Loader) — escape hatch
145
158
 
@@ -157,7 +170,9 @@ path("/product/:slug", async (ctx) => {
157
170
  ])
158
171
  ```
159
172
 
160
- `ctx.use(Loader)` is a live read, memoized for the request: however many
173
+ `ctx.use(Loader)` is a per-request read on any render that runs the handler,
174
+ memoized for the request (a route `cache()` HIT or a `ppr` shell HIT runs no
175
+ handler, so the handler's output from the read is replayed): however many
161
176
  times it is called, and whether or not the loader is also registered with
162
177
  `loader()`, it runs once per request. It never reads or writes the loader
163
178
  store cache on its own: a loader's `cache()` belongs to its DSL binding
@@ -171,11 +186,15 @@ the tree).
171
186
  - The handler output depends on the loader data. If the route is inside
172
187
  `cache()`, the handler is cached with the loader result baked in —
173
188
  defeating the live data guarantee.
174
- - The same holds under a PPR shell capture (`/ppr`): handler consumption is
175
- the BAKED lane — the loader executes at capture (identity reads permitted)
176
- and the rendered value is a capture-time copy; `useLoader` client-side is
177
- the live lane. One rule across `cache()`, `"use cache"`, and PPR: the
178
- consumption-lane rule (`/rango` → Invariants).
189
+ - The same holds under a PPR shell capture (`/ppr`): handler consumption is the
190
+ BAKED lane — the loader executes at capture and the rendered value is a
191
+ capture-time copy served on every HIT (a HIT never runs the handler);
192
+ `useLoader` of a loader without `ssr: false` is the live lane (an `ssr: false`
193
+ loader is the bake lane however it is read). Unlike `cache()`, an identity
194
+ read inside that loader (`cookies()`, `headers()`, a `{ cache: false }`
195
+ variable) refuses the capture, so the route stays uncached. One rule across
196
+ `cache()`, `"use cache"`, and PPR: the consumption-lane rule (`/rango` →
197
+ Invariants).
179
198
  - Non-cacheable variable reads (`createVar({ cache: false })`) inside the
180
199
  handler still throw, even if the data came from a loader.
181
200
  - Prefer DSL `loader()` + client `useLoader()` for data that depends on
@@ -491,11 +510,16 @@ Loaders resolve fresh on every request, even when the route's UI segments are
491
510
  served from cache. Route-level `cache()` caches rendered segments but never
492
511
  loader data: loaders are excluded when the segments are stored and re-resolved
493
512
  when they are served. Caching a loader's own data is a separate opt-in (see
494
- "Opting a Loader into Caching").
513
+ "Opting a Loader into Caching"). A `ppr` shell does store some loader data: an
514
+ `ssr: false` loader's settled data bakes into the shell, and a HIT runs that
515
+ loader only when its return carries promises (see "`ssr: false`" below and
516
+ `/ppr`).
495
517
 
496
- Pre-rendering follows the same rule: at build time loaders are skipped entirely
497
- (there is no real request context), and at runtime the worker resolves them
498
- fresh against the live database.
518
+ Pre-rendering follows the same rule: the build-time Flight payload skips
519
+ loaders (there is no real request context), and at runtime the worker resolves
520
+ them fresh against the live database. The exception is a `Prerender` + `ppr`
521
+ route: its build-time shell capture runs `ssr: false` loaders and bakes their
522
+ settled data into the shell, like a runtime capture.
499
523
 
500
524
  ### Parallel and streaming — latency overlaps first paint
501
525
 
@@ -516,8 +540,8 @@ If you come from a framework where the loader is a blocking step that runs
516
540
  before the response is built, this is the shift to internalize: here the
517
541
  response starts streaming first and loader data fills in. (The one deliberate
518
542
  exception is per-loader: `loader(Def, { ssr: false })` awaits that
519
- loader before first flush on document renders — see "`ssr: false`"
520
- below.)
543
+ loader before first flush on document renders other than a `ppr` shell HIT,
544
+ where the shell flushes first — see "`ssr: false`" below.)
521
545
 
522
546
  ### See it: `debugPerformance`
523
547
 
@@ -579,7 +603,11 @@ loader(ProductLoader, () => [cache({ ttl: 300 })]),
579
603
 
580
604
  This works wherever the DSL binding is declared, an intercept's `use()`
581
605
  included. A handler's `ctx.use(Loader)` read never caches by itself (see
582
- "ctx.use(Loader) — escape hatch").
606
+ "ctx.use(Loader) — escape hatch"). Once the binding started, every
607
+ `ctx.use(Loader)` in the request, from the handler or from another loader's
608
+ body, gets the binding's value: one value per loader per request, a HIT
609
+ included. A reader that runs before the binding starts (a layout's loader
610
+ reading a route's cached loader) gets the value of its own run, as before.
583
611
 
584
612
  The loader's data is cached independently from the route's segment cache,
585
613
  using the same `SegmentCacheStore` (app-level or per-loader override).
@@ -648,6 +676,56 @@ loader fails as if its body threw. There is no silent fallback to the default
648
676
  key, because a personalised key collapsing onto the broad default would share
649
677
  one user's data with everyone.
650
678
 
679
+ **Request-scoped reads in the body must be part of the key.** The default key
680
+ names no user, so one entry serves everyone who requests the same loader, host,
681
+ path and params. A route `cache()` around the route does not partition it
682
+ either: a loader's own `cache()` is an independent layer, keyed only by what it
683
+ declares. On a miss, a body that reads `cookies()`, `headers()` or a
684
+ non-cacheable variable (`createVar({ cache: false })`, or a value written with
685
+ `ctx.set(..., { cache: false })`) therefore **fails**, and nothing is stored,
686
+ unless the binding declares identity with a `key()` or a store `keyGenerator`.
687
+ The check tests only that one of them is there, not what it contains: any
688
+ `key()` or store `keyGenerator` switches it off, so it must itself include the
689
+ value. That includes a store-wide `keyGenerator` that only adds a region
690
+ prefix, like the one described above.
691
+
692
+ ```typescript
693
+ export const SessionLoader = createLoader(async () =>
694
+ getAccount(cookies().get("session")?.value),
695
+ );
696
+
697
+ // The miss fails: cookies() cannot be called inside loader "…SessionLoader",
698
+ // whose own cache() has no key().
699
+ loader(SessionLoader, () => [cache({ ttl: 60 })]),
700
+
701
+ // One entry per session.
702
+ loader(SessionLoader, () => [
703
+ cache({
704
+ ttl: 60,
705
+ key: (ctx) => `account:${cookies().get("session")?.value ?? "anon"}`,
706
+ }),
707
+ ]),
708
+ ```
709
+
710
+ The same holds in a stale entry's background refresh (the refresh fails and the
711
+ stale entry keeps serving) and for the loaders the body reads with `ctx.use()`,
712
+ because their values land in the entry. That includes a keyed cached loader
713
+ served from its own cache: its entry keeps the read its miss made. It also holds
714
+ when something else ran the loader first, such as a parent layout's handler
715
+ calling `ctx.use(SessionLoader)`: the miss reuses that run and fails the same
716
+ way. A read that settles after the value, in a promise inside it or in a
717
+ handle push that settles within the 5 s handle-encode timeout, can't fail a
718
+ value that was already served: that user gets their own value, nothing is
719
+ stored, and `onError` gets the same error. A push still pending after the
720
+ timeout drops the entry's handles, and the value is stored without them. A loader with no `cache()` of its own
721
+ reads request data freely.
722
+
723
+ Two kinds of read are not seen, the same as in `"use cache"`: raw reads such
724
+ as `ctx.request.headers` or `getRequestContext().cookie()`, and a value
725
+ computed outside the loader and handed in, such as a per-request memo a handler
726
+ filled from `cookies()` before the loader awaited it. Key what those carry
727
+ too. If the value must stay per request, drop the loader's `cache()` instead.
728
+
651
729
  ### Tags for Invalidation
652
730
 
653
731
  ```typescript
@@ -665,6 +743,34 @@ loader(ProductLoader, () => [
665
743
  ]),
666
744
  ```
667
745
 
746
+ The tags the loader body records tag the entry too: `cacheTag()` calls, the
747
+ tags of `"use cache"` reads, render-time `cacheTag()` in server components of
748
+ the value or a handle push, and the tags the loaders it reads with `ctx.use`
749
+ record, including one the route, a layout or the handler started first. A
750
+ loader it reads that has its own `cache()` also brings its `cache({ tags })`
751
+ and, on a HIT, its entry's tags. `updateTag()` of any of them drops the entry,
752
+ and a HIT records them again, so a route `cache()`, ppr shell or document built
753
+ over the HIT stays evictable. A stale hit's background refresh stores the
754
+ refreshed body's tags; it runs the loaders it reads again rather than reusing
755
+ the page's (possibly stale) values.
756
+
757
+ One case keeps a `cache({ tags })` off: a cached loader that reads another
758
+ loader's `cache()` binding before that binding starts (for example a cached
759
+ layout loader reading a route's cached loader), and writes its entry before
760
+ it. That reader's entry has the tags the loader's body records, but not the
761
+ tags only its `cache({ tags })` lists. If the reader must drop with them, record
762
+ them in the loader body with `cacheTag()` too.
763
+
764
+ ```typescript
765
+ export const ProductLoader = createLoader(async (ctx) => {
766
+ cacheTag(`product:${ctx.params.slug}`);
767
+ return db.product(ctx.params.slug);
768
+ });
769
+
770
+ loader(ProductLoader, () => [cache({ ttl: 600 })]),
771
+ // updateTag(`product:${slug}`) drops this entry.
772
+ ```
773
+
668
774
  ### Stale-While-Revalidate
669
775
 
670
776
  ```typescript
@@ -818,7 +924,10 @@ flushes, so both signals are resolved server-side while the tree is built:
818
924
  `redirect()` replaces the whole page with the redirect carrier (still a 200
819
925
  document, the client replaces to the target on hydration) and `notFound()`
820
926
  renders the not-found UI at the owning segment with a real 404. No read site
821
- runs, so a `useLoader` in a layout above every Suspense boundary is safe.
927
+ runs, so a `useLoader` in a layout above every Suspense boundary is safe. On a
928
+ `ppr` route, a flagged loader that settles with `notFound()` or `redirect()`
929
+ during the shell capture refuses the capture (warned once per key): that URL
930
+ gets no shell, and its renders deliver the signal as above.
822
931
 
823
932
  Session/auth gates belong in middleware (they are request-shaped, not
824
933
  data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
@@ -857,7 +966,9 @@ they are replayed from the entry (see "Opting a Loader into Caching").
857
966
 
858
967
  Under `cache()`, loader pushes are not stored with the cached segments: the
859
968
  loader re-runs on every hit and its live push is the only copy. (`ppr` keeps
860
- settled `ssr: false` pushes in the shell record — see `/ppr`.)
969
+ the settled pushes of an `ssr: false` loader, and of the loaders it awaits, in
970
+ the shell, and a promise-free `ssr: false` loader does not run on a shell HIT —
971
+ see `/ppr`.)
861
972
 
862
973
  Reads are the other direction and gated: `ctx.get(handle)` throws unless the
863
974
  loader first does `await ctx.rendered()` (DSL-registered loaders only —
@@ -883,14 +994,17 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
883
994
  The options object is typed as `LoaderOptions`
884
995
  (`import type { LoaderOptions } from "@rangojs/router"`).
885
996
 
886
- The knob mirrors `loading(fallback, { ssr: false })` — SSR delivery is off
887
- for this loader, so nothing of it is left to stream in the document:
888
- document renders await this loader before first flush — data is settled
889
- (`useLoader` reads it synchronously, no fallback paints), handle pushes beat
890
- the barrier snapshot, and a thrown `notFound()` deterministically precedes
891
- Response construction (real 404, no warm-up race). Client navigations stream
892
- exactly as before. Scoped per LOADER: the flagged loader awaits only itself;
893
- siblings keep streaming.
997
+ The knob mirrors `loading(fallback, { ssr: false })` — SSR delivery is off for
998
+ this loader, so nothing of it is left to stream in the document: document
999
+ renders await this loader before first flush — data is settled (`useLoader`
1000
+ reads it synchronously, no fallback paints), handle pushes beat the barrier
1001
+ snapshot, and a thrown `notFound()` deterministically precedes Response
1002
+ construction (real 404, no warm-up race). Client navigations stream exactly as
1003
+ before (on a `ppr` route, a navigation that replays a shell captured by a
1004
+ document request serves a promise-free flagged loader from its pin, as a
1005
+ document HIT does; a snapshot captured by a navigation alone carries no pins,
1006
+ and its replay runs the loader fresh). Scoped per LOADER: the flagged loader
1007
+ awaits only itself; siblings keep streaming.
894
1008
 
895
1009
  Delivery is in-place, not merely in-document. React's Fizz outlines any
896
1010
  COMPLETED Suspense boundary over ~500 bytes to an end-of-stream
@@ -906,17 +1020,25 @@ still outline; their reveal must wait for the CSS.
906
1020
 
907
1021
  The costs and constraints:
908
1022
 
909
- - Every document load pays the flagged loader's latency before first byte.
910
- That is the point — but keep flagged loaders fast, and flag loaders, not
911
- routes.
1023
+ - Every document render that is not a `ppr` shell HIT pays the flagged
1024
+ loader's latency before first byte. That is the point — but keep flagged
1025
+ loaders fast, and flag loaders, not routes. On a HIT the shell flushes
1026
+ first: a flagged loader whose return has no promises does not run (unless
1027
+ the capture marked it to run: see `/ppr` → On a shell HIT), and one whose
1028
+ return carries promises runs to fill its holes.
912
1029
  - A flagged loader must not `await ctx.rendered()` / `ctx.get(handle)` — the
913
1030
  document render awaits the loader before the render barrier resolves, so
914
1031
  that wait is a cycle by construction; it throws a deadlock error naming the
915
1032
  fix.
916
1033
  - Under PPR the flag is also the bake lane: the settled non-promise data
917
1034
  (handle pushes included) freezes into the stored shell — see `/ppr` → The
918
- loader lane rule. The `progressiveChunkSize` auto-raise is live-document
919
- only; captured shells outline per the explicit option or React's default.
1035
+ loader lane rule. On a HIT, a flagged loader whose return has no promises is
1036
+ served from the shell and does not run (unless the capture marked it to run:
1037
+ see `/ppr` → On a shell HIT); one whose return carries promises
1038
+ runs on every HIT, with its baked parts overlaid. A `cookies()`, `headers()`
1039
+ or `{ cache: false }` read in it refuses the capture. The
1040
+ `progressiveChunkSize` auto-raise is live-document only; captured shells
1041
+ outline per the explicit option or React's default.
920
1042
 
921
1043
  Also available in `clientUrls()` route groups (`/client-urls`), where the
922
1044
  loader-heavy shape makes it most useful.
@@ -75,7 +75,10 @@ render pass — normal renders, post-action revalidation, PE re-renders — its
75
75
  variables are never stale: middleware is the safest `ctx.set` rung on the
76
76
  data-passing ladder (`/rango` → "Passing data down the tree"). But it does
77
77
  not change partial revalidation boundaries between handler/layout/parallel
78
- segments.
78
+ segments. On a PPR shell HIT no handler or layout runs; on a route `cache()`
79
+ HIT the segments inside the `cache()` boundary don't run, and those above it
80
+ do. Output that didn't run shows the values read when the shell was captured
81
+ or the entry written.
79
82
 
80
83
  For shared segment data, use named revalidation contracts on both the producer
81
84
  and consumer segments, even when middleware is present in the chain.
@@ -334,19 +337,23 @@ the token (`createVar<T>({ cache: false })`) or per write
334
337
  (`ctx.set("user", user, { cache: false })`). Reading it with `ctx.get()` inside
335
338
  a `cache()` boundary or a `"use cache"` function then throws instead of baking
336
339
  one user's data into a shared entry (inside `"use cache"`, also through a
337
- middleware `ctx` passed in); loaders can still read it because they always run
338
- fresh (a `"use cache"` function a loader calls cannot). A middleware `ctx`
339
- passed to a `"use cache"` function keys the call by host, pathname, params and
340
- search, plus the route name once the route is matched (a global middleware
341
- before `next()` has none yet), and its `set()`, `header()` and `headers` writes
342
- throw inside the function (`/use-cache`).
340
+ middleware `ctx` passed in); under `cache()` loaders can still read it because a
341
+ DSL loader re-runs on every hit (a `"use cache"` function a loader calls cannot,
342
+ and a loader bound with its own `cache()` only with a `key()`, which switches
343
+ the check off, so the key must include the value). On a `ppr` route only a
344
+ loader without `ssr: false` that no handler awaits may read it: a read the shell
345
+ capture waits for refuses the capture. A middleware `ctx` passed to a
346
+ `"use cache"` function keys the call by host, pathname, params and search, plus
347
+ the route name once the route is matched (a global middleware before `next()`
348
+ has none yet), and its `set()`, `header()` and `headers` writes throw inside the
349
+ function (`/use-cache`).
343
350
 
344
351
  ## Build-Time PPR Middleware
345
352
 
346
353
  Normal `Prerender` Flight payload collection does not run middleware: there is
347
354
  no request to wrap. The exception is `Prerender` + `ppr` build-shell capture.
348
- After the Flight payload exists, the shell producer replays global and route
349
- middleware for each generated URL before it captures HTML.
355
+ After the Flight payload exists, the build replays global and route middleware
356
+ for each generated URL before capturing the shell.
350
357
 
351
358
  In that build-shell pass:
352
359