@rangojs/router 0.0.0-experimental.98 → 0.0.0-experimental.98914650

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 (355) hide show
  1. package/README.md +24 -9
  2. package/dist/bin/rango.js +157 -63
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +1584 -639
  5. package/package.json +60 -11
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/breadcrumbs/SKILL.md +60 -0
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +222 -30
  10. package/skills/caching/SKILL.md +263 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +235 -28
  16. package/skills/host-router/SKILL.md +122 -22
  17. package/skills/intercept/SKILL.md +29 -5
  18. package/skills/layout/SKILL.md +13 -9
  19. package/skills/links/SKILL.md +173 -17
  20. package/skills/loader/SKILL.md +170 -23
  21. package/skills/middleware/SKILL.md +16 -10
  22. package/skills/migrate-nextjs/SKILL.md +38 -16
  23. package/skills/mime-routes/SKILL.md +27 -0
  24. package/skills/observability/SKILL.md +137 -0
  25. package/skills/parallel/SKILL.md +11 -7
  26. package/skills/prerender/SKILL.md +14 -33
  27. package/skills/rango/SKILL.md +250 -26
  28. package/skills/react-compiler/SKILL.md +168 -0
  29. package/skills/response-routes/SKILL.md +114 -47
  30. package/skills/route/SKILL.md +22 -5
  31. package/skills/router-setup/SKILL.md +3 -3
  32. package/skills/server-actions/SKILL.md +78 -42
  33. package/skills/tailwind/SKILL.md +27 -3
  34. package/skills/testing/SKILL.md +129 -0
  35. package/skills/testing/bindings.md +89 -0
  36. package/skills/testing/cache-prerender.md +124 -0
  37. package/skills/testing/client-components.md +122 -0
  38. package/skills/testing/e2e-parity.md +125 -0
  39. package/skills/testing/flight.md +92 -0
  40. package/skills/testing/handles.md +129 -0
  41. package/skills/testing/loader.md +128 -0
  42. package/skills/testing/middleware.md +99 -0
  43. package/skills/testing/render-handler.md +121 -0
  44. package/skills/testing/response-routes.md +95 -0
  45. package/skills/testing/reverse-and-types.md +84 -0
  46. package/skills/testing/server-actions.md +107 -0
  47. package/skills/testing/server-tree.md +128 -0
  48. package/skills/testing/setup.md +120 -0
  49. package/skills/typesafety/SKILL.md +310 -26
  50. package/skills/use-cache/SKILL.md +36 -5
  51. package/skills/vercel/SKILL.md +107 -0
  52. package/skills/view-transitions/SKILL.md +294 -0
  53. package/src/__augment-tests__/augment.ts +81 -0
  54. package/src/__augment-tests__/augmented.check.ts +116 -0
  55. package/src/__internal.ts +0 -65
  56. package/src/browser/action-coordinator.ts +53 -36
  57. package/src/browser/action-fence.ts +47 -0
  58. package/src/browser/app-shell.ts +14 -27
  59. package/src/browser/cookie-name.ts +140 -0
  60. package/src/browser/event-controller.ts +37 -143
  61. package/src/browser/history-state.ts +21 -0
  62. package/src/browser/index.ts +3 -3
  63. package/src/browser/invalidate-client-cache.ts +52 -0
  64. package/src/browser/navigation-bridge.ts +30 -59
  65. package/src/browser/navigation-client.ts +96 -84
  66. package/src/browser/navigation-store-handle.ts +38 -0
  67. package/src/browser/navigation-store.ts +32 -82
  68. package/src/browser/navigation-transaction.ts +9 -59
  69. package/src/browser/partial-update.ts +60 -127
  70. package/src/browser/prefetch/cache.ts +82 -72
  71. package/src/browser/prefetch/fetch.ts +108 -33
  72. package/src/browser/prefetch/queue.ts +6 -3
  73. package/src/browser/rango-state.ts +157 -115
  74. package/src/browser/react/Link.tsx +0 -2
  75. package/src/browser/react/NavigationProvider.tsx +41 -48
  76. package/src/browser/react/ScrollRestoration.tsx +10 -6
  77. package/src/browser/react/filter-segment-order.ts +0 -2
  78. package/src/browser/react/index.ts +0 -48
  79. package/src/browser/react/location-state-shared.ts +166 -8
  80. package/src/browser/react/location-state.ts +39 -14
  81. package/src/browser/react/use-action.ts +6 -15
  82. package/src/browser/react/use-handle.ts +17 -14
  83. package/src/browser/react/use-link-status.ts +0 -4
  84. package/src/browser/react/use-navigation.ts +0 -3
  85. package/src/browser/react/use-params.ts +3 -6
  86. package/src/browser/react/use-reverse.ts +106 -0
  87. package/src/browser/react/use-router.ts +20 -5
  88. package/src/browser/react/use-search-params.ts +0 -5
  89. package/src/browser/react/use-segments.ts +0 -13
  90. package/src/browser/response-adapter.ts +52 -1
  91. package/src/browser/rsc-router.tsx +70 -34
  92. package/src/browser/scroll-restoration.ts +22 -14
  93. package/src/browser/segment-structure-assert.ts +2 -2
  94. package/src/browser/server-action-bridge.ts +168 -44
  95. package/src/browser/types.ts +36 -21
  96. package/src/browser/validate-redirect-origin.ts +43 -16
  97. package/src/build/collect-fallback-refs.ts +107 -0
  98. package/src/build/generate-manifest.ts +60 -35
  99. package/src/build/generate-route-types.ts +3 -0
  100. package/src/build/index.ts +8 -2
  101. package/src/build/prefix-tree-utils.ts +123 -0
  102. package/src/build/route-trie.ts +89 -11
  103. package/src/build/route-types/codegen.ts +4 -4
  104. package/src/build/route-types/include-resolution.ts +1 -1
  105. package/src/build/route-types/param-extraction.ts +6 -3
  106. package/src/build/route-types/per-module-writer.ts +7 -4
  107. package/src/build/route-types/router-processing.ts +122 -22
  108. package/src/build/route-types/scan-filter.ts +1 -1
  109. package/src/build/route-types/source-scan.ts +118 -0
  110. package/src/build/runtime-discovery.ts +9 -20
  111. package/src/cache/cache-error.ts +104 -0
  112. package/src/cache/cache-policy.ts +68 -28
  113. package/src/cache/cache-runtime.ts +134 -32
  114. package/src/cache/cache-scope.ts +100 -74
  115. package/src/cache/cache-tag.ts +98 -0
  116. package/src/cache/cf/cf-cache-store.ts +2255 -238
  117. package/src/cache/cf/index.ts +6 -16
  118. package/src/cache/document-cache.ts +61 -20
  119. package/src/cache/handle-snapshot.ts +63 -0
  120. package/src/cache/index.ts +22 -20
  121. package/src/cache/memory-segment-store.ts +136 -37
  122. package/src/cache/profile-registry.ts +6 -30
  123. package/src/cache/read-through-swr.ts +41 -11
  124. package/src/cache/segment-codec.ts +0 -16
  125. package/src/cache/tag-invalidation.ts +230 -0
  126. package/src/cache/types.ts +33 -100
  127. package/src/cache/vercel/index.ts +11 -0
  128. package/src/cache/vercel/vercel-cache-store.ts +799 -0
  129. package/src/client.rsc.tsx +6 -21
  130. package/src/client.tsx +25 -61
  131. package/src/component-utils.ts +19 -0
  132. package/src/context-var.ts +17 -5
  133. package/src/decode-loader-results.ts +36 -0
  134. package/src/defer.ts +196 -0
  135. package/src/deps/ssr.ts +0 -1
  136. package/src/errors.ts +30 -4
  137. package/src/handle.ts +31 -23
  138. package/src/handles/MetaTags.tsx +0 -14
  139. package/src/handles/breadcrumbs.ts +16 -5
  140. package/src/handles/meta.ts +0 -39
  141. package/src/host/cookie-handler.ts +0 -36
  142. package/src/host/errors.ts +0 -24
  143. package/src/host/index.ts +8 -2
  144. package/src/host/pattern-matcher.ts +7 -50
  145. package/src/host/router.ts +107 -99
  146. package/src/host/testing.ts +40 -27
  147. package/src/host/types.ts +37 -4
  148. package/src/host/utils.ts +1 -1
  149. package/src/href-client.ts +137 -22
  150. package/src/index.rsc.ts +63 -9
  151. package/src/index.ts +64 -9
  152. package/src/internal-debug.ts +2 -4
  153. package/src/loader-store.ts +500 -0
  154. package/src/loader.rsc.ts +20 -13
  155. package/src/loader.ts +12 -11
  156. package/src/missing-id-error.ts +68 -0
  157. package/src/network-error-thrower.tsx +1 -6
  158. package/src/outlet-provider.tsx +1 -5
  159. package/src/prerender/param-hash.ts +10 -11
  160. package/src/prerender/store.ts +32 -37
  161. package/src/prerender.ts +61 -6
  162. package/src/redirect-origin.ts +100 -0
  163. package/src/response-utils.ts +9 -0
  164. package/src/reverse.ts +65 -41
  165. package/src/root-error-boundary.tsx +1 -19
  166. package/src/route-content-wrapper.tsx +7 -72
  167. package/src/route-definition/dsl-helpers.ts +244 -281
  168. package/src/route-definition/helper-factories.ts +29 -139
  169. package/src/route-definition/helpers-types.ts +40 -17
  170. package/src/route-definition/redirect.ts +43 -9
  171. package/src/route-definition/resolve-handler-use.ts +6 -0
  172. package/src/route-definition/use-item-types.ts +32 -0
  173. package/src/route-map-builder.ts +0 -16
  174. package/src/route-types.ts +19 -41
  175. package/src/router/basename.ts +14 -0
  176. package/src/router/content-negotiation.ts +15 -15
  177. package/src/router/error-handling.ts +13 -17
  178. package/src/router/find-match.ts +44 -23
  179. package/src/router/handler-context.ts +4 -42
  180. package/src/router/intercept-resolution.ts +14 -19
  181. package/src/router/lazy-includes.ts +9 -46
  182. package/src/router/loader-resolution.ts +91 -46
  183. package/src/router/logging.ts +0 -6
  184. package/src/router/manifest.ts +18 -29
  185. package/src/router/match-api.ts +0 -20
  186. package/src/router/match-context.ts +0 -22
  187. package/src/router/match-handlers.ts +57 -58
  188. package/src/router/match-middleware/background-revalidation.ts +0 -7
  189. package/src/router/match-middleware/cache-lookup.ts +150 -271
  190. package/src/router/match-middleware/cache-store.ts +3 -33
  191. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  192. package/src/router/match-middleware/segment-resolution.ts +0 -22
  193. package/src/router/match-pipelines.ts +1 -42
  194. package/src/router/match-result.ts +31 -80
  195. package/src/router/metrics.ts +0 -34
  196. package/src/router/middleware-types.ts +0 -116
  197. package/src/router/middleware.ts +118 -133
  198. package/src/router/navigation-snapshot.ts +0 -51
  199. package/src/router/params-util.ts +23 -0
  200. package/src/router/pattern-matching.ts +20 -58
  201. package/src/router/prerender-match.ts +99 -63
  202. package/src/router/preview-match.ts +3 -1
  203. package/src/router/request-classification.ts +28 -62
  204. package/src/router/revalidation.ts +50 -56
  205. package/src/router/route-snapshot.ts +0 -1
  206. package/src/router/router-context.ts +0 -27
  207. package/src/router/router-interfaces.ts +68 -35
  208. package/src/router/router-options.ts +55 -1
  209. package/src/router/router-registry.ts +2 -5
  210. package/src/router/segment-resolution/fresh.ts +44 -63
  211. package/src/router/segment-resolution/helpers.ts +34 -0
  212. package/src/router/segment-resolution/loader-cache.ts +40 -37
  213. package/src/router/segment-resolution/revalidation.ts +203 -285
  214. package/src/router/segment-resolution/static-store.ts +19 -5
  215. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  216. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  217. package/src/router/segment-resolution.ts +4 -1
  218. package/src/router/segment-wrappers.ts +0 -3
  219. package/src/router/state-cookie-name.ts +33 -0
  220. package/src/router/substitute-pattern-params.ts +56 -0
  221. package/src/router/telemetry-otel.ts +0 -20
  222. package/src/router/telemetry.ts +96 -19
  223. package/src/router/timeout.ts +0 -20
  224. package/src/router/trie-matching.ts +87 -47
  225. package/src/router/types.ts +9 -63
  226. package/src/router/url-params.ts +0 -5
  227. package/src/router.ts +80 -41
  228. package/src/rsc/handler-context.ts +3 -2
  229. package/src/rsc/handler.ts +83 -78
  230. package/src/rsc/helpers.ts +93 -5
  231. package/src/rsc/index.ts +1 -1
  232. package/src/rsc/json-route-result.ts +38 -0
  233. package/src/rsc/manifest-init.ts +28 -41
  234. package/src/rsc/origin-guard.ts +39 -25
  235. package/src/rsc/progressive-enhancement.ts +12 -1
  236. package/src/rsc/redirect-guard.ts +99 -0
  237. package/src/rsc/response-error.ts +79 -12
  238. package/src/rsc/response-route-handler.ts +76 -62
  239. package/src/rsc/rsc-rendering.ts +41 -60
  240. package/src/rsc/runtime-warnings.ts +23 -10
  241. package/src/rsc/server-action.ts +62 -67
  242. package/src/rsc/ssr-setup.ts +16 -0
  243. package/src/rsc/types.ts +10 -5
  244. package/src/runtime-env.ts +18 -0
  245. package/src/search-params.ts +4 -20
  246. package/src/segment-loader-promise.ts +14 -2
  247. package/src/segment-system.tsx +199 -142
  248. package/src/serialize.ts +243 -0
  249. package/src/server/context.ts +150 -51
  250. package/src/server/cookie-store.ts +80 -5
  251. package/src/server/handle-store.ts +7 -24
  252. package/src/server/loader-registry.ts +5 -24
  253. package/src/server/request-context.ts +165 -87
  254. package/src/ssr/index.tsx +14 -14
  255. package/src/static-handler.ts +10 -13
  256. package/src/testing/cache-status.ts +162 -0
  257. package/src/testing/collect-handle.ts +40 -0
  258. package/src/testing/dispatch.ts +618 -0
  259. package/src/testing/dom.entry.ts +22 -0
  260. package/src/testing/e2e/fixture.ts +188 -0
  261. package/src/testing/e2e/index.ts +128 -0
  262. package/src/testing/e2e/matchers.ts +35 -0
  263. package/src/testing/e2e/page-helpers.ts +272 -0
  264. package/src/testing/e2e/parity.ts +387 -0
  265. package/src/testing/e2e/server.ts +195 -0
  266. package/src/testing/flight-matchers.ts +97 -0
  267. package/src/testing/flight-normalize.ts +11 -0
  268. package/src/testing/flight-runtime.d.ts +57 -0
  269. package/src/testing/flight-tree.ts +682 -0
  270. package/src/testing/flight.entry.ts +52 -0
  271. package/src/testing/flight.ts +232 -0
  272. package/src/testing/generated-routes.ts +183 -0
  273. package/src/testing/index.ts +99 -0
  274. package/src/testing/internal/context.ts +348 -0
  275. package/src/testing/internal/flight-client-globals.ts +30 -0
  276. package/src/testing/internal/seed-vars.ts +54 -0
  277. package/src/testing/render-handler.ts +330 -0
  278. package/src/testing/render-route.tsx +566 -0
  279. package/src/testing/run-loader.ts +378 -0
  280. package/src/testing/run-middleware.ts +205 -0
  281. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  282. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  283. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  284. package/src/testing/vitest-stubs/version.ts +5 -0
  285. package/src/testing/vitest.ts +305 -0
  286. package/src/theme/ThemeProvider.tsx +0 -52
  287. package/src/theme/ThemeScript.tsx +0 -6
  288. package/src/theme/constants.ts +0 -12
  289. package/src/theme/index.ts +0 -7
  290. package/src/theme/theme-context.ts +1 -5
  291. package/src/theme/theme-script.ts +0 -14
  292. package/src/theme/use-theme.ts +0 -3
  293. package/src/types/boundaries.ts +0 -35
  294. package/src/types/cache-types.ts +13 -4
  295. package/src/types/error-types.ts +30 -90
  296. package/src/types/global-namespace.ts +54 -41
  297. package/src/types/handler-context.ts +97 -22
  298. package/src/types/index.ts +1 -10
  299. package/src/types/loader-types.ts +6 -3
  300. package/src/types/request-scope.ts +0 -19
  301. package/src/types/route-config.ts +6 -50
  302. package/src/types/route-entry.ts +0 -6
  303. package/src/types/segments.ts +18 -14
  304. package/src/urls/include-helper.ts +9 -56
  305. package/src/urls/index.ts +1 -11
  306. package/src/urls/path-helper-types.ts +19 -5
  307. package/src/urls/path-helper.ts +17 -106
  308. package/src/urls/pattern-types.ts +36 -19
  309. package/src/urls/response-types.ts +20 -19
  310. package/src/urls/type-extraction.ts +58 -139
  311. package/src/urls/urls-function.ts +1 -18
  312. package/src/use-loader.tsx +292 -107
  313. package/src/vite/debug.ts +1 -0
  314. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  315. package/src/vite/discovery/discover-routers.ts +95 -82
  316. package/src/vite/discovery/discovery-errors.ts +194 -0
  317. package/src/vite/discovery/prerender-collection.ts +26 -34
  318. package/src/vite/discovery/route-types-writer.ts +40 -84
  319. package/src/vite/discovery/state.ts +39 -1
  320. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  321. package/src/vite/index.ts +4 -0
  322. package/src/vite/plugin-types.ts +185 -10
  323. package/src/vite/plugins/cjs-to-esm.ts +3 -18
  324. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  325. package/src/vite/plugins/client-ref-hashing.ts +12 -11
  326. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  327. package/src/vite/plugins/expose-action-id.ts +4 -75
  328. package/src/vite/plugins/expose-id-utils.ts +3 -54
  329. package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
  330. package/src/vite/plugins/expose-ids/handler-transform.ts +6 -74
  331. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
  332. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  333. package/src/vite/plugins/expose-internal-ids.ts +57 -67
  334. package/src/vite/plugins/performance-tracks.ts +9 -16
  335. package/src/vite/plugins/refresh-cmd.ts +1 -1
  336. package/src/vite/plugins/use-cache-transform.ts +26 -49
  337. package/src/vite/plugins/vercel-output.ts +258 -0
  338. package/src/vite/plugins/version-injector.ts +2 -32
  339. package/src/vite/plugins/version-plugin.ts +32 -23
  340. package/src/vite/plugins/virtual-entries.ts +35 -17
  341. package/src/vite/rango.ts +148 -115
  342. package/src/vite/router-discovery.ts +220 -68
  343. package/src/vite/utils/ast-handler-extract.ts +15 -31
  344. package/src/vite/utils/bundle-analysis.ts +10 -15
  345. package/src/vite/utils/client-chunks.ts +184 -0
  346. package/src/vite/utils/forward-user-plugins.ts +171 -0
  347. package/src/vite/utils/manifest-utils.ts +4 -59
  348. package/src/vite/utils/package-resolution.ts +1 -73
  349. package/src/vite/utils/prerender-utils.ts +0 -35
  350. package/src/vite/utils/shared-utils.ts +95 -43
  351. package/src/browser/action-response-classifier.ts +0 -99
  352. package/src/browser/react/use-client-cache.ts +0 -58
  353. package/src/browser/shallow.ts +0 -40
  354. package/src/handles/index.ts +0 -7
  355. package/src/router/middleware-cookies.ts +0 -55
