@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
@@ -20,8 +20,9 @@ The handler code and its imports are removed from the production bundle.
20
20
 
21
21
  ## Not this skill if…
22
22
 
23
- - You want a cached HTML shell captured at runtime, with holes and loaders
24
- staying live per request — see `/ppr`.
23
+ - You want a cached HTML shell captured at runtime, with holes staying live
24
+ per request (loaders without `ssr: false` run per request; `ssr: false`
25
+ loaders bake) — see `/ppr`.
25
26
  - You want runtime segment caching with TTL/SWR — that is the `cache()` DSL:
26
27
  see `/caching`. Prerender is the same cache filled at build time.
27
28
  - You are unsure which cache layer you need — start at `/cache-guide`.
@@ -244,15 +245,14 @@ In client and SSR environments, ALL prerender handlers are always stubbed.
244
245
  ## Sub-use Semantics
245
246
 
246
247
  Everything inside the path's use() callback is part of the pre-rendered route
247
- subtree (the "B segment" in the design docs) and gets pre-rendered. Loaders are
248
- the exception — they stay live:
248
+ subtree and gets pre-rendered. Loaders are the exception — they stay live:
249
249
 
250
250
  ```typescript
251
251
  path("/blog/:slug", BlogPost, { name: "blog.post" }, () => [
252
- layout(<PostLayout />, () => [ // inside B -> pre-rendered
252
+ layout(<PostLayout />, () => [ // inside the route -> pre-rendered
253
253
  loader(PostMetaLoader), // live at runtime, bundled normally
254
254
  ]),
255
- parallel({ "@sidebar": BlogSidebar }), // inside B -> pre-rendered
255
+ parallel({ "@sidebar": BlogSidebar }), // inside the route -> pre-rendered
256
256
  ])
257
257
  ```
258
258
 
@@ -327,9 +327,16 @@ implicitly re-run outer prerender-derived handlers/layouts.
327
327
  ## Prerender + PPR Build Shells
328
328
 
329
329
  A `Prerender` page may also declare `ppr` on the path option. The build still
330
- stores the Flight payload first. After that, a build-shell step ("producer B"
331
- in the design docs) tries to bake the HTML shell for each generated URL so the
332
- first document request can be an `x-rango-shell: HIT`.
330
+ stores the Flight payload first. After that, the build-time shell capture
331
+ tries to bake the HTML shell for each generated URL so the first document
332
+ request can be an `x-rango-shell: HIT`. A route partitioned by
333
+ `cache({ key })` or a store `keyGenerator` never serves that build shell (the
334
+ build captured one partition); each partition captures at runtime, a
335
+ once-per-route warning says so, and a `keyGenerator` that returns the default
336
+ key unchanged keeps it. When the route's `cache()` scope (its own, or one
337
+ inherited from a layout) refuses a request — `cache(false)`, or a
338
+ `condition()` that returns false — that request gets no shell at all, the
339
+ build shell included: it renders like a cache miss.
333
340
 
334
341
  That shell capture is request-shaped enough to run middleware safely:
335
342
 
@@ -362,14 +369,15 @@ entry STILL serves and a runtime recapture is scheduled that upgrades it in plac
362
369
  (SWR is the upgrade path from build entry → fresher runtime entry). Because the
363
370
  `Prerender` handler is evicted from the production bundle, that recapture never
364
371
  re-runs the handler — it replays the same build-time segments and only refreshes
