@rangojs/router 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -1,62 +1,122 @@
1
1
  ---
2
2
  name: caching
3
- description: Configure route/segment-subtree caching with memory, Cloudflare KV, or Vercel cache stores in @rangojs/router. Use when responses should be cached or revalidated, data is stale or not updating after code changes, or you are wiring up a cache store.
3
+ description: Configure segment caching with the cache() DSL in @rangojs/router — TTL/SWR, tags and updateTag/revalidateTag, loader-level caching, and the Memory, Cloudflare (Cache API + optional KV), and Vercel Runtime Cache stores. Use when responses should be cached or revalidated, data is stale or not updating after changes, or you are wiring up a cache store.
4
4
  argument-hint: "[setup]"
5
5
  ---
6
6
 
7
7
  # Caching
8
8
 
9
- @rangojs/router supports segment-level caching with stale-while-revalidate (SWR) for optimal performance.
9
+ This skill covers the route-level `cache()` DSL (segment caching), loader-level
10
+ caching, tag invalidation, and the built-in cache stores. `cache()` stores the
11
+ rendered segments of a route subtree and serves them with a TTL and an optional
12
+ stale-while-revalidate (SWR) window; loaders keep running on every request.
10
13
 
11
- > SWR support is store-specific. `CFCacheStore` revalidates segment, response,
12
- > and `"use cache"` entries in the background. `MemorySegmentCacheStore`
13
- > supports SWR for response and `"use cache"` item entries, but its
14
- > route-segment entries expire at TTL with no background revalidation — use
15
- > `CFCacheStore` for real segment SWR. See `/cache-guide`.
14
+ > SWR support is store-specific. `CFCacheStore` and `VercelCacheStore` serve a
15
+ > stale entry and revalidate it in the background for every entry family
16
+ > (segments, responses, loader and `"use cache"` items, PPR shells).
17
+ > `MemorySegmentCacheStore` does this for responses, items, and shells, but its
18
+ > route-segment entries expire at `ttl` (the `swr` window is ignored) with no
19
+ > background revalidation. Use `CFCacheStore` or `VercelCacheStore` to see
20
+ > segment SWR. See `/cache-guide`.
16
21
 
17
22
  ## Not this skill if…
18
23
 
19
24
  - You want to cache ONE function or component's return value — that is
20
25
  `"use cache"`: see `/use-cache`.
21
- - You want the whole HTTP response frozen at the edge, loader output included —
22
- see `/document-cache`.
23
- - You want the HTML shell cached while loaders stay live per request — see
24
- `/ppr`.
26
+ - You want the whole HTTP response frozen, loader output included — see
27
+ `/document-cache`.
28
+ - You want the rendered HTML shell cached and only the live holes resumed per
29
+ request — that is the `ppr` path option: see `/ppr`.
30
+ - You want segments rendered once at build time — see `/prerender`.
25
31
  - You are unsure which cache layer you need — start at `/cache-guide`.
26
32
 
27
- ## cache() is Partial Prerendering (PPR)
33
+ ## What cache() caches: everything except loaders
28
34
 
29
35
  `cache()` caches **everything except loaders**. On a cache hit, the cached
30
36
  segments (layouts, route components, parallels — including any resolved
31
- Suspense) are served from the store, and **loaders re-run fresh on every
32
- request**, streaming their results into the same response. Loaders are the
33
- dynamic "holes" of an otherwise-cached tree.
37
+ Suspense content) are served from the store, and **loaders re-run fresh on every
38
+ request**, streaming their results into the same response. Loaders are the live
39
+ holes of an otherwise-cached segment tree.
34
40
 
35
- This means a `cache()` boundary at the document root **is** whole-document
36
- Partial Prerendering: the static shell is cached and served instantly while
37
- per-request/per-user data stays live — in one streamed response, no extra round
38
- trip. The browser cannot tell the shell came from cache.
41
+ A `cache()` boundary at the document root therefore gives you a cached shell
42
+ with live loader holes, in one streamed response with no extra round trip. The
43
+ browser cannot tell the segments came from cache. This is a Flight segment
44
+ cache: HTML is still rendered on every request. The `ppr` path option is a
45
+ separate layer that also caches the rendered HTML shell and resumes only the
46
+ holes — see `/ppr`, and `/cache-guide` for how the layers stack.
39
47
 
40
48
  ```typescript
41
49
  cache({ ttl: 60, swr: 300 }, () => [
42
- layout(<RootLayout />), // cached shell
50
+ layout(<RootLayout />), // cached
43
51
  path("/dashboard", Dashboard, { name: "dashboard" }, () => [
44
- loader(StatsLoader), // DYNAMIC HOLE — re-runs every request
52
+ loader(StatsLoader), // live hole — re-runs every request
45
53
  ]),
46
54
  ]);
47
55
  ```
48
56
 
