@rangojs/router 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -1,15 +1,21 @@
1
1
  ---
2
2
  name: loader
3
- description: Define data loaders for fetching data in routes with createLoader. Use when pages need per-request data that stays fresh, data should stream while the page renders, client components need reactive server data, a loader should throw notFound()/redirect(), set page meta/breadcrumbs from loader data (handle writes), or loader data must be guaranteed in the SSR'd document (ssr:false).
3
+ description: Define data loaders with createLoader and register them on routes with loader(). Use when a route needs per-request data that stays fresh and streams while the page renders, a client component reads server data with useLoader, a loader should re-run after specific actions (revalidate), be cached (cache), be callable from the client (fetchable loaders), throw notFound()/redirect(), write page meta/breadcrumbs handles, or must be settled in the SSR'd document (ssr:false).
4
4
  argument-hint: "[loader]"
5
5
  ---
6
6
 
7
7
  # Data Loaders with loader()
8
8
 
9
- Loaders fetch data on the server and stream it to the client. For mutations
10
- (writes triggered by forms or buttons), use server actions instead — see
11
- `/server-actions`. Loaders re-resolve after an action runs, so the typical
12
- flow is _action mutates → loader re-reads → UI updates_.
9
+ A loader is a server function created with `createLoader()` and registered on a
10
+ route segment with `loader()`. It runs fresh on every request (on a `ppr` route,
11
+ one registered with `ssr: false` is baked into the shell instead), streams its
12
+ result to the client, and client components read it with `useLoader()`. This
13
+ skill covers defining, registering, consuming, revalidating, caching, and
14
+ client-fetching loaders.
15
+
16
+ For mutations (writes triggered by forms or buttons), use server actions
17
+ instead — see `/server-actions`. Loaders re-resolve after an action runs, so
18
+ the typical flow is _action mutates → loader re-reads → UI updates_.
13
19
 
14
20
  ## Not this skill if…
15
21
 
@@ -116,20 +122,37 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
116
122
  > (plain GET only). See the hooks skill ("Scoping refetch with a `key`" and
117
123
  > "Refreshing multiple loaders together").
118
124
  > - `cache({ key })` — a **server** cache identity (storage hit/miss/ttl/swr).
125
+ > On a route `cache()` over a `ppr` route, each key value also gets its own
126
+ > shell; keep the value set small.
119
127
  > - `revalidate()` — which **server** segments/loaders recompute during
120
128
  > navigation and action refreshes.
121
129
 
122
- DSL loaders are the **live data layer** — they resolve fresh on every
123
- request, even when the route is inside a `cache()` boundary. The router
124
- excludes them from the segment cache at storage time and re-resolves them
125
- on retrieval. This means `cache()` gives you cached UI + fresh data by
126
- default.
130
+ DSL loaders are the **live data layer**: they resolve fresh on every request,
131
+ even when the route is inside a `cache()` boundary, so `cache()` gives you
132
+ cached UI + fresh data by default. See "Loaders: The Live Data Layer" below.
133
+ The exception is a `ppr` route's `ssr: false` loader: it bakes into the shell,
134
+ and a shell HIT runs it only when its return carries promises.
127
135
 
128
136
  ### Cache safety
129
137
 
130
- DSL loaders can safely read `createVar({ cache: false })` variables
131
- because they are always resolved fresh. The read guard is bypassed for
132
- loader functions — they never produce stale data.
138
+ A DSL loader without `ssr: false` can read `createVar({ cache: false })`
139
+ variables: it runs on every request, `cache()` and PPR shell HITs included, so
140
+ the read guard is bypassed for it. On a `ppr` route the capture runs an
141
+ `ssr: false` loader, and a loader a handler awaits, and bakes their values into
142
+ the shell: a `cookies()`, `headers()` or `{ cache: false }` read there refuses
143
+ the capture, so the route keeps rendering without a shell (warned once per key).
144
+ A loader bound with its own `cache()` is the other exception: its value is
145
+ stored and shared, so a miss whose body read `cookies()`, `headers()`, a
146
+ non-cacheable variable or the theme fails unless the binding has a `key()` or a
147
+ store `keyGenerator`, which must then include them (see "Cache Key"). A
148
+ `"use cache"` function the loader calls is not a loader body: a non-cacheable
149
+ read inside it throws, also through the loader's `ctx` passed in, so read the
150
+ var in the loader and pass the value in as an argument. Passing the loader's
151
+ `ctx` itself is fine: the call keys by route, params and search, like a handler
152
+ `ctx` (`/use-cache`). A fetchable loader called with a request body
153
+ (`load({ method: "POST", body })` or form data) is the exception: the body is
154
+ not in the key, so the call runs uncached. Pass the body fields the function
155
+ needs as arguments instead.
133
156
 
134
157
  ### ctx.use(Loader) — escape hatch
135
158
 
@@ -137,6 +160,7 @@ For cases where you need loader data in the server handler itself (e.g.,
137
160
  to set ctx variables or make routing decisions), use `ctx.use(Loader)`:
138
161
 
139
162
  ```typescript
163
+ // Product is a context-variable token: export const Product = createVar<ProductData>();
140
164
  path("/product/:slug", async (ctx) => {
141
165
  const { product } = await ctx.use(ProductLoader);
142
166
  ctx.set(Product, product); // make available to children
@@ -146,19 +170,31 @@ path("/product/:slug", async (ctx) => {
146
170
  ])
147
171
  ```
148
172
 