365
- `cache()`-scoped data baked into the shell. **If nothing in the shell is
366
- `cache()`-backed, `ttl` has nothing to refresh** — reach for `updateTag` (or a
372
+ what the capture reads again: `cache()`-scoped data baked into the shell, and
373
+ the data of `ssr: false` loaders, which run at every capture. **If the shell
374
+ bakes neither, `ttl` has nothing to refresh** — reach for `updateTag` (or a
367
375
  redeploy) instead of a shorter `ttl`.
368
376
 
369
- `ctx.dynamic()` opts a request off the shell axis ONLY. A `Prerender` route has
377
+ `ctx.dynamic()` opts a request off the shell ONLY. A `Prerender` route has
370
378
  no live handler to fall back to (it was evicted), so a `dynamic()` request still
371
- serves the build-baked segments — fresh loaders in their holes, not a fresh
372
- handler render. There is no "fully dynamic" render for a prerendered route.
379
+ serves the build-baked segments — fresh loaders, not a fresh handler render.
380
+ There is no "fully dynamic" render for a prerendered route.
373
381
 
374
382
  ## Dev Mode
375
383
 
@@ -635,6 +643,11 @@ Passthrough entries are logged distinctly:
635
643
  Loaders on pre-rendered routes run at request time. They are bundled normally
636
644
  and need `cache()` for caching. Do not use build-only APIs in loaders.
637
645
 
646
+ The exception is an `ssr: false` loader on a `Prerender` + `ppr` route: the
647
+ build-time shell capture runs it and bakes its settled data into the shell,
648
+ and a shell HIT runs it only when its return carries promises (`/ppr` → "The
649
+ bake lane"). The Flight payload collection itself still skips loaders.
650
+
638
651
  ### Build-time handle data is frozen
639
652
 
640
653
  Handle values pushed via `ctx.use()` DURING pre-rendering (handler pushes at
@@ -26,7 +26,9 @@ with the shape, then pick a primitive.
26
26
  inside a cached render. They run **in parallel** right after middleware and
27
27
  **stream**, so data latency overlaps first paint instead of blocking it (a
28
28
  cache hit streams UI instantly while loaders resolve fresh alongside). Opt into
29
- caching explicitly. See `/loader` → "Parallel and streaming".
29
+ caching explicitly. See `/loader` → "Parallel and streaming". The one
30
+ exception is a `ppr` route's `ssr: false` loader: it bakes into the shell
31
+ and runs on a shell HIT only when its return carries promises (`/ppr`).
30
32
  - **One identity, one store** — loaders, handles, cached fns, and actions are all
31
33
  `path#export`; all caches share one store. Entries expire by TTL/SWR, and are
32
34
  tagged via `cache({ tags })` or runtime `cacheTag(...tags)`; built-in stores
@@ -68,8 +70,9 @@ Reach for the next rung only when the one above doesn't fit — the higher rungs
68
70
  are immune to partial-revalidation staleness by construction.
69
71
 
70
72
  1. **A loader** (`loader()` + `useLoader()`). Loaders resolve fresh on every
71
- pass — full renders, action revalidations, cache hits. Nothing to keep in
72
- sync. If the data can be a loader, make it a loader.
73
+ pass — full renders, action revalidations, cache hits, PPR shell HITs (on a
74
+ `ppr` route, keep it off `ssr: false`, which bakes it into the shell).
75
+ Nothing to keep in sync. If the data can be a loader, make it a loader.
73
76
  2. **Middleware `ctx.set()`**. Route middleware wraps every render pass,
74
77
  including post-action revalidation and PE re-renders, so its variables are
75
78
  never stale. Right for request-shaped context: auth, session, locale.
@@ -114,7 +117,7 @@ stated, greppable contract.
114
117
  | guarantee loader output in the SSR HTML | `loader(L, { ssr: false })` | /loader |
115
118
  | pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
116
119
  | feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
117
- | cache the HTML shell, keep loaders live | `ppr` path option | /ppr |
120
+ | cache HTML shell, live loaders as holes | `ppr` path option | /ppr |
118
121
  | choose in-function vs CDN caching | deployment cache boundary | /deployment-caching |
119
122
  | stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()` | /streams-and-websockets |
120
123
 
@@ -123,15 +126,26 @@ stated, greppable contract.
123
126
  - `path()`/`include()` are always visible in `urls()`; config helpers are extractable.
124
127
  - **Cache decides freshness; `revalidate()` decides client-update.** Orthogonal; compose.
125
128
  - Loaders resolve fresh every request (even inside `cache()`) and never run twice/request.
129
+ The exception is a `ppr` route's `ssr: false` loader: it bakes into the shell
130
+ and runs on a shell HIT only when its return carries promises.
126
131
  - **The consumption-lane rule.** For every shared artifact (`cache()`,
127
132
  `"use cache"`, the PPR shell): server-side handler consumption
128
- (`await ctx.use(loader)`) yields a BAKED copy — identity reads
129
- (`cookies()`/`headers()`) are permitted there and the capture-time value
130
- freezes into the shared artifact (a documented footgun; see `/caching` →
131
- "Cache purity & tainted objects"). Client-side consumption (`useLoader` in
132
- a `"use client"` component) is the LIVE lane. DSL `loader()` segments
133
- follow their PPR lane (only `ssr: false` bakes; see `/ppr` → The loader
134
- lane rule). Pinned by semantic-matrix row PPR3.
133
+ (`await ctx.use(loader)`) yields a BAKED copy — the capture-time value
134
+ freezes into the shared artifact. Identity reads (`cookies()`/`headers()`,
135
+ a `{ cache: false }` variable) inside that loader are permitted under
136
+ `cache()`/`"use cache"` (a documented footgun; see `/caching` → "Cache
137
+ purity & tainted objects") but REFUSE a PPR capture: the route serves
138
+ uncached until the read moves to a live loader. Client-side consumption
139
+ (`useLoader` in a `"use client"` component) is the LIVE lane. DSL
140
+ `loader()` segments follow their PPR lane (only `ssr: false` bakes; see
141
+ `/ppr` → The loader lane rule).
142
+ - **A PPR shell HIT never runs a handler.** Everything a handler produces
143
+ (promises it passes under `<Suspense>`, async server components, handle
144
+ pushes, loaders it awaits) is shell material, as under `cache()`; a HIT
145
+ runs middleware and live loaders only (an `ssr: false` loader runs there
146
+ only when its return carries promises). Live data belongs in a loader without
147
+ `ssr: false`, read with `useLoader` under `loading()` or an inline
148
+ `<Suspense>`.
135
149
  - Inside `"use cache"`: `cookies()`/`headers()` and `ctx` side-effects
136
150
  (`set`/`header`/`setTheme`/`onResponse`/`setLocationState`) throw; `ctx.use(Handle)`
137
151
  is captured on miss and replayed on hit. A non-cacheable variable read
@@ -162,17 +176,22 @@ stated, greppable contract.
162
176
  Same words, different jobs — this is the most common source of the
163
177
  `revalidate()`-is-caching misread.
164
178
 
165
- | You may know | Maps to Rango axis | Watch out |
166
- | --------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
167
- | Next.js `export const revalidate = N` | **Axis 1** (cache) | Same word, opposite meaning. Next's `revalidate` is time-based cache expiry; Rango's `revalidate()` is **axis 2**. Use `cache({ ttl })` for the Next behavior. |
168
- | Next.js `revalidateTag` / `updateTag` | **Axis 1** (cache) | Cache busting by tag. Tag via `cache({ tags })` / `cacheTag(...tags)`; invalidate with `updateTag(...tags)` (awaitable, read-your-own-writes) or `revalidateTag(...tags)` (background, non-blocking). Built-in stores index by tag. No `revalidatePath` (path-based busting); use tags. |
169
- | React Router / Remix `shouldRevalidate` | **Axis 2** | This is the correct mental model for Rango's `revalidate()`. |
170
- | HTTP `Cache-Control` / ISR | Deployment layer | Complete-response deployment layer. A CDN hit bypasses Rango entirely; the store-backed middleware does not. See `/deployment-caching` and `/document-cache`. |
171
- | Next.js PPR (partial prerendering) | HTML shell layer | Same React primitive, different transport: Rango serves shells in-function after middleware. Ordinary `ppr` captures at runtime; `Prerender + ppr` captures at build. See `/ppr`, `/prerender`, and `/deployment-caching`. |
172
- | Remix/RR `loader` | live data | Like Rango loaders, fresh per request — but Rango loaders run in parallel and stream (latency overlaps first paint), and can opt into caching on demand. |
179
+ | You may know | Maps to Rango | Watch out |
180
+ | --------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
181
+ | Next.js `export const revalidate = N` | **Value freshness** (cache) | Same word, opposite meaning. Next's `revalidate` is time-based cache expiry; Rango's `revalidate()` is **partial-render selection**. Use `cache({ ttl })` for the Next behavior. |
182
+ | Next.js `revalidateTag` / `updateTag` | **Value freshness** (cache) | Cache busting by tag. Tag via `cache({ tags })` / `cacheTag(...tags)`; invalidate with `updateTag(...tags)` (awaitable, read-your-own-writes) or `revalidateTag(...tags)` (background, non-blocking). Built-in stores index by tag. No `revalidatePath` (path-based busting); use tags. |
183
+ | React Router / Remix `shouldRevalidate` | **Partial-render selection** | This is the correct mental model for Rango's `revalidate()`. |
184
+ | HTTP `Cache-Control` / ISR | Deployment layer | Complete-response deployment layer. A CDN hit bypasses Rango entirely; the store-backed middleware does not. See `/deployment-caching` and `/document-cache`. |
185
+ | Next.js PPR (partial prerendering) | HTML shell layer | Same React primitive, different transport: Rango serves shells in-function after middleware. Ordinary `ppr` captures at runtime; `Prerender + ppr` captures at build. See `/ppr`, `/prerender`, and `/deployment-caching`. |
186
+ | Remix/RR `loader` | live data | Like Rango loaders, fresh per request — but Rango loaders run in parallel and stream (latency overlaps first paint), and can opt into caching on demand. |
173
187
 
174
- See `/cache-guide` for the axis-1 decision guide, `/loader` and `/route` for
175
- `revalidate()` (axis 2), and `/document-cache` for the edge layer.
188
+ Next.js PPR and Rango's `ppr` also differ in the hole model: in Rango only
189
+ loaders make holes. A handler's own promise under `<Suspense>` bakes into the
190
+ shell, as under `cache()`.
191
+
192
+ See `/cache-guide` for the cache decision guide, `/loader` and `/route` for
193
+ `revalidate()` (partial-render selection), and `/document-cache` for the edge
194
+ layer.
176
195
 
177
196
  ## Canonical shape
178
197
 
@@ -277,18 +296,18 @@ Grouped by concern — read when you need to…
277
296
 
278
297
  **Data & caching** — fetch, mutate, and cache:
279
298
 
280
- | Skill | Description |
281
- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
282
- | `/loader` | Data loaders with `createLoader()` and `revalidate()` |
283
- | `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
284
- | `/caching` | Segment caching with memory or KV stores |
285
- | `/use-cache` | Function-level caching with `"use cache"` directive |
286
- | `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
287
- | `/document-cache` | Store-backed complete-response middleware using Cache-Control policy |
288
- | `/deployment-caching` | Choose between in-function caches, store-backed responses, and an external CDN cache |
289
- | `/ppr` | PPR shell caching: cached shell served instantly, live holes resumed — a hole is a `loading()` subtree OR a pending promise under `<Suspense>` (no loader needed) |
290
- | `/prerender` | Pre-render route segments at build time (Passthrough live fallback) |
291
- | `/shell-manifest` | Replayed handles as cache metadata read by live loaders (frozen shell, batched live holes) |
299
+ | Skill | Description |
300
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
301
+ | `/loader` | Data loaders with `createLoader()` and `revalidate()` |
302
+ | `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
303
+ | `/caching` | Segment caching with memory or KV stores |
304
+ | `/use-cache` | Function-level caching with `"use cache"` directive |
305
+ | `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
306
+ | `/document-cache` | Store-backed complete-response middleware using Cache-Control policy |
307
+ | `/deployment-caching` | Choose between in-function caches, store-backed responses, and an external CDN cache |
308
+ | `/ppr` | PPR shell caching: cached shell served instantly, live holes resumed — a hole is a live loader read under `loading()` or an inline `<Suspense>`; handler output bakes |
309
+ | `/prerender` | Pre-render route segments at build time (Passthrough live fallback) |
310
+ | `/shell-manifest` | Replayed handles as cache metadata read by live loaders (frozen shell, batched live holes) |
292
311
 
293
312
  **Client & presentation** — build the client-side UX:
294
313
 
@@ -329,12 +329,17 @@ cache({ ttl: 60 }, () => [
329
329
  ]);
330
330
  ```
331
331
 
332
- The same read throws inside a `"use cache"` function body, where the value
333
- would be stored without being part of the key — pass it in as an argument
334
- instead. The guard does not follow derived values (a string copied out of
335
- `Session` and cached elsewhere is not tracked). Loaders are exempt — they run
336
- fresh on every request — but a `"use cache"` function a loader calls is not. Cacheable vars (the default) can be read freely
337
- inside cache scopes. See `/cache-guide` → "Context Variable Cache Safety".
332
+ The same read throws inside a `"use cache"` function body, where the value would
333
+ be stored without being part of the key — pass it in as an argument instead. The
334
+ guard does not follow derived values (a string copied out of `Session` and
335
+ cached elsewhere is not tracked). Under `cache()`, loaders are exempt — a DSL
336
+ loader re-runs on every hit — but a `"use cache"` function a loader calls is
337
+ not, and neither is a loader bound with its own `cache()` without a `key()` (any
338
+ `key()` switches the check off, so it must include the value; `/loader` → "Cache
339
+ Key"). On a `ppr` route the exemption does not cover a loader the shell capture
340
+ waits for (`ssr: false`, or awaited by a handler via `ctx.use()`): the read
341
+ refuses the capture. Cacheable vars (the default) can be read freely inside
342
+ cache scopes. See `/cache-guide` → "Context Variable Cache Safety".
338
343
 
339
344
  ### Revalidation Contracts for Handler Data
340
345
 
@@ -543,7 +548,7 @@ interface HandlerContext<TParams = {}, TEnv = DefaultEnv, TSearch = {}> {
543
548
  build: boolean; // true while pre-rendering at build time
544
549
  dev: boolean; // true under Vite dev
545
550
  dynamic(): void; // opt this request out of PPR shell capture (see /ppr)
546
- theme?: Theme; // plus setTheme?() — only when the router enables themes (/theme)
551
+ readonly theme?: Theme; // plus setTheme?() — only when the router enables themes; a read is guarded like cookies() (/theme)
547
552
  }
548
553
  ```
549
554
 
@@ -163,7 +163,7 @@ interface RangoOptions<TEnv> {
163
163
  viewTransition?: "auto" | false; // Router-placed <ViewTransition> default (/view-transitions)
164
164
  warmup?: boolean; // Connection warmup after idle (default true)
165
165
  strictMode?: boolean; // Hydrate inside <React.StrictMode> (default true)
166
- stateCookiePrefix?: string; // Prefix of the state cookie `{prefix}_{routerId}` (default "rango-state")
166
+ stateCookiePrefix?: string; // Prefix of the state cookie `{prefix}_{routerId}` and the fresh-reads cookie `{prefix}-fresh` (default "rango-state")
167
167
 
168
168
  // --- Request handling ---
169
169
  nonce?: (
@@ -752,7 +752,9 @@ the check.
752
752
  render counts in development. Production behavior is unchanged.
753
753
  - `stateCookiePrefix`: rename the state cookie that keys the client caches. The
754
754
  `_{routerId}` suffix is always kept so sibling apps on one origin do not
755
- collide.
755
+ collide. The prefix also names the fresh-reads cookie `{prefix}-fresh`
756
+ (`rango-state-fresh` by default) that `updateTag()`/`revalidateTag()` set
757
+ on a store with per-isolate memos; a consent-manager list needs both names.
756
758
  - `nonce: () => true`: generate a CSP nonce per request and apply it to the
757
759
  router's inline scripts. Return a string to supply your own, or
758
760
  `false`/`""` to skip it for one request. Middleware reads it with
@@ -114,7 +114,10 @@ during navigation.
114
114
  > means it is silently dropped. If a loader must contribute an inline script
115
115
  > to the document, register it `loader(Def, { ssr: false })` so the
116
116
  > document render awaits the push; otherwise push from a handler (or use an
117
- > `async` config, which stays reactive).
117
+ > `async` config, which stays reactive). On a `ppr` route a loader without
118
+ > `ssr: false` never runs during the shell capture, so its inline/ordered
119
+ > script is dropped on every shell HIT; with `ssr: false` the push bakes into
120
+ > the shell.
118
121
 
119
122
  **Nonce caveat for soft-nav async.** The "nonce is applied automatically" claim
120
123
  holds for DOCUMENT-RENDERED scripts (they carry the nonce in the SSR HTML). An
@@ -185,6 +188,10 @@ Otherwise allow the vendor hosts. For GTM/GA4 (Google's wildcards): `script-src
185
188
  `frame-src https://*.googletagmanager.com` for the GTM `<noscript>` iframe. See
186
189
  [Google's CSP guide](https://developers.google.com/tag-platform/security/guides/csp).
187
190
 
191
+ A per-request nonce also takes a `ppr` route off its shell: a shared shell
192
+ cannot carry one request's nonce, so the route renders normally (no shell) and
193
+ warns once per key. See `/ppr` → "What always renders without a shell".
194
+
188
195
  ## Not covered (do it yourself)
189
196
 
190
197
  - **`onLoad` / `onReady` / `onError`** — callbacks can't cross the server handle
@@ -37,6 +37,17 @@ Actions mutate state; route handlers and loaders read the latest state. After
37
37
  an action finishes, Rango performs a server-side revalidation render for the
38
38
  matched route so the UI receives fresh segment output and loader data.
39
39
 
40
+ Segments under a route `cache()` re-render fresh on that render (actions skip
41
+ the route cache lookup and write). A loader with its own `cache()` and a
42
+ `"use cache"` read return their stored entry, and a `ppr` route's stored shell
43
+ is not evicted, unless the action invalidates their tags.
44
+ `await updateTag(...tags)` purges them before the revalidation render, so the
45
+ action's own response reads fresh (`revalidateTag()` gives that render fresh
46
+ reads too; only its durable write runs in the background). On a store that keeps
47
+ per-isolate memos (`CFCacheStore`, `VercelCacheStore`) the response also sets
48
+ the fresh-reads cookie (`rango-state-fresh` by default), so the same user's next
49
+ requests skip those memos (see `/caching` → "The fresh-reads cookie").
50
+
40
51
  The main control point is `revalidate((ctx) => ...)` on the segment that owns
41
52
  the data. Match specific actions by imported reference with `ctx.isAction()`;
42
53
  use raw `actionId` only when you intentionally need path or directory matching.
@@ -9,8 +9,8 @@ argument-hint:
9
9
  Use this when a cached or prerendered shell has dynamic holes, and the live
10
10
  data layer needs to know **what the shell actually contains** — which
11
11
  products, which slots, which keys. The frozen render describes itself
12
- through a handle; loaders (always live) read that description and fetch
13
- exactly the dynamic data the shell needs, in one batch.
12
+ through a handle; live loaders (no `ssr: false`) read that description and
13
+ fetch exactly the dynamic data the shell needs, in one batch.
14
14
 
15
15
  Canonical case: a prerendered product list where prices must stay live.
16
16
 
@@ -32,16 +32,19 @@ batched.
32
32
  ## The mechanism (three features composed)
33
33
 
34
34
  1. **Handles record data at render time.** The handler pushes to a handle
35
- (`ctx.use(Handle)`) while it renders — at build time for `Prerender`, on
36
- the cache miss for `cache()`. (Loader bodies can push handles too — see
37
- `/loader` — but a live loader's push is request-time and is NOT part of
38
- the replayed artifact; a manifest handle must be pushed by the code that
39
- gets frozen with the shell. An `ssr: false` loader on a `ppr` route is the
40
- exception: its settled pushes are recorded and replayed, and its re-run
41
- replaces them with its live pushes — `/ppr` "On a shell HIT".)
35
+ (`ctx.use(Handle)`) while it renders — at build time for `Prerender`, on the
36
+ cache miss for `cache()`. (Loader bodies can push handles too — see `/loader`
37
+ — but a live loader's push is request-time and is NOT part of the replayed
38
+ artifact; a manifest handle must be pushed by the code that gets frozen with
39
+ the shell. An `ssr: false` loader on a `ppr` route is the exception: its
40
+ settled pushes are recorded with the shell. On a HIT, one whose return has no
41
+ promises does not run (unless the capture marked it to run), so the recorded
42
+ pushes are what the page shows; one whose return carries promises runs on
43
+ every HIT, and its pushes replace the recorded ones — `/ppr` "On a shell
44
+ HIT".)
42
45
  2. **Replay on every hit.** Handle data is stored with the Flight payload
43
- and replayed into the handle store on cache/prerender hits — handler code
44
- does not re-run, but its pushes do.
46
+ and replayed into the handle store on cache, prerender and `ppr` shell
47
+ hits — handler code does not re-run, but its pushes do.
45
48
  3. **Loaders read after the render barrier.** A DSL loader can
46
49
  `await ctx.rendered()` (waits for all non-loader segments to settle —
47
50
  fresh render or replay alike), then `ctx.get(Handle)` returns the
@@ -185,7 +188,13 @@ const prices = await runLoader(PriceLoader, {
185
188
 
186
189
  This tests the loader's post-barrier logic. The real
187
190
  push → store → replay → barrier wiring is covered at the e2e tier (dev +
188
- production), like every cache-path behavior.
191
+ production), like every cache-path behavior. On a `ppr` route,
192
+ `serveShellRequest(router, url, { cacheStore })` from
193
+ `@rangojs/router/testing/flight` runs the real capture and HIT in a unit test
194
+ (react-server Vitest): serve the URL twice with the same store; the second
195
+ response is a HIT, and its `flight` carries the live loader's output for the
196
+ replayed ids. Call `resetShellTestState()` in `beforeEach`. Recipe:
197
+ `/testing` → `serve-shell-request.md`.
189
198
 
190
199
  ## Related
191
200
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: testing
3
- description: Test @rangojs/router apps — unit (loaders, middleware, server actions, handles, transition/clientUrls predicates), client components (renderRoute), RSC/Flight (renderServerTree, renderHandler), integration (dispatch), and e2e (dev+prod parity, progressive enhancement). Use when writing a unit test for a loader, middleware, action, or handler, setting up vitest for a Rango app, or testing a route end-to-end in dev and production.
3
+ description: Test @rangojs/router apps — unit (loaders, middleware, server actions, handles, transition/clientUrls predicates), client components (renderRoute), RSC/Flight (renderServerTree, renderHandler, serveShellRequest for PPR shells), integration (dispatch), and e2e (dev+prod parity, progressive enhancement). Use when writing a unit test for a loader, middleware, action, or handler, setting up vitest for a Rango app, or testing a route end-to-end in dev and production.
4
4
  argument-hint: [layer]
5
5
  ---
6
6
 
@@ -108,8 +108,10 @@ Each primitive links to its sub-file (API + recipe + caveats).
108
108
  | navigation, hydration, PE parity, view transitions, real SSR | e2e | [`createRangoE2E` -> `parityDescribe`/`expectParity`](./e2e-parity.md) | `@rangojs/router/testing/e2e` |
109
109
  | a `"use cache"` hit in a handler (the body runs once across renders; the value round-trips) | RSC unit | [`renderHandler` + seeded `cacheStore`](./render-handler.md#a-use-cache-hit) | `@rangojs/router/testing/flight` |
110
110
  | a `"use cache"` hit in a loader (the same, in the react-server project) | RSC unit | [`runLoader` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
111
+ | a loader's own `cache()` (HIT, `updateTag()` of a tag its body recorded) | RSC unit | [`runLoader` + `cache` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
111
112
  | cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | [`assertCacheStatus` (header) / `assertCacheDecision` (telemetry sink)](./cache-prerender.md) | `@rangojs/router/testing[/e2e]` |
112
- | PPR shell HIT/MISS (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-and-navigation-replay) | `@rangojs/router/testing[/e2e]` |
113
+ | a `ppr` route's shell: MISS -> capture -> HIT, what the shell froze vs what a HIT renders live, tag eviction, navigation replay | RSC unit | [`serveShellRequest`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
114
+ | PPR shell HIT/MISS in real HTML (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-and-navigation-replay) | `@rangojs/router/testing[/e2e]` |
113
115
  | generated route map drift vs runtime | unit (node) | [`assertGeneratedRoutesMatch`](./reverse-and-types.md) | `@rangojs/router/testing` |
114
116
  | a platform binding (`env.DB` / Durable Object / `env.R2`) | unit/integr. | [your own double via `env`](./bindings.md) | (any primitive's `env` option) |
115
117
 
@@ -125,7 +127,8 @@ Cross-references to the DSL skills: `/loader`, `/middleware`, `/server-actions`,
125
127
  [`predicates.md`](./predicates.md), [`reverse-and-types.md`](./reverse-and-types.md)
126
128
  - Unit (DOM): [`client-components.md`](./client-components.md)
127
129
  - RSC unit: [`flight.md`](./flight.md), [`server-tree.md`](./server-tree.md),
128
- [`render-handler.md`](./render-handler.md)
130
+ [`render-handler.md`](./render-handler.md),
131
+ [`serve-shell-request.md`](./serve-shell-request.md)
129
132
  - Integration: [`response-routes.md`](./response-routes.md)
130
133
  - E2E: [`e2e-parity.md`](./e2e-parity.md), [`cache-prerender.md`](./cache-prerender.md)
131
134
 
@@ -134,24 +137,25 @@ Cross-references to the DSL skills: `/loader`, `/middleware`, `/server-actions`,
134
137
  Where each runtime export is documented (types such as `RunLoaderOptions` sit
135
138
  beside their function):
136
139
 
137
- | Entry | Exports | Documented in |
138
- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
139
- | `@rangojs/router/testing` | `runLoader`, `runLoaderResult` | [`loader.md`](./loader.md) |
140
- | | `runMiddleware` | [`middleware.md`](./middleware.md) |
141
- | | `runInRequestContext`, `createTestRequestContext`, `runWithRequestContext`, `toRequest`, `seedVariables` | [`server-actions.md`](./server-actions.md) |
142
- | | `runTransitionWhen`, `runClientRevalidate` | [`predicates.md`](./predicates.md) |
143
- | | `collectHandle` | [`handles.md`](./handles.md) |
144
- | | `dispatch` | [`response-routes.md`](./response-routes.md) |
145
- | | `assertCacheStatus`, `assertCacheDecision`, `parseCacheHeader`, `createCacheSink`, `filterCacheDecisions` | [`cache-prerender.md`](./cache-prerender.md) |
146
- | | `assertShellStatus`, `parseShellStatus`, `shellCacheKey`, `SHELL_STATUS_HEADER`, `assertPprReplayStatus`, `parsePprReplayStatus`, `PPR_REPLAY_STATUS_HEADER` | [`cache-prerender.md`](./cache-prerender.md) |
147
- | | `assertGeneratedRoutesMatch`, `diffGeneratedRoutes` | [`reverse-and-types.md`](./reverse-and-types.md) |
148
- | `@rangojs/router/testing/dom` | `renderRoute` | [`client-components.md`](./client-components.md) |
149
- | `@rangojs/router/testing/flight` | `renderToFlightString`, `normalizeFlight`, `assertFlightRuntimeAvailable` | [`flight.md`](./flight.md) |
150
- | | `renderServerTree`, `findClientBoundaries`, `findElements`, `textContent`, `assertFlightTreeRuntimeAvailable` | [`server-tree.md`](./server-tree.md) |
151
- | | `renderHandler` | [`render-handler.md`](./render-handler.md) |
152
- | `@rangojs/router/testing/flight-matchers` | `flightMatchers` (`toMatchFlight`, `toMatchFlightSnapshot`) | [`flight.md`](./flight.md) |
153
- | `@rangojs/router/testing/vitest` | `rangoTestConfig`, `rangoTestAliases`, `rangoInlineDeps`, `rangoUseClientTransform`, `rangoUseCacheTransform` | [`setup.md`](./setup.md) |
154
- | `@rangojs/router/testing/e2e` | `createRangoE2E` and its standalone helpers; the cache/shell status helpers above are re-exported here | [`e2e-parity.md`](./e2e-parity.md) |
140
+ | Entry | Exports | Documented in |
141
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
142
+ | `@rangojs/router/testing` | `runLoader`, `runLoaderResult` | [`loader.md`](./loader.md) |
143
+ | | `runMiddleware` | [`middleware.md`](./middleware.md) |
144
+ | | `runInRequestContext`, `createTestRequestContext`, `runWithRequestContext`, `toRequest`, `seedVariables` | [`server-actions.md`](./server-actions.md) |
145
+ | | `runTransitionWhen`, `runClientRevalidate` | [`predicates.md`](./predicates.md) |
146
+ | | `collectHandle` | [`handles.md`](./handles.md) |
147
+ | | `dispatch` | [`response-routes.md`](./response-routes.md) |
148
+ | | `assertCacheStatus`, `assertCacheDecision`, `parseCacheHeader`, `createCacheSink`, `filterCacheDecisions` | [`cache-prerender.md`](./cache-prerender.md) |
149
+ | | `assertShellStatus`, `parseShellStatus`, `shellCacheKey`, `SHELL_STATUS_HEADER`, `assertPprReplayStatus`, `parsePprReplayStatus`, `PPR_REPLAY_STATUS_HEADER` | [`cache-prerender.md`](./cache-prerender.md) |
150
+ | | `assertGeneratedRoutesMatch`, `diffGeneratedRoutes` | [`reverse-and-types.md`](./reverse-and-types.md) |
151
+ | `@rangojs/router/testing/dom` | `renderRoute` | [`client-components.md`](./client-components.md) |
152
+ | `@rangojs/router/testing/flight` | `renderToFlightString`, `normalizeFlight`, `assertFlightRuntimeAvailable` | [`flight.md`](./flight.md) |
153
+ | | `renderServerTree`, `findClientBoundaries`, `findElements`, `textContent`, `assertFlightTreeRuntimeAvailable` | [`server-tree.md`](./server-tree.md) |
154
+ | | `renderHandler` | [`render-handler.md`](./render-handler.md) |
155
+ | | `serveShellRequest`, `resetShellTestState` | [`serve-shell-request.md`](./serve-shell-request.md) |
156
+ | `@rangojs/router/testing/flight-matchers` | `flightMatchers` (`toMatchFlight`, `toMatchFlightSnapshot`) | [`flight.md`](./flight.md) |
157
+ | `@rangojs/router/testing/vitest` | `rangoTestConfig`, `rangoTestAliases`, `rangoInlineDeps`, `rangoUseClientTransform`, `rangoUseCacheTransform` | [`setup.md`](./setup.md) |
158
+ | `@rangojs/router/testing/e2e` | `createRangoE2E` and its standalone helpers; the cache/shell status helpers above are re-exported here | [`e2e-parity.md`](./e2e-parity.md) |
155
159
 
156
160
  ## Pre-push checklist
157
161
 
@@ -119,17 +119,17 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
119
119
 
120
120
  ## PPR shell and navigation replay
121
121
 
122
- **DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — shell is a second render axis.
123
-
124
- | Helper | Import | Role |
125
- | ----------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
- | `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
127
- | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
128
- | `shellCacheKey(url, searchParams?)` | same | Production shell store key (`host+pathname+sorted search+:shell`) for `store.getShell` / custom stores. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. |
129
- | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
130
- | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
131
- | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
132
- | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
122
+ **DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — the shell is cached separately from the segments.
123
+
124
+ | Helper | Import | Role |
125
+ | ----------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
127
+ | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
128
+ | `shellCacheKey(url, searchParams?, partition?)` | same | Production shell store key (`host+pathname+sorted search+:shell`, then `\|partition`) for `store.getShell` / custom stores. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. For a route whose `cache({ key })` or store `keyGenerator` partitions its record, pass what that function returns for the request as `partition`: the shell is partitioned the same way. Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. |
129
+ | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
130
+ | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
131
+ | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
132
+ | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
133
133
 
134
134
  ### What unit can prove vs e2e
135
135
 
@@ -137,10 +137,11 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
137
137
  | -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
138
138
  | **Unit** | store family + key identity | `MemorySegmentCacheStore` + `shellCacheKey(url)` + `putShell`/`getShell` / tag eviction — dogfood in `e2e/mini/test/shell-store-family.test.ts` and `src/testing/__tests__/shell-status.test.ts` |
139
139
  | **Unit** | header helper contract | `assertShellStatus` on a Response that already carries the header (characterizes the helper; **never** invent a HIT to claim capture worked) |
140
+ | **Unit** | real capture → HIT, replay | `serveShellRequest(router, url, { cacheStore })` (react-server project); only the HTML step is stubbed — [`./serve-shell-request.md`](./serve-shell-request.md) |
140
141
  | **E2E** | live MISS → capture → HIT | document GET, poll until `x-rango-shell: HIT` (background capture) — `e2e/shell-cache.test.ts` |
141
142
  | **E2E** | partial replay decision | soft navigation to a warmed shell and `assertPprReplayStatus(response, { outcome: "HIT", freshness: "fresh" })` |
142
143
 
143
- `dispatch` is RSC-free: it never runs shell serve/capture. `renderHandler` only surfaces `ctx.dynamic()` opt-out, not bake/serve.
144
+ `dispatch` is RSC-free: it never runs shell serve/capture. `renderHandler` only surfaces `ctx.dynamic()` opt-out, not bake/serve. The HIT contract — no handler runs, the handler output is the capture's copy, live loaders re-run — is asserted through `serveShellRequest`: count your handler's runs and a live loader's across a MISS and the HITs after it, and check that only the loader's moves ([`./serve-shell-request.md`](./serve-shell-request.md)). A route partitioned by `cache({ key })` or a store `keyGenerator` has a shell per partition: `serveShellRequest` reports the key the serve path resolved (`result.key`), and `shellCacheKey` takes the partition as its third argument.
144
145
 
145
146
  ### Recipe (e2e)
146
147
 
@@ -185,7 +186,7 @@ it("stores the shell under the production key", async () => {
185
186
  ## Caveats
186
187
 
187
188
  - The `X-Rango-Cache` header is emitted ONLY when the gate is on: `createRouter({ debugCacheSignal: true })` or `process.env.RANGO_TEST_SIGNALS === "1"`. Off by default — zero production surface. With the gate off, `assertCacheStatus` throws a clear "header missing" error.
188
- - `x-rango-shell` is always set on document GETs the serve path considered for a ppr route (no debug gate). Absent header = axis-1 fall-open or non-ppr.
189
+ - `x-rango-shell` is always set on document GETs the serve path considered for a ppr route (no debug gate). Absent header = a normal render (no shell): the request fell open, or the route has no `ppr`.
189
190
  - v1 is COARSE: route-level, keyed by the route NAME (e.g. `product.detail`), NOT the URL pattern (`/products/:id`); not per-individual-segment. The signal is built from `ctx.routeKey`, so a pattern-shaped key never matches. (`parseCacheHeader` exposes the raw `{ routeKey: status }` map if you need it.)
190
191
  - Prerender is indistinguishable from a cache hit by design — no static `.html`/`.rsc` files, the worker handles every request and looks up a stored Flight payload; the browser cannot tell. Do not assert "prerendered" from the DOM. Assert via the signal (`assertCacheStatus(res, seg, "prerendered")`) and run prerender assertions in PRODUCTION mode (the build-time artifacts only exist after `pnpm build`).
191
192
  - In a Playwright e2e import the cache-status / shell-status helpers from the `/e2e` entry — the `@rangojs/router/testing` barrel is Vitest-only (it pulls a build-only virtual that does not resolve in a plain Playwright runner). Zero-prod-surface alternative for segment cache: the telemetry sink (`createCacheSink`/`filterCacheDecisions`), no header at all. Note: the non-RSC `dispatch()` primitive never emits either header — get the Response from the router's real RSC fetch path.
@@ -194,5 +195,5 @@ it("stores the shell under the production key", async () => {
194
195
  ## See also
195
196
 
196
197
  - `/caching`, `/prerender`, `/use-cache`, `/ppr` — the DSL this tests
197
- - Siblings: [`./e2e-parity.md`](./e2e-parity.md), [`./response-routes.md`](./response-routes.md)
198
+ - Siblings: [`./e2e-parity.md`](./e2e-parity.md), [`./response-routes.md`](./response-routes.md), [`./serve-shell-request.md`](./serve-shell-request.md)
198
199
  - Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "Cache, SWR, and prerender"
@@ -27,6 +27,7 @@
27
27
  | `use` | `UseResolver` | Dynamic resolver for `ctx.use(OtherLoader)` composition. `loaders` wins when both match. |
28
28
  | `cacheStore` | `SegmentCacheStore` | Cache store backing `use cache` functions. Without one, a cached function bypasses and runs uncached (its taint/profile guards never fire). |
29
29
  | `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles, the `createRouter({ cacheProfiles })` shape. |
30
+ | `cache` | `PartialCacheOptions` | Run the loader through the `cache()` a route binds it with (`loader(Loader, () => [cache({...})])` takes the same options), against `cache.store`, else `cacheStore`. See the caveat below. |
30
31
  | `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a loader calling `invalidateClientCache()` rotates (the name is always seeded — default `rango-state_router_0`). |
31
32
  | `rendered` | `boolean \| (() => void \| Promise<void>)` | Mock the `ctx.rendered()` render barrier so a loader that `await ctx.rendered()`s can be unit-tested. By default `ctx.rendered()` throws. `true` resolves immediately; a function controls timing/side effects. |
32
33
  | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown]>` | Seed the values `ctx.get(SomeHandle)` returns — the ACCUMULATED handle data read after `await ctx.rendered()`. Matched by handle reference. (Loader handle WRITES need no seed — see `runLoaderResult(...).handlePushes`.) |
@@ -122,8 +123,24 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
122
123
  - A `createLoader()` definition imported through the CLIENT build has its body dropped — `runLoader` throws a clear error pointing to the `rangoTestConfig()` preset or the raw body. A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`); only the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules).
123
124
  - No `cookies`/`headers` option: seed a cookie by passing a full Request with a Cookie header — `{ request: new Request(url, { headers: { Cookie: "sid=abc" } }) }`. (`search`/`method` are baked onto this request for you.)
124
125
  - `ctx.search` (typed) defaults to `{}`; `opts.search` only sets the raw `ctx.searchParams`. Seed the typed object with `searchData`. (The harness seeds `searchData` verbatim — it does NOT run a typed-search SCHEMA, so schema parsing/validation is e2e.)
125
- - `ctx.theme`/`ctx.setTheme` are NOT on the loader context — theme accessors are handler-only. (The `theme` option seeds the underlying request context for `use cache` theme resolution, but a loader body cannot read theme.) `redirect()` does no basename prefixing unless you seed `basename`.
126
+ - `ctx.theme`/`ctx.setTheme` are NOT on the loader context — they are handler/middleware-only. The `theme` option seeds the request context's theme config, so a loader body reads the visitor's theme with `getRequestContext().theme` (seed the cookie through `request`). That read is guarded like `cookies()` (#971): allowed in a loader body, but it throws inside a `"use cache"` function. `redirect()` does no basename prefixing unless you seed `basename`.
126
127
  - A `"use cache"` hit needs real Flight, which loads only under the react-server condition. In the react-server project (a `*.rsc-test.ts` file, see [`./setup.md`](./setup.md)) with `cacheStore` seeded, the body runs once across `runLoader` calls and the value round-trips. The write is a background (`waitUntil`) task `runLoader` does not await: a call before it lands joins the still-running execution, a call after it reads the store (wait with `vi.waitFor` on a `cacheStore.setItem` spy). In the node project a call looks up the store but never writes, so every call runs the body. A function written with the directive needs `rangoUseCacheTransform()` in the project's `plugins` (see [`./render-handler.md`](./render-handler.md#a-use-cache-hit)).
128
+ - The `cache` option runs the loader through the production loader-cache read-through, so it needs the react-server project for the same reason. A HIT returns the stored value without running the body and records the tags the body recorded when the entry was written, so `updateTag()` of one (inside `runInRequestContext(fn, { cacheStore })`) drops the entry. There is no in-flight join here: wait for the write before the next call, or that call misses too. A raw body is keyed by its function reference. `runLoaderResult(...).handlePushes` lists the body's pushes, so a HIT reports none (the replay into the page's handle store is not modeled).
129
+
130
+ ```ts
131
+ const load = () =>
132
+ runLoader(productLoader, {
133
+ params: { id: "1" },
134
+ cacheStore,
135
+ cache: { ttl: 300 },
136
+ });
137
+ await load(); // MISS: the body runs and calls cacheTag("product:1")
138
+ await vi.waitFor(() => expect(setItem).toHaveBeenCalledTimes(1));
139
+ await load(); // HIT: the body does not run
140
+ await runInRequestContext(() => updateTag("product:1"), { cacheStore });
141
+ await load(); // MISS again
142
+ ```
143
+
127
144
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
128
145
 
129
146
  ## See also