@@ -6,8 +6,140 @@ argument-hint:
6
6
 
7
7
  # cache() vs "use cache" — When to Use Which
8
8
 
9
- Both mechanisms share the same backing store, cache profiles, and tag-based
10
- invalidation. They differ in scope, cache key, execution model, and runtime control.
9
+ Both mechanisms share the same backing store and cache profiles, and both accept
10
+ an optional `tags` field (honored by the built-in stores invalidate with
11
+ `updateTag`/`revalidateTag`; see "Two axes" below). They differ in scope, cache
12
+ key, execution model, and runtime control.
13
+
14
+ ## Two axes — do not conflate
15
+
16
+ Everything on this page is **axis 1: stored-value freshness** — _is a cached
17
+ value still good?_ There is a second, orthogonal axis it is easy to mistake for
18
+ caching:
19
+
20
+ 1. **Stored-value freshness** — _is a cached value still good?_
21
+ → `"use cache"` (fn/component), `cache()` (segment), loader `cache()` (loader data).
22
+ Entries expire by **TTL/SWR** and can be tagged (`cache({ tags })` or runtime
23
+ `cacheTag(...tags)`). Built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`)
24
+ index by tag; invalidate on demand with `updateTag(...tags)` (awaitable,
25
+ read-your-own-writes) or `revalidateTag(...tags)` (background, non-blocking).
26
+ Both hard-purge; the difference is awaitability, not stale-serving.
27
+ 2. **Client-update selection** — _should this segment re-run and stream to the
28
+ client on this navigation/action?_
29
+ → `revalidate()`. Covered in `/loader` and `/route`, **not here**.
30
+
31
+ They are orthogonal and compose: a segment selected by `revalidate()` still
32
+ consults its cache (hit → no recompute); a cache bust does **not** force a client
33
+ update, and `revalidate()` never reads, writes, or expires a cached value. If you
34
+ know React Router, `revalidate()` is `shouldRevalidate`, not `Cache-Control`. See
35
+ `/rango` → "Coming from another framework" for the cross-framework mapping.
36
+
37
+ ## Fast choice
38
+
39
+ Read this first; use the rest of the page when the choice has edge cases.
40
+
41
+ 1. Do you want to cache an entire route or group of routes?
42
+ **Yes** -> `cache()`
43
+ 2. Do you need runtime conditions, such as skip for authed users or key by
44
+ locale?
45
+ **Yes** -> `cache()` with `condition` / `key`
46
+ 3. Do you want to cache a data fetch or helper shared across routes?
47
+ **Yes** -> `"use cache"`
48
+ 4. Do you need different cache entries for different function arguments?
49
+ **Yes** -> `"use cache"` (keyed by args)
50
+ 5. Is the expensive part rendering a subtree?
51
+ **Yes** -> `cache()` (caches rendered segments)
52
+ 6. Is the expensive part one query inside a larger live handler?
53
+ **Yes** -> `"use cache"` on the query function
54
+
55
+ ## Correctness & invalidation
56
+
57
+ rango's caches are built so a hit can't serve wrong or stale-shaped data. These
58
+ guarantees are mostly automatic — worth knowing so you don't reimplement
59
+ protection the framework already gives you (or assume one it deliberately
60
+ doesn't).
61
+
62
+ There are two guard models to keep separate. Both block response side effects
63
+ (`ctx.header()`, cookie writes) that would be lost on a hit; they differ in what
64
+ else they allow:
65
+
66
+ - **`cache()` boundary guard** (route-level) — fires while the handler runs on a
67
+ miss. `cookies()` and `headers()` throw (request-scoped data would be baked into
68
+ the shared cached shell), `ctx.get(nonCacheableVar)` throws (a tainted value
69
+ would be baked in), and response side effects (`ctx.header()`, `ctx.setCookie()`,
70
+ `ctx.setStatus()`, `ctx.onResponse()`) throw. `ctx.set()` of a cacheable var is
71
+ **allowed** — children are cached too and can read it. **Loaders are exempt**
72
+ (they always run fresh) — read request data inside a loader.
73
+ - **`"use cache"` exec-guard** (function-level) — the same request-scoped APIs
74
+ throw inside the cached function (`cookies()`, `headers()`, `ctx.set()`,
75
+ `ctx.header()`); additionally, tainted `ctx`/`env`/`req` args are excluded from
76
+ the cache key.
77
+
78
+ ### Cross-deploy safety: version-segmented store keys
79
+
80
+ `CFCacheStore` prefixes every **physical** store key (the CF Cache API URL and
81
+ the KV key) with the build version — auto-generated from the
82
+ `@rangojs/router:version` virtual module, overridable via the store's `version`
83
+ option. A new deploy reads under a new prefix, so it can **never** read a
84
+ previous build's entries: no cross-deploy shape drift, and no dead client-chunk
85
+ references baked into cached RSC.
86
+
87
+ The tradeoff to know: **loader/data caches use the same store**, so they're
88
+ version-segmented too. Every deploy is therefore a _cold data cache_ — SWR can't
89
+ soften it, because no stale entry exists under the new key. For high-traffic,
90
+ frequently-deploying, data-bound apps that's a deploy-time origin warm-up. Decide
91
+ deliberately: accept it (correctness over hit-rate), or split the policy — let
92
+ the render/edge cache auto-version while a separate data store gets a stable
93
+ `version` so its entries survive deploys. (Per-process stores like
94
+ `MemorySegmentCacheStore` are cold on every restart anyway; this matters for
95
+ persistent stores.) See `/caching` for store setup.
96
+
97
+ ### Client cache: forward/back is mutation-aware
98
+
99
+ The browser keeps a history (forward/back) cache of rendered segments. Any
100
+ client-side mutation (a server action) marks those entries **stale** and
101
+ broadcasts it to other tabs. On back/forward (popstate) the router looks up the
102
+ entry, sees it's stale, and revalidates — so your `revalidate()` predicates re-run
103
+ and the segment refreshes (SWR: the stale view paints instantly, fresh data
104
+ streams in). It's the client-side analog of the server-cache correctness problem,
105
+ solved on the partial-render axis.
106
+
107
+ ### Request-scoped data: the `cache: false` taint
108
+
109
+ `createVar({ cache: false })` (or a `ctx.set(var, v, { cache: false })` write)
110
+ taints a value as request-scoped; reading it **directly** with `ctx.get()` inside
111
+ a `cache()` boundary throws — the guard against the catastrophic "serve user A's
112
+ data to user B" bug. The guarantee is precise and intentionally narrow — see
113
+ "Context Variable Cache Safety" below for exactly what it does and does not catch.
114
+
115
+ ## Stale-while-revalidate
116
+
117
+ SWR is a first-class cache behavior when the backing store supports it: while an
118
+ entry is within its SWR window the cache serves the **stale value instantly** and
119
+ refreshes it in the **background** (`waitUntil`), so users never wait on a
120
+ recompute for a merely-aging entry.
121
+
122
+ - **`"use cache"`** resolves to the `default` profile `{ ttl: 900, swr: 1800 }`,
123
+ so function/component caching gets a 30-minute SWR window **out of the box**.
124
+ Tune or add profiles via `createRouter({ cacheProfiles: { … } })`
125
+ (`"use cache: short"` → the `short` profile).
126
+ - **`cache()` DSL and loader caches** take an explicit `swr` in seconds (or
127
+ inherit `store.defaults.swr`): `cache({ ttl: 60, swr: 300 })` → fresh ≤60s,
128
+ stale-served 60–360s, miss after 360s in stores that implement SWR for that
129
+ layer.
130
+ - **Client forward/back** is SWR after a mutation — see "Correctness &
131
+ invalidation" → Client cache.
132
+ - **Edge / document layer** uses the HTTP `stale-while-revalidate` directive; see
133
+ `/document-cache`.
134
+
135
+ SWR softens normal TTL expiry, **not** a cross-deploy cold cache — a new build
136
+ has no stale entry to serve (see version-segmented store keys above).
137
+
138
+ Store support is layer-specific. `CFCacheStore` supports SWR for segment,
139
+ document/response, and `"use cache"` item entries. `MemorySegmentCacheStore`
140
+ supports SWR for response and `"use cache"` item entries, but its route-segment
141
+ entries expire at TTL and never background-revalidate. Use the memory store for
142
+ local/dev behavior, not as proof that segment SWR is active.
11
143
 
12
144
  ## Key Differences
13
145
 
@@ -18,7 +150,7 @@ invalidation. They differ in scope, cache key, execution model, and runtime cont
18
150
  | **Cache key** | Request type + pathname + params (+ optional custom) | Function identity + serialized non-tainted args |
19
151
  | **Execution on hit** | All-or-nothing: entire handler skipped | Partial: function body skipped, calling code runs |
20
152
  | **Runtime control** | `condition` to disable, custom `key` function | None — if the directive is present, it caches |
21
- | **Side effects** | No guards needed handler doesn't run on hit | `ctx.header()`, `ctx.set()`, etc. throw at runtime |
153
+ | **Side effects** | Response side effects throw inside the boundary | `ctx.header()`, `ctx.set()`, etc. throw at runtime |
22
154
  | **Handle data** | Captured and replayed | Captured and replayed |
23
155
  | **Loaders** | Always fresh — excluded from cache, opt-in per loader | Can be used inside loaders |
24
156
  | **Nesting** | Nest `cache()` boundaries with different TTLs | Compose by calling cached functions from uncached |
@@ -144,13 +276,38 @@ On cache hit for the route, the handler doesn't run and `getProductData` is neve
144
276
  called. On cache miss, the handler runs and `getProductData` may itself return a
145
277
  cached value from a previous call with the same slug.
146
278
 
279
+ ### Nesting rule: the outer window bounds the inner
280
+
281
+ A cache's window bounds everything rendered inside it (loaders excepted). An
282
+ inner shorter TTL only takes effect when the **enclosing** cache recomputes — it
283
+ does **not** keep a value fresher than its parent:
284
+
285
+ - Outer `cache()` **fresh hit** → the subtree is served from stored RSC, so inner
286
+ `"use cache"` functions are **not consulted** (frozen at the outer's age — no
287
+ code inside the boundary runs on a hit).
288
+ - Outer **miss / SWR revalidation** → inner caches are consulted, each per its own
289
+ ttl/swr. With SWR on the outer, a stale subtree serves instantly and refreshes
290
+ in the background, so under traffic it keeps refreshing rather than rotting to
291
+ the worst case.
292
+ - **Loaders are the exception** — excluded from the segment cache, re-resolved
293
+ live even on an outer hit.
294
+
295
+ So `"use cache: short"` (60s) inside `cache({ ttl: 600 })` yields ~600s freshness
296
+ on hits, **not** 60s. This is not a bug: setting `cache({ ttl: 600 })` declares
297
+ "this subtree may be ~600s stale." **If a value must be fresher than its
298
+ enclosing segment, put it in a loader** (always live). `debugPerformance` prints
299
+ cache hits per layer, so the actual per-request behavior is observable.
300
+
147
301
  ## Headers and Cookies
148
302
 
149
303
  Neither mechanism caches response headers or cookies.
150
304
 
151
- - **cache()**: Headers set by handlers are naturally absent on hit because no
152
- handler runs. If you need headers on every response, set them in middleware
153
- (which runs before cache lookup).
305
+ - **cache()**: Response-level side effects throw inside the cache boundary even
306
+ on a miss: `ctx.header()`, `ctx.setCookie()`, `ctx.deleteCookie()`,
307
+ `ctx.setStatus()`, `ctx.onResponse()`, and direct `ctx.headers` mutation. On a
308
+ hit the handler would be skipped, so allowing the write on a miss would produce
309
+ inconsistent responses. If you need headers or cookies on every response, set
310
+ them in middleware or a live segment outside the cache boundary.
154
311
  - **"use cache"**: cookies() and headers() throw inside the cached function
155
312
  (both reads and writes). ctx.header() also throws. Move them outside.
156
313
 
@@ -165,8 +322,9 @@ middleware(async (ctx, next) => {
165
322
  ## Context Variable Cache Safety
166
323
 
167
324
  Context variables created with `createVar()` are cacheable by default and can
168
- be read freely inside `cache()` and `"use cache"` scopes. Non-cacheable vars
169
- throw at read time to prevent request-specific data from being captured.
325
+ be read freely inside cached scopes. A non-cacheable var throws when read
326
+ **directly** with `ctx.get()` inside a `cache()` boundary where the value would
327
+ otherwise be serialized into the stored segment.
170
328
 
171
329
  There are two ways to mark a value as non-cacheable:
172
330
 
@@ -181,19 +339,68 @@ ctx.set(Theme, derivedTheme, { cache: false });
181
339
  "Least cacheable wins": if either the var definition or the `ctx.set()` call
182
340
  specifies `cache: false`, the value is non-cacheable.
183
341
 
184
- **Behavior inside cache scopes:**
342
+ **Behavior inside a `cache()` boundary:**
185
343
 
186
- | Operation | Inside `cache()` / `"use cache"` |
187
- | ----------------------------------- | -------------------------------- |
188
- | `ctx.get(cacheableVar)` | Allowed |
189
- | `ctx.get(nonCacheableVar)` | Throws |
190
- | `ctx.set(var, value)` (cacheable) | Allowed |
191
- | `ctx.header()`, `ctx.cookie()`, etc | Throws (response side effects) |
344
+ | Operation | Inside a `cache()` boundary |
345
+ | ----------------------------------------- | ------------------------------------------------------ |
346
+ | `cookies()` / `headers()` (read or write) | Throws (request-scoped, would poison the shared entry) |
347
+ | `ctx.get(cacheableVar)` | Allowed |
348
+ | `ctx.get(nonCacheableVar)` | Throws (would be baked in) |
349
+ | `ctx.set(var, value)` (cacheable) | Allowed |
350
+ | `ctx.header()` / cookie writes | Throws (response side effect would be lost on hit) |
351
+ | Any of the above **inside a loader** | Allowed (loaders always run fresh) |
352
+
353
+ (Both scopes block the same request-scoped APIs — `cookies()`, `headers()`,
354
+ response side effects, and non-cacheable `ctx.get()` — because each would leak
355
+ per-request data into a shared cache entry. The `cache()` boundary tracks the
356
+ scope via `isInsideCacheScope()`; `"use cache"` uses the exec guard and also
357
+ excludes tainted `ctx`/`env`/`req` args from the cache key. Loaders are exempt in
358
+ both — see "Headers and Cookies" and the precise guarantee below.)
192
359
 
193
360
  Write is dumb — `ctx.set()` stores the cache metadata but does not enforce.
194
361
  Enforcement happens at read time (`ctx.get()`), where ALS detects the cache
195
362
  scope and rejects non-cacheable reads.
196
363
 
364
+ ### The guarantee is precise — a direct read inside `cache()`, not propagating
365
+
366
+ The guard fires on a **direct** `ctx.get(taintedVar)` **inside a `cache()`
367
+ boundary** (the scope `isInsideCacheScope` detects). The taint lives on the
368
+ variable; a value **derived** from it and read **outside** the boundary is not
369
+ tracked:
370
+
371
+ ```typescript
372
+ // CAUGHT — direct read of a tainted var inside a cache() boundary
373
+ cache({ ttl: 60 }, () => [
374
+ path("/dashboard", (ctx) => {
375
+ const user = ctx.get(User); // throws: non-cacheable read inside cache()
376
+ return <Dashboard user={user} />;
377
+ }, { name: "dashboard" }),
378
+ ]);
379
+
380
+ // NOT CAUGHT — read outside the boundary, derived value cached
381
+ layout((ctx) => {
382
+ const name = ctx.get(User).name; // allowed — this layout is not cached
383
+ ctx.set(UserName, name); // now a plain (cacheable) string
384
+ return <Outlet />;
385
+ }, () => [
386
+ cache({ ttl: 60 }, () => [
387
+ // a child reads ctx.get(UserName) and silently caches user-derived data
388
+ ]),
389
+ ]);
390
+ ```
391
+
392
+ So do **not** read this as "you can't cache user data" — that overstates it and
393
+ breeds the false confidence that makes the derived leak _more_ likely. The guard
394
+ is deliberately non-propagating (propagation would cost a wrapper per derivation
395
+ on the hot path), and it is scoped to the `cache()` segment boundary. `"use
396
+ cache"` functions block the same request-scoped reads (`cookies()` / `headers()`
397
+ throw inside them) and additionally exclude tainted `ctx`/`env`/`req` args from
398
+ the cache key. The pattern that stays safe is also the natural one:
399
+ **read tainted context at the point of use, in the path that needs it (a loader or
400
+ live segment) — never extract user data into a plain value and cache that.**
401
+ Loaders are exempt because they run outside the cache scope and resolve fresh
402
+ every request.
403
+
197
404
  ## Loaders Are Always Fresh