149
- When you register with `loader()` in the DSL, `ctx.use()` returns the
150
- same memoized result — loaders never run twice per request.
173
+ `ctx.use(Loader)` is a per-request read on any render that runs the handler,
174
+ memoized for the request (a route `cache()` HIT or a `ppr` shell HIT runs no
175
+ handler, so the handler's output from the read is replayed): however many
176
+ times it is called, and whether or not the loader is also registered with
177
+ `loader()`, it runs once per request. It never reads or writes the loader
178
+ store cache on its own: a loader's `cache()` belongs to its DSL binding
179
+ (`loader(Def, () => [cache()])` on a route, layout or intercept). A handler
180
+ read of a cached binding gets the binding's (possibly cached) result once the
181
+ binding has started (a read from its own entry's handler or from later in
182
+ the tree).
151
183
 
152
184
  **Limitations of ctx.use(Loader):**
153
185
 
154
186
  - The handler output depends on the loader data. If the route is inside
155
187
  `cache()`, the handler is cached with the loader result baked in —
156
188
  defeating the live data guarantee.
157
- - The same holds under a PPR shell capture (`/ppr`): handler consumption is
158
- the BAKED lane — the loader executes at capture (identity reads permitted)
159
- and the rendered value is a capture-time copy; `useLoader` client-side is
160
- the live lane. One rule across `cache()`, `"use cache"`, and PPR: the
161
- consumption-lane rule (`/rango` → Invariants).
189
+ - The same holds under a PPR shell capture (`/ppr`): handler consumption is the
190
+ BAKED lane — the loader executes at capture and the rendered value is a
191
+ capture-time copy served on every HIT (a HIT never runs the handler);
192
+ `useLoader` of a loader without `ssr: false` is the live lane (an `ssr: false`
193
+ loader is the bake lane however it is read). Unlike `cache()`, an identity
194
+ read inside that loader (`cookies()`, `headers()`, a `{ cache: false }`
195
+ variable) refuses the capture, so the route stays uncached. One rule across
196
+ `cache()`, `"use cache"`, and PPR: the consumption-lane rule (`/rango` →
197
+ Invariants).
162
198
  - Non-cacheable variable reads (`createVar({ cache: false })`) inside the
163
199
  handler still throw, even if the data came from a loader.
164
200
  - Prefer DSL `loader()` + client `useLoader()` for data that depends on
@@ -175,27 +211,34 @@ same memoized result — loaders never run twice per request.
175
211
 
176
212
  ## Loader Context
177
213
 
178
- Loaders receive the same context shape as route handlers.
214
+ Loaders receive a request-scoped context that shares its read surface with
215
+ route handlers (`params`, `request`, `url`, `env`, `get`, `use`, `reverse`).
216
+ It has no handler-only writers: there is no `ctx.set`, `ctx.headers`, or
217
+ `ctx.setLocationState` (a loader can stream after the response has started,
218
+ so response-shaping belongs in middleware or the handler).
179
219
 
180
220
  ### Full field surface
181
221
 
182
- | Field | Type | Notes |
183
- | -------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
184
- | `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`. |
185
- | `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden. |
186
- | `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort). |
187
- | `url` | `URL` | Parsed request URL. |
188
- | `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`). |
189
- | `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`. |
190
- | `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise. |
191
- | `env` | `TEnv` | Plain bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.). |
192
- | `get` | `(key \| ContextVar \| handle)` | Reads middleware variables/context-vars — or READS a handle's collected data, after `await ctx.rendered()`. |
193
- | `use` | `(loader \| handle) => T` | Access another loader's data (Promise), or WRITE a handle: `ctx.use(Meta)({ title })` returns the push function — handler parity. Reads moved to `get`. |
194
- | `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for all non-loader segments (including `loading()` streaming handlers) to settle before reading handle data. Not with `ssr: false` (cycle; throws). |
195
- | `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders. |
196
- | `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls. |
197
- | `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission. |
198
- | `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers). |
222
+ | Field | Type | Notes |
223
+ | ------------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
224
+ | `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`. |
225
+ | `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden. |
226
+ | `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort). |
227
+ | `url` | `URL` | Request URL with internal `_rsc*` transport params stripped. Use this for application logic. |
228
+ | `originalUrl` | `URL` | Request URL with every param intact, including `_rsc*`. Only for debugging or custom cache keying. |
229
+ | `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`). |
230
+ | `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`. |
231
+ | `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise. |
232
+ | `env` | `any` | Bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.). `createLoader` types it as `any`; annotate or cast at the use site. |
233
+ | `waitUntil` | `(fn: () => Promise<void>)` | Run work after the response is sent. Takes a function, not a promise. Delegates to Cloudflare's `waitUntil`; fire-and-forget with error logging elsewhere. |
234
+ | `executionContext` | `ExecutionContext \| undefined` | Raw Cloudflare `ExecutionContext` for libraries that need it; `undefined` off Cloudflare. Prefer `waitUntil`. |
235
+ | `get` | `(key \| ContextVar \| handle)` | Reads middleware variables/context-vars — or READS a handle's collected data, after `await ctx.rendered()`. |
236
+ | `use` | `(loader \| handle) => T` | Access another loader's data (Promise), or WRITE a handle: `ctx.use(Meta)({ title })` returns the push function — handler parity. Reads moved to `get`. |
237
+ | `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for all non-loader segments (including `loading()` streaming handlers) to settle before reading handle data. Not with `ssr: false` (cycle; throws). |
238
+ | `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders. |
239
+ | `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls. |
240
+ | `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission. |
241
+ | `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers). |
199
242
 
200
243
  ### Example
201
244
 
@@ -275,10 +318,13 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
275
318
  revalidate(() => false), // Never revalidate
276
319
  ]),
277
320
 
278
- // Loader that revalidates after cart actions (defer otherwise — keeps the
279
- // permissive loader defaults for navigation and other actions intact)
321
+ // Loader that re-runs only after cart actions. Loaders re-run after every
322
+ // action by default; on navigation, `undefined` keeps the default
323
+ // (re-run when params or search change).
280
324
  loader(CartLoader, () => [
281
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
325
+ revalidate((ctx) =>
326
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
327
+ ),
282
328
  ]),
