@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dc2bd2b4

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 (253) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +647 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +84 -11
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +2 -5
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +21 -0
  183. package/src/testing/flight.entry.ts +22 -0
  184. package/src/testing/flight.ts +182 -0
  185. package/src/testing/generated-routes.ts +223 -0
  186. package/src/testing/index.ts +105 -0
  187. package/src/testing/internal/context.ts +193 -0
  188. package/src/testing/render-route.tsx +536 -0
  189. package/src/testing/run-loader.ts +296 -0
  190. package/src/testing/run-middleware.ts +170 -0
  191. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  192. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  193. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  194. package/src/testing/vitest-stubs/version.ts +5 -0
  195. package/src/testing/vitest.ts +183 -0
  196. package/src/types/global-namespace.ts +39 -26
  197. package/src/types/handler-context.ts +68 -50
  198. package/src/types/index.ts +1 -0
  199. package/src/types/loader-types.ts +5 -6
  200. package/src/types/request-scope.ts +126 -0
  201. package/src/types/route-entry.ts +11 -0
  202. package/src/types/segments.ts +35 -2
  203. package/src/urls/include-helper.ts +34 -67
  204. package/src/urls/index.ts +0 -3
  205. package/src/urls/path-helper-types.ts +41 -7
  206. package/src/urls/path-helper.ts +17 -52
  207. package/src/urls/pattern-types.ts +36 -19
  208. package/src/urls/response-types.ts +22 -29
  209. package/src/urls/type-extraction.ts +26 -116
  210. package/src/urls/urls-function.ts +1 -5
  211. package/src/use-loader.tsx +413 -42
  212. package/src/vite/debug.ts +185 -0
  213. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  214. package/src/vite/discovery/discover-routers.ts +101 -51
  215. package/src/vite/discovery/discovery-errors.ts +194 -0
  216. package/src/vite/discovery/gate-state.ts +171 -0
  217. package/src/vite/discovery/prerender-collection.ts +67 -26
  218. package/src/vite/discovery/route-types-writer.ts +40 -84
  219. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  220. package/src/vite/discovery/state.ts +33 -0
  221. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  222. package/src/vite/index.ts +2 -0
  223. package/src/vite/plugin-types.ts +67 -0
  224. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  225. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  226. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  227. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  228. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  229. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  230. package/src/vite/plugins/expose-action-id.ts +54 -30
  231. package/src/vite/plugins/expose-id-utils.ts +12 -8
  232. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  233. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  234. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  235. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  236. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  237. package/src/vite/plugins/performance-tracks.ts +29 -25
  238. package/src/vite/plugins/use-cache-transform.ts +65 -50
  239. package/src/vite/plugins/version-injector.ts +39 -23
  240. package/src/vite/plugins/version-plugin.ts +59 -2
  241. package/src/vite/plugins/virtual-entries.ts +2 -2
  242. package/src/vite/rango.ts +116 -29
  243. package/src/vite/router-discovery.ts +750 -100
  244. package/src/vite/utils/ast-handler-extract.ts +15 -15
  245. package/src/vite/utils/banner.ts +1 -1
  246. package/src/vite/utils/bundle-analysis.ts +4 -2
  247. package/src/vite/utils/client-chunks.ts +190 -0
  248. package/src/vite/utils/forward-user-plugins.ts +193 -0
  249. package/src/vite/utils/manifest-utils.ts +21 -5
  250. package/src/vite/utils/package-resolution.ts +41 -1
  251. package/src/vite/utils/prerender-utils.ts +21 -6
  252. package/src/vite/utils/shared-utils.ts +107 -26
  253. package/src/browser/action-response-classifier.ts +0 -99