198
405
 
199
406
  Loaders are **never cached** by route-level `cache()`. Even on a full cache hit
@@ -272,21 +479,6 @@ data is cached independently from the route's segment cache. Loader caching
272
479
  supports custom keys, tags, SWR, conditional bypass, and per-loader store
273
480
  overrides — see `/loader` for the full reference.
274
481
 
275
- ## Decision Flowchart
276
-
277
- 1. Do you want to cache an entire route or group of routes?
278
- **Yes** -> `cache()`
279
- 2. Do you need runtime conditions (skip for auth users, key by locale)?
280
- **Yes** -> `cache()` with `condition` / `key`
281
- 3. Do you want to cache a data fetch shared across routes?
282
- **Yes** -> `"use cache"`
283
- 4. Do you need different cache entries for different arguments?
284
- **Yes** -> `"use cache"` (keyed by args)
285
- 5. Is the expensive part rendering, not data fetching?
286
- **Yes** -> `cache()` (caches rendered segments)
287
- 6. Is the expensive part a single query inside a larger handler?
288
- **Yes** -> `"use cache"` on the query function
289
-
290
482
  ## See Also
291
483
 
292
484
  - `/caching` — cache() DSL setup, stores, nested boundaries
@@ -8,6 +8,46 @@ argument-hint: [setup]
8
8
 