283
329
  ]);
284
330
  ```
@@ -292,7 +338,7 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
292
338
  > Caching a loader is a separate, opt-in step (`loader(Fn, () => [cache({...})])`).
293
339
  > See `/cache-guide` → "Two axes" and `/rango` → "The shape of rango".
294
340
 
295
- A `revalidate(fn)` callback can return one of four shapes. The chain
341
+ A `revalidate(fn)` callback returns one of three kinds of value. The chain
296
342
  processes revalidators in order; each call's return controls how the
297
343
  chain continues:
298
344
 
@@ -318,8 +364,24 @@ revalidate(() => undefined); // explicit defer
318
364
  revalidate(() => null); // explicit defer
319
365
  ```
320
366
 
321
- If every revalidator on a segment defers, the segment-type default
322
- (e.g. params-changed for routes, `false` for parallels) is used.
367
+ If every revalidator on a segment defers, the segment default is used
368
+ (`defaultShouldRevalidate`, see the table below).
369
+
370
+ The callback must be **synchronous**. A returned Promise is ignored (dev
371
+ warning) and the running suggestion is kept, so move async work into a loader.
372
+ A callback that throws is logged and treated as a defer; a thrown `Response`
373
+ (e.g. `throw redirect(...)`) propagates as control flow.
374
+
375
+ Segment defaults when no revalidator decides (from
376
+ `src/router/revalidation.ts`). A segment the client does not hold yet always
377
+ renders.
378
+
379
+ | Segment | After an action (POST) | Navigation |
380
+ | -------------------------------------------------------------------- | ---------------------- | ----------------------------------- |
381
+ | Route (`path()`) | `true` | `true` when params or search change |
382
+ | Owned by the route (loaders, layouts, parallels inside the `path()`) | `true` | `true` when params or search change |
383
+ | Loader above the route (e.g. on a parent `layout()`) | `true` | `false` |
384
+ | Layout or parallel above the route | `false` | `false` |
323
385
 
324
386
  #### `|| undefined` (defer) vs `?? false` (hard) — pick deliberately
325
387
 
@@ -335,17 +397,28 @@ revalidate(({ actionId }) => actionId?.includes("Cart") || undefined);
335
397
  revalidate(({ actionId }) => actionId?.includes("Cart") ?? false);
336
398
  ```
337
399
 
338
- This matters most for loaders, whose defaults are permissive: a loader defaults
339
- to revalidating on **any** action (`POST`) and on **param/search changes**
340
- during navigation. So `?? false` on a loader silently suppresses both — the
341
- loader will not refetch when you navigate to a different `:id`. Use
342
- `|| undefined` when you want to _add_ a revalidation signal on top of the
343
- sensible defaults, and reserve `?? false` for the rare case where you genuinely
344
- want the loader to refetch on nothing but your matched action.
345
-
346
- When **composing multiple revalidators** on one segment (see below), defer is
347
- mandatory: the first hard `?? false` ends the chain and the later contracts
348
- never run.
400
+ Pick the idiom by what the segment's default already does (table above). After
401
+ an action, loaders, the route, and segments inside the `path()` already re-run;
402
+ layouts and parallels above the route are skipped:
403
+
404
+ - **Add a signal where the default skips.** `ctx.isAction(X) || undefined` on a
405
+ parent layout or a layout-level parallel re-renders it after `X` and defers
406
+ otherwise. On a loader, the route, or a segment inside the `path()` it
407
+ changes nothing: they already re-run after every action, and on navigation it
408
+ defers.
409
+ - **Narrow after actions where the default re-runs.**
410
+ `ctx.isAction() ? ctx.isAction(X) : undefined` re-runs a loader or route
411
+ segment only for `X`'s actions and keeps the navigation default (params or
412
+ search changed). After an action it is a hard decision, so later revalidators
413
+ on the same segment do not run for actions.
414
+ - **Avoid bare `ctx.isAction(X)` (and `?? false`).** It returns a hard `false`
415
+ on navigation too, so a loader stops refetching when you navigate to a
416
+ different `:id`. Reserve it for the rare case where the segment should re-run
417
+ on nothing but the matched action.
418
+
419
+ When **composing multiple revalidators** on one segment (see below), prefer the
420
+ defer form: the first hard `false` ends the chain and the later contracts never
421
+ run.
349
422
 
350
423
  #### Matching actions: `ctx.isAction()`
351
424
 
@@ -358,21 +431,31 @@ import { addToCart, removeFromCart } from "../actions/cart";
358
431
  import * as CartActions from "../actions/cart";
359
432
 
360
433
  loader(CartLoader, () => [
361
- revalidate((ctx) => ctx.isAction(addToCart) || undefined), // one action
434
+ // After an action, re-run only for addToCart; on navigation, keep the default.
435
+ revalidate((ctx) => (ctx.isAction() ? ctx.isAction(addToCart) : undefined)),
362
436
  ]);
363
- revalidate((ctx) => ctx.isAction(addToCart, removeFromCart) || undefined); // several
364
- revalidate((ctx) => ctx.isAction(CartActions) || undefined); // any action in the module
365
- revalidate((ctx) => ctx.isAction({ addToCart, removeFromCart }) || undefined); // object form
437
+
438
+ // Matcher forms (each returns a boolean):
439
+ ctx.isAction(addToCart); // one action
440
+ ctx.isAction(addToCart, removeFromCart); // several
441
+ ctx.isAction(CartActions); // any action in the module
442
+ ctx.isAction({ addToCart, removeFromCart }); // object form
443
+ ctx.isAction({ Cart: CartActions }); // grouped namespaces
444
+ ctx.isAction(); // no args: any action at all
366
445
  ```
