@rangojs/router 0.20.0 → 0.22.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 (331) hide show
  1. package/dist/bin/rango.js +39 -15
  2. package/dist/types/browser/event-controller.d.ts +49 -0
  3. package/dist/types/browser/history-state.d.ts +16 -4
  4. package/dist/types/browser/navigation-store.d.ts +9 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +7 -0
  6. package/dist/types/browser/partial-update.d.ts +2 -1
  7. package/dist/types/browser/prefetch/default-strategy.d.ts +16 -20
  8. package/dist/types/browser/rango-state.d.ts +3 -1
  9. package/dist/types/browser/react/NavigationProvider.d.ts +14 -1
  10. package/dist/types/browser/react/context.d.ts +25 -0
  11. package/dist/types/browser/react/location-state-shared.d.ts +84 -10
  12. package/dist/types/browser/react/location-state.d.ts +7 -2
  13. package/dist/types/browser/rsc-router.d.ts +13 -2
  14. package/dist/types/browser/types.d.ts +10 -0
  15. package/dist/types/build/generate-manifest.d.ts +2 -0
  16. package/dist/types/cache/background-task.d.ts +22 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +50 -5
  18. package/dist/types/cache/cache-policy.d.ts +17 -0
  19. package/dist/types/cache/cache-scope.d.ts +5 -6
  20. package/dist/types/cache/cf/cf-cache-store.d.ts +34 -7
  21. package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
  22. package/dist/types/cache/document-cache.d.ts +8 -5
  23. package/dist/types/cache/handle-snapshot.d.ts +59 -50
  24. package/dist/types/cache/index.d.ts +3 -1
  25. package/dist/types/cache/invalidation-order.d.ts +14 -0
  26. package/dist/types/cache/memory-segment-store.d.ts +33 -1
  27. package/dist/types/cache/shell-snapshot.d.ts +29 -4
  28. package/dist/types/cache/store-scope.d.ts +22 -0
  29. package/dist/types/cache/tag-invalidation.d.ts +7 -6
  30. package/dist/types/cache/types.d.ts +84 -24
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +35 -17
  32. package/dist/types/client-urls/navigation.d.ts +7 -1
  33. package/dist/types/client-urls/optimistic-location.d.ts +10 -4
  34. package/dist/types/errors.d.ts +6 -0
  35. package/dist/types/handles/deferred-resolution.d.ts +18 -1
  36. package/dist/types/internal-suspense-audit.d.ts +1 -0
  37. package/dist/types/loader-store.d.ts +4 -8
  38. package/dist/types/outlet-provider.d.ts +2 -1
  39. package/dist/types/prerender/build-shell-capture.d.ts +8 -6
  40. package/dist/types/prerender/cloudflare.d.ts +49 -0
  41. package/dist/types/prerender/create-prerender-trigger.d.ts +121 -0
  42. package/dist/types/prerender/index.d.ts +12 -0
  43. package/dist/types/prerender/memory-prerender-store.d.ts +29 -0
  44. package/dist/types/prerender/on-demand.d.ts +272 -0
  45. package/dist/types/prerender/producer-guard.d.ts +33 -0
  46. package/dist/types/prerender/shell-manifest-key.d.ts +1 -18
  47. package/dist/types/prerender/warm-request.d.ts +65 -0
  48. package/dist/types/prerender/warm.d.ts +65 -0
  49. package/dist/types/prerender/writable-store.d.ts +170 -0
  50. package/dist/types/prerender.d.ts +27 -0
  51. package/dist/types/reverse.d.ts +1 -1
  52. package/dist/types/route-content-wrapper.d.ts +3 -1
  53. package/dist/types/router/error-handling.d.ts +2 -0
  54. package/dist/types/router/handler-context.d.ts +1 -1
  55. package/dist/types/router/match-middleware/cache-lookup.d.ts +15 -1
  56. package/dist/types/router/navigation-snapshot.d.ts +8 -1
  57. package/dist/types/router/pattern-matching.d.ts +2 -0
  58. package/dist/types/router/prefetch-default.d.ts +4 -4
  59. package/dist/types/router/prerender-match.d.ts +23 -1
  60. package/dist/types/router/route-trie-builder.d.ts +8 -1
  61. package/dist/types/router/router-context.d.ts +1 -0
  62. package/dist/types/router/router-interfaces.d.ts +32 -1
  63. package/dist/types/router/router-options.d.ts +39 -5
  64. package/dist/types/router/segment-resolution/fresh.d.ts +7 -0
  65. package/dist/types/router/segment-resolution/loader-cache.d.ts +23 -10
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +5 -1
  67. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +43 -0
  68. package/dist/types/router/trie-matching.d.ts +2 -0
  69. package/dist/types/router-versions.d.ts +39 -0
  70. package/dist/types/rsc/handler-context.d.ts +5 -0
  71. package/dist/types/rsc/shell-build-manifest.d.ts +9 -6
  72. package/dist/types/rsc/shell-capture-constants.d.ts +20 -5
  73. package/dist/types/rsc/shell-capture.d.ts +14 -1
  74. package/dist/types/rsc/shell-serve.d.ts +2 -2
  75. package/dist/types/rsc/ssr-module-loader.d.ts +15 -0
  76. package/dist/types/rsc/types.d.ts +12 -6
  77. package/dist/types/segment-boundary-content.d.ts +17 -0
  78. package/dist/types/segment-loader-promise.d.ts +2 -6
  79. package/dist/types/server/build-version-table.d.ts +53 -0
  80. package/dist/types/server/context.d.ts +12 -4
  81. package/dist/types/server/handle-store.d.ts +89 -46
  82. package/dist/types/server/request-context.d.ts +84 -9
  83. package/dist/types/suspense-audit-react.d.ts +26 -0
  84. package/dist/types/suspense-audit.d.ts +157 -0
  85. package/dist/types/testing/action-form.d.ts +34 -0
  86. package/dist/types/testing/build-versions.d.ts +32 -0
  87. package/dist/types/testing/dom.entry.d.ts +1 -1
  88. package/dist/types/testing/flight.entry.d.ts +3 -1
  89. package/dist/types/testing/index.d.ts +6 -0
  90. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  91. package/dist/types/testing/internal/server-modules.d.ts +8 -0
  92. package/dist/types/testing/location-state-key.d.ts +5 -1
  93. package/dist/types/testing/render-route.d.ts +77 -6
  94. package/dist/types/testing/serve-shell-request.d.ts +55 -4
  95. package/dist/types/testing/shell-status.d.ts +14 -6
  96. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +9 -6
  97. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  98. package/dist/types/thenable-status.d.ts +6 -0
  99. package/dist/types/types/handler-context.d.ts +15 -5
  100. package/dist/types/types/route-entry.d.ts +5 -0
  101. package/dist/types/urls/path-helper.d.ts +18 -0
  102. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  103. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  104. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  105. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  106. package/dist/types/vite/discovery/state.d.ts +25 -0
  107. package/dist/types/vite/encryption-key.d.ts +18 -0
  108. package/dist/types/vite/inject-client-debug.d.ts +6 -25
  109. package/dist/types/vite/plugin-types.d.ts +27 -0
  110. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  111. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  112. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  113. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  114. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  115. package/dist/types/vite/plugins/virtual-entries.d.ts +18 -1
  116. package/dist/types/vite/router-discovery.d.ts +6 -0
  117. package/dist/types/vite/utils/bundle-analysis.d.ts +6 -2
  118. package/dist/types/vite/utils/client-chunks.d.ts +2 -0
  119. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  120. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  121. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  122. package/dist/vite/index.js +1707 -931
  123. package/package.json +12 -3
  124. package/skills/cache-guide/SKILL.md +102 -37
  125. package/skills/caching/SKILL.md +56 -9
  126. package/skills/catalog.json +1 -1
  127. package/skills/client-urls/SKILL.md +16 -0
  128. package/skills/cloudflare/SKILL.md +133 -1
  129. package/skills/comparison/references/framework-comparison.md +17 -13
  130. package/skills/deployment-caching/SKILL.md +33 -12
  131. package/skills/document-cache/SKILL.md +16 -14
  132. package/skills/hooks/SKILL.md +25 -0
  133. package/skills/hooks/handle-and-actions.md +5 -0
  134. package/skills/hooks/navigation.md +38 -0
  135. package/skills/hooks/state.md +213 -11
  136. package/skills/hooks/urls.md +40 -1
  137. package/skills/host-router/SKILL.md +31 -0
  138. package/skills/intercept/SKILL.md +6 -0
  139. package/skills/loader/SKILL.md +25 -5
  140. package/skills/middleware/SKILL.md +2 -0
  141. package/skills/migrate-nextjs/SKILL.md +82 -9
  142. package/skills/ppr/SKILL.md +150 -48
  143. package/skills/prerender/SKILL.md +651 -10
  144. package/skills/rango/SKILL.md +9 -7
  145. package/skills/response-routes/SKILL.md +5 -5
  146. package/skills/route/SKILL.md +29 -0
  147. package/skills/router-setup/SKILL.md +15 -1
  148. package/skills/testing/SKILL.md +32 -27
  149. package/skills/testing/cache-prerender.md +18 -17
  150. package/skills/testing/client-components.md +193 -8
  151. package/skills/testing/handles.md +2 -0
  152. package/skills/testing/loader.md +1 -1
  153. package/skills/testing/middleware.md +1 -1
  154. package/skills/testing/render-handler.md +18 -18
  155. package/skills/testing/serve-shell-request.md +167 -22
  156. package/skills/testing/server-actions.md +17 -17
  157. package/skills/testing/server-tree.md +1 -1
  158. package/skills/typesafety/generated-files-and-cli.md +1 -1
  159. package/skills/use-cache/SKILL.md +25 -10
  160. package/skills/vercel/SKILL.md +5 -3
  161. package/skills/view-transitions/SKILL.md +9 -4
  162. package/src/bin/rango.ts +1 -1
  163. package/src/browser/event-controller.ts +119 -2
  164. package/src/browser/history-state.ts +39 -17
  165. package/src/browser/link-interceptor.ts +2 -2
  166. package/src/browser/navigation-bridge.ts +104 -41
  167. package/src/browser/navigation-store.ts +26 -0
  168. package/src/browser/navigation-transaction.ts +24 -12
  169. package/src/browser/network-error-handler.ts +2 -0
  170. package/src/browser/partial-update.ts +58 -14
  171. package/src/browser/prefetch/default-strategy.ts +22 -42
  172. package/src/browser/rango-state.ts +3 -1
  173. package/src/browser/react/Link.tsx +37 -43
  174. package/src/browser/react/NavigationProvider.tsx +85 -1
  175. package/src/browser/react/context.ts +30 -0
  176. package/src/browser/react/location-state-shared.ts +200 -34
  177. package/src/browser/react/location-state.ts +28 -77
  178. package/src/browser/react/use-action.ts +4 -1
  179. package/src/browser/react/use-handle.ts +3 -0
  180. package/src/browser/react/use-link-status.ts +4 -3
  181. package/src/browser/react/use-navigation.ts +4 -1
  182. package/src/browser/react/use-params.ts +4 -1
  183. package/src/browser/react/use-pathname.ts +4 -1
  184. package/src/browser/react/use-search-params.ts +5 -3
  185. package/src/browser/react/use-segments.ts +36 -54
  186. package/src/browser/rsc-router.tsx +93 -31
  187. package/src/browser/segment-reconciler.ts +3 -3
  188. package/src/browser/server-action-bridge.ts +43 -7
  189. package/src/browser/types.ts +11 -0
  190. package/src/build/generate-manifest.ts +17 -0
  191. package/src/build/merge-full-manifests.ts +5 -0
  192. package/src/build/route-trie.ts +1 -0
  193. package/src/cache/background-task.ts +61 -0
  194. package/src/cache/cache-key-utils.ts +91 -12
  195. package/src/cache/cache-policy.ts +11 -2
  196. package/src/cache/cache-runtime.ts +23 -23
  197. package/src/cache/cache-scope.ts +48 -36
  198. package/src/cache/cf/cf-cache-store.ts +91 -66
  199. package/src/cache/cf/cf-cache-types.ts +12 -4
  200. package/src/cache/document-cache.ts +35 -14
  201. package/src/cache/handle-snapshot.ts +118 -86
  202. package/src/cache/index.ts +5 -0
  203. package/src/cache/invalidation-order.ts +53 -0
  204. package/src/cache/memory-segment-store.ts +52 -1
  205. package/src/cache/shell-snapshot.ts +52 -5
  206. package/src/cache/store-scope.ts +36 -0
  207. package/src/cache/tag-invalidation.ts +7 -5
  208. package/src/cache/types.ts +90 -24
  209. package/src/cache/vercel/vercel-cache-store.ts +68 -26
  210. package/src/client-urls/client-root.tsx +1 -0
  211. package/src/client-urls/navigation.ts +10 -0
  212. package/src/client-urls/optimistic-location.ts +10 -4
  213. package/src/client.tsx +40 -3
  214. package/src/errors.ts +13 -6
  215. package/src/handles/deferred-resolution.ts +36 -0
  216. package/src/internal-suspense-audit.ts +12 -0
  217. package/src/loader-store.ts +4 -9
  218. package/src/outlet-provider.tsx +25 -1
  219. package/src/prerender/build-shell-capture.ts +11 -9
  220. package/src/prerender/cloudflare.ts +130 -0
  221. package/src/prerender/create-prerender-trigger.ts +780 -0
  222. package/src/prerender/index.ts +39 -0
  223. package/src/prerender/memory-prerender-store.ts +115 -0
  224. package/src/prerender/on-demand.ts +339 -0
  225. package/src/prerender/producer-guard.ts +66 -0
  226. package/src/prerender/shell-manifest-key.ts +12 -17
  227. package/src/prerender/warm-request.ts +154 -0
  228. package/src/prerender/warm.ts +193 -0
  229. package/src/prerender/writable-store.ts +278 -0
  230. package/src/prerender.ts +29 -0
  231. package/src/reverse.ts +1 -1
  232. package/src/route-content-wrapper.tsx +107 -13
  233. package/src/router/error-handling.ts +12 -12
  234. package/src/router/find-match.ts +1 -0
  235. package/src/router/handler-context.ts +2 -0
  236. package/src/router/intercept-resolution.ts +15 -8
  237. package/src/router/loader-resolution.ts +2 -10
  238. package/src/router/manifest.ts +2 -0
  239. package/src/router/match-middleware/background-revalidation.ts +2 -0
  240. package/src/router/match-middleware/cache-lookup.ts +239 -54
  241. package/src/router/navigation-snapshot.ts +25 -6
  242. package/src/router/pattern-matching.ts +14 -0
  243. package/src/router/prefetch-default.ts +4 -4
  244. package/src/router/prerender-match.ts +460 -352
  245. package/src/router/request-classification.ts +15 -12
  246. package/src/router/route-trie-builder.ts +9 -0
  247. package/src/router/router-context.ts +1 -0
  248. package/src/router/router-interfaces.ts +35 -1
  249. package/src/router/router-options.ts +45 -5
  250. package/src/router/segment-resolution/fresh.ts +20 -19
  251. package/src/router/segment-resolution/loader-cache.ts +110 -77
  252. package/src/router/segment-resolution/revalidation.ts +34 -24
  253. package/src/router/segment-resolution/streamed-handler-boundary.ts +133 -0
  254. package/src/router/trie-matching.ts +3 -0
  255. package/src/router-versions.ts +60 -0
  256. package/src/router.ts +107 -1
  257. package/src/rsc/full-payload.ts +6 -2
  258. package/src/rsc/handler-context.ts +5 -0
  259. package/src/rsc/handler.ts +64 -7
  260. package/src/rsc/progressive-enhancement.ts +5 -1
  261. package/src/rsc/response-cache-serve.ts +10 -4
  262. package/src/rsc/rsc-rendering.ts +65 -12
  263. package/src/rsc/shell-build-manifest.ts +17 -12
  264. package/src/rsc/shell-capture-constants.ts +39 -7
  265. package/src/rsc/shell-capture.ts +144 -45
  266. package/src/rsc/shell-serve.ts +12 -6
  267. package/src/rsc/ssr-module-loader.ts +26 -0
  268. package/src/rsc/types.ts +12 -6
  269. package/src/segment-boundary-content.ts +25 -0
  270. package/src/segment-loader-promise.ts +28 -38
  271. package/src/segment-system.tsx +117 -57
  272. package/src/server/build-version-table.ts +130 -0
  273. package/src/server/context.ts +40 -17
  274. package/src/server/cookie-store.ts +5 -0
  275. package/src/server/handle-store.ts +357 -127
  276. package/src/server/request-context.ts +149 -12
  277. package/src/ssr/ssr-root.tsx +32 -13
  278. package/src/suspense-audit-react.tsx +98 -0
  279. package/src/suspense-audit.ts +1030 -0
  280. package/src/testing/action-form.ts +66 -0
  281. package/src/testing/build-versions.ts +64 -0
  282. package/src/testing/dispatch.ts +6 -0
  283. package/src/testing/dom.entry.ts +1 -0
  284. package/src/testing/flight-runtime.d.ts +27 -0
  285. package/src/testing/flight.entry.ts +4 -0
  286. package/src/testing/index.ts +22 -0
  287. package/src/testing/internal/context.ts +2 -1
  288. package/src/testing/internal/seed-vars.ts +14 -2
  289. package/src/testing/internal/server-modules.ts +32 -0
  290. package/src/testing/location-state-key.ts +18 -8
  291. package/src/testing/render-handler.ts +2 -1
  292. package/src/testing/render-route.tsx +293 -46
  293. package/src/testing/serve-shell-request.ts +262 -37
  294. package/src/testing/shell-status.ts +17 -6
  295. package/src/testing/vitest-stubs/plugin-rsc.ts +37 -11
  296. package/src/testing/vitest-stubs/version.ts +4 -1
  297. package/src/thenable-status.ts +10 -0
  298. package/src/types/handler-context.ts +15 -5
  299. package/src/types/route-entry.ts +6 -0
  300. package/src/urls/path-helper.ts +69 -9
  301. package/src/use-loader.tsx +57 -30
  302. package/src/vite/discovery/build-versions.ts +1281 -0
  303. package/src/vite/discovery/bundle-postprocess.ts +4 -0
  304. package/src/vite/discovery/discover-routers.ts +15 -0
  305. package/src/vite/discovery/installed-packages.ts +159 -0
  306. package/src/vite/discovery/prerender-collection.ts +38 -1
  307. package/src/vite/discovery/router-versions-phase.ts +336 -0
  308. package/src/vite/discovery/shell-prerender-phase.ts +33 -43
  309. package/src/vite/discovery/state.ts +36 -1
  310. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  311. package/src/vite/encryption-key.ts +101 -9
  312. package/src/vite/inject-client-debug.ts +42 -20
  313. package/src/vite/plugin-types.ts +28 -0
  314. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  315. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  316. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  317. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  318. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  319. package/src/vite/plugins/version-plugin.ts +19 -24
  320. package/src/vite/plugins/version.d.ts +17 -2
  321. package/src/vite/plugins/virtual-entries.ts +39 -6
  322. package/src/vite/rango.ts +28 -3
  323. package/src/vite/router-discovery.ts +96 -13
  324. package/src/vite/utils/bundle-analysis.ts +39 -7
  325. package/src/vite/utils/client-chunks.ts +5 -1
  326. package/src/vite/utils/compare-strings.ts +9 -0
  327. package/src/vite/utils/package-resolution.ts +9 -0
  328. package/src/vite/utils/prerender-utils.ts +4 -1
  329. package/src/vite/utils/shared-utils.ts +1 -1
  330. package/dist/types/segment-content-promise.d.ts +0 -13
  331. package/src/segment-content-promise.ts +0 -67
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: prerender
3
- description: Pre-render route segments at build time with Prerender (with getParams), Passthrough live fallback, Static segments, and Skip. Use when a page's content is mostly static and shouldn't render on every request, speeding up cold responses, reading build-only data (files, build env), or deciding which routes to prerender vs render live.
3
+ description: Pre-render route segments at build time with Prerender (with getParams), Passthrough live fallback, Static segments, and Skip, refresh them from a running app with on-demand prerender (Prerender onDemand + router.prerender, ISR-style), and warm any other route's runtime caches (ppr shell, cache(), "use cache", document cache) before traffic with the same router.prerender. Use when a page's content is mostly static and shouldn't render on every request, speeding up cold responses, reading build-only data (files, build env), refreshing one page from a webhook, cron or queue without a redeploy, removing a deleted item's page (prerender.remove, or notFound() in a refresh), making routes ready after a deploy or an updateTag so no visitor pays the first render, or deciding which routes to prerender vs render live.
4
4
  argument-hint: [passthrough]