9
9
  @rangojs/router supports segment-level caching with stale-while-revalidate (SWR) for optimal performance.
10
10
 
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`.
16
+
17
+ ## cache() is Partial Prerendering (PPR)
18
+
19
+ `cache()` caches **everything except loaders**. On a cache hit, the cached
20
+ segments (layouts, route components, parallels — including any resolved
21
+ Suspense) are served from the store, and **loaders re-run fresh on every
22
+ request**, streaming their results into the same response. Loaders are the
23
+ dynamic "holes" of an otherwise-cached tree.
24
+
25
+ This means a `cache()` boundary at the document root **is** whole-document
26
+ Partial Prerendering: the static shell is cached and served instantly while
27
+ per-request/per-user data stays live — in one streamed response, no extra round
28
+ trip. The browser cannot tell the shell came from cache.
29
+
30
+ ```typescript
31
+ cache({ ttl: 60, swr: 300 }, () => [
32
+ layout(<RootLayout />), // cached shell
33
+ path("/dashboard", Dashboard, { name: "dashboard" }, () => [
34
+ loader(StatsLoader), // DYNAMIC HOLE — re-runs every request
35
+ ]),
36
+ ]);
37
+ ```
38
+
39
+ The consumer rule: **want it cached? render it inline. want it dynamic? put it
40
+ in a loader and read it with `useLoader()` in a client component.** Anything
41
+ read with `cookies()`, `headers()`, or a non-cacheable variable belongs in a
42
+ loader (loaders always run fresh). Reading it directly in a cached handler
43
+ throws; awaiting it with `ctx.use()` and rendering the result in a cached
44
+ handler silently bakes per-request data into the shared shell (see "Cache purity
45
+ & tainted objects" below).
46
+
47
+ Pre-rendering (`/prerender`) is the build-time twin: it caches the same shell at
48
+ build time instead of on first request. Both feed the segment system
49
+ identically, and loaders always run fresh at request time.
50
+
11
51
  ## Route-Level Caching with cache()
12
52
 
13
53
  Use the `cache()` DSL function to cache routes:
@@ -41,6 +81,78 @@ cache(
41
81
  );
42
82
  ```