367
446
 
368
447
  `isAction()` is a method on the revalidate predicate's **context argument** —
369
448
  there is no standalone `isAction` import; you always reach it through the callback
370
449
  parameter (`revalidate((ctx) => ctx.isAction(...))`). It returns a raw boolean, so
371
- pair it with `|| undefined` for the usual "revalidate on match, else defer"
372
- intent. It returns `false` on plain navigation and on non-matches, and resolves
373
- the reference the same way the router derives `actionId` (`$id` in production,
374
- `$$id` in dev), so it matches in both modes. The raw `actionId` string stays
375
- available on the same context as an escape hatch.
450
+ wrap it by placement (the three cases above): the ternary on a loader or route
451
+ segment, `|| undefined` on a layout or layout-level parallel the default skips.
452
+ It returns `false` on plain navigation and on non-matches; called with no
453
+ arguments it answers "is this request an action?". It resolves the reference with
454
+ the same `$id ?? $$id` precedence the router uses to derive `actionId`, so it
455
+ matches in both dev and production. The raw `actionId` string stays available on
456
+ the same context as an escape hatch, alongside `actionResult`, `formData`,
457
+ `actionUrl`, `currentParams`/`nextParams`, `currentUrl`/`nextUrl`,
458
+ `fromRouteName`/`toRouteName`, and the full handler `context`.
376
459
 
377
460
  ### Revalidation Contracts for Loader Dependencies
378
461
 
@@ -381,18 +464,23 @@ the same named revalidation contract across producer and consumer segments.
381
464
 
382
465
  ```typescript
383
466
  // revalidation-contracts.ts
467
+ import type { Revalidate } from "@rangojs/router";
384
468
  import * as AccountActions from "./actions/account";
385
469
 
386
470
  // Match by reference with ctx.isAction() (rename-safe), and defer (|| undefined)
387
471
  // so these contracts compose — a hard `false` would short-circuit the rest.
388
- export const revalidateAccountScope = (ctx) =>
472
+ export const revalidateAccountScope: Revalidate = (ctx) =>
389
473
  ctx.isAction(AccountActions) || undefined;
390
474
 
475
+ // urls.tsx
391
476
  layout(AccountLayout, () => [
392
- revalidate(revalidateAccountScope), // producer reruns
477
+ // producer: adds the re-render the default skips for a parent layout
478
+ revalidate(revalidateAccountScope),
393
479
  path("/account/orders", OrdersPage, { name: "account.orders" }, () => [
394
480
  loader(OrdersLoader, () => [
395
- revalidate(revalidateAccountScope), // consumer reruns
481
+ // consumer: loaders re-run after every action anyway; the shared
482
+ // contract names the dependency
483
+ revalidate(revalidateAccountScope),
396
484
  ]),
397
485
  ]),
398
486
  ]);
@@ -418,16 +506,20 @@ layout(AccountLayout, () => [
418
506
 
419
507
  ## Loaders: The Live Data Layer
420
508
 
421
- Loaders are the live data layer of the router. They resolve fresh on every
422
- request, even when the route's UI segments are served from cache. This is a
423
- core design principle — route-level `cache()` caches rendered components but
424
- never caches loader data. Loaders are excluded at storage time and re-resolved
425
- on retrieval.
426
-
427
- This means `cache()` gives you cached UI + fresh data by default. Pre-rendering
428
- follows the same rule: at build time, loaders are skipped entirely (there is no
429
- real request context), and at runtime the worker resolves them fresh against
430
- the live database.
509
+ Loaders resolve fresh on every request, even when the route's UI segments are
510
+ served from cache. Route-level `cache()` caches rendered segments but never
511
+ loader data: loaders are excluded when the segments are stored and re-resolved
512
+ when they are served. Caching a loader's own data is a separate opt-in (see
513
+ "Opting a Loader into Caching"). A `ppr` shell does store some loader data: an
514
+ `ssr: false` loader's settled data bakes into the shell, and a HIT runs that
515
+ loader only when its return carries promises (see "`ssr: false`" below and
516
+ `/ppr`).
517
+
518
+ Pre-rendering follows the same rule: the build-time Flight payload skips
519
+ loaders (there is no real request context), and at runtime the worker resolves
520
+ them fresh against the live database. The exception is a `Prerender` + `ppr`
521
+ route: its build-time shell capture runs `ssr: false` loaders and bakes their
522
+ settled data into the shell, like a runtime capture.
431
523
 
432
524
  ### Parallel and streaming — latency overlaps first paint
433
525
 
@@ -448,8 +540,8 @@ If you come from a framework where the loader is a blocking step that runs
448
540
  before the response is built, this is the shift to internalize: here the
449
541
  response starts streaming first and loader data fills in. (The one deliberate
450
542
  exception is per-loader: `loader(Def, { ssr: false })` awaits that
451
- loader before first flush on document renders — see "`ssr: false`"
452
- below.)
543
+ loader before first flush on document renders other than a `ppr` shell HIT,
544
+ where the shell flushes first — see "`ssr: false`" below.)
453
545
 
454
546
  ### See it: `debugPerformance`
455
547
 
@@ -462,16 +554,26 @@ const router = createRouter({ document: Document, debugPerformance: true });
462
554
  ```
463
555
 
464
556
  Or enable it per-request from middleware (e.g. only when `?debug` is present) by
465
- calling `ctx.debugPerformance()` **before** `await next()`. Each HTML request
466
- then prints a shared-axis waterfall (and emits a `Server-Timing` header):
557
+ calling `ctx.debugPerformance()` **before** `await next()`:
558
+
559
+ ```typescript
560
+ router.use(async (ctx, next) => {
561
+ if (ctx.searchParams.has("debug")) ctx.debugPerformance();
562
+ return next();
563
+ });
564
+ ```
565
+
566
+ Each metered request then prints a shared-axis waterfall to the server console
567
+ (and adds the timings to the `Server-Timing` response header):
467
568
 