5
5
  ---
6
6
 
@@ -354,13 +354,27 @@ string (after `cache.searchParams` filtering). A URL with search params has its
354
354
  own shell identity and is captured at runtime. The runtime shell store is read
355
355
  first, so a runtime capture supersedes the baked entry once it exists.
356
356
 
357
+ Handle values behave as on any `ppr` shell (`/ppr` → "Handles on a shell
358
+ HIT"): a value an `ssr: false` loader pushes settled is in the shell's HTML and
359
+ in the data a HIT hydrates with, and one it pushes as a promise arrives after
360
+ hydration. The prerendered payload holds only the handler's pushes (loaders
361
+ never run at build), so the shell entry keeps the loader pushes its HTML
362
+ rendered, on a build-baked shell and on one captured at runtime alike. A shell
363
+ stored by router 0.21 or earlier lacks them, and a promise-free `ssr: false`
364
+ loader's settled push fails hydration on its HITs until the shell is replaced
365
+ (#1057).
366
+
357
367
  ### Freshness of a build shell
358
368
 
359
369
  A build-baked shell is not on a wall clock the way a pure runtime `ppr` shell is.
360
370
  It serves from the first request after a deploy and keeps serving until one of:
361
371
 
362
- - **a redeploy** — the `buildVersion` gate retires every build entry (like a
363
- React-version bump); the new build re-bakes;
372
+ - **a redeploy** — the new build ships its own build shell manifest and
373
+ re-bakes. A shell stored at runtime for the route is replaced when the
374
+ router's document version changes, and a change of the prerendered payload
375
+ changes it (a Prerender payload is part of its router's document version, not
376
+ its data version, so cached data is kept); a React-version bump also retires
377
+ it;
364
378
  - **`updateTag`** on a tag the shell carries — drops it, so the next request
365
379
  MISSes and a runtime capture takes over.
366
380
 
@@ -582,6 +596,10 @@ export const BlogPost = Passthrough(BlogPostDef, async (ctx) => {
582
596
  - `ctx.passthrough()` on a route not wrapped with `Passthrough()` throws.
583
597
  - `ctx.passthrough()` at runtime (`ctx.build === false`) also throws.
584
598
  It is a build-time-only control flow.
599
+ - On an on-demand route, a refresh (`router.prerender()`) whose build handler
600
+ returns `ctx.passthrough()` reports `skipped-passthrough` and stores a
601
+ "removed" marker for that param, so the live handler answers there too, not
602
+ a page a refresh stored or the build baked earlier (see "Remove a page").
585
603
  - `getParams()` still enumerates the param set; the build handler decides
586
604
  per-param whether to produce an artifact or defer to the live handler.
587
605
 
@@ -664,7 +682,9 @@ and are revalidated by actions as usual.
664
682
 
665
683
  - **Plain `Prerender`**: the handler was evicted, so the action re-render serves
666
684
  the stored prerender entry again. The prerendered output stays frozen; the
667
- action's return value reaches the client as usual.
685
+ action's return value reaches the client as usual. An `onDemand` route serves
686
+ its on-demand entry first, and re-sends its own segments from it, so an
687
+ action that calls `router.prerender()` for the page shows the new entry.
668
688
  - **`Passthrough()`**: the action re-render runs the live handler, replacing the
669
689
  prerendered output. To keep the frozen tree mounted after actions (for example
670
690
  so a client component's form state survives), suppress action
@@ -695,18 +715,635 @@ emitted — wrap the route in `Passthrough()` when its segments must re-render.
695
715
  ### Tag invalidation does not reach prerendered segments
696
716
 
697
717
  `updateTag()`/`revalidateTag()` evict runtime cache entries. They do not refresh
698
- a prerendered route's build-time Flight payload, which is served before the tag
699
- system is consulted, and a `cacheTag()` inside a `"use cache"` function that runs
700
- during the build has no runtime effect. A redeploy replaces the payload. To make
701
- a route tag-invalidatable, serve it from the runtime cache instead (`cache()`,
702
- or a `Passthrough()` live handler). A build-baked **ppr shell** is different: a
703
- tag it carries does drop it (see "Freshness of a build shell").
718
+ a prerendered route's payload, build-time or on-demand: the prerender store is
719
+ read before the tag system is consulted, and a `cacheTag()` inside a
720
+ `"use cache"` function that runs during the build has no runtime effect. A
721
+ redeploy replaces the build payload; because a Prerender payload is part of its
722
+ router's document version, a deploy whose only change is new prerendered content
723
+ keeps cached data and replaces stored HTML (shells, document-cache responses)
724
+ for that router.
725
+
726
+ To refresh a prerendered page without a redeploy, opt the route into
727
+ **on-demand prerender** and call `router.prerender()` (next section); its tags
728
+ are a separate namespace, invalidated with the runner's `markStale()`. To
729
+ make a route invalidatable by `updateTag()`, serve it from the runtime cache
730
+ instead (`cache()`, or a `Passthrough()` live handler). A build-baked **ppr
731
+ shell** is different: a tag it carries does drop it (see "Freshness of a build
732
+ shell").
733
+
734
+ ## On-demand refresh (ISR)
735
+
736
+ Build-time prerender freezes a page until the next deploy. On-demand prerender
737
+ lets a running app re-render one page and serve that to everyone: a CMS
738
+ webhook, a cron sweep, a queue consumer, or a server action calls
739
+ `router.prerender()`, the router renders the route with route params and env
740
+ only (no request), and writes the payload to a durable store that the serve
741
+ path reads before the build manifest.
742
+
743
+ ### Opt a route in
744
+
745
+ ```typescript
746
+ export const ProductPage = Prerender(
747
+ async () => [{ id: "featured" }],
748
+ async (ctx) => <Product data={await ctx.env.PRODUCTS.get(ctx.params.id)} />,
749
+ {
750
+ onDemand: {
751
+ ttl: 3600, // soft staleness, seconds; overrides the router `ttl`
752
+ tags: ({ params }) => [`product:${params.id}`], // or a string[]
753
+ },
754
+ },
755
+ );
756
+ ```
757
+
758
+ `onDemand: true` uses the router defaults. Any truthy spelling works (a literal,
759
+ a spread, an imported const). The opt-in keeps the handler in the production
760
+ bundle, which plain `Prerender` evicts. `Passthrough()` alone is not an opt-in:
761
+ wrap an `onDemand` definition to get both a live fallback and refreshes.
762
+
763
+ `onDemand` and the `ppr` path option cannot be combined: the route definition
764
+ throws, because a refresh cannot replace a captured document shell atomically.
765
+
766
+ ### Configure a store
767
+
768
+ ```typescript
769
+ import { createKVPrerenderStore } from "@rangojs/router/prerender/cloudflare";
770
+
771
+ export const router = createRouter<Env>({
772
+ prerender: (env) => ({
773
+ store: createKVPrerenderStore(env.PRERENDER_KV),
774
+ ttl: 3600, // default soft TTL; absent = never stale
775
+ // Optional. Its presence turns on stale-while-revalidate scheduling.
776
+ onRevalidate: (target, liveEnv) => liveEnv.PRERENDER_QUEUE.send({ target }),
777
+ }),
778
+ });
779
+ ```
780
+
781
+ The option takes an object or a factory resolved per request and per trigger
782
+ call, like `cache`. `createMemoryPrerenderStore()` from
783
+ `@rangojs/router/prerender` is a single-process store (Node, tests). In dev a
784
+ zero-config in-memory store is used when the option is absent.
785
+
786
+ ### Trigger a refresh
787
+
788
+ ```typescript
789
+ const prerender = router.prerender({ env, ctx });
790
+ await prerender("/products/42");
791
+ await prerender(
792
+ { route: "products.detail", params: { id: "42" } },
793
+ { throwOnError: true },
794
+ );
795
+ await prerender.many(targets, { concurrency: 4, onlyIfStale: true });
796
+ await prerender.markStale(["product:42"]);
797
+ await prerender.remove("/products/42"); // see "Remove a page"
798
+ ```
799
+
800
+ `router.prerender({ env, ctx })` binds the live env (and the Cloudflare `ctx`,
801
+ absent on Node) once and returns the runner synchronously; binding does no
802
+ work. Per-call options (`onlyIfStale`, `throwOnError`, and `concurrency` on
803
+ `.many()`) go on each call.
804
+
805
+ `.many()` runs targets through a bounded pool and returns one result per target
806
+ in input order. `concurrency` defaults to 1 (any invalid value is 1). A large
807
+ list belongs in a queue, one message per batch: one invocation runs under the
808
+ platform's time and CPU limits, so a single `.many()` over thousands of targets
809
+ will not finish.
810
+
811
+ A refresh always renders and replaces; `{ onlyIfStale: true }` (cron sweeps)
812
+ skips a fresh entry and returns `already-fresh`. The result is inspectable
813
+ (`{ ok, path: "on-demand", status, key, tags, ttl }`); statuses are `rendered`,
814
+ `already-fresh`, `removed` (the render hit `notFound()`, see "Remove a
815
+ page"), `no-match`, `no-store`, `skipped-personalized`,
816
+ `skipped-unsupported-target` (a target with `?search` or `#hash`),
817
+ `skipped-passthrough`, `render-failed` and `store-failed`. Every failure keeps
818
+ the previous entry, except `skipped-passthrough`, which hands the page to the
819
+ live handler. A `markStale()` that lands while a refresh renders marks the entry that
820
+ refresh stores.
821
+
822
+ A route that is not on-demand is not refused: the same call warms its runtime
823
+ caches instead (see "Warm any route before traffic"). An on-demand route gets
824
+ both: after the store write, the runner sends one warm request so the route's
825
+ loaders' own `cache()` and the document cache are rebuilt on the new entry.
826
+ That request needs a shared app cache store and an origin; without them the
827
+ refresh is complete as it is, and the result has no `caches`.
828
+
829
+ ### Remove a page
830
+
831
+ A deleted product's page has to stop serving, and for a param the build baked,
832
+ the older build-time page must not come back in its place. So a removal does
833
+ not delete the entry: it stores a "removed" marker in the prerender store in
834
+ place of the page. There are two ways to write it.
835
+
836
+ ```typescript
837
+ // 1. Explicitly, for example from a "product deleted" webhook. Nothing is
838
+ // rendered, so it does not wait for the data source to catch up.
839
+ const prerender = router.prerender({ env, ctx });
840
+ await prerender.remove("/products/42");
841
+ await prerender.remove.many(paths, { concurrency: 4 });
842
+
843
+ // 2. By refreshing: a render that hits notFound() removes its own page.
844
+ export const ProductPage = Prerender(
845
+ async () => [{ id: "featured" }],
846
+ async (ctx) => {
847
+ const product = await ctx.env.PRODUCTS.get(ctx.params.id);
848
+ if (!product) notFound();
849
+ return <Product data={product} />;
850
+ },
851
+ { onDemand: { ttl: 3600, tags: ({ params }) => [`product:${params.id}`] } },
852
+ );
853
+ await prerender("/products/42"); // { ok: true, status: "removed", ... }
854
+ ```
855
+
856
+ Both return `{ ok: true, path: "on-demand", status: "removed", key, tags }`.
857
+ The result is `ok: true`: a removal worked, so count `removed` in what a sweep
858
+ reports, next to `rendered` and `already-fresh`. From the next request on, the
859
+ route answers 404 for that param, in dev and in production; a `Passthrough`
860
+ route runs its live handler. A page the build baked is covered too: its
861
+ build-time entry is not served.
862
+
863
+ The two differ in how long they last:
864
+
865
+ | | `prerender.remove(url)` | a refresh that hits `notFound()` |
866
+ | ------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------------- |
867
+ | renders | no | yes |
868
+ | the marker | permanent: no `ttl`, no tags | has the route's `ttl` and `tags`, like the page it replaces |
869
+ | `onRevalidate` | never scheduled | scheduled by a request once the marker is stale; the 404 keeps answering meanwhile |
870
+ | `{ onlyIfStale: true }` | leaves it and returns `removed` | leaves it while fresh (`removed`); renders again once stale |
871
+ | `markStale(tags)` | never reaches it | marks it stale through the route's tags, so it is rechecked |
872
+ | the page comes back when | a refresh without `onlyIfStale` renders it | the next recheck renders it, or any refresh does |
873
+
874
+ - **A `notFound()` anywhere in the render removes the page**: in the route
875
+ handler, a layout handler, a parallel slot handler, or any server component
876
+ in the tree, sync or async. It removes it only when nothing else went wrong.
877
+ When the data source may be failing, throw anything else: that is
878
+ `render-failed` and keeps the page. A render that hits a `notFound()` and
879
+ another error is `render-failed` too, whichever came first.
880
+ - **A `notFound()` removal is rechecked; `remove()` is permanent.** A data
881
+ source that loses an item for a moment must not remove its page until the
882
+ next deploy, so the marker of a refresh goes stale by the route's `ttl`
883
+ (else the router's `prerender.ttl`; with neither it never does) and is
884
+ rendered again by `onRevalidate` or a sweep, like a stale page. `remove()` is
885
+ a decision, not an observation: its marker stays until a
886
+ `prerender(url)` without `onlyIfStale` renders the page.
887
+ - **`ctx.passthrough()` in a refresh stores the marker too.** A `Passthrough`
888
+ route whose build handler declines a param gets `skipped-passthrough`, and
889
+ the live handler answers from then on, not a page stored or baked earlier.
890
+ The marker is stamped and rechecked like a `notFound()` one.
891
+ - **`remove()` is for on-demand routes.** On any other route it returns
892
+ `skipped-not-on-demand` and never warms. `no-match`, `no-store`,
893
+ `skipped-unsupported-target` and `store-failed` are as for a refresh, and
894
+ `throwOnError` works the same. `remove.many()` takes `concurrency` and
895
+ `throwOnError`, and returns one result per target in input order.
896
+ - **Last write wins.** `remove()` and a refresh of the same page are two
897
+ writes to one key. A job queued before a removal (an `onRevalidate` for the
898
+ page when it was stale) that runs a plain refresh after it brings the page
899
+ back: pass `{ onlyIfStale: true }` in `onRevalidate` and queue consumers, so
900
+ they find the marker and render nothing. A marker that lands while a refresh
901
+ is still rendering is not overwritten by that render's page; that narrows
902
+ the race and cannot close it (the check and the write are two store calls,
903
+ and KV is eventually consistent).
904
+ - **The marker does not reach the runtime caches around the route**, and no
905
+ warm request follows a removal. A document cache
906
+ (`createDocumentCacheMiddleware`) answers ahead of the router, so it keeps
907
+ serving the document it stored until that entry's `s-maxage` and
908
+ `stale-while-revalidate` run out. A `Passthrough` route's live handler serves
909
+ through its own `cache()` as it does for a page that was never refreshed.
910
+ Remove first, then invalidate:
911
+
912
+ ```typescript
913
+ path.json("/hooks/product-deleted", async (ctx) => {
914
+ const { id } = (await ctx.request.json()) as { id: string };
915
+ const result = await router
916
+ .prerender({ env: ctx.env, ctx: ctx.executionContext })
917
+ .remove(`/products/${id}`);
918
+ await updateTag(`product:${id}`); // then drop the cached document
919
+ return { status: result.status };
920
+ });
921
+ ```
922
+
923
+ The order matters. With the invalidation first, a visitor between the two
924
+ calls misses the document cache, is served the page the store still holds,
925
+ and the document cache stores it again; the removal then cannot reach it.
926
+ With the removal first, that visitor gets the document that is still
927
+ cached, or, past the cache, the marker's 404, which is never stored: nothing
928
+ can put the page back. `updateTag()` needs a request context: a webhook
929
+ route has one, a
930
+ queue or cron handler does not, and there the stored document serves until
931
+ it expires. The cached document carries the tags its own request recorded
932
+ (for example a `cacheTag()` call in middleware), not the route's
933
+ `onDemand.tags`, which are prerender tags. A plain on-demand route's own
934
+ `cache()` and its loaders' `cache()` need nothing: the request is a 404
935
+ before any of them is shown.
936
+
937
+ - **A store that cannot be read is a miss**, for a marker as for a page: while
938
+ the prerender store is failing, the build-time entry of a removed page can
939
+ serve again.
940
+ - **The marker is per data version**, like every entry: a deploy that leaves
941
+ the router's server code unchanged keeps it. After one that changes it the
942
+ build decides again: a param `getParams()` still lists is baked and served.
943
+ In dev every edit to a server module is such a change, so a marker (like a
944
+ refreshed page) is gone after one.
945
+ - **Intercepted navigations do not see it.** They do not read the prerender
946
+ store (see the rules below), so the build's intercept variant of a removed
947
+ page still serves.
948
+
949
+ The `notFound()` half is the Next.js Pages Router's rule: "With
950
+ `notFound: true`, the page will return a `404` even if there was a
951
+ successfully generated page before. This is meant to support use cases like
952
+ user-generated content getting removed by its author. Note, `notFound`
953
+ follows the same `revalidate` behavior described here."
954
+ ([getStaticProps](https://nextjs.org/docs/pages/api-reference/functions/get-static-props))
955
+
956
+ ### What is served
957
+
958
+ | Request finds | `Prerender(..., { onDemand })` | `Passthrough(def, live)` |
959
+ | --------------------------------- | ----------------------------------------------------------- | ------------------------------------------ |
960
+ | fresh overlay entry | the overlay entry, loaders fresh (dev and production) | same |
961
+ | stale overlay entry | the overlay entry; `onRevalidate` scheduled if configured | same |
962
+ | "removed" marker | **404** (dev and production); the build entry is not served | the live handler (dev and production) |
963
+ | stale "removed" marker | the same 404; `onRevalidate` scheduled if configured | the live handler; `onRevalidate` likewise |
964
+ | no overlay entry, param baked | production: the build entry; dev: the dev prerender render | same |
965
+ | no overlay entry, param not baked | production: **404**; dev: rendered through the dev endpoint | the live handler (dev and production) |
966
+ | server action re-render | overlay, then build entry, then 404, as above | the live handler (the overlay is not read) |
967
+
968
+ The retained handler never renders inside a production request: a plain
969
+ on-demand route 404s for a param until something refreshes it. Dev renders any
970
+ param through the dev prerender endpoint, so a production 404 does not show up
971
+ in dev. A removed page is the exception: its marker answers 404 in dev too.
972
+
973
+ ### Rules worth knowing
974
+
975
+ - **`ttl` is soft staleness.** Entries never expire: a stale entry still serves
976
+ (it is newer than the build entry below it) and only decides whether
977
+ `onRevalidate` is scheduled. The KV store writes no `expirationTtl`.
978
+ - **`onRevalidate` runs once per stale key per isolate** while one is in
979
+ flight, so calling `router.prerender()` from it renders once per stale key
980
+ on a single Node process. Across isolates, dedup belongs to the queue. Its
981
+ `target` is a `PrerenderTargetObject`, which the runner and its `.many()`
982
+ accept as is, so
983
+ `onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target, { onlyIfStale: true })`
984
+ typechecks without a cast. `onRevalidate` receives `(target, env, ctx)`;
985
+ `ctx` is the stale request's execution context, absent where none exists.
986
+ Pass `{ onlyIfStale: true }` wherever the refresh runs later than it was
987
+ scheduled (here, and in a queue consumer): the entry is stale, so it
988
+ renders, unless another job already refreshed it (`already-fresh`) or
989
+ `prerender.remove()` removed the page in between (`removed`, nothing
990
+ rendered). A refresh without it would bring a removed page back.
991
+ - **Prerender tags are their own namespace.** `cacheTag()`, `updateTag()` and
992
+ `revalidateTag()` never reach the prerender store; the runner's `markStale()`
993
+ never reaches the runtime cache. (`updateTag()` needs a request context;
994
+ refreshes run from queues and crons.)
995
+ - **`markStale()` only marks.** The entry keeps serving; nothing re-renders
996
+ unless `onRevalidate` is configured or a sweep calls
997
+ `prerender(target, { onlyIfStale: true })`. Dev warns when you mark
998
+ with no `onRevalidate`.
999
+ - **The producer is requestless.** `cookies()`, `headers()` and the client-cache
1000
+ directives make a refresh return `skipped-personalized`. `ctx.env` is the
1001
+ live env bound by `router.prerender({ env })`, not `buildEnv`, and `ctx.onDemand` is `true`.
1002
+ - **A trigger may render params `getParams()` never returned.** Validate ids
1003
+ from webhooks before refreshing them.
1004
+ - **A throw in a refresh keeps the old entry** (`render-failed`). A
1005
+ `notFound()` on its own is not a failure: it removes the page (see "Remove
1006
+ a page").
1007
+ - **Intercepted navigations are not refreshed.** A refresh writes the main
1008
+ variant only; an intercept navigation still serves the build's intercept
1009
+ variant, and an on-demand route used as an intercept target 404s on a param
1010
+ that only a refresh produced.
1011
+ - **Connected clients can lag.** A refresh has no response to reach open tabs:
1012
+ a client that prefetched the page keeps its copy for `prefetchCacheTTL`
1013
+ (default 300s) and the browser HTTP cache (`prefetchCacheControl`). Lower
1014
+ them if the window matters.
1015
+ - **Entries are per deploy.** Keys carry the router's data version, so a deploy
1016
+ that changes server code starts from the build entries again; a client-only
1017
+ deploy keeps refreshed pages.
1018
+
1019
+ ### Custom stores
1020
+
1021
+ A store is plain get/set; the router composes the envelope on write and
1022
+ verifies it on read (version and params collision guard):
1023
+
1024
+ ```typescript
1025
+ import {
1026
+ serializePrerenderKey,
1027
+ type WritablePrerenderStore,
1028
+ } from "@rangojs/router/prerender";
1029
+
1030
+ const store: WritablePrerenderStore = {
1031
+ async get(key) {
1032
+ const raw = await blob.get(serializePrerenderKey(key));
1033
+ return raw ? JSON.parse(raw) : null;
1034
+ },
1035
+ async set(key, stored) {
1036
+ await blob.put(serializePrerenderKey(key), JSON.stringify(stored));
1037
+ },
1038
+ // optional: delete(key), markStale(routerId, tags)
1039
+ };
1040
+ ```
1041
+
1042
+ `markStale(routerId, tags)` receives the id of the router whose runner was
1043
+ called (`key.routerId` on that router's entries). Mark only entries whose
1044
+ `key.routerId` equals it: routers behind a host router share one store, and a
1045
+ tag means something only inside the router whose routes declared it.
1046
+
1047
+ Key everything off the `key` you are given (`serializePrerenderKey` includes
1048
+ `key.version`). Do not call `getCacheVersions()` in a prerender store: `set()`
1049
+ runs outside the producer's request context.
1050
+
1051
+ `stored` is a page (`{ v, entry, meta }`) or the "removed" marker
1052
+ (`{ v, removed: true, meta }`, no `entry`). A store that persists the JSON it
1053
+ is given, like the one above, handles both and needs no change. `meta` has the
1054
+ same fields on both, and means the same: a store that indexes by `meta.tags`
1055
+ or lowers `meta.staleAt` for `markStale()` treats a marker as it treats a
1056
+ page. That is how a `notFound()` marker is rechecked. A `remove()` marker has
1057
+ no tags and no `staleAt`. Never drop or rewrite a value you do not
1058
+ recognize: the router decides what it means. The router does not call
1059
+ `delete(key)`; removing a page is a `set()` of the marker.
1060
+
1061
+ ### Several routers (host router)
1062
+
1063
+ A runner belongs to the router `router.prerender()` was called on, so an app
1064
+ with several routers behind a host router calls it on each router:
1065
+
1066
+ - It matches only that router's routes. A URL only another router has is
1067
+ `no-match`.
1068
+ - Entries are keyed by router id plus that router's data version, so two
1069
+ routers can share one `prerender.store` (one KV namespace) without touching
1070
+ each other's entries. A deploy that changes one router's server code leaves
1071
+ the others' refreshed entries served when their code is separate (for
1072
+ example lazy mounts); routers that share a statically imported module move
1073
+ together.
1074
+ - `markStale(tags)` marks only that router's entries, in every shipped store:
1075
+ `markStale(["product:1"])` on one router does not make another router's
1076
+ `product:1` entry stale.
1077
+ - A warm of a non-on-demand route goes through that router's own handler, and
1078
+ its cache keys carry the host: pass the host the visitors use as `origin`
1079
+ (`router.prerender({ env, ctx, origin: "https://a.example" })`), or a path
1080
+ target resolves against the calling request's origin. A multi-router app
1081
+ passes `origin` or a full URL on every warm, so each router warms the host
1082
+ its visitors use. A warm that resolves another router's host stores the
1083
+ warming router's entries under that host; the router that owns the host
1084
+ never reads them (cache keys start with the router's id: `/host-router`,
1085
+ "Shared cache store").
1086
+
1087
+ ### KV tag markers
1088
+
1089
+ `createKVPrerenderStore(kv)` writes a marker per tag at
1090
+ `__rango_pr_tag__/{encodeURIComponent(routerId)}/{tag}` (the encoded router id)
1091
+ and markers have no expiry: an entry without a `ttl` never goes stale by
1092
+ itself, so an expired marker would silently drop an invalidation.
1093
+
1094
+ ### Test it
1095
+
1096
+ `router.prerender()` and the serve path run under the public testing
1097
+ primitives: render with `router.prerender()`, then `serveShellRequest(router,
1098
+ url)` from `@rangojs/router/testing/flight` serves the route through the
1099
+ production handler and returns the Flight payload. After
1100
+ `prerender.remove(url)` the same request is a `404` (`result.response.status`).
1101
+ `createMemoryPrerenderStore` is exported from `@rangojs/router/testing` too.
1102
+ See `/testing`.
704
1103
 
705
1104
  ### loading() is ignored without Passthrough
706
1105
 
707
1106
  Pre-rendered segments are fully resolved at build time and never suspend.
708
1107
  With `Passthrough()`, `loading()` works for live fallback renders.
709
1108
 
1109
+ ## Warm any route before traffic
1110
+
1111
+ `router.prerender()` is one verb for "make this URL ready before a visitor
1112
+ asks for it". For a `Prerender(..., { onDemand })` route that is the
1113
+ requestless render above. For **every other route** it warms: the runner
1114
+ sends the URL through the router's own request handler as an anonymous
1115
+ visitor would, in a mode where every runtime cache read misses and every
1116
+ write replaces the entry under the visitor's key. The old entries keep
1117
+ serving until the new ones are written, so there is no cold window.
1118
+
1119
+ After a deploy that changes server code, and after an `updateTag()`, every
1120
+ cache key starts cold and the first visitor per key pays the render. A warm
1121
+ pays it for them.
1122
+
1123
+ ```typescript
1124
+ const prerender = router.prerender({
1125
+ env,
1126
+ ctx,
1127
+ origin: "https://shop.example",
1128
+ });
1129
+
1130
+ await prerender("/products/1"); // any route
1131
+ await prerender("/search?q=wine"); // search params are part of the key
1132
+ await prerender.many(urls, { concurrency: 4 }); // default concurrency 1
1133
+ await prerender.many(urls, { onlyIfStale: true }); // top up what is cold or stale
1134
+ ```
1135
+
1136
+ ### What one call does
1137
+
1138
+ | Route | `prerender(url)` | `result.path` |
1139
+ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------- |
1140
+ | `Prerender(..., { onDemand })` | requestless render into the prerender store; then a warm request, when the app cache store is shared and an origin resolves | `on-demand` |
1141
+ | any other route, shared app cache store | a warm request through the router's handler | `warm` |
1142
+ | any other route, store not shared | refused before anything renders: `skipped-store-not-shared` (dev warns once) | `warm` |
1143
+ | any other route, no `createRouter({ cache })` | `no-store` | `warm` |
1144
+
1145
+ A warm replaces what a cookie-free visitor's document request for that URL
1146
+ would fill:
1147
+
1148
+ - a `ppr` route's shell (the next document request is `x-rango-shell: HIT`);
1149
+ - the route's `cache()` records for a document request;
1150
+ - the `"use cache"` results the render reaches (a function keyed by its
1151
+ arguments only is refreshed for every URL that calls it);
1152
+ - its loaders' own `cache()`;
1153
+ - the document cache entry (`createDocumentCacheMiddleware`), and a response
1154
+ route's cache.
1155
+
1156
+ It does not write the records a client navigation reads (`partial:`) or an
1157
+ intercept's: those fill on the first navigation. A `ppr` route's navigations
1158
+ replay the document shell, so they are covered.
1159
+
1160
+ A `clientUrls()` group cannot use `Prerender()`; with `ppr` on a group route,
1161
+ warming is how that route is ready before its first visitor.
1162
+
1163
+ ### The store must be shared
1164
+
1165
+ A warm fills the cache where the call runs. That helps other traffic only when
1166
+ the store is shared beyond that place, so the router asks the store
1167
+ (`SegmentCacheStore.scope`):
1168
+
1169
+ | Store | `scope` | Warm |
1170
+ | --------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------- |
1171
+ | `CFCacheStore` with `kv` | `"global"` | yes |
1172
+ | `CFCacheStore` without `kv` | `"local"` | refused: the Cache API is per colo |
1173
+ | `VercelCacheStore` | `"regional"` | yes; fills the region the call runs in (all traffic only on a single-region project) |
1174
+ | `MemorySegmentCacheStore` | `"local"` | refused in production; allowed under the dev server (one process serves every request); `{ scope: "global" }` admits it |
1175
+ | a custom store | its own | only when it declares `scope: "global"` or `"regional"`; a store that declares none is refused |
1176
+
1177
+ `VercelCacheStore` is not checked for the number of regions: Vercel's Runtime
1178
+ Cache is per region, and a warm fills the one it runs in. New projects and
1179
+ every Hobby project run functions in one region (`iad1`); on a multi-region
1180
+ project warm from each region, or accept that the others fill on first use.
1181
+
1182
+ The shared-store check covers the app store (`createRouter({ cache })`) only.
1183
+ A warm also overwrites entries in a route's or loader's own `cache({ store })`,
1184
+ and that store is not checked: one that is `local` is warmed for the calling
1185
+ process only, so no other process or location sees the new entry.
1186
+
1187
+ The dev exception exists so you can try warming locally on the Node preset. It
1188
+ follows the dev server, not `NODE_ENV`: a production build, `vite preview` and
1189
+ a test runner all refuse the memory store. A single-process deployment whose
1190
+ memory store really is the only copy says so with the `scope` option:
1191
+
1192
+ ```typescript
1193
+ const store = new MemorySegmentCacheStore({ scope: "global" });
1194
+ ```
1195
+
1196
+ It is not the default because the store is per process and the router cannot
1197
+ tell one long-running server from several replicas or serverless instances. A
1198
+ default of `"global"` would report `warmed` while most visitors hit a cold
1199
+ instance, so you opt in only when exactly one process serves the app. Two
1200
+ instances with the same `name` share their maps but each declares its own
1201
+ `scope`: keep them identical.
1202
+
1203
+ ### The origin
1204
+
1205
+ Cache keys carry the host, so a warm must request the host visitors use. In
1206
+ order:
1207
+
1208
+ 1. the target's own origin (`prerender("https://shop.example/p/1")`, or a
1209
+ `URL`);
1210
+ 2. `origin` on the binding (`router.prerender({ env, ctx, origin })`), for
1211
+ path and `{ route, params }` targets;
1212
+ 3. the origin of the request the runner is called from, when there is one: a
1213
+ server action, a route handler, `onRevalidate`;
1214
+ 4. none: `skipped-no-origin`.
1215
+
1216
+ A cron or queue handler has no request, so give it `origin`. An on-demand
1217
+ render never needs one (prerender keys carry no host); without one it only
1218
+ skips the warm request that follows.
1219
+
1220
+ A hash, or a parameter the router reserves (`_rsc*`, `__no_cache`), is
1221
+ `skipped-unsupported-target`.
1222
+
1223
+ ### The request is anonymous
1224
+
1225
+ The warm is a `GET` with `accept: text/html` and nothing else: no cookies, no
1226
+ authorization, no user agent, no `accept-language`, and on Cloudflare no
1227
+ `request.cf`. It lands in the partition a header-less visitor lands in (a
1228
+ route partitioned by `cache({ key })` or the store's `keyGenerator` gets that
1229
+ one partition warmed).
1230
+
1231
+ - Middleware runs and sees an anonymous request. An auth middleware that
1232
+ redirects makes the result `render-failed` with `responseStatus: 302`: an
1233
+ anonymous visitor is not served a cached page there either.
1234
+ - `cookies()` or `headers()` read outside any cached scope see nothing, and
1235
+ the anonymous view is rendered and stored wherever a cookie-free visitor's
1236
+ would be.
1237
+ - A read inside `cache()`, `"use cache"` or a `ppr` shell capture trips the
1238
+ same guard it trips for a visitor; the result is `skipped-personalized`.
1239
+ - Analytics that count document GETs count warms, and a rate limiter keyed by
1240
+ IP sees an empty key.
1241
+
1242
+ Nothing a client sends makes a request a warm: the mark is in process.
1243
+
1244
+ ### Results
1245
+
1246
+ ```typescript
1247
+ const result = await prerender("/products/1");
1248
+ if (!result.ok) console.warn(result.status, result.caches);
1249
+ ```
1250
+
1251
+ | `status` | `ok` | Meaning |
1252
+ | ---------------------------- | ------- | ----------------------------------------------------------------------------------------------- |
1253
+ | `warmed` | `true` | the request rendered and at least one cache wrote |
1254
+ | `already-fresh` | `true` | `onlyIfStale`: nothing was cold or stale |
1255
+ | `skipped-store-not-shared` | `false` | the app store's `scope` is `"local"` or undeclared; nothing rendered |
1256
+ | `skipped-no-origin` | `false` | a path target with no origin to request it on |
1257
+ | `no-store` | `false` | no `createRouter({ cache })`, or `enabled: false` |
1258
+ | `skipped-personalized` | `false` | an identity guard refused a read |
1259
+ | `shell-not-stored` | `false` | a `ppr` route whose shell was not stored; `caches.shell` and `caches.refusal` say why |
1260
+ | `skipped-uncached` | `false` | the request rendered and no cache wrote (the route caches nothing, or every write was refused) |
1261
+ | `render-failed` | `false` | the handler threw, the response was not 200 (`responseStatus`), or the render reported an error |
1262
+ | `skipped-unsupported-target` | `false` | a hash or a reserved parameter |
1263
+ | `no-match` | `false` | no route matches the path |
1264
+
1265
+ `result.target` is the absolute URL that was requested. `result.caches` has
1266
+ the detail: `writes` by store family (`record`, `item`, `response`, `shell`),
1267
+ `shell` (`stored`, `fresh`, `refused`, `no-shell`, `not-eligible`,
1268
+ `skipped-capacity`, `skipped-queue-timeout`, `error`), `refusal` when the
1269
+ capture refused, and `document` (`stored` or `not-cacheable`) when the document
1270
+ cache middleware ran. `throwOnError: true` throws a `PrerenderError` on any
1271
+ `ok: false`, refusals included.
1272
+
1273
+ `.many()` dispatches each target on its own, so a batch can mix on-demand and
1274
+ plain routes: one result per target, in input order.
1275
+
1276
+ `onlyIfStale` on a plain route makes the request read its caches normally
1277
+ instead of forcing misses: a fresh entry is left alone, a stale one refreshes,
1278
+ a missing one is written. It costs a visitor's request when everything is
1279
+ fresh.
1280
+
1281
+ ### Refreshing changed content: `updateTag`, then `prerender`
1282
+
1283
+ A warm replaces the entry where it runs, and on a shared store that is the
1284
+ entry other locations read on their next miss. It does not reach a copy
1285
+ another location already holds: on `CFCacheStore`, each colo keeps serving its
1286
+ own Cache API copy until it expires, and an entry whose `ttl + swr` is under
1287
+ 60 seconds never reaches KV at all.
1288
+
1289
+ So after a deploy (new keys: no location holds a copy) a warm alone makes
1290
+ the route ready everywhere. For a content change, invalidate first, then
1291
+ warm:
1292
+
1293
+ ```typescript
1294
+ // A CMS webhook route. A webhook is a request, so updateTag() works here and
1295
+ // the warm takes the request's origin.
1296
+ path.json("/hooks/product-changed", async (ctx) => {
1297
+ const { id } = (await ctx.request.json()) as { id: string };
1298
+ await updateTag(`product:${id}`); // drops every location's copy
1299
+ const result = await router.prerender({
1300
+ env: ctx.env,
1301
+ ctx: ctx.executionContext,
1302
+ })(`/products/${id}`); // writes the new one
1303
+ return { status: result.status };
1304
+ });
1305
+ ```
1306
+
1307
+ The invalidation makes every location's copy unservable, so no visitor is
1308
+ served the old content, and the warm writes the new entry where it runs and
1309
+ to the shared store. A location with no copy of its own reads that entry on
1310
+ its next request and renders nothing. On `CFCacheStore`, a colo that was
1311
+ still holding its own (now invalidated) copy renders once for itself instead
1312
+ of reading the warmed entry, unless the store uses `tagPurge`, which evicts
1313
+ those copies. Await `updateTag()` before the warm: a write that started
1314
+ before an invalidation of one of its tags is refused.
1315
+
1316
+ ### Limits worth knowing
1317
+
1318
+ - **Only the router's own handler.** A warm goes through `router.fetch`, the
1319
+ handler `createRouter`'s `cache`, `nonce` and `version` configure. An app
1320
+ entry that builds its own `createRSCHandler({ cache, version })` is not what
1321
+ a warm runs: put those options on `createRouter`.
1322
+ - **Document records only.** Navigation (`partial:`) and intercept records
1323
+ fill on the first navigation.
1324
+ - **One location at a time.** A warm does not reach a copy another location
1325
+ already holds; see the refresh pattern above.
1326
+ - **A warm costs a visitor's MISS:** one render, plus a shell capture on a
1327
+ `ppr` route. `concurrency` defaults to 1 so a sweep never fans out against
1328
+ your data source unless you ask. One call waits for the request's
1329
+ background work (the cache writes, the capture), up to about 40 seconds in
1330
+ the worst case; a large list belongs in a queue, one message per batch.
1331
+ - **Gradual deployments:** a warm fills the version that received the call.
1332
+ - **The correctness guards still decide.** A warm never stores what a
1333
+ visitor's request would refuse to store: `cache(false)`, a false
1334
+ `condition()`, `private` / `no-store` / `Set-Cookie` on a document, a tag
1335
+ invalidated mid-render, an entry over the store's size limit.
1336
+
1337
+ ### Test it
1338
+
1339
+ `router.prerender()` warms under the public testing primitives too: warm, then
1340
+ `serveShellRequest(router, url)` from `@rangojs/router/testing/flight` serves
1341
+ the next visitor's request through the production handler (`shellStatus:
1342
+ "HIT"`). Configure the store on the router (`createRouter({ cache: { store }
1343
+ })`), not through `serveShellRequest`'s `cacheStore` option, and give it a
1344
+ shared scope: the shipped `MemorySegmentCacheStore` is refused, so pass
1345
+ `{ scope: "global" }` as above. See `/testing`.
1346
+
710
1347
  ## Complete Example
711
1348
 
712
1349
  Known guides are pre-rendered; any other slug is rendered live by the
@@ -835,9 +1472,12 @@ Pre-rendered routes set flags on the route trie leaf at build time:
835
1472
 
836
1473
  - `pr: true` -- route has pre-rendered segment data
837
1474
  - `pt: true` -- route wrapped with `Passthrough()` (live handler available)
1475
+ - `od: true` -- route opted into on-demand refresh (`onDemand`); the writable
1476
+ store is read before the build manifest
838
1477
 
839
1478
  At runtime, the cache-lookup middleware uses these flags:
840
1479
 
1480
+ - `od + overlay hit` -- serve the on-demand entry
841
1481
  - `pr + hit` -- serve pre-rendered Flight payload
842
1482
  - `pr + pt + miss` -- fall through to Passthrough live handler
843
1483
  - `pr + miss` (no pt) -- fall through to the stubbed handler, which throws a
@@ -850,4 +1490,5 @@ At runtime, the cache-lookup middleware uses these flags:
850
1490
  - `/ppr` — the `ppr` path option; combine with `Prerender` for build-time shells
851
1491
  - `/shell-manifest` — prerendered shell feeding ids to a live loader
852
1492
  - `/deployment-caching` — why prerender output is not a CDN static file
1493
+ - `/cloudflare` — the KV-backed on-demand store and a queue consumer
853
1494
  - `/loader` — loaders stay live on prerendered routes