43
83
 
84
+ ## Tag-Based Invalidation
85
+
86
+ Tag cached entries, then invalidate them on demand. Tags can be attached three ways:
87
+
88
+ ```typescript
89
+ // 1. Static tags in the cache() DSL
90
+ cache({ ttl: 300, tags: ["products"] }, () => [path("/products", List)]);
91
+
92
+ // 2. Dynamic tags (function of ctx)
93
+ cache(
94
+ { ttl: 300, tags: (ctx) => [`product:${ctx.params.id}`, "products"] },
95
+ () => [path("/products/:id", Detail)],
96
+ );
97
+
98
+ // 3. Runtime tags inside a "use cache" function
99
+ async function getProduct(id: string) {
100
+ "use cache";
101
+ cacheTag(`product:${id}`, "products"); // variadic, additive
102
+ return db.getProduct(id);
103
+ }
104
+ ```
105
+
106
+ Invalidate with one of two server-only verbs (both variadic, imported from
107
+ `@rangojs/router`):
108
+
109
+ ```typescript
110
+ // Server Action — read-your-own-writes. Await it so the action's own re-render
111
+ // (and the next navigation) sees fresh data.
112
+ async function updateProduct(formData: FormData) {
113
+ "use server";
114
+ await db.updateProduct(formData);
115
+ await updateTag("products");
116
+ }
117
+
118
+ // Route handler / webhook — background, non-blocking (waitUntil). Hard-purge:
119
+ // the next read re-renders fresh (NOT stale-while-revalidate).
120
+ export async function POST() {
121
+ "use server";
122
+ revalidateTag("products");
123
+ return new Response("ok");
124
+ }
125
+ ```
126
+
127
+ | API | Timing | Use in | Semantics |
128
+ | ------------------------ | --------------------------- | ------------------------- | ----------------------------------------------------- |
129
+ | `updateTag(...tags)` | awaitable (`Promise<void>`) | server actions | immediate; next read is fresh |
130
+ | `revalidateTag(...tags)` | background (`void`) | route handlers / webhooks | background (non-blocking); next read re-renders fresh |
131
+
132
+ Both built-in stores support tags. For `CFCacheStore`, distributed (cross-colo)
133
+ invalidation requires a `kv` namespace — the tag-invalidation markers live in
134
+ that same namespace; there is **no** separate tag-invalidation store to wire.
135
+ If no tag-capable store is configured, `updateTag`/`revalidateTag` warn and no-op.
136
+
137
+ By default `CFCacheStore` reads the KV marker on every tagged cache read
138
+ (strongest invalidation latency). To cut KV reads on hot tagged routes, set
139
+ `tagCacheTtl` (seconds) to cache each marker in the per-colo edge cache for that
140
+ window — the colo running `updateTag`/`revalidateTag` writes the fresh marker
141
+ into its own edge cache immediately (read-your-own-writes), while other colos
142
+ converge within `tagCacheTtl` (the **maximum extra cross-colo invalidation
143
+ latency** when no purge is wired). Keep it small (e.g. 30–60), or wire a purge
144
+ (below) and set it large. (Contrast `tagInvalidationTtl`, which must be _large_
145
+ — it bounds how long the KV marker itself lives and must exceed your max entry
146
+ TTL+SWR.)
147
+
148
+ To make other colos prompt without a short `tagCacheTtl`, pass `onRevalidateTag`:
149
+ each cached marker carries a namespaced Cloudflare `Cache-Tag`, and the hook is
150
+ handed exactly those tags (batched, once per `updateTag`/`revalidateTag` call) to
151
+ feed Cloudflare's purge-by-tag API — evicting the cached lookups everywhere.
152
+ Purge-by-tag is available on all plans (since April 2025), subject to per-plan
153
+ rate limits, so the batched single call matters. With a purge wired, `tagCacheTtl`
154
+ becomes a pure read-cost reducer + fallback window.
155
+
44
156
  ## Named Profile Shorthand