468
569
  ```
469
570
  [RSC Perf] GET /product/widget (24.53ms)
470
571
  start dur span timeline
471
572
  0.08ms 3.20ms route-matching |#####...................................|
472
- 3.40ms 8.70ms ssr-render-html |.....##############.....................|
573
+ 3.30ms 20.10ms render:total:product |....############################........|
574
+ 3.40ms 8.70ms ssr:render-html |.....##############.....................|
473
575
  3.42ms 11.90ms loader:…#ProductLoader |.....###################................|
474
- 3.45ms 11.40ms loader:…#ReviewsLoader |.....##################.................|
576
+ 3.45ms 11.40ms loader:…#ReviewsLoader |.....##################.................|
475
577
  0.00ms 24.53ms handler:total |########################################|
476
578
  ```
477
579
 
@@ -479,12 +581,13 @@ How to read it:
479
581
 
480
582
  - **Humans:** scan the `#` bars on the shared axis. Bars that start at the same
481
583
  offset and run side by side are executing **in parallel** — loaders should
482
- overlap `ssr-render-html` / `render:total`, not sit alone to the right of
584
+ overlap `ssr:render-html` / `render:total`, not sit alone to the right of
483
585
  everything. A lone `loader:*` bar past the render bar is serialized latency to
484
- chase. `handler:total` is the whole request; `render:total` is the render pass.
586
+ chase. `handler:total` is the whole request; `render:total` is the render pass
587
+ (labelled `render:total:<routeName>` when the matched route has a name).
485
588
  - **LLMs / programmatic:** read each row as `{ start, dur, label }`. A loader
486
589
  overlaps paint when its `[start, start+dur]` interval intersects
487
- `render:total` / `ssr-render-html`. Flag a regression when a `loader:*`
590
+ `render:total` / `ssr:render-html`. Flag a regression when a `loader:*`
488
591
  interval is **disjoint from and starts after** `render:total`, or when its
489
592
  `dur` approaches `handler:total` — that loader is on the critical path instead
490
593
  of overlapping it. Two `loader:*` rows with near-equal `start` confirm
@@ -498,6 +601,14 @@ To cache a specific loader's data, attach a `cache()` child:
498
601
  loader(ProductLoader, () => [cache({ ttl: 300 })]),
499
602
  ```
500
603
 
604
+ This works wherever the DSL binding is declared, an intercept's `use()`
605
+ included. A handler's `ctx.use(Loader)` read never caches by itself (see
606
+ "ctx.use(Loader) — escape hatch"). Once the binding started, every
607
+ `ctx.use(Loader)` in the request, from the handler or from another loader's
608
+ body, gets the binding's value: one value per loader per request, a HIT
609
+ included. A reader that runs before the binding starts (a layout's loader
610
+ reading a route's cached loader) gets the value of its own run, as before.
611
+
501
612
  The loader's data is cached independently from the route's segment cache,
502
613
  using the same `SegmentCacheStore` (app-level or per-loader override).
503
614
 
@@ -505,12 +616,43 @@ Values are serialized through RSC Flight, so loaders can return ReactNode,
505
616
  Promises, null, and any RSC-serializable type — all round-trip correctly
506
617
  through the cache.
507
618
 
619
+ A value Flight cannot encode cleanly is not cached: a promise in it that
620
+ rejects, an async component that throws, a function or a class instance.
621
+ Storing it would bake the failure into every hit until expiry. Instead the
622
+ write is skipped and reported to `onError` (phase `"cache"`, category
623
+ `cache-write`, or `stale-revalidation` for a background refresh): a miss still
624
+ returns the loader's live result, the next request runs the loader again, and
625
+ a stale entry keeps serving.
626
+
627
+ Handle pushes the body makes (`ctx.use(Meta)(...)`, breadcrumbs — see
628
+ "Writing Handles from Loaders"), including those of loaders it awaits via
629
+ `ctx.use`, are stored with the value and replayed on every hit, stale hits
630
+ included: appended to the loader's segment in push order, so a cached
631
+ loader's title or crumb stays on the page. Relative to the handler's and
632
+ sibling loaders' pushes, the replay lands when the cache read completes —
633
+ the same race a live loader push has. A loader the cached body awaited can
634
+ still run on a hit, because a sibling loader or the handler also reads it
635
+ (loaders stay live); its pushes still appear once, and they are the live
636
+ ones. If it ran before the replay, the replay skips its recorded pushes; if
637
+ it runs after, its pushes replace the replayed ones in their position (after
638
+ hydration, when it lands after the document's handle snapshot). A pending push
639
+ (a promise, an async callback, a `.defer()` slot) is recorded once it settles,
640
+ as in `"use cache"`: the background write waits up to 5 s, and a push still
641
+ pending at 5 s drops that entry's handle record (the data is still cached; its
642
+ hits replay no pushes). A pushed value Flight cannot encode (a promise that
643
+ rejects) skips the whole entry, as above. Entries written
644
+ before handles were recorded replay none until they are rewritten (expiry or
645
+ SWR revalidation).
646
+
508
647
  ### Cache Key
509
648
 
510
- The default cache key is `loader:{loaderId}:{pathname}:{sortedParams}`.
511
- This can be customized at two levels:
649
+ The default cache key is `loader:{loaderId}:{host}{pathname}:{sortedParams}`
650
+ (the host keeps multi-tenant hosts from sharing entries). This can be customized
651
+ at two levels:
512
652
 
513
653
  ```typescript
654
+ import { cookies } from "@rangojs/router";
655
+
514
656
  // Full override — key function replaces the default entirely