@@ -6,8 +6,138 @@ 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 (not yet honored by the built-in stores — see "Two axes"
11
+ below). They differ in scope, cache key, execution model, and runtime control.
12
+
13
+ ## Two axes — do not conflate
14
+
15
+ Everything on this page is **axis 1: stored-value freshness** — _is a cached
16
+ value still good?_ There is a second, orthogonal axis it is easy to mistake for
17
+ caching:
18
+
19
+ 1. **Stored-value freshness** — _is a cached value still good?_
20
+ → `"use cache"` (fn/component), `cache()` (segment), loader `cache()` (loader data).
21
+ Entries expire by **TTL/SWR**. They accept an optional `tags` field, but the
22
+ built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`) do not yet index or
23
+ invalidate by tag, so tag-based invalidation (`revalidateTag`) is a
24
+ forward-looking API requiring a custom store with secondary indices.
25
+ 2. **Client-update selection** — _should this segment re-run and stream to the
26
+ client on this navigation/action?_
27
+ → `revalidate()`. Covered in `/loader` and `/route`, **not here**.
28
+
29
+ They are orthogonal and compose: a segment selected by `revalidate()` still
30
+ consults its cache (hit → no recompute); a cache bust does **not** force a client
31
+ update, and `revalidate()` never reads, writes, or expires a cached value. If you
32
+ know React Router, `revalidate()` is `shouldRevalidate`, not `Cache-Control`. See
33
+ `/rango` → "Coming from another framework" for the cross-framework mapping.
34
+
35
+ ## Fast choice
36
+
37
+ Read this first; use the rest of the page when the choice has edge cases.
38
+
39
+ 1. Do you want to cache an entire route or group of routes?
40
+ **Yes** -> `cache()`
41
+ 2. Do you need runtime conditions, such as skip for authed users or key by
42
+ locale?
43
+ **Yes** -> `cache()` with `condition` / `key`
44
+ 3. Do you want to cache a data fetch or helper shared across routes?
45
+ **Yes** -> `"use cache"`
46
+ 4. Do you need different cache entries for different function arguments?
47
+ **Yes** -> `"use cache"` (keyed by args)
48
+ 5. Is the expensive part rendering a subtree?
49
+ **Yes** -> `cache()` (caches rendered segments)
50
+ 6. Is the expensive part one query inside a larger live handler?
51
+ **Yes** -> `"use cache"` on the query function
52
+
53
+ ## Correctness & invalidation
54
+
55
+ rango's caches are built so a hit can't serve wrong or stale-shaped data. These
56
+ guarantees are mostly automatic — worth knowing so you don't reimplement
57
+ protection the framework already gives you (or assume one it deliberately
58
+ doesn't).
59
+
60
+ There are two guard models to keep separate. Both block response side effects
61
+ (`ctx.header()`, cookie writes) that would be lost on a hit; they differ in what
62
+ else they allow:
63
+
64
+ - **`cache()` boundary guard** (route-level) — fires while the handler runs on a
65
+ miss. `cookies()` and `headers()` throw (request-scoped data would be baked into
66
+ the shared cached shell), `ctx.get(nonCacheableVar)` throws (a tainted value
67
+ would be baked in), and response side effects (`ctx.header()`, `ctx.setCookie()`,
68
+ `ctx.setStatus()`, `ctx.onResponse()`) throw. `ctx.set()` of a cacheable var is
69
+ **allowed** — children are cached too and can read it. **Loaders are exempt**
70
+ (they always run fresh) — read request data inside a loader.
71
+ - **`"use cache"` exec-guard** (function-level) — the same request-scoped APIs
72
+ throw inside the cached function (`cookies()`, `headers()`, `ctx.set()`,
73
+ `ctx.header()`); additionally, tainted `ctx`/`env`/`req` args are excluded from
74
+ the cache key.
75
+
76
+ ### Cross-deploy safety: version-segmented store keys
77
+
78
+ `CFCacheStore` prefixes every **physical** store key (the CF Cache API URL and
79
+ the KV key) with the build version — auto-generated from the
80
+ `@rangojs/router:version` virtual module, overridable via the store's `version`
81
+ option. A new deploy reads under a new prefix, so it can **never** read a
82
+ previous build's entries: no cross-deploy shape drift, and no dead client-chunk
83
+ references baked into cached RSC.
84
+
85
+ The tradeoff to know: **loader/data caches use the same store**, so they're
86
+ version-segmented too. Every deploy is therefore a _cold data cache_ — SWR can't
87
+ soften it, because no stale entry exists under the new key. For high-traffic,
88
+ frequently-deploying, data-bound apps that's a deploy-time origin warm-up. Decide
89
+ deliberately: accept it (correctness over hit-rate), or split the policy — let
90
+ the render/edge cache auto-version while a separate data store gets a stable
91
+ `version` so its entries survive deploys. (Per-process stores like
92
+ `MemorySegmentCacheStore` are cold on every restart anyway; this matters for
93
+ persistent stores.) See `/caching` for store setup.
94
+
95
+ ### Client cache: forward/back is mutation-aware
96
+
97
+ The browser keeps a history (forward/back) cache of rendered segments. Any
98
+ client-side mutation (a server action) marks those entries **stale** and
99
+ broadcasts it to other tabs. On back/forward (popstate) the router looks up the
100
+ entry, sees it's stale, and revalidates — so your `revalidate()` predicates re-run
101
+ and the segment refreshes (SWR: the stale view paints instantly, fresh data
102
+ streams in). It's the client-side analog of the server-cache correctness problem,
103
+ solved on the partial-render axis.
104
+
105
+ ### Request-scoped data: the `cache: false` taint
106
+
107
+ `createVar({ cache: false })` (or a `ctx.set(var, v, { cache: false })` write)
108
+ taints a value as request-scoped; reading it **directly** with `ctx.get()` inside
109
+ a `cache()` boundary throws — the guard against the catastrophic "serve user A's
110
+ data to user B" bug. The guarantee is precise and intentionally narrow — see
111
+ "Context Variable Cache Safety" below for exactly what it does and does not catch.
112
+
113
+ ## Stale-while-revalidate
114
+
115
+ SWR is a first-class cache behavior when the backing store supports it: while an
116
+ entry is within its SWR window the cache serves the **stale value instantly** and
117
+ refreshes it in the **background** (`waitUntil`), so users never wait on a
118
+ recompute for a merely-aging entry.
119
+
120
+ - **`"use cache"`** resolves to the `default` profile `{ ttl: 900, swr: 1800 }`,
121
+ so function/component caching gets a 30-minute SWR window **out of the box**.
122
+ Tune or add profiles via `createRouter({ cacheProfiles: { … } })`
123
+ (`"use cache: short"` → the `short` profile).
124
+ - **`cache()` DSL and loader caches** take an explicit `swr` in seconds (or
125
+ inherit `store.defaults.swr`): `cache({ ttl: 60, swr: 300 })` → fresh ≤60s,
126
+ stale-served 60–360s, miss after 360s in stores that implement SWR for that
127
+ layer.
128
+ - **Client forward/back** is SWR after a mutation — see "Correctness &
129
+ invalidation" → Client cache.
130
+ - **Edge / document layer** uses the HTTP `stale-while-revalidate` directive; see
131
+ `/document-cache`.
132
+
133
+ SWR softens normal TTL expiry, **not** a cross-deploy cold cache — a new build
134
+ has no stale entry to serve (see version-segmented store keys above).
135
+
136
+ Store support is layer-specific. `CFCacheStore` supports SWR for segment,
137
+ document/response, and `"use cache"` item entries. `MemorySegmentCacheStore`
138
+ supports SWR for response and `"use cache"` item entries, but its route-segment
139
+ entries expire at TTL and never background-revalidate. Use the memory store for
140
+ local/dev behavior, not as proof that segment SWR is active.
11
141
 
12
142
  ## Key Differences
13
143
 
@@ -18,7 +148,7 @@ invalidation. They differ in scope, cache key, execution model, and runtime cont
18
148
  | **Cache key** | Request type + pathname + params (+ optional custom) | Function identity + serialized non-tainted args |
19
149
  | **Execution on hit** | All-or-nothing: entire handler skipped | Partial: function body skipped, calling code runs |
20
150
  | **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 |
151
+ | **Side effects** | Response side effects throw inside the boundary | `ctx.header()`, `ctx.set()`, etc. throw at runtime |
22
152
  | **Handle data** | Captured and replayed | Captured and replayed |
23
153
  | **Loaders** | Always fresh — excluded from cache, opt-in per loader | Can be used inside loaders |
24
154
  | **Nesting** | Nest `cache()` boundaries with different TTLs | Compose by calling cached functions from uncached |
@@ -144,13 +274,38 @@ On cache hit for the route, the handler doesn't run and `getProductData` is neve
144
274
  called. On cache miss, the handler runs and `getProductData` may itself return a
145
275
  cached value from a previous call with the same slug.
146
276
 
277
+ ### Nesting rule: the outer window bounds the inner
278
+
279
+ A cache's window bounds everything rendered inside it (loaders excepted). An
280
+ inner shorter TTL only takes effect when the **enclosing** cache recomputes — it
281
+ does **not** keep a value fresher than its parent:
282
+
283
+ - Outer `cache()` **fresh hit** → the subtree is served from stored RSC, so inner
284
+ `"use cache"` functions are **not consulted** (frozen at the outer's age — no
285
+ code inside the boundary runs on a hit).
286
+ - Outer **miss / SWR revalidation** → inner caches are consulted, each per its own
287
+ ttl/swr. With SWR on the outer, a stale subtree serves instantly and refreshes
288
+ in the background, so under traffic it keeps refreshing rather than rotting to
289
+ the worst case.
290
+ - **Loaders are the exception** — excluded from the segment cache, re-resolved
291
+ live even on an outer hit.
292
+
293
+ So `"use cache: short"` (60s) inside `cache({ ttl: 600 })` yields ~600s freshness
294
+ on hits, **not** 60s. This is not a bug: setting `cache({ ttl: 600 })` declares
295
+ "this subtree may be ~600s stale." **If a value must be fresher than its
296
+ enclosing segment, put it in a loader** (always live). `debugPerformance` prints
297
+ cache hits per layer, so the actual per-request behavior is observable.
298
+
147
299
  ## Headers and Cookies
148
300
 
149
301
  Neither mechanism caches response headers or cookies.
150
302
 
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).
303
+ - **cache()**: Response-level side effects throw inside the cache boundary even
304
+ on a miss: `ctx.header()`, `ctx.setCookie()`, `ctx.deleteCookie()`,
305
+ `ctx.setStatus()`, `ctx.onResponse()`, and direct `ctx.headers` mutation. On a
306
+ hit the handler would be skipped, so allowing the write on a miss would produce
307
+ inconsistent responses. If you need headers or cookies on every response, set
308
+ them in middleware or a live segment outside the cache boundary.
154
309
  - **"use cache"**: cookies() and headers() throw inside the cached function
155
310
  (both reads and writes). ctx.header() also throws. Move them outside.
156
311
 
@@ -165,8 +320,9 @@ middleware(async (ctx, next) => {
165
320
  ## Context Variable Cache Safety
166
321
 
167
322
  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.
323
+ be read freely inside cached scopes. A non-cacheable var throws when read
324
+ **directly** with `ctx.get()` inside a `cache()` boundary where the value would
325
+ otherwise be serialized into the stored segment.
170
326
 
171
327
  There are two ways to mark a value as non-cacheable:
172
328
 
@@ -181,19 +337,68 @@ ctx.set(Theme, derivedTheme, { cache: false });
181
337
  "Least cacheable wins": if either the var definition or the `ctx.set()` call
182
338
  specifies `cache: false`, the value is non-cacheable.
183
339
 
184
- **Behavior inside cache scopes:**
340
+ **Behavior inside a `cache()` boundary:**
341
+
342
+ | Operation | Inside a `cache()` boundary |
343
+ | ----------------------------------------- | ------------------------------------------------------ |
344
+ | `cookies()` / `headers()` (read or write) | Throws (request-scoped, would poison the shared entry) |
345
+ | `ctx.get(cacheableVar)` | Allowed |
346
+ | `ctx.get(nonCacheableVar)` | Throws (would be baked in) |
347
+ | `ctx.set(var, value)` (cacheable) | Allowed |
348
+ | `ctx.header()` / cookie writes | Throws (response side effect would be lost on hit) |
349
+ | Any of the above **inside a loader** | Allowed (loaders always run fresh) |
185
350
 
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) |
351
+ (Both scopes block the same request-scoped APIs — `cookies()`, `headers()`,
352
+ response side effects, and non-cacheable `ctx.get()` — because each would leak
353
+ per-request data into a shared cache entry. The `cache()` boundary tracks the
354
+ scope via `isInsideCacheScope()`; `"use cache"` uses the exec guard and also
355
+ excludes tainted `ctx`/`env`/`req` args from the cache key. Loaders are exempt in
356
+ both see "Headers and Cookies" and the precise guarantee below.)
192
357
 
193
358
  Write is dumb — `ctx.set()` stores the cache metadata but does not enforce.
194
359
  Enforcement happens at read time (`ctx.get()`), where ALS detects the cache
195
360
  scope and rejects non-cacheable reads.
196
361
 
362
+ ### The guarantee is precise — a direct read inside `cache()`, not propagating
363
+
364
+ The guard fires on a **direct** `ctx.get(taintedVar)` **inside a `cache()`
365
+ boundary** (the scope `isInsideCacheScope` detects). The taint lives on the
366
+ variable; a value **derived** from it and read **outside** the boundary is not
367
+ tracked:
368
+
369
+ ```typescript
370
+ // CAUGHT — direct read of a tainted var inside a cache() boundary
371
+ cache({ ttl: 60 }, () => [
372
+ path("/dashboard", (ctx) => {
373
+ const user = ctx.get(User); // throws: non-cacheable read inside cache()
374
+ return <Dashboard user={user} />;
375
+ }, { name: "dashboard" }),
376
+ ]);
377
+
378
+ // NOT CAUGHT — read outside the boundary, derived value cached
379
+ layout((ctx) => {
380
+ const name = ctx.get(User).name; // allowed — this layout is not cached
381
+ ctx.set(UserName, name); // now a plain (cacheable) string
382
+ return <Outlet />;
383
+ }, () => [
384
+ cache({ ttl: 60 }, () => [
385
+ // a child reads ctx.get(UserName) and silently caches user-derived data
386
+ ]),
387
+ ]);
388
+ ```
389
+
390
+ So do **not** read this as "you can't cache user data" — that overstates it and
391
+ breeds the false confidence that makes the derived leak _more_ likely. The guard
392
+ is deliberately non-propagating (propagation would cost a wrapper per derivation
393
+ on the hot path), and it is scoped to the `cache()` segment boundary. `"use
394
+ cache"` functions block the same request-scoped reads (`cookies()` / `headers()`
395
+ throw inside them) and additionally exclude tainted `ctx`/`env`/`req` args from
396
+ the cache key. The pattern that stays safe is also the natural one:
397
+ **read tainted context at the point of use, in the path that needs it (a loader or
398
+ live segment) — never extract user data into a plain value and cache that.**
399
+ Loaders are exempt because they run outside the cache scope and resolve fresh
400
+ every request.
401
+
197
402
  ## Loaders Are Always Fresh