45
157
 
46
158
  Use a named cache profile string instead of an options object. The profile must be
@@ -116,7 +228,6 @@ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
116
228
 
117
229
  const store = new MemorySegmentCacheStore({
118
230
  defaults: { ttl: 60, swr: 300 },
119
- maxSize: 1000, // Max entries
120
231
  });
121
232
  ```
122
233
 
@@ -173,13 +284,156 @@ const router = createRouter<AppBindings>({
173
284
  KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
174
285
  are only cached in L1.
175
286
 
176
- ## Context Variables Inside Cache Boundaries
287
+ ### Resilience & latency budgets
288
+
289
+ Every cache read is **fail-safe**: a degraded tier never stalls or fails the
290
+ request — it degrades to the next tier (L1 → L2 → render). Three optional latency
291
+ budgets (milliseconds) bound each tier so a slow colo or KV namespace cannot pin
292
+ a request behind it:
293
+
294
+ | Option | Default | Bounds |
295
+ | --------------------- | ------- | ----------------------------------- |
296
+ | `edgeLookupTimeoutMs` | `10` | L1 `cache.match` (the lookup) |
297
+ | `edgeReadTimeoutMs` | `20` | L1 body read (CF streams it lazily) |
298
+ | `kvReadTimeoutMs` | `170` | L2 / KV read |
299
+
300
+ Set any to `0` (or a negative value) to disable that budget and always await the
301
+ read. A non-finite value (e.g. `Number(env.UNSET)`) falls back to the default.
302
+ The tag-invalidation marker reads inherit these same budgets and **fail open** on
303
+ a KV timeout — the entry is served rather than wrongly treated as invalidated.
304
+
305
+ ```typescript
306
+ new CFCacheStore({
307
+ ctx,
308
+ kv: env.CACHE_KV,
309
+ defaults: { ttl: 60, swr: 300 },
310
+ // Raise a budget only if your HEALTHY reads legitimately run slower (large
311
+ // Flight payloads, far-from-colo regions); measure the p99 first. These are
312
+ // degradation guard-rails, not tuning levers for "slow is normal here".
313
+ kvReadTimeoutMs: 250,
314
+ });
315
+ ```
316
+
317
+ Failure handling, by kind — none of these fail the request:
318
+
319
+ | Failure | Behavior |
320
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
321
+ | Transient read error (5xx/blip) | Degrade to the next tier; entry left intact |
322
+ | Read budget exceeded (timeout) | Abandon the read, degrade to the next tier |
323
+ | Corrupt / unparseable L1 entry | Reported corrupt; degrade to L2 (served if present). The L1 entry is evicted ONLY when L2 has no copy — so the evict can't race the L2→L1 promote |
324
+ | Corrupt / unparseable KV entry | Reported corrupt; evicted (self-heal) + render (no tier below it) |
325
+ | Write failure | No-op (entry simply not cached); never throws |
326
+
327
+ Each is surfaced to the router's `onError` callback (phase `"cache"`, with
328
+ `metadata.category` one of `cache-read`, `cache-corrupt`, `cache-write`,
329
+ `cache-delete`, `cache-invalidate`, `stale-revalidation`) so you can observe
330
+ cache health without affecting users.
331
+
332
+ ### Validating cache behavior with `debug`
333
+
334
+ Pass `debug` to emit one structured event per L1 read — use it to confirm on a
335
+ real deployment (via `wrangler tail`) that the store behaves as expected before
336
+ relying on it. It is intended for validation, not steady-state production.
337
+
338
+ ```typescript
339
+ new CFCacheStore({
340
+ ctx,
341
+ kv: env.CACHE_KV,
342
+ debug: true, // logs each CFCacheReadDebugEvent to the console
343
+ // ...or capture programmatically:
344
+ // debug: (event) => myTelemetry.record(event),
345
+ });
346
+ ```
347
+
348
+ Each event reports which tier answered and why (`outcome`: `l1-fresh`,
349
+ `l1-stale-revalidate`, `l1-revalidating-guarded`, `match-timeout`, `match-error`,
350
+ `body-timeout`, `body-error`, `non-200`, `tag-invalidated`, `l1-miss`, `kv-fresh`,
351
+ `kv-stale`, `kv-stale-suppressed`, `kv-miss`, `kv-timeout`, `error`), the
352
+ staleness / revalidating timestamps, and the measured per-tier durations:
353
+ `matchMs` (the L1 `match`), `markerMs` (the tag-marker resolution tail for a
354
+ tagged entry, between `matchMs` and `bodyReadMs`; absent or 0 for an untagged
355
+ entry or a per-request memo hit), and `bodyReadMs` (the L1 body read). A
356
+ persistently large `markerMs` signals a degraded KV namespace; on a healthy
357
+ deployment KV keeps markers hot in its per-colo edge cache, so it stays a few
358
+ milliseconds. `match-error` (a transient `cache.match` rejection that falls
359
+ through to L2) is kept distinct from a plain `l1-miss`.
360
+
361
+ ## Cache purity & tainted objects
362
+
363
+ A `cache()` boundary caches everything except loaders, so anything read inside a
364
+ cached handler is **frozen into the shared cache entry** and served to every
365
+ subsequent visitor. To stop one user's request-scoped data from leaking to
366
+ another, request-scoped APIs are guarded inside a cache scope:
367
+
368
+ | Inside a `cache()` boundary | Behavior |
369
+ | --------------------------------------------------------------- | --------------------------------------------------- |
370
+ | `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
371
+ | `ctx.header()` / `setCookie()` / `setStatus()` / `onResponse()` | **throws** — response side effects lost on a hit |
372
+ | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
373
+ | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
374
+ | Any of the above **inside a loader** | **allowed** — loaders always run fresh |
375
+
376
+ **Tainted objects.** Request-scoped objects (`ctx`, `env`, `request`) carry an
377
+ internal taint symbol so they are excluded from `"use cache"` cache keys, and
378
+ the cache scope is tracked via async-local state. Two flags back the guards:
379
+ `INSIDE_CACHE_EXEC` (set while a `"use cache"` function runs) and the `cache()`
380
+ DSL scope (`isInsideCacheScope()`). `isInsideCacheScope()` deliberately returns
381
+ `false` inside loaders — which is exactly why loaders are the dynamic holes:
382
+ they may read `cookies()`/`headers()` and re-run on every request.
383
+
384
+ The fix for "I need request data in a cached route": register a `loader()` and
385
+ **consume it with `useLoader()` in a client component**. The loader is the
386
+ dynamic hole — its data rides the fresh (never-cached) loader segment and is
387
+ rendered in the client component, so it never lands in the cached shell.
388
+
389
+ This is NOT the same as awaiting the loader in the handler. A cached handler
390
+ that does `await ctx.use(Loader)` and renders the result bakes that per-request
391
+ data straight into the shared cached segment — the loader running "fresh" does
392
+ not help, because its output was inlined into the cached parent, and `ctx.use()`
393
+ is **not** guarded. `ctx.use()` is a server-side escape hatch for non-rendered
394
+ uses (set a ctx var, make a routing decision); never render its result inside a
395
+ cached handler.
396
+
397
+ ```typescript
398
+ // WRONG — throws: cookies() read directly in a cached handler
399
+ cache({ ttl: 60 }, () => [
400
+ path("/me", () => <Profile id={cookies().get("uid")?.value} />),
401
+ ]);
402
+
403
+ // ALSO WRONG (unguarded, but leaks) — the awaited loader data is rendered into
404
+ // the cached handler, so the user's data is frozen into the shared shell.
405
+ cache({ ttl: 60 }, () => [
406
+ path(
407
+ "/me",
408
+ async (ctx) => {
409
+ const { user } = await ctx.use(MeLoader); // runs fresh…
410
+ return <Profile user={user} />; // …but inlined into the CACHED segment → leak
411
+ },
412
+ { name: "me" },
413
+ () => [loader(MeLoader)],
414
+ ),
415
+ ]);
416
+
417
+ // RIGHT — consume the loader in a CLIENT component via useLoader(). The cached
418
+ // route segment holds only the <Profile/> reference; the user data rides the
419
+ // fresh loader segment and renders client-side.
420
+
421
+ // profile.tsx (client component)
422
+ "use client";
423
+ import { useLoader } from "@rangojs/router/client";
424
+
425
+ export function Profile() {
426
+ const { user } = useLoader(MeLoader); // fresh per request; never cached
427
+ return <span>{user.name}</span>;
428
+ }
429
+
430
+ // urls — register the loader; MeLoader reads cookies() inside the loader (allowed)
431
+ cache({ ttl: 60 }, () => [
432
+ path("/me", () => <Profile />, { name: "me" }, () => [loader(MeLoader)]),
433
+ ]);
434
+ ```
177
435
 