515
657
  loader(ProductLoader, () => [
516
658
  cache({
@@ -527,10 +669,62 @@ Resolution priority (same as route-level `cache()`):
527
669
 
528
670
  1. `key(ctx)` from cache options — full override
529
671
  2. `store.keyGenerator(ctx, defaultKey)` — store-level modification
530
- 3. Default key — `loader:{id}:{pathname}:{params}`
672
+ 3. Default key — `loader:{loaderId}:{host}{pathname}:{sortedParams}`
673
+
674
+ A `key` function (or store `keyGenerator`) that throws is **not** caught: the
675
+ loader fails as if its body threw. There is no silent fallback to the default
676
+ key, because a personalised key collapsing onto the broad default would share
677
+ one user's data with everyone.
678
+
679
+ **Request-scoped reads in the body must be part of the key.** The default key
680
+ names no user, so one entry serves everyone who requests the same loader, host,
681
+ path and params. A route `cache()` around the route does not partition it
682
+ either: a loader's own `cache()` is an independent layer, keyed only by what it
683
+ declares. On a miss, a body that reads `cookies()`, `headers()` or a
684
+ non-cacheable variable (`createVar({ cache: false })`, or a value written with
685
+ `ctx.set(..., { cache: false })`) therefore **fails**, and nothing is stored,
686
+ unless the binding declares identity with a `key()` or a store `keyGenerator`.
687
+ The check tests only that one of them is there, not what it contains: any
688
+ `key()` or store `keyGenerator` switches it off, so it must itself include the
689
+ value. That includes a store-wide `keyGenerator` that only adds a region
690
+ prefix, like the one described above.
691
+
692
+ ```typescript
693
+ export const SessionLoader = createLoader(async () =>
694
+ getAccount(cookies().get("session")?.value),
695
+ );
531
696
 
532
- If a custom key function throws, it falls back to the default key silently
533
- (logged to console.error).
697
+ // The miss fails: cookies() cannot be called inside loader "…SessionLoader",
698
+ // whose own cache() has no key().
699
+ loader(SessionLoader, () => [cache({ ttl: 60 })]),
700
+
701
+ // One entry per session.
702
+ loader(SessionLoader, () => [
703
+ cache({
704
+ ttl: 60,
705
+ key: (ctx) => `account:${cookies().get("session")?.value ?? "anon"}`,
706
+ }),
707
+ ]),
708
+ ```
709
+
710
+ The same holds in a stale entry's background refresh (the refresh fails and the
711
+ stale entry keeps serving) and for the loaders the body reads with `ctx.use()`,
712
+ because their values land in the entry. That includes a keyed cached loader
713
+ served from its own cache: its entry keeps the read its miss made. It also holds
714
+ when something else ran the loader first, such as a parent layout's handler
715
+ calling `ctx.use(SessionLoader)`: the miss reuses that run and fails the same
716
+ way. A read that settles after the value, in a promise inside it or in a
717
+ handle push that settles within the 5 s handle-encode timeout, can't fail a
718
+ value that was already served: that user gets their own value, nothing is
719
+ stored, and `onError` gets the same error. A push still pending after the
720
+ timeout drops the entry's handles, and the value is stored without them. A loader with no `cache()` of its own
721
+ reads request data freely.
722
+
723
+ Two kinds of read are not seen, the same as in `"use cache"`: raw reads such
724
+ as `ctx.request.headers` or `getRequestContext().cookie()`, and a value
725
+ computed outside the loader and handed in, such as a per-request memo a handler
726
+ filled from `cookies()` before the loader awaited it. Key what those carry
727
+ too. If the value must stay per request, drop the loader's `cache()` instead.
534
728
 
535
729
  ### Tags for Invalidation
536
730
 
@@ -549,6 +743,34 @@ loader(ProductLoader, () => [
549
743
  ]),
550
744
  ```
551
745
 
746
+ The tags the loader body records tag the entry too: `cacheTag()` calls, the
747
+ tags of `"use cache"` reads, render-time `cacheTag()` in server components of
748
+ the value or a handle push, and the tags the loaders it reads with `ctx.use`
749
+ record, including one the route, a layout or the handler started first. A
750
+ loader it reads that has its own `cache()` also brings its `cache({ tags })`
751
+ and, on a HIT, its entry's tags. `updateTag()` of any of them drops the entry,
752
+ and a HIT records them again, so a route `cache()`, ppr shell or document built
753
+ over the HIT stays evictable. A stale hit's background refresh stores the
754
+ refreshed body's tags; it runs the loaders it reads again rather than reusing
755
+ the page's (possibly stale) values.
756
+
757
+ One case keeps a `cache({ tags })` off: a cached loader that reads another
758
+ loader's `cache()` binding before that binding starts (for example a cached
759
+ layout loader reading a route's cached loader), and writes its entry before
760
+ it. That reader's entry has the tags the loader's body records, but not the
761
+ tags only its `cache({ tags })` lists. If the reader must drop with them, record
762
+ them in the loader body with `cacheTag()` too.
763
+
764
+ ```typescript
765
+ export const ProductLoader = createLoader(async (ctx) => {
766
+ cacheTag(`product:${ctx.params.slug}`);
767
+ return db.product(ctx.params.slug);
768
+ });
769
+
770
+ loader(ProductLoader, () => [cache({ ttl: 600 })]),
771
+ // updateTag(`product:${slug}`) drops this entry.
772
+ ```
773
+
552
774
  ### Stale-While-Revalidate
553
775
 
554
776
  ```typescript
@@ -589,8 +811,9 @@ loader(PricingLoader, () => [
589
811
  ]),
590
812
  ```
591
813
 
592
- Without an explicit store, the loader uses the app-level store from the
593
- handler config (`cache.store`).
814
+ Without an explicit store, the loader uses the app-level store from the router
815
+ config (`createRouter({ cache: { store } })`). Without any store, `cache()` on
816
+ a loader is inert and the loader runs fresh.
594
817
 
595
818
  ## Multiple Loaders
596
819
 
@@ -621,8 +844,9 @@ layout(<ShopLayout />, () => [
621
844
 
622
845
  ## Passing Loaders as Props
623
846
 
624
- Loaders can be passed as props from server to client components. RSC serialization
625
- uses `toJSON()` to send only `{ __brand, $$id }` — the loader function is stripped.
847
+ Loaders can be passed as props from server to client components. A loader
848
+ definition is a plain `{ __brand, $$id }` object (the function itself stays in the
849
+ server registry), so it serializes as-is.
626
850
 
627
851
  ```typescript
628
852
  // Server component (route handler)
@@ -700,9 +924,10 @@ flushes, so both signals are resolved server-side while the tree is built:
700
924
  `redirect()` replaces the whole page with the redirect carrier (still a 200
701
925
  document, the client replaces to the target on hydration) and `notFound()`
702
926
  renders the not-found UI at the owning segment with a real 404. No read site
703
- runs, so a `useLoader` in a layout above every Suspense boundary is safe.
704
- (Before this was fixed, the flagged read threw inside the Fizz shell and
705
- 500ed the document.)
927
+ runs, so a `useLoader` in a layout above every Suspense boundary is safe. On a
928
+ `ppr` route, a flagged loader that settles with `notFound()` or `redirect()`
929
+ during the shell capture refuses the capture (warned once per key): that URL
930
+ gets no shell, and its renders deliver the signal as above.
706
931
 
707
932
  Session/auth gates belong in middleware (they are request-shaped, not
708
933
  data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
@@ -731,12 +956,19 @@ export const ProductLoader = createLoader(async (ctx) => {
731
956
  ```
732
957
 
733
958
  Delivery is async **by the race model**: pushes that settle before the handler
734
- barrier ride the SSR handle snapshot (in the SSR'd document — `<MetaTags />`,
959
+ barrier ride the SSR handle snapshot (in the SSR'd document — `<Html.Meta />`,
735
960
  `useHandle` reads); later pushes stream to the client and apply post-hydration
736
961
  on document loads (`metadata.handlesLate`) or progressively on navigations.
737
962
  A push before your slow fetch usually beats the barrier; a push derived from
738
963
  the fetched data usually does not. When it MUST be in the document, use
739
- `ssr: false` below.
964
+ `ssr: false` below. A loader with its own `cache()` keeps its pushes on a hit —
965
+ they are replayed from the entry (see "Opting a Loader into Caching").
966
+
967
+ Under `cache()`, loader pushes are not stored with the cached segments: the
968
+ loader re-runs on every hit and its live push is the only copy. (`ppr` keeps
969
+ the settled pushes of an `ssr: false` loader, and of the loaders it awaits, in
970
+ the shell, and a promise-free `ssr: false` loader does not run on a shell HIT —
971
+ see `/ppr`.)
740
972
 
741
973
  Reads are the other direction and gated: `ctx.get(handle)` throws unless the
742
974
  loader first does `await ctx.rendered()` (DSL-registered loaders only —
@@ -759,14 +991,20 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
759
991
  ]),
760
992
  ```
761
993
 
762
- The knob mirrors `loading(fallback, { ssr: false })` — SSR delivery is off
763
- for this loader, so nothing of it is left to stream in the document:
764
- document renders await this loader before first flush — data is settled
765
- (`useLoader` reads it synchronously, no fallback paints), handle pushes beat
766
- the barrier snapshot, and a thrown `notFound()` deterministically precedes
767
- Response construction (real 404, no warm-up race). Client navigations stream
768
- exactly as before. Scoped per LOADER: the flagged loader awaits only itself;
769
- siblings keep streaming.
994
+ The options object is typed as `LoaderOptions`
995
+ (`import type { LoaderOptions } from "@rangojs/router"`).
996
+
997
+ The knob mirrors `loading(fallback, { ssr: false })` — SSR delivery is off for
998
+ this loader, so nothing of it is left to stream in the document: document
999
+ renders await this loader before first flush — data is settled (`useLoader`
1000
+ reads it synchronously, no fallback paints), handle pushes beat the barrier
1001
+ snapshot, and a thrown `notFound()` deterministically precedes Response
1002
+ construction (real 404, no warm-up race). Client navigations stream exactly as
1003
+ before (on a `ppr` route, a navigation that replays a shell captured by a
1004
+ document request serves a promise-free flagged loader from its pin, as a
1005
+ document HIT does; a snapshot captured by a navigation alone carries no pins,
1006
+ and its replay runs the loader fresh). Scoped per LOADER: the flagged loader
1007
+ awaits only itself; siblings keep streaming.
770
1008
 
771
1009
  Delivery is in-place, not merely in-document. React's Fizz outlines any
772
1010
  COMPLETED Suspense boundary over ~500 bytes to an end-of-stream
@@ -782,28 +1020,35 @@ still outline; their reveal must wait for the CSS.
782
1020
 
783
1021
  The costs and constraints:
784
1022
 
785
- - Every document load pays the flagged loader's latency before first byte.
786
- That is the point — but keep flagged loaders fast, and flag loaders, not
787
- routes.
1023
+ - Every document render that is not a `ppr` shell HIT pays the flagged
1024
+ loader's latency before first byte. That is the point — but keep flagged
1025
+ loaders fast, and flag loaders, not routes. On a HIT the shell flushes
1026
+ first: a flagged loader whose return has no promises does not run (unless
1027
+ the capture marked it to run: see `/ppr` → On a shell HIT), and one whose
1028
+ return carries promises runs to fill its holes.
788
1029
  - A flagged loader must not `await ctx.rendered()` / `ctx.get(handle)` — the
789
1030
  document render awaits the loader before the render barrier resolves, so
790
1031
  that wait is a cycle by construction; it throws a deadlock error naming the
791
1032
  fix.
792
- - PPR capture is the BAKE lane for flagged loaders (`/ppr`): the capture
793
- render awaits them too, and the settled result — handle pushes included —
794
- freezes into the stored shell. Unflagged loaders stay masked as live
795
- holes. The `progressiveChunkSize` auto-raise is live-document only;
796
- captured shells outline per the explicit option or React's default.
1033
+ - Under PPR the flag is also the bake lane: the settled non-promise data
1034
+ (handle pushes included) freezes into the stored shell — see `/ppr` → The
1035
+ loader lane rule. On a HIT, a flagged loader whose return has no promises is
1036
+ served from the shell and does not run (unless the capture marked it to run:
1037
+ see `/ppr` → On a shell HIT); one whose return carries promises
1038
+ runs on every HIT, with its baked parts overlaid. A `cookies()`, `headers()`
1039
+ or `{ cache: false }` read in it refuses the capture. The
1040
+ `progressiveChunkSize` auto-raise is live-document only; captured shells
1041
+ outline per the explicit option or React's default.
797
1042
 
798
1043
  Also available in `clientUrls()` route groups (`/client-urls`), where the
799
- loader-heavy shape makes it most useful. `LoaderOptions` is exported from the
800
- package root.
1044
+ loader-heavy shape makes it most useful.
801
1045
 
802
1046
  ## Fetchable Loaders
803
1047
 
804
- By default, loaders only run during SSR and navigation. Pass `true` as the second
805
- argument to `createLoader` to make a loader **fetchable** — callable from the client
806
- via `useFetchLoader()` and `load()`:
1048
+ By default, loaders only run as part of a render (document, navigation, or
1049
+ post-action revalidation); the `_rsc_loader` endpoint rejects them. Pass `true`
1050
+ as the second argument to `createLoader` to make a loader **fetchable** —
1051
+ callable from the client via `useFetchLoader()` and `load()`:
807
1052
 
808
1053
  ```typescript
809
1054
  import { createLoader } from "@rangojs/router";
@@ -839,9 +1084,10 @@ requireAuth] })` (next section).
839
1084
 
840
1085
  ### Fetchable Loader with Middleware
841
1086
 
842
- Pass an options object instead of `true` to attach per-loader middleware.
843
- This middleware runs only on `_rsc_loader` fetch requests (client-side
844
- `load()` / `useFetchLoader()` calls), not during SSR `ctx.use()` execution:
1087
+ Pass an options object instead of `true` to attach per-loader middleware (any
1088
+ options object makes the loader fetchable). This middleware runs only on
1089
+ `_rsc_loader` fetch requests (client-side `load()` / `useFetchLoader()` /
1090
+ `useRefreshLoaders()` calls), not when the loader runs as part of a render:
845
1091
 
846
1092
  ```typescript
847
1093
  import { createLoader } from "@rangojs/router";
@@ -893,6 +1139,13 @@ export const MutationLoader = createLoader(async (ctx) => {
893
1139
  }, true);
894
1140
  ```
895
1141
 
1142
+ A fetchable-loader POST is not a server action: the router does not run a
1143
+ revalidation render afterwards and the client does not invalidate its caches.
1144
+ Other loaders on the page keep their current data until you refresh them
1145
+ (`load()`, `useRefreshLoaders()`, `router.refresh()`), and cached history or
1146
+ prefetch entries stay valid unless you call `invalidateClientCache()`. For
1147
+ writes that should refresh the page, prefer a server action (`/server-actions`).
1148
+
896
1149
  ### File Upload Example
897
1150
 
898
1151
  ```typescript
@@ -904,9 +1157,11 @@ export const FileUploadLoader = createLoader(async (ctx) => {
904
1157
  if (file && file.size > 0) {
905
1158
  // Save to R2, D1, etc.
906
1159
  await ctx.env.BUCKET.put(file.name, file.stream());
907
- return { uploaded: { name: file.name, size: file.size, type: file.type } };
1160
+ return {
1161
+ uploadedFile: { name: file.name, size: file.size, type: file.type },
1162
+ };
908
1163
  }
909
- return { uploaded: null };
1164
+ return { uploadedFile: null };
910
1165
  }, true);
911
1166
  ```
912
1167
 
@@ -923,7 +1178,7 @@ Client usage — see `/hooks useFetchLoader` for the full client-side pattern.
923
1178
 
924
1179
  ```typescript
925
1180
  // loaders/shop.ts
926
- import { createLoader } from "@rangojs/router";
1181
+ import { createLoader, notFound } from "@rangojs/router";
927
1182
 
928
1183
  export const ProductLoader = createLoader(async (ctx) => {
929
1184
  const product = await ctx.env.DB
@@ -947,12 +1202,15 @@ export const CartLoader = createLoader(async (ctx) => {
947
1202
  });
948
1203
 
949
1204
  // urls.tsx — register loaders in the DSL
1205
+ import { urls } from "@rangojs/router";
950
1206
  import * as CartActions from "./actions/cart";
1207
+ import { CartLoader, ProductLoader } from "./loaders/shop";
951
1208
 
952
1209
  export const urlpatterns = urls(({ path, layout, loader, loading, cache, revalidate }) => [
953
1210
  layout(<ShopLayout />, () => [
954
1211
  loader(CartLoader, () => [
955
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
1212
+ // after an action, re-run only for cart actions; on navigation, keep the default
1213
+ revalidate((ctx) => (ctx.isAction() ? ctx.isAction(CartActions) : undefined)),
956
1214
  ]),
957
1215
 
958
1216
  path("/shop/product/:slug", ProductPage, { name: "product" }, () => [