198
403
 
199
404
  Loaders are **never cached** by route-level `cache()`. Even on a full cache hit
@@ -272,21 +477,6 @@ data is cached independently from the route's segment cache. Loader caching
272
477
  supports custom keys, tags, SWR, conditional bypass, and per-loader store
273
478
  overrides — see `/loader` for the full reference.
274
479
 
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
480
  ## See Also
291
481
 
292
482
  - `/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:
@@ -116,7 +156,6 @@ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
116
156
 
117
157
  const store = new MemorySegmentCacheStore({
118
158
  defaults: { ttl: 60, swr: 300 },
119
- maxSize: 1000, // Max entries
120
159
  });
121
160
  ```
122
161
 
@@ -173,13 +212,82 @@ const router = createRouter<AppBindings>({
173
212
  KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
174
213
  are only cached in L1.
175
214
 
176
- ## Context Variables Inside Cache Boundaries
215
+ ## Cache purity & tainted objects
216
+
217
+ A `cache()` boundary caches everything except loaders, so anything read inside a
218
+ cached handler is **frozen into the shared cache entry** and served to every
219
+ subsequent visitor. To stop one user's request-scoped data from leaking to
220
+ another, request-scoped APIs are guarded inside a cache scope:
221
+
222
+ | Inside a `cache()` boundary | Behavior |
223
+ | --------------------------------------------------------------- | --------------------------------------------------- |
224
+ | `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
225
+ | `ctx.header()` / `setCookie()` / `setStatus()` / `onResponse()` | **throws** — response side effects lost on a hit |
226
+ | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
227
+ | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
228
+ | Any of the above **inside a loader** | **allowed** — loaders always run fresh |
229
+
230
+ **Tainted objects.** Request-scoped objects (`ctx`, `env`, `request`) carry an
231
+ internal taint symbol so they are excluded from `"use cache"` cache keys, and
232
+ the cache scope is tracked via async-local state. Two flags back the guards:
233
+ `INSIDE_CACHE_EXEC` (set while a `"use cache"` function runs) and the `cache()`
234
+ DSL scope (`isInsideCacheScope()`). `isInsideCacheScope()` deliberately returns
235
+ `false` inside loaders — which is exactly why loaders are the dynamic holes:
236
+ they may read `cookies()`/`headers()` and re-run on every request.
237
+
238
+ The fix for "I need request data in a cached route": register a `loader()` and
239
+ **consume it with `useLoader()` in a client component**. The loader is the
240
+ dynamic hole — its data rides the fresh (never-cached) loader segment and is
241
+ rendered in the client component, so it never lands in the cached shell.
242
+
243
+ This is NOT the same as awaiting the loader in the handler. A cached handler
244
+ that does `await ctx.use(Loader)` and renders the result bakes that per-request
245
+ data straight into the shared cached segment — the loader running "fresh" does
246
+ not help, because its output was inlined into the cached parent, and `ctx.use()`
247
+ is **not** guarded. `ctx.use()` is a server-side escape hatch for non-rendered
248
+ uses (set a ctx var, make a routing decision); never render its result inside a
249
+ cached handler.
250
+
251
+ ```typescript
252
+ // WRONG — throws: cookies() read directly in a cached handler
253
+ cache({ ttl: 60 }, () => [
254
+ path("/me", () => <Profile id={cookies().get("uid")?.value} />),
255
+ ]);
256
+
257
+ // ALSO WRONG (unguarded, but leaks) — the awaited loader data is rendered into
258
+ // the cached handler, so the user's data is frozen into the shared shell.
259
+ cache({ ttl: 60 }, () => [
260
+ path(
261
+ "/me",
262
+ async (ctx) => {
263
+ const { user } = await ctx.use(MeLoader); // runs fresh…
264
+ return <Profile user={user} />; // …but inlined into the CACHED segment → leak
265
+ },
266
+ { name: "me" },
267
+ () => [loader(MeLoader)],
268
+ ),
269
+ ]);
270
+
271
+ // RIGHT — consume the loader in a CLIENT component via useLoader(). The cached
272
+ // route segment holds only the <Profile/> reference; the user data rides the
273
+ // fresh loader segment and renders client-side.
274
+
275
+ // profile.tsx (client component)
276
+ "use client";
277
+ import { useLoader } from "@rangojs/router/client";
278
+
279
+ export function Profile() {
280
+ const { user } = useLoader(MeLoader); // fresh per request; never cached
281
+ return <span>{user.name}</span>;
282
+ }
283
+
284
+ // urls — register the loader; MeLoader reads cookies() inside the loader (allowed)
285
+ cache({ ttl: 60 }, () => [
286
+ path("/me", () => <Profile />, { name: "me" }, () => [loader(MeLoader)]),
287
+ ]);
288
+ ```
177
289
 
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.
290
+ See `/cache-guide` for the full decision guide and the `cache()` vs `"use cache"` comparison.
183
291
 
184
292
  ## Nested Cache Boundaries
185
293
 
@@ -245,7 +353,7 @@ export const urlpatterns = urls(({ path, layout, cache, loader, revalidate }) =>
245
353
  path("/shop/product/:slug", ProductPage, { name: "product" }, () => [
246
354
  loader(ProductLoader, () => [cache({ ttl: 120 })]),
247
355
  loader(CartLoader, () => [
248
- revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
356
+ revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
249
357
  ]),
250
358
  ]),
251
359
  ]),
@@ -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 = () => [