49
- The consumer rule: **want it cached? render it inline. want it dynamic? put it
50
- in a loader and read it with `useLoader()` in a client component.** Anything
51
- read with `cookies()`, `headers()`, or a non-cacheable variable belongs in a
52
- loader (loaders always run fresh). Reading it directly in a cached handler
53
- throws; awaiting it with `ctx.use()` and rendering the result in a cached
54
- handler silently bakes per-request data into the shared shell (see "Cache purity
55
- & tainted objects" below).
57
+ `cache()` covers only its own subtree. A layout declared above the boundary is
58
+ not stored: it renders on every request, hits included, with its parallels and
59
+ loaders, so a per-request shell or a header it writes stays live. On a `ppr`
60
+ route the whole chain bakes into the shell, so there `cache()` covers the whole
61
+ chain, and a shell never outlives the route `cache()` entry it was captured
62
+ from (it is fresh and served no longer than that entry; `/ppr`).
56
63
 
57
- Pre-rendering (`/prerender`) is the build-time twin: it caches the same shell at
58
- build time instead of on first request. Both feed the segment system
59
- identically, and loaders always run fresh at request time.
64
+ ```typescript
65
+ layout(AccountShell, () => [
66
+ // live: runs on every request, cache hits included
67
+ cache({ ttl: 60 }, () => [
68
+ path("/products/:id", ProductPage, { name: "product" }), // cached
69
+ ]),
70
+ ]);
71
+ ```
72
+
73
+ A `cache()` among a path's own children caches that one path: its handler and
74
+ its own layouts and parallels. Every other item in the list still attaches to
75
+ the path, and everything above the path stays live:
76
+
77
+ ```typescript
78
+ layout(AccountShell, () => [
79
+ // live
80
+ path("/products/:id", ProductPage, { name: "product" }, () => [
81
+ cache({ ttl: 60 }), // caches ProductPage and ProductChrome
82
+ layout(ProductChrome),
83
+ ]),
84
+ ]);
85
+ ```
86
+
87
+ A `cache()` inside a `layout()` or `middleware()` wrapper in the path does the
88
+ same: `layout(ProductChrome, () => [cache({ ttl: 60 })])` caches the whole
89
+ path, not only ProductChrome.
90
+
91
+ The consumer rule: **want it cached? render it inline. Want it live? put it in a
92
+ loader and read it with `useLoader()` in a client component.** Anything read
93
+ with `cookies()`, `headers()`, or a non-cacheable variable belongs in a loader:
94
+ a route `cache()` does not store loader values, so a loader runs on every
95
+ request. A loader bound with its own `cache()` is stored, so without a `key()`
96
+ its miss fails; any `key()` switches that check off, so it must include the
97
+ value (see `/loader` → "Cache Key"). Reading it directly in a cached handler throws;
98
+ awaiting a loader with `ctx.use()` and rendering the result in a cached handler
99
+ silently bakes per-request data into the shared entry (see "Cache purity &
100
+ tainted objects" below).
101
+
102
+ Handle pushes follow the same split. Handler pushes (`ctx.use(Meta)`,
103
+ `ctx.use(Breadcrumbs)`, custom handles) are stored with the segments and
104
+ replayed on a hit. Pushes from a `loader()` body are not stored: the loader
105
+ re-runs on the hit and pushes again, so each value appears once. A loader that
106
+ runs only because a cached handler awaited it with `ctx.use(Loader)` does not
107
+ re-run on a hit, so its pushes are stored with the handler's.
108
+
109
+ A component that throws while the entry is written is not cached. Writing
110
+ re-renders the segments, so an async server component whose fetch fails there
111
+ would otherwise be stored as an error and rendered on every hit. Instead the
112
+ write is skipped and reported to `onError` (phase `"cache"`, category
113
+ `cache-write`): the next request renders fresh, and a stale entry keeps serving.
114
+ The same applies to a stored handle push whose value fails to encode, such as a
115
+ rejected promise: the whole entry is skipped, not just its handles.
116
+
117
+ Pre-rendering (`/prerender`) is the build-time counterpart: it stores the same
118
+ kind of segment payload at build time instead of on first request. Both feed the
119
+ segment system identically, and loaders always run fresh at request time.
60
120
 
61
121
  ## Route-Level Caching with cache()
62
122
 
@@ -66,7 +126,8 @@ Use the `cache()` DSL function to cache routes:
66
126
  import { urls } from "@rangojs/router";
67
127
 
68
128
  export const urlpatterns = urls(({ path, cache }) => [
69
- // Cache these routes for 60 seconds, SWR for 5 minutes
129
+ // Fresh for 60 seconds, then served stale for up to 5 more minutes
130
+ // while it refreshes in the background
70
131
  cache({ ttl: 60, swr: 300 }, () => [
71
132
  path("/blog", BlogIndex, { name: "blog" }),
72
133
  path("/blog/:slug", BlogPost, { name: "blogPost" }),
@@ -77,13 +138,24 @@ export const urlpatterns = urls(({ path, cache }) => [
77
138
  ]);
78
139
  ```
79
140
 
141
+ `cache()` needs a store — the app-level one (see "Global Cache Configuration")
142
+ or a per-boundary `store` option. Without one, the boundary renders live.
143
+
80
144
  ## Cache Options
81
145
 
146
+ Every option is optional. Omitted `ttl`/`swr` fall back to the store's
147
+ `defaults`.
148
+
82
149
  ```typescript
83
150
  cache(
84
151
  {
85
- ttl: 60, // Time-to-live in seconds (default: 60)
86
- swr: 300, // Stale-while-revalidate window (default: 300)
152
+ ttl: 60, // seconds fresh. Default: store.defaults.ttl, else 60
153
+ swr: 300, // seconds a stale entry is served while it refreshes.
154
+ // Default: store.defaults.swr, else no SWR window
155
+ tags: ["blog"], // or (ctx) => string[]; see "Tag-Based Invalidation"
156
+ key: (ctx) => `blog:${ctx.pathname}`, // replaces the default key entirely
157
+ condition: (ctx) => !ctx.request.headers.has("x-preview"), // false = bypass
158
+ store: blogStore, // per-boundary store; see "Custom Cache Store"
87
159
  },
88
160
  () => [
89
161
  // Cached routes
@@ -91,6 +163,22 @@ cache(
91
163
  );
92
164
  ```
93
165
 
166
+ - **Default key**: `{doc|partial|intercept}:{host}{pathname}[:params][?search]`
167
+ — document requests, client navigations, and intercept navigations are cached
168
+ separately. The search part honors `cache.searchParams` (below).
169
+ - **`key`** is a full override: it bypasses the default key, the store's
170
+ `keyGenerator`, and the search-param filter. A nested `cache()` keys its
171
+ records within that partition (see "Keys nest" under "Nested Cache
172
+ Boundaries"). On a `ppr` route the same key
173
+ (or, without one, the store's `keyGenerator`) partitions the PPR shell: each
174
+ partition captures and serves its own shell (`/ppr`, "Request-partitioned
175
+ shells").
176
+ - **`condition`** returning `false` skips both the cache read and write for that
177
+ request (the boundary renders live).
178
+ - `cache(() => [...])` with no options uses the store defaults.
179
+ - `cache(false, () => [...])` disables caching for a subtree (see "Nested Cache
180
+ Boundaries").
181
+
94
182
  ## When cache() does not pay
95
183
 
96
184
  A cache hit is not free: it still runs middleware, the store read, and the
@@ -114,6 +202,8 @@ Caching") over caching the rendered segment.
114
202
  Tag cached entries, then invalidate them on demand. Tags can be attached four ways:
115
203
 
116
204
  ```typescript
205
+ import { cacheTag } from "@rangojs/router";
206
+
117
207
  // 1. Static tags in the cache() DSL
118
208
  cache({ ttl: 300, tags: ["products"] }, () => [path("/products", List)]);
119
209
 
@@ -133,22 +223,39 @@ async function getProduct(id: string) {
133
223
  // 4. Render-callable — a plain server component (no "use cache" in its tree)
134
224
  // records onto the request's document/shell artifact.
135
225
  function CampaignBanner() {
136
- cacheTag("campaign:spring"); // rides ctx._requestTags → shell/document entry
226
+ cacheTag("campaign:spring"); // tags the ppr shell / document-cache entry
137
227
  return <aside>Spring sale</aside>;
138
228
  }
139
229
  ```
140
230
 
141
- Form 4 is how you make a PPR shell or a `/document-cache` page tag-invalidatable
142
- without wrapping anything in `"use cache"`: the tag rides the request's
143
- `_requestTags` onto the shell/document entry, and `revalidateTag` then evicts it.
144
- On a route that is neither PPR nor document-cached the tag records where nothing
145
- reads it — a silent no-op, so don't expect a bare `cacheTag()` to tag an ordinary
146
- uncached page.
231
+ Each tag attaches to one entry, so know which entry you are tagging:
232
+
233
+ - Forms 1 and 2 tag the `cache()` segment entry.
234
+ - Form 3 tags the `"use cache"` entry (plus any profile `tags`).
235
+ - Form 4 tags the request's `ppr` shell entry (`/ppr`) or `/document-cache`
236
+ entry, with no `"use cache"` needed. On a route that is neither `ppr` nor
237
+ document-cached and has no `cache()`, nothing reads the tag — a silent
238
+ no-op, so do not expect a bare `cacheTag()` to tag an ordinary uncached page.
239
+ - A route `cache()` entry also carries the tags its own content recorded when
240
+ it was written: form 4 calls and form 3 reads in the handlers and server
241
+ components it covers. `updateTag()` of such a tag drops the entry, and a HIT
242
+ re-records the tags, so a ppr shell or document built from the HIT stays
243
+ evictable. `loading()` subtrees count too: a HIT replays their output. A
244
+ loader's tags reach the entry only when a handler consumes its value
245
+ (`await ctx.use(Loader)`); a loader nobody reads on the server runs per
246
+ request and stays off.
247
+ - A loader with its own `cache()` stores the tags its body recorded (form 4
248
+ calls and form 3 reads, including those of the loaders it reads with
249
+ `ctx.use()`) next to its `cache({ tags })`. They invalidate the loader's
250
+ entry, and a HIT records them again, so a route `cache()` entry, ppr shell
251
+ or document built over the HIT carries them.
147
252
 
148
253
  Invalidate with one of two server-only verbs (both variadic, imported from
149
254
  `@rangojs/router`):
150
255
 
151
256
  ```typescript
257
+ import { updateTag, revalidateTag } from "@rangojs/router";
258
+
152
259
  // Server Action — read-your-own-writes. Await it so the action's own re-render
153
260
  // (and the next navigation) sees fresh data.
154
261
  async function updateProduct(formData: FormData) {
@@ -157,10 +264,9 @@ async function updateProduct(formData: FormData) {
157
264
  await updateTag("products");
158
265
  }
159
266
 
160
- // Route handler / webhook — background, non-blocking (waitUntil). Hard-purge:
161
- // the next read re-renders fresh (NOT stale-while-revalidate).
162
- export async function POST() {
163
- "use server";
267
+ // Webhook or response-route handler — background, non-blocking (waitUntil).
268
+ // Hard purge: the next read re-renders fresh (NOT stale-while-revalidate).
269
+ function onProductsChanged() {
164
270
  revalidateTag("products");
165
271
  return new Response("ok");
166
272
  }
@@ -171,10 +277,38 @@ export async function POST() {
171
277
  | `updateTag(...tags)` | awaitable (`Promise<void>`) | server actions | immediate; next read is fresh |
172
278
  | `revalidateTag(...tags)` | background (`void`) | route handlers / webhooks | background (non-blocking); next read re-renders fresh |
173
279
 
174
- All three built-in stores support tags. For `CFCacheStore`, distributed (cross-colo)
175
- invalidation requires a `kv` namespace — the tag-invalidation markers live in
176
- that same namespace; there is **no** separate tag-invalidation store to wire.
177
- If no tag-capable store is configured, `updateTag`/`revalidateTag` warn and no-op.
280
+ The request that calls either verb reads its own writes. Before the call
281
+ returns, each built-in store marks the tags as invalidated for the rest of that
282
+ request, so an action that calls `revalidateTag()` and then renders gets fresh
283
+ `"use cache"` and `cache()` reads, as with `await updateTag()`. What
284
+ `revalidateTag()` does not wait for is the durable write (the KV marker, the
285
+ tag purge, Vercel's `expireTag`): other requests see the invalidation once it
286
+ lands. A `"use cache"` call that this request started before the
287
+ invalidation, and that carries one of the invalidated tags, is not reused
288
+ after it and does not store its value; neither does a stale entry's
289
+ background refresh. The tags are the call's own (its `cacheTag()` calls and
290
+ profile `tags`): a nested `"use cache"` function's tags do not reach the
291
+ enclosing entry, so invalidating one of them neither evicts the outer entry
292
+ nor stops an outer call already running
293
+ ([#980](https://github.com/rangojs/rango/issues/980)). Not covered
294
+ ([#977](https://github.com/rangojs/rango/issues/977)): such a call started
295
+ by another request, or a loader's own `cache()` that started before the
296
+ invalidation, still stores its value when it finishes, and reads after that
297
+ can get it.
298
+
299
+ Both must run inside a request (action, handler, middleware, response route).
300
+ Called from a queue consumer or cron job, there is no request context, so they
301
+ warn and invalidate nothing. `updateTag()` rejects when a store's durable write
302
+ fails, so you can retry; `revalidateTag()` reports the failure to `onError`
303
+ (phase `"cache"`, category `cache-invalidate`) instead.
304
+
305
+ All three built-in stores support tags. If no tag-capable store is configured,
306
+ `updateTag`/`revalidateTag` warn and no-op.
307
+
308
+ For `CFCacheStore`, cross-colo invalidation needs either a `kv` namespace
309
+ (marker mode — the tag-invalidation markers live in that same namespace; there
310
+ is **no** separate tag-invalidation store to wire) or `tagPurge` (purge mode,
311
+ below). With neither, invalidation logs a warning and has no effect.
178
312
 
179
313
  By default `CFCacheStore` reads the KV marker on every tagged cache read
180
314
  (strongest invalidation latency). To cut KV reads on hot tagged routes, set
@@ -196,14 +330,37 @@ Purge-by-tag is available on all plans (since April 2025), subject to per-plan
196
330
  rate limits, so the batched single call matters. With a purge wired, `tagCacheTtl`
197
331
  becomes a pure read-cost reducer + fallback window.
198
332
 
333
+ **Purge mode (`tagPurge`).** Instead of checking a KV marker on every L1 read,
334
+ the store can evict tagged L1 entries with Cloudflare's purge-by-tag API:
335
+
336
+ ```typescript
337
+ new CFCacheStore({
338
+ ctx,
339
+ kv: env.CACHE_KV,
340
+ tagPurge: { zoneId: env.CF_ZONE_ID, apiToken: env.CF_PURGE_TOKEN },
341
+ });
342
+ ```
343
+
344
+ The token needs the `Zone.Cache Purge` permission; keep it in a Worker secret.
345
+ You can also pass a `(cacheTags) => Promise<void>` function (for a proxy or a
346
+ test stub); `createCloudflareZonePurge({ zoneId, apiToken })` from
347
+ `@rangojs/router/cache` builds the default one. In purge mode every tagged L1
348
+ entry carries a namespaced `Cache-Tag`, `updateTag()`/`revalidateTag()` await one
349
+ batched purge call, and L1 hits skip the marker lookup. The KV tier and PPR shell
350
+ reads still use KV markers, so keep `kv` configured when you have it; without
351
+ `kv`, purge mode is the only tag invalidation and the store runs L1-only. A
352
+ failed purge makes `updateTag()` reject. Purge-by-tag clears only your zone:
353
+ `workers.dev` previews have an inert Cache API.
354
+
199
355
  ## Named Cache Profiles
200
356
 
201
- Define named profiles in `createRouter({ cacheProfiles })` so the same TTL/SWR
202
- values can be shared across the DSL and `"use cache"` functions without repetition.
203
- Unknown names throw at boot time.
357
+ Named profiles in `createRouter({ cacheProfiles })` are for the
358
+ `"use cache: <name>"` directive only (see `/use-cache`). `default` is built in as
359
+ `{ ttl: 900, swr: 1800 }` and can be overridden. An invalid profile name or
360
+ ttl/swr value throws when the router is created; an unknown profile name throws
361
+ on the first call of a function that uses it.
204
362
 
205
363
  ```typescript
206
- // Define profiles in router
207
364
  createRouter({
208
365
  cacheProfiles: {
209
366
  default: { ttl: 900, swr: 1800 },
@@ -213,7 +370,8 @@ createRouter({
213
370
  });
214
371
  ```
215
372
 
216
- In the DSL, pass the profile's options directly to `cache()`:
373
+ The DSL `cache()` helper does NOT accept a profile name — pass the options
374
+ object directly:
217
375
 
218
376
  ```typescript
219
377
  export const urlpatterns = urls(({ path, cache }) => [
@@ -221,23 +379,34 @@ export const urlpatterns = urls(({ path, cache }) => [
221
379
  path("/blog", BlogIndex, { name: "blog" }),
222
380
  ]),
223
381
 
224
- // Orphan cache boundary (covers subsequent siblings)
382
+ // Orphan cache boundary: with no children callback it covers the
383
+ // siblings that follow it
225
384
  cache({ ttl: 60, swr: 120 }),
226
385
  path("/feed", FeedPage, { name: "feed" }),
227
386
  ]);
228
387
  ```
229
388
 
230
- The DSL `cache()` helper does NOT accept a string profile name — strings are only
231
- valid in the `"use cache: <name>"` directive inside server functions. See
232
- `/use-cache` for function-level caching with named profiles.
389
+ In a layout's children, a `layout()` after the bare `cache()` wraps every
390
+ route of that layout, as it would without the `cache()`. It is cached only with
391
+ the routes after the `cache()`; on a route before it, it renders live:
392
+
393
+ ```typescript
394
+ layout(<AppShell />, () => [
395
+ path("/a", PageA, { name: "a" }), // PromoBanner renders live
396
+ cache({ ttl: 60 }),
397
+ layout(<PromoBanner />),
398
+ path("/b", PageB, { name: "b" }), // PromoBanner cached with PageB
399
+ ]);
400
+ ```
233
401
 
234
402
  ## Loader-Level Caching
235
403
 
236
- Cache individual loaders:
404
+ Loaders are never part of a segment entry. To cache a loader's DATA, give the
405
+ loader its own `cache()`:
237
406
 
238
407
  ```typescript
239
408
  path("/product/:slug", ProductPage, { name: "product" }, () => [
240
- // Cache this loader's results
409
+ // Cache this loader's results for 5 minutes
241
410
  loader(ProductLoader, () => [cache({ ttl: 300 })]),
242
411
 
243
412
  // This loader is not cached
@@ -245,15 +414,38 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
245
414
  ]);
246
415
  ```
247
416
 
417
+ A loader `cache()` takes the same options as the DSL (`ttl`, `swr`, `tags`,
418
+ `key`, `condition`, `store`) and is stored in the store's item family, so SWR
419
+ works on every built-in store. Without options, `cache()` uses the store's
420
+ defaults (ttl 60 when the store sets none); it does **not** inherit the options
421
+ of an enclosing `cache()` boundary. Inside `loader()` only the direct form
422
+ `cache({...})` is valid — the wrapper form `cache(opts, () => [...])` throws.
423
+ Handle pushes from the loader body (`Meta`, `Breadcrumbs`) are stored with the
424
+ value and replayed on every hit, stale included. The tags the body records
425
+ (`cacheTag()`, `"use cache"` reads, and those of the loaders it reads) are
426
+ stored with it too: `updateTag()` of one drops the entry, as `cache({ tags })`
427
+ does. See `/loader` for the full loader reference.
428
+
429
+ The entry's default key is the loader, host, path and params. It does not
430
+ inherit an enclosing `cache()` boundary's `key()` either, so it is shared
431
+ across users: a body that reads `cookies()`, `headers()` or a non-cacheable
432
+ variable fails on a miss unless the loader `cache()` has a `key()` or its store
433
+ a `keyGenerator`. Either one switches the check off, so it must itself include
434
+ the value (`/loader` → "Cache Key").
435
+
248
436
  ## Global Cache Configuration
249
437
 
250
- Configure a cache store in the router:
438
+ Configure the app-level store on the router. `cache()` boundaries, cached
439
+ loaders, `"use cache"`, `ppr` shells, and `createDocumentCacheMiddleware()` all
440
+ use it.
251
441
 
252
442
  ```typescript
253
443
  import { createRouter } from "@rangojs/router";
254
444
  import { MemorySegmentCacheStore } from "@rangojs/router/cache";
255
445
 
256
446
  const store = new MemorySegmentCacheStore({
447
+ // On the memory store `swr` applies to loader, "use cache", response and
448
+ // shell entries; route segments expire at ttl.
257
449
  defaults: { ttl: 60, swr: 300 },
258
450
  });
259
451
 
@@ -262,11 +454,14 @@ const router = createRouter({
262
454
  urls: urlpatterns,
263
455
  cache: {
264
456
  store,
265
- enabled: true,
457
+ enabled: true, // false = no app store for the request: every layer above is off
266
458
  },
267
459
  });
268
460
  ```
269
461
 
462
+ `cache` may also be a function `(env, ctx) => ({ store, enabled?, searchParams? })`
463
+ when the store needs runtime bindings (Cloudflare, Vercel).
464
+
270
465
  ### Search param key filtering (`cache.searchParams`)
271
466
 
272
467
  By default every non-reserved query param keys the cache, so
@@ -308,23 +503,32 @@ already expressible with `cache({ key })`.
308
503
 
309
504
  ## Cache Stores
310
505
 
506
+ | Store | Import | Use for |
507
+ | ------------------------- | ----------------------- | -------------------------------------------------- |
508
+ | `MemorySegmentCacheStore` | `@rangojs/router/cache` | dev, tests, single-process Node deployments |
509
+ | `CFCacheStore` | `@rangojs/router/cache` | Cloudflare Workers (Cache API L1 + optional KV L2) |
510
+ | `VercelCacheStore` | `@rangojs/router/cache` | Vercel Functions (Vercel Runtime Cache) |
511
+
311
512
  ### Memory Store
312
513
 
313
- For single-instance deployments:
514
+ For development, tests, and single-instance deployments. Entries live in
515
+ process memory, so each instance has its own cache and a restart empties it.
314
516
 
315
517
  ```typescript
316
518
  import { MemorySegmentCacheStore } from "@rangojs/router/cache";
317
519
 
318
520
  const store = new MemorySegmentCacheStore({
319
- defaults: { ttl: 60, swr: 300 },
521
+ defaults: { ttl: 60 }, // segments ignore swr on this store
320
522
  maxEntries: 1000, // per-family FIFO cap (default 1000)
523
+ name: "app", // optional: keep entries across Vite HMR module reloads
321
524
  });
322
525
  ```
323
526
 
324
527
  Each internal family (segments, responses, `"use cache"` items, PPR shells) is
325
528
  capped at `maxEntries`; on insert past the cap the oldest entry is evicted FIFO
326
529
  and its tag-index entries are cleaned up, so a long-lived process cannot grow
327
- without bound. TTL expiry stays lazy on top of the cap.
530
+ without bound. TTL expiry stays lazy on top of the cap. Two stores with the same
531
+ `name` share their backing maps.
328
532
 
329
533
  ### Cloudflare Edge Cache Store
330
534
 
@@ -338,7 +542,7 @@ const router = createRouter<AppBindings>({
338
542
  urls: urlpatterns,
339
543
  cache: (env, ctx) => ({
340
544
  store: new CFCacheStore({
341
- ctx,
545
+ ctx: ctx!, // ExecutionContext, required (non-blocking writes)
342
546
  defaults: { ttl: 60, swr: 300 },
343
547
  }),
344
548
  enabled: true,
@@ -346,6 +550,10 @@ const router = createRouter<AppBindings>({
346
550
  });
347
551
  ```
348
552
 
553
+ `CFCacheStore` prefixes every key with the build version, so a deploy never
554
+ reads the previous build's entries (override with `version`). See `/cache-guide`
555
+ → "Cross-deploy safety" and `/cloudflare` for bindings.
556
+
349
557
  ### With KV L2 Persistence
350
558
 
351
559
  Add a KV namespace for global cross-colo persistence. On Cache API miss, KV is
@@ -359,7 +567,7 @@ const router = createRouter<AppBindings>({
359
567
  urls: urlpatterns,
360
568
  cache: (env, ctx) => ({
361
569
  store: new CFCacheStore({
362
- ctx,
570
+ ctx: ctx!,
363
571
  kv: env.CACHE_KV, // optional KV namespace binding
364
572
  defaults: { ttl: 60, swr: 300 },
365
573
  }),
@@ -376,8 +584,9 @@ const router = createRouter<AppBindings>({
376
584
  | Cold colo | MISS | HIT | Serve from KV, promote to L1 |
377
585
  | First render | MISS | MISS | Render, write to both L1 + KV |
378
586
 
379
- KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
380
- are only cached in L1.
587
+ KV entries require `expirationTtl >= 60s`. Short-lived data entries (< 60s total
588
+ TTL) are only cached in L1. A PPR shell is always written to KV, a short one
589
+ with the 60 s minimum; its reads still expire it at its own deadline.
381
590
 
382
591
  ### Resilience & latency budgets
383
592
 
@@ -397,6 +606,74 @@ read. A non-finite value (e.g. `Number(env.UNSET)`) falls back to the default.
397
606
  The tag-invalidation marker reads inherit these same budgets and **fail open** on
398
607
  a KV timeout — the entry is served rather than wrongly treated as invalidated.
399
608
 
609
+ A PPR shell HIT reads its entry prelude-first: the body budget (or the KV budget
610
+ on a KV read, counted from opening the value) covers only the entry's head and
611
+ prelude, the tag-marker read runs alongside the prelude read, and the capture
612
+ snapshot behind them is read off the commit path, bounded by `kvReadTimeoutMs`
613
+ (at least 1 s: the tail cannot render without it). A HIT whose snapshot read
614
+ times out, or whose snapshot is truncated or corrupt (also evicted), cannot
615
+ replay the captured segment record, so it degrades: the entry is replaced and
616
+ recaptured and the page reloads once into a cache-miss render (`/ppr`). The
617
+ snapshot holds only what a HIT reads: every HIT replays the handler layer from
618
+ the captured segment record, so the `"use cache"` items only handler code read
619
+ are not stored.
620
+
621
+ **Shell memo.** After a shell read, the same isolate serves that key's next
622
+ HITs from memory for `memo.shellMs` (default 2000; `{ shellMs: 0 }` turns it
623
+ off), with no Cache API or KV read. The tag-marker check still runs on every
624
+ HIT, and the isolate that runs `updateTag()`/`revalidateTag()` drops its own
625
+ copies, so that request and later ones on the same isolate never get the
626
+ invalidated shell. What another isolate can serve for up to one window: the
627
+ previous capture of a key another isolate just recaptured, and, without KV in
628
+ purge mode, a purged shell (the purge reaches the stored entry, not other
629
+ isolates' memos). `memo.shellMaxBytes` (default 16 MiB, shared by every
630
+ `CFCacheStore` in the isolate) caps what it holds; only fresh shells are kept.
631
+
632
+ **Tag-marker memo.** With KV, PPR shell reads check their tag markers through
633
+ a per-isolate memo, stale-while-revalidate: a marker read within
634
+ `memo.markerFreshMs` (default 1000) is used as is, one within
635
+ `memo.markerMaxStaleMs` (default 10000) is used while a background read
636
+ refreshes it, an older one waits for KV. The isolate also remembers each
637
+ shell's tag names and starts those marker reads with the Cache API match
638
+ (`ppr.tags` are known up front). `cache()`, `"use cache"` and response entries
639
+ keep reading their markers. `{ markerFreshMs: 0 }` turns the value memo off.
640
+
641
+ **The fresh-reads cookie.** A response whose request ran `updateTag()` or
642
+ `revalidateTag()` sets `<state cookie prefix>-fresh` (`rango-state-fresh` by
643
+ default, shared by every router on the host with that prefix; `HttpOnly`,
644
+ `SameSite=Lax`, `Path=/`, `Secure` on https, `Max-Age` 11 s with KV and 3 s
645
+ without at the defaults: the longest memo staleness plus 1 s). It is strictly
646
+ functional: its value is `1`, it identifies no one, and it only sends that
647
+ browser's reads past the memos. The same user's requests that carry it skip
648
+ both memos on every isolate, so a mutating user never sees a memoized shell or
649
+ marker from before their write. What they read past the memos is the store's
650
+ own consistency. `VercelCacheStore`: `expireTag` is global (about 300 ms), so
651
+ they read fresh in any region. `CFCacheStore` with KV: the shell's tag check
652
+ reads that colo's marker (its Cache API copy when `tagCacheTtl` is set, then
653
+ KV), so in the colo that ran the mutation they read fresh, while a request
654
+ another colo serves can still get the old shell until KV propagates the
655
+ marker there (up to about 60 s) and that colo's cached marker (`tagCacheTtl`)
656
+ expires. `CFCacheStore` without KV in purge mode (`tagPurge`) has no markers:
657
+ the purge is what removes the shell, and they see it once it has reached the
658
+ colo serving them. Other users can see a memoized shell or marker for up to
659
+ `markerMaxStaleMs` (with KV) or the shell window (`VercelCacheStore` in
660
+ another region, `CFCacheStore` in KV-less purge mode, where another isolate's
661
+ memo can keep serving the purged shell). Set `{ shellMs: 0, markerFreshMs: 0 }` where every user's next
662
+ request must see an invalidation (with KV, `{ shellMs: 0 }` alone still leaves
663
+ the marker memo); that also means no cookie. The cookie is not set when the
664
+ invalidation runs after the response headers were sent (a streaming loader or
665
+ render; dev warns): invalidate in a server action, route handler or
666
+ middleware. Any client can send it, which only makes its own requests read
667
+ the store.
668
+
669
+ ```typescript
670
+ new CFCacheStore({
671
+ ctx,
672
+ kv: env.CACHE_KV,
673
+ memo: { shellMs: 5000, markerFreshMs: 500 },
674
+ });
675
+ ```
676
+
400
677
  ```typescript
401
678
  new CFCacheStore({
402
679
  ctx,
@@ -453,6 +730,54 @@ deployment KV keeps markers hot in its per-colo edge cache, so it stays a few
453
730
  milliseconds. `match-error` (a transient `cache.match` rejection that falls
454
731
  through to L2) is kept distinct from a plain `l1-miss`.
455
732
 
733
+ ### Vercel Runtime Cache Store
734
+
735
+ For Vercel Functions. Pass the Runtime Cache handle from `@vercel/functions`;
736
+ the store adds SWR, tag invalidation, and the family split on top of it.
737
+
738
+ ```typescript
739
+ import { getCache, waitUntil } from "@vercel/functions";
740
+ import { VercelCacheStore } from "@rangojs/router/cache";
741
+
742
+ // A per-deploy namespace: Vercel does not reconcile entries across deploys.
743
+ // One handle per process: the store keeps its PPR shell and tag-marker
744
+ // memos per handle.
745
+ const runtimeCache = getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID });
746
+
747
+ const router = createRouter({
748
+ document: Document,
749
+ urls: urlpatterns,
750
+ cache: () => ({
751
+ store: new VercelCacheStore({
752
+ cache: runtimeCache,
753
+ waitUntil,
754
+ defaults: { ttl: 60, swr: 300 },
755
+ }),
756
+ }),
757
+ });
758
+ ```
759
+
760
+ The store keeps the same PPR shell memo as `CFCacheStore` (`memo.shellMs`,
761
+ default 2000; `memo.shellMaxBytes`, default 16 MiB) and the same tag-marker
762
+ memo (`memo.markerFreshMs`, default 300; `memo.markerMaxStaleMs`, default
763
+ 2000), one per `cache` handle: create the handle once, as above, or the memos
764
+ never hit. The request that runs `updateTag()`/`revalidateTag()` misses on
765
+ the entries carrying those tags before `expireTag` lands. A shell read's
766
+ marker check sees the invalidation in the process that ran it once its
767
+ markers are written, and in the rest of the region once their marker memo
768
+ refreshes. The tag markers are a regional `cache.set` (only
769
+ `expireTag` is global), so another region serves a shell it memoized before
770
+ the invalidation until its window passes, where without the memo `expireTag`
771
+ removes it within about 300 ms; a platform `expireTag` issued outside rango is
772
+ likewise seen when the window passes. The fresh-reads cookie (`Max-Age` 3 s)
773
+ sends the mutating user's next requests past both memos, and `expireTag` is
774
+ global, so they read fresh in any region.
775
+
776
+ Writes over 2 MB (the platform limit, `maxItemBytes`) are skipped, and tags
777
+ beyond the per-item cap are dropped with a warning. The official client swallows
778
+ `expireTag` failures, so treat `updateTag()` on Vercel as best-effort. See
779
+ `/vercel` for the preset and the remaining options (`version`, `name`, `debug`).
780
+
456
781
  ## Cache purity & tainted objects
457
782
 
458
783
  A `cache()` boundary caches everything except loaders, so anything read inside a
@@ -460,26 +785,35 @@ cached handler is **frozen into the shared cache entry** and served to every
460
785
  subsequent visitor. To stop one user's request-scoped data from leaking to
461
786
  another, request-scoped APIs are guarded inside a cache scope:
462
787
 
463
- | Inside a `cache()` boundary | Behavior |
464
- | --------------------------------------------------------------- | --------------------------------------------------- |
465
- | `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
466
- | `ctx.header()` / `setCookie()` / `setStatus()` / `onResponse()` | **throws** — response side effects lost on a hit |
467
- | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
468
- | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
469
- | Any of the above **inside a loader** | **allowed** — loaders always run fresh |
788
+ | Inside a `cache()` boundary | Behavior |
789
+ | ---------------------------------------------------------------------------------- | --------------------------------------------------- |
790
+ | `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
791
+ | Response writes: `ctx.headers.set()`, `setCookie()`, `setStatus()`, `onResponse()` | **throws** — response side effects lost on a hit |
792
+ | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
793
+ | `ctx.theme`, `getRequestContext().theme` | **throws** — the theme is the visitor's cookie |
794
+ | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
795
+ | Any of the above **inside a registered loader** (`loader(...)`) | **allowed** — loaders always run fresh |
796
+
797
+ A loader body invoked from a handler with `await ctx.use(Loader)` may read
798
+ `cookies()`/`headers()`, but its response writes still throw: on a hit the
799
+ handler is skipped, so that loader never runs.
800
+
801
+ A loader bound with its **own** `cache()` (`loader(Def, () => [cache({...})])`)
802
+ stores its value, and its key does not inherit the route's. A miss whose body
803
+ read `cookies()`, `headers()` or a non-cacheable `ctx.get()` fails unless the
804
+ binding has a `key()` or its store a `keyGenerator`, which must then include
805
+ the value (`/loader` → "Cache Key").
470
806
 
471
807
  **Tainted objects.** Request-scoped objects (`ctx`, `env`, `request`) carry an
472
- internal taint symbol so they are excluded from `"use cache"` cache keys, and
473
- the cache scope is tracked via async-local state. Two flags back the guards:
474
- `INSIDE_CACHE_EXEC` (set while a `"use cache"` function runs) and the `cache()`
475
- DSL scope (`isInsideCacheScope()`). `isInsideCacheScope()` deliberately returns
476
- `false` inside loaders — which is exactly why loaders are the dynamic holes:
477
- they may read `cookies()`/`headers()` and re-run on every request.
808
+ internal taint symbol so they are excluded from `"use cache"` cache keys. The
809
+ `cache()` scope is tracked in async-local state and deliberately ends inside
810
+ loaders — which is exactly why loaders are the live holes: they may read
811
+ `cookies()`/`headers()` and re-run on every request.
478
812
 
479
813
  The fix for "I need request data in a cached route": register a `loader()` and
480
814
  **consume it with `useLoader()` in a client component**. The loader is the
481
- dynamic hole — its data rides the fresh (never-cached) loader segment and is
482
- rendered in the client component, so it never lands in the cached shell.
815
+ live hole — its data rides the fresh (never-cached) loader segment and is
816
+ rendered in the client component, so it never lands in the cached entry.
483
817
 
484
818
  This is NOT the same as awaiting the loader in the handler. A cached handler
485
819
  that does `await ctx.use(Loader)` and renders the result bakes that per-request
@@ -489,20 +823,21 @@ is **not** guarded. `ctx.use()` is a server-side escape hatch for non-rendered
489
823
  uses (set a ctx var, make a routing decision); never render its result inside a
490
824
  cached handler.
491
825
 
492
- This is the **consumption-lane rule**, and it holds identically for every
493
- shared artifact — `cache()`, `"use cache"`, and the PPR shell (`/ppr`):
494
- handler consumption = baked copy with identity reads permitted; client-side
495
- `useLoader` = live. Stated once in `/rango` → Invariants; pinned by
496
- semantic-matrix row PPR3 and the `e2e/cache.test.ts` "baked copy" case.
826
+ This is the **consumption-lane rule**, and it holds for every shared
827
+ artifact — `cache()`, `"use cache"`, and the PPR shell (`/ppr`): handler
828
+ consumption = baked copy; client-side `useLoader` = live. The tiers differ on
829
+ identity reads inside a handler-consumed loader: `cache()` and `"use cache"`
830
+ permit them (the leak above), while a PPR shell capture refuses them and the
831
+ route stays uncached. It is stated once in `/rango` → Invariants.
497
832
 
498
833
  ```typescript
499
834
  // WRONG — throws: cookies() read directly in a cached handler
500
835
  cache({ ttl: 60 }, () => [
501
- path("/me", () => <Profile id={cookies().get("uid")?.value} />),
836
+ path("/me", () => <Profile id={cookies().get("uid")?.value} />, { name: "me" }),
502
837
  ]);
503
838
 
504
839
  // ALSO WRONG (unguarded, but leaks) — the awaited loader data is rendered into
505
- // the cached handler, so the user's data is frozen into the shared shell.
840
+ // the cached handler, so the user's data is frozen into the shared entry.
506
841
  cache({ ttl: 60 }, () => [
507
842
  path(
508
843
  "/me",
@@ -522,10 +857,11 @@ cache({ ttl: 60 }, () => [
522
857
  // profile.tsx (client component)
523
858
  "use client";
524
859
  import { useLoader } from "@rangojs/router/client";
860
+ import { MeLoader } from "./loaders";
525
861
 
526
862
  export function Profile() {
527
- const { user } = useLoader(MeLoader); // fresh per request; never cached
528
- return <span>{user.name}</span>;
863
+ const { data } = useLoader(MeLoader); // fresh per request; never cached
864
+ return <span>{data.user.name}</span>;
529
865
  }
530
866
 
531
867
  // urls — register the loader; MeLoader reads cookies() inside the loader (allowed)
@@ -538,20 +874,98 @@ See `/cache-guide` for the full decision guide and the `cache()` vs `"use cache"
538
874
 
539
875
  ## Nested Cache Boundaries
540
876
 
541
- Override cache settings for specific sections:
877
+ An inner boundary overrides the settings of the outer one for its subtree
878
+ (`key` composes instead, see below); `cache(false)` turns caching off for a
879
+ subtree:
542
880
 
543
881
  ```typescript
544
- // Global cache
545
882
  cache({ ttl: 300 }, () => [
546
883
  path("/blog", BlogIndex, { name: "blog" }),
547
884
 
548
- // Override: shorter TTL for dynamic content
885
+ // Override: shorter TTL for this section
549
886
  cache({ ttl: 30 }, () => [
550
887
  path("/blog/:slug", BlogPost, { name: "blogPost" }),
551
888
  ]),
889
+
890
+ // Opt out: always rendered live
891
+ cache(false, () => [path("/blog/drafts", Drafts, { name: "drafts" })]),
892
+ ]);
893
+ ```
894
+
895
+ On a `ppr` route the opt-out also means no shell: `cache(false)`, or a
896
+ `condition()` that returns false, renders that route (or that request) like a
897
+ cache miss, with no shell served and none captured. A `ppr` route under a
898
+ layout's `cache()` therefore cannot opt out of that `cache()` and still get a
899
+ shell (`/ppr`).
900
+
901
+ ### Keys nest: a partition covers the whole subtree
902
+
903
+ A `key()` partitions every record under its `cache()`. A nested `cache()`
904
+ keys its records within that partition, so a request in one partition never
905
+ HITs another partition's inner record:
906
+
907
+ ```typescript
908
+ const tier = (ctx) =>
909
+ ctx.request.headers.get("x-tier") === "gold" ? "gold" : "silver";
910
+
911
+ cache({ ttl: 300, key: (ctx) => `tier:${tier(ctx)}` }, () => [
912
+ layout(TierLayout, () => [
913
+ // No key() of its own: the outer key() result, then this boundary's own
914
+ // default key ("tier%3Agold|doc%3Aexample.com%2Fpricing"), so /pricing
915
+ // and /faq keep their own records
916
+ cache({ ttl: 60 }, () => [
917
+ path("/pricing", PricingPage, { name: "pricing" }),
918
+ path("/faq", FaqPage, { name: "faq" }),
919
+ ]),
920
+
921
+ // Its own key() composes with the outer one ("tier%3Agold|v%3Ab")
922
+ cache(
923
+ { ttl: 60, key: (ctx) => `v:${ctx.searchParams.get("v") ?? "a"}` },
924
+ () => [path("/plans", PlansPage, { name: "plans" })],
925
+ ),
926
+ ]),
552
927
  ]);
553
928
  ```
554
929
 
930
+ The parts are joined by `|`, outermost first, each URI-encoded:
931
+
932
+ | Boundary the route sits in | Record key |
933
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
934
+ | `cache({ ttl })`, no `key`, under a keyed one | The chain's `key()` results, then this boundary's default key (its store's `keyGenerator` result, else the default key) |
935
+ | `cache({ key })` under a keyed one | The chain's `key()` results |
936
+ | deeper nesting | Composes the same way; only the innermost boundary's missing `key` adds its default key |
937
+ | `cache(false)` | No caching, as before; a `cache()` re-enabled below it stays in the partition |
938
+ | a single keyed `cache()`, or no `key` anywhere | Its raw `key()` result, or the default key, as before |
939
+
940
+ - The encoding is unambiguous among composed keys: an encoded part holds no
941
+ `|`, so no two partitions, and no two inner keys, compose to the same
942
+ record key.
943
+ - A single keyed `cache()` stores its `key()` result as is, so a raw result
944
+ can equal a composed key: a header value `gold|doc%3Aexample.com%2Fpricing`
945
+ returned raw names the record gold's pricing page keeps under the inner
946
+ `cache()` in the example above. Never return raw
947
+ request input from `key()`: normalize it to the values you serve, prefix
948
+ it (`tier:${value}`), or encode it. A composed key holds no `:` (every
949
+ part is URI-encoded), so a prefixed result never equals one.
950
+ - An inner boundary without `key` keeps everything its default key tells
951
+ apart (path, params, search, document vs navigation), so its routes never
952
+ share a record, even when the outer `key()` names no route.
953
+ - A boundary with its own `key()` skips the store's `keyGenerator`, as a
954
+ single `key()` does.
955
+ - Each `key()` runs once per request, however many boundaries and lookups use
956
+ it.
957
+ - A response route's entry (`path.json()` and the other response routes) is
958
+ keyed the same way. A `ppr` route's shell is partitioned by the chain's
959
+ `key()` results alone, since the shell key already carries the URL (plus the
960
+ store `keyGenerator`'s result for an inner boundary without `key`).
961
+ - To share an inner cache across partitions, move it outside the keyed
962
+ `cache()`.
963
+
964
+ Before issue #970, the innermost boundary alone decided the key: an inner
965
+ `cache()` without `key` wrote under the default key and an inner `key()`
966
+ dropped the outer partition, so a silver visitor could HIT what a gold
967
+ visitor's request had cached.
968
+
555
969
  ## Custom Cache Store
556
970
 
557
971
  Create a dedicated store for specific routes:
@@ -567,6 +981,20 @@ cache({ store: checkoutCache }, () => [
567
981
  ]);
568
982
  ```
569
983
 
984
+ A per-boundary store becomes reachable by `updateTag()`/`revalidateTag()` only
985
+ once that boundary has been matched in the current process. For data you
986
+ invalidate by tag, prefer the app-level store.
987
+
988
+ If you implement `SegmentCacheStore` yourself, `invalidateTags(tags)` is called
989
+ synchronously inside the invalidating request, and `revalidateTag()` does not
990
+ await it. For that request to read its own writes, record the tags and the
991
+ time as invalidated in request-scoped state your reads check (a `WeakMap`
992
+ keyed by the object `getRequestContext()` returns) before the first `await`,
993
+ then start the durable write. Only let that state turn hits into misses, and
994
+ only for entries written at or before that time. A store that skips this
995
+ still works, but the request that ran `revalidateTag()` can read invalidated
996
+ entries until the durable write lands.
997
+
570
998
  ## Complete Example
571
999
 
572
1000
  ```typescript
@@ -591,7 +1019,8 @@ export const urlpatterns = urls(({ path, layout, cache, loader, revalidate }) =>
591
1019
  layout(<BlogLayout />, () => [
592
1020
  path("/blog", BlogIndex, { name: "blog" }),
593
1021
  path("/blog/:slug", BlogPost, { name: "blogPost" }, () => [
594
- loader(BlogPostLoader, () => [cache()]), // Use boundary cache settings
1022
+ // Uses the app store's defaults, not the enclosing boundary's options
1023
+ loader(BlogPostLoader, () => [cache()]),
595
1024
  ]),
596
1025
  ]),
597
1026
  ]),
@@ -601,7 +1030,10 @@ export const urlpatterns = urls(({ path, layout, cache, loader, revalidate }) =>
601
1030
  path("/shop/product/:slug", ProductPage, { name: "product" }, () => [
602
1031
  loader(ProductLoader, () => [cache({ ttl: 120 })]),
603
1032
  loader(CartLoader, () => [
604
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
1033
+ // after an action, re-run only for cart actions; on navigation, keep the default
1034
+ revalidate((ctx) =>
1035
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
1036
+ ),
605
1037
  ]),
606
1038
  ]),
607
1039
  ]),