178
- Context variables (`createVar`) are cacheable by default and can be read and
179
- written inside `cache()` scopes. Variables marked with `{ cache: false }` (at
180
- the var level or write level) throw when read inside a cache scope. Response
181
- side effects (`ctx.header()`, `ctx.cookie()`) always throw inside cache
182
- boundaries. See `/cache-guide` for the full cache safety table.
436
+ See `/cache-guide` for the full decision guide and the `cache()` vs `"use cache"` comparison.
183
437
 
184
438
  ## Nested Cache Boundaries
185
439
 
@@ -217,6 +471,7 @@ cache({ store: checkoutCache }, () => [
217
471
  ```typescript
218
472
  import { urls } from "@rangojs/router";
219
473
  import { MemorySegmentCacheStore } from "@rangojs/router/cache";
474
+ import * as CartActions from "./actions/cart";
220
475
 
221
476
  // Custom store for checkout (short TTL)
222
477
  const checkoutCache = new MemorySegmentCacheStore({
@@ -245,7 +500,7 @@ export const urlpatterns = urls(({ path, layout, cache, loader, revalidate }) =>
245
500
  path("/shop/product/:slug", ProductPage, { name: "product" }, () => [
246
501
  loader(ProductLoader, () => [cache({ ttl: 120 })]),
247
502
  loader(CartLoader, () => [
248
- revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
503
+ revalidate((ctx) => ctx.isAction(CartActions) || undefined),
249
504
  ]),
250
505
  ]),
251
506
  ]),
@@ -55,7 +55,9 @@ import { cache, revalidate, loading, errorBoundary, middleware } from "@rangojs/
55
55
  // Shared caching configuration
56
56
  const withCaching = () => [
57
57
  cache({ ttl: 600_000 }),
58
- revalidate(({ actionId }) => !!actionId),
58
+ // Defer on navigation (|| undefined) so each route keeps its own param/search
59
+ // revalidation default; only force a re-run when an action ran.
60
+ revalidate(({ actionId }) => (actionId ? true : undefined)),
59
61
  ];
60
62
 
61
63
  // Shared loading and error handling
@@ -71,6 +73,29 @@ const withAuth = () => [
71
73
  ];
72
74
  ```
73
75
 
76
+ > **Factories compose logic, not just values.** A `revalidate()` predicate in a
77
+ > shared factory applies its logic to _every_ route that composes it, so a
78
+ > footgun here is amplified across the app. Two rules:
79
+ >
80
+ > 1. Use `|| undefined` (defer), not `?? false` (hard short-circuit), in shared
81
+ > predicates — a hard `false` ends the chain and overrides each consuming
82
+ > route's own default, and a downstream revalidator never runs. See `/loader`
83
+ > → "`|| undefined` (defer) vs `?? false` (hard)".
84
+ > 2. Match actions with `ctx.isAction(Action)`, not an inline
85
+ > `actionId.includes("…")` buried in a factory: it resolves the action from an
86
+ > imported reference, so a rename is a compile error in one place instead of
87
+ > silent drift across every consumer.
88
+ >
89
+ > Remember the axis: a factory's `revalidate()` controls client-update
90
+ > selection, while its `cache()` controls stored-value freshness. They are
91
+ > independent even when bundled in the same factory (`/cache-guide` → "Two axes").
92
+
93
+ > **Keep factories small and intention-named.** The anti-pattern that kills
94
+ > readability is over-bundling — a `withDefaults()` that secretly adds five
95
+ > things — and factory-of-factories nesting (leaning on `.flat(3)`). Surprising
96
+ > config stays inline; extract only the boring, repeated parts; compose by
97
+ > _naming concerns_ (`withAuth()`, `withCaching()`), not by hiding them.
98
+
74
99
  ## Using Factories in Routes
75
100
 
76
101
  Place factory calls inside `path()` or `layout()` use callbacks. The returned arrays are flattened automatically (up to 3 levels):
@@ -107,7 +132,7 @@ import { authMiddleware } from "./middleware/auth";
107
132
 
108
133
  export const withPublicDefaults = () => [
109
134
  cache({ ttl: 300 }),
110
- revalidate(({ actionId }) => !!actionId),
135
+ revalidate(({ actionId }) => (actionId ? true : undefined)),
111
136
  ];
112
137
 
113
138
  export const withProtectedDefaults = () => [