@rangojs/router 0.31.0 → 0.33.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 (192) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10143
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-CZ2QHT4Q.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-QRR6HD6T.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/history-state.d.ts +13 -2
  19. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  20. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  21. package/dist/types/browser/navigation-client.d.ts +19 -0
  22. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  23. package/dist/types/browser/navigation-store.d.ts +2 -2
  24. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  25. package/dist/types/browser/partial-update.d.ts +13 -0
  26. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  27. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  28. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  29. package/dist/types/browser/rango-state.d.ts +34 -5
  30. package/dist/types/browser/react/Link.d.ts +19 -13
  31. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  32. package/dist/types/browser/types.d.ts +25 -0
  33. package/dist/types/build/generate-route-types.d.ts +1 -1
  34. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  35. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  36. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  37. package/dist/types/cache/cache-scope.d.ts +4 -2
  38. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  39. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  40. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  41. package/dist/types/cache/segment-codec.d.ts +2 -2
  42. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  43. package/dist/types/cache/types.d.ts +12 -7
  44. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  45. package/dist/types/client-urls/types.d.ts +7 -5
  46. package/dist/types/prefetch-markers.d.ts +9 -0
  47. package/dist/types/prefetch-source-scope.d.ts +66 -0
  48. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  49. package/dist/types/route-definition/redirect.d.ts +5 -0
  50. package/dist/types/router/intercept-resolution.d.ts +25 -11
  51. package/dist/types/router/match-context.d.ts +1 -0
  52. package/dist/types/router/match-handlers.d.ts +1 -1
  53. package/dist/types/router/middleware-types.d.ts +6 -0
  54. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  55. package/dist/types/router/router-context.d.ts +1 -1
  56. package/dist/types/router/router-options.d.ts +6 -3
  57. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  58. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  59. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  60. package/dist/types/router/source-reads.d.ts +53 -0
  61. package/dist/types/router/types.d.ts +1 -1
  62. package/dist/types/rsc/helpers.d.ts +8 -3
  63. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  64. package/dist/types/rsc/request-stages.d.ts +4 -0
  65. package/dist/types/rsc/shell-serve.d.ts +6 -1
  66. package/dist/types/server/context.d.ts +23 -6
  67. package/dist/types/server/cookie-store.d.ts +15 -1
  68. package/dist/types/server/document-location-state.d.ts +59 -0
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +65 -12
  71. package/dist/types/testing/e2e/server.d.ts +16 -1
  72. package/dist/types/testing/render-route.d.ts +83 -2
  73. package/dist/types/testing/serve-shell-request.d.ts +15 -3
  74. package/dist/types/types/handler-context.d.ts +21 -4
  75. package/dist/types/types/loader-types.d.ts +7 -4
  76. package/dist/types/types/segments.d.ts +18 -1
  77. package/dist/types/urls/path-helper-types.d.ts +7 -5
  78. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  79. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  80. package/dist/vite/index.js +451 -191
  81. package/package.json +2 -2
  82. package/skills/app-structure/SKILL.md +74 -0
  83. package/skills/app-structure/e-commerce.md +10 -9
  84. package/skills/app-structure/large-app.md +15 -2
  85. package/skills/caching/SKILL.md +10 -5
  86. package/skills/client-urls/SKILL.md +18 -11
  87. package/skills/comparison/references/framework-comparison.md +16 -10
  88. package/skills/document-cache/SKILL.md +13 -0
  89. package/skills/hooks/navigation.md +8 -8
  90. package/skills/hooks/state.md +132 -12
  91. package/skills/intercept/SKILL.md +33 -11
  92. package/skills/layout/SKILL.md +11 -0
  93. package/skills/links/SKILL.md +101 -4
  94. package/skills/loader/SKILL.md +49 -22
  95. package/skills/migrate-nextjs/SKILL.md +8 -2
  96. package/skills/migrate-react-router/data-and-actions.md +5 -0
  97. package/skills/ppr/SKILL.md +38 -5
  98. package/skills/prerender/SKILL.md +4 -1
  99. package/skills/route/SKILL.md +6 -6
  100. package/skills/router-setup/SKILL.md +2 -1
  101. package/skills/server-actions/SKILL.md +49 -8
  102. package/skills/testing/client-components.md +73 -32
  103. package/skills/testing/serve-shell-request.md +45 -20
  104. package/skills/testing/server-actions.md +8 -0
  105. package/skills/ux-architecture/SKILL.md +67 -15
  106. package/src/bin/rango.ts +39 -13
  107. package/src/browser/action-fence.ts +72 -0
  108. package/src/browser/cookie-name.ts +82 -14
  109. package/src/browser/history-state.ts +24 -2
  110. package/src/browser/invalidate-client-cache.ts +9 -7
  111. package/src/browser/navigation-bridge.ts +164 -32
  112. package/src/browser/navigation-client.ts +95 -20
  113. package/src/browser/navigation-store-handle.ts +67 -5
  114. package/src/browser/navigation-store.ts +86 -17
  115. package/src/browser/navigation-transaction.ts +0 -6
  116. package/src/browser/partial-update.ts +31 -12
  117. package/src/browser/prefetch/cache.ts +188 -11
  118. package/src/browser/prefetch/fetch.ts +211 -51
  119. package/src/browser/prefetch/queue.ts +3 -2
  120. package/src/browser/rango-state.ts +50 -8
  121. package/src/browser/react/Link.tsx +24 -16
  122. package/src/browser/react/location-state-shared.ts +8 -1
  123. package/src/browser/react/use-navigation.ts +1 -1
  124. package/src/browser/rsc-router.tsx +13 -3
  125. package/src/browser/server-action-bridge.ts +3 -3
  126. package/src/browser/types.ts +25 -0
  127. package/src/build/generate-route-types.ts +1 -0
  128. package/src/build/route-types/include-resolution.ts +40 -3
  129. package/src/build/route-types/router-processing.ts +170 -25
  130. package/src/build/route-types/scan-filter.ts +1 -0
  131. package/src/build/runtime-discovery.ts +46 -8
  132. package/src/cache/cache-key-utils.ts +12 -8
  133. package/src/cache/cache-runtime.ts +5 -1
  134. package/src/cache/cache-scope.ts +28 -7
  135. package/src/cache/cf/cf-cache-types.ts +9 -5
  136. package/src/cache/document-cache-policy.ts +37 -1
  137. package/src/cache/document-cache.ts +12 -0
  138. package/src/cache/memory-segment-store.ts +3 -2
  139. package/src/cache/segment-codec.ts +2 -2
  140. package/src/cache/shell-snapshot.ts +1 -1
  141. package/src/cache/types.ts +12 -7
  142. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  143. package/src/client-urls/types.ts +7 -5
  144. package/src/prefetch-markers.ts +10 -0
  145. package/src/prefetch-source-scope.ts +137 -0
  146. package/src/route-definition/helpers-types.ts +8 -5
  147. package/src/route-definition/redirect.ts +5 -0
  148. package/src/router/intercept-resolution.ts +94 -50
  149. package/src/router/loader-resolution.ts +8 -6
  150. package/src/router/match-api.ts +33 -29
  151. package/src/router/match-context.ts +5 -0
  152. package/src/router/match-handlers.ts +2 -1
  153. package/src/router/match-middleware/cache-lookup.ts +39 -14
  154. package/src/router/match-result.ts +45 -85
  155. package/src/router/middleware-types.ts +6 -0
  156. package/src/router/navigation-snapshot.ts +18 -3
  157. package/src/router/prefetch-cache-ttl.ts +15 -3
  158. package/src/router/revalidation.ts +63 -13
  159. package/src/router/router-context.ts +2 -1
  160. package/src/router/router-options.ts +6 -3
  161. package/src/router/segment-resolution/loader-cache.ts +21 -3
  162. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  163. package/src/router/segment-resolution/revalidation.ts +40 -12
  164. package/src/router/source-reads.ts +122 -0
  165. package/src/router/types.ts +2 -1
  166. package/src/router.ts +11 -12
  167. package/src/rsc/helpers.ts +20 -6
  168. package/src/rsc/prefetch-render-error.ts +7 -2
  169. package/src/rsc/progressive-enhancement.ts +8 -2
  170. package/src/rsc/request-stages.ts +87 -1
  171. package/src/rsc/rsc-rendering.ts +78 -32
  172. package/src/rsc/server-action.ts +2 -1
  173. package/src/rsc/shell-capture.ts +4 -5
  174. package/src/rsc/shell-serve.ts +12 -1
  175. package/src/segment-system.tsx +1 -1
  176. package/src/server/context.ts +37 -6
  177. package/src/server/cookie-store.ts +22 -1
  178. package/src/server/document-location-state.ts +334 -0
  179. package/src/server/handle-store.ts +2 -0
  180. package/src/server/request-context.ts +137 -33
  181. package/src/testing/dispatch.ts +6 -0
  182. package/src/testing/e2e/fixture.ts +4 -12
  183. package/src/testing/e2e/server.ts +91 -4
  184. package/src/testing/render-route.tsx +309 -10
  185. package/src/testing/run-revalidate.ts +11 -1
  186. package/src/testing/serve-shell-request.ts +22 -3
  187. package/src/types/handler-context.ts +21 -4
  188. package/src/types/loader-types.ts +7 -4
  189. package/src/types/segments.ts +18 -1
  190. package/src/urls/path-helper-types.ts +7 -5
  191. package/src/vite/temp-rsc-server.ts +2 -2
  192. package/src/vite/utils/forward-user-plugins.ts +13 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -259,7 +259,7 @@
259
259
  "node": ">=24.0.0"
260
260
  },
261
261
  "scripts": {
262
- "build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js && mkdir -p dist/bin/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/bin/plugins/cloudflare-protocol-loader-hook.mjs",
262
+ "build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && rm -rf dist/bin && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --splitting --outdir=dist/bin --platform=node --packages=external && chmod +x dist/bin/rango.js && mkdir -p dist/bin/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/bin/plugins/cloudflare-protocol-loader-hook.mjs",
263
263
  "build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
264
264
  "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
265
265
  "test": "playwright test",
@@ -112,6 +112,14 @@ here for the rules they share.
112
112
  keeps the visitor's URL. A loader, handler or server action
113
113
  cannot rewrite, and a loop answers 508. Redirect when the new URL should
114
114
  show (`/middleware`, "Rewrite").
115
+ 9. **Keep prefetches shared.** One prefetch of a page serves every page
116
+ that links to it. A `revalidate()` that reads the page being left and
117
+ keeps its segment shares those prefetches only between the pages with
118
+ equal values of what it read (`currentParams.locale`: the pages of one
119
+ locale); one that reads `currentUrl` makes them page-only. Read params
120
+ by name, keep page-specific blocks in their route, and write such a rule
121
+ for chrome that shows a param's data
122
+ ([Prefetches are shared](#prefetches-are-shared)).
115
123
 
116
124
  ## A small app: one module
117
125
 
@@ -217,6 +225,72 @@ name a route alike. One unnamed level in the root module is fine.
217
225
  For where boundaries and `revalidate()` rules go inside that chrome so
218
226
  navigations stay smooth, see the `ux-architecture` skill.
219
227
 
228
+ ## Prefetches are shared
229
+
230
+ In production every visible `<Link>` prefetches its page, and one prefetch
231
+ of a page serves every page that links to it: a product prefetched on the
232
+ home page is not fetched again on the search page or on page 2 of a list
233
+ (`/links` → "Shared prefetches and `prefetchKey`"). A structure keeps that
234
+ sharing as long as no segment's answer depends on the page being left.
235
+
236
+ The default rules keep it: a standalone layout is held from every page that
237
+ shows it, and a route re-renders when its params or search change. A
238
+ `revalidate()` that reads the page being left and keeps its segment narrows
239
+ it to what the rule read. A rule that reads `currentParams` or
240
+ `fromRouteName` shares the answer between the pages with equal values of
241
+ those reads (the params a rule reads by name, every param when it spreads
242
+ `currentParams`, the route name for `fromRouteName`; the page's set of params
243
+ always counts). A rule that reads `currentUrl` makes it page-only: the server
244
+ stores the answer for the exact page that sent it, query string and hash
245
+ included, and each page prefetches those links again. The answer stays
246
+ correct; what is lost is the sharing.
247
+
248
+ | What you are placing | Put it in | Prefetches |
249
+ | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
250
+ | A block that belongs to one page (a category's hero, a product's breadcrumb) | that page's route: its handler, or a layout inside its `path()` (below) | Shared. It re-renders with its route on a param or search change. In a layout other pages share it would need a rule that reads the page being left |
251
+ | Links that follow a mount param (`:locale`, `:accountId`, `:projectId`) | a client component with `useReverse(routes)` (rule 7) | Shared. The links follow `useParams()`, and the layout stays held with no rule |
252
+ | Chrome that shows a param's data (the locale's messages, a project's name) | the layout, with a `revalidate()` comparing `currentParams` and `nextParams` | Shared between the pages with the same value of that param where the rule keeps the layout (the pages of one locale or one project) |
253
+ | A page whose answer depends on the page that sent it through something the server cannot see (`context` in a `revalidate()`, the `Referer` header) | `prefetchKey=":source"` on the links to it | Page-only, for those links |
254
+
255
+ The third row is the one to weigh: write that rule for chrome that must
256
+ follow the param, read the param by name, and keep everything else that
257
+ varies by page out of the layout.
258
+
259
+ Chrome that one kind of page has (a category's hero and breadcrumbs, a
260
+ product's gallery frame) can be a layout of its own route instead of a
261
+ template shared with other pages. A layout declared inside a `path()` belongs
262
+ to that route (`/layout`, "Orphan Layout"):
263
+
264
+ ```tsx
265
+ urls(({ path, layout }) => [
266
+ path("/c/:category", CategoryPage, { name: "category" }, () => [
267
+ layout(CategoryLayout), // the hero: re-renders with the category
268
+ ]),
269
+ path("/p/:slug", ProductPage, { name: "product" }, () => [
270
+ layout(ProductLayout),
271
+ ]),
272
+ ]);
273
+ ```
274
+
275
+ - **No rule, shared prefetches.** It re-renders on its route's param or
276
+ search change by default, so nothing reads the page being left.
277
+ - **It cannot leak.** The category's layout is not in the product page's
278
+ tree, so no navigation can keep it above a product page or the cart.
279
+ - **What it costs.** Its handler runs on every param or search change of its
280
+ route, `?page=2` included; cache an expensive read with `"use cache"`. Without
281
+ a `transition()` in its chain (the route, this layout or a layout above) it
282
+ remounts on a param change, like the route. A navigation to another route
283
+ mounts that route's layout: nothing is held between them.
284
+ - **In a `clientUrls()` group** a `path()` takes no `layout()`, only
285
+ `loader()`, `loading()` and `transition()` (`/client-urls`). A group
286
+ layout has no `revalidate()` of its own: it is a client component, so the
287
+ params it reads with `useParams()` are always the current page's. Its
288
+ loaders are held on navigation like a server layout's, though, so hero
289
+ data from a layout loader needs a rule that reads the page being left.
290
+ Put the block in the route component and its data in a loader on the
291
+ `path()`: that loader re-runs when the params or search change, with no
292
+ rule.
293
+
220
294
  ## Growing an app
221
295
 
222
296
  1. **Start with one `urls()` module** ([A small app](#a-small-app-one-module)).
@@ -424,12 +424,13 @@ without JavaScript too (`/server-actions` → "Progressive Enhancement").
424
424
 
425
425
  ## Why
426
426
 
427
- | Choice | Why |
428
- | --------------------------------------------- | -------------------------------------------------------------------------- |
429
- | `ppr` on category and product pages | The shared part is HTML from the shell; only the holes render per request |
430
- | `"use cache"` + `cacheTag()` for product data | One read for every visitor, and the tag reaches the shells that baked it |
431
- | Live loaders for price, stock, cart | Fresh on every request, shell HITs included |
432
- | `CartLoader` on the store layout | One badge on every page; held on navigations; narrowed to cart actions |
433
- | `TRACKING_SEARCH_PARAMS` excluded | Campaign links do not multiply shells |
434
- | `prefetch: false` on reviews | Every product card prefetches its page; few shoppers scroll to the reviews |
435
- | Checkout without `ppr` or `cache()` | Nothing on it is shared |
427
+ | Choice | Why |
428
+ | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
429
+ | `ppr` on category and product pages | The shared part is HTML from the shell; only the holes render per request |
430
+ | `"use cache"` + `cacheTag()` for product data | One read for every visitor, and the tag reaches the shells that baked it |
431
+ | Live loaders for price, stock, cart | Fresh on every request, shell HITs included |
432
+ | `CartLoader` on the store layout | One badge on every page; held on navigations; narrowed to cart actions |
433
+ | `TRACKING_SEARCH_PARAMS` excluded | Campaign links do not multiply shells |
434
+ | `prefetch: false` on reviews | Every product card prefetches its page; few shoppers scroll to the reviews |
435
+ | Category content in `CategoryPage`, not a shared layout | A category hero belongs there too, or in a layout inside the category `path()`. In a layout shared with product pages it needs a `revalidate()` that reads the page being left, and every prefetch under that layout is shared only between the pages of one category ([Prefetches are shared](./SKILL.md#prefetches-are-shared)) |
436
+ | Checkout without `ppr` or `cache()` | Nothing on it is shared |
@@ -710,6 +710,10 @@ Rules of thumb:
710
710
  keeps the page being left on screen until the fill answers.
711
711
  - A same-route click (`/app/projects/1` to `/app/projects/2`) prefetches a
712
712
  flagged loader as usual: only data the page does not have yet is deferred.
713
+ - One prefetch of a page serves every page that links to it. A `revalidate()`
714
+ that reads the page being left and keeps its segment shares it only between
715
+ the pages with equal values of what it read
716
+ ([Prefetches are shared](./SKILL.md#prefetches-are-shared)).
713
717
 
714
718
  Details: `/loader` → "`prefetch: false`", `/client-urls` → "`prefetch: false`".
715
719
 
@@ -802,11 +806,14 @@ reverse(".issues", { projectId: "api" }); // /acme/api/issues
802
806
 
803
807
  - A link into another module (the project nav's "Account settings") is built
804
808
  on the server and passed as a prop or loader data. `useReverse(accountRoutes)`
805
- in a project component gives a wrong URL with no error (dc-t57).
809
+ in a project component gives a wrong URL with no error.
806
810
  - A server layout stays held when `:projectId` changes (partial rendering by
807
811
  design), so links it rendered keep the old project. Render the project
808
812
  switcher and nav in a client component with `useReverse(routes)`, or give
809
- the layout a `revalidate()` comparing `projectId`.
813
+ the layout a `revalidate()` comparing `projectId`. Prefer the client
814
+ component: the rule reads the page being left, so a prefetch it keeps the
815
+ layout for is shared only between the pages of one project
816
+ ([Prefetches are shared](./SKILL.md#prefetches-are-shared)).
810
817
  - The account layout's `ctx.reverse(".settings")` resolves in the account
811
818
  module on every page below it, the project's pages included: it links to
812
819
  `/acme/settings`, not the project's settings route.
@@ -854,6 +861,12 @@ export function ProjectNav() {
854
861
  }
855
862
  ```
856
863
 
864
+ `ProjectNavLoader`'s rule has the same cost: it reads `currentParams`, so a
865
+ prefetch from `/acme/web/issues` to `/acme/web/settings` is shared only by
866
+ the pages of the `web` project with the same set of params. Use it for links that need the server; links that
867
+ stay inside the project module can come from `useReverse(routes)` in
868
+ `ProjectNav`, with no rule.
869
+
857
870
  ## Why each piece sits where it does
858
871
 
859
872
  | Piece | Where | Why |
@@ -1121,7 +1121,7 @@ results first, outermost first:
1121
1121
  | deeper nesting | Composes the same way; the innermost boundary adds its default key without a `key` of its own, its request kind with one |
1122
1122
  | under a `cache({ store })` on another store | Also that store's `keyGenerator` result, one part per store: empty for a store that returns the default key while another's differs, none when all return it |
1123
1123
  | `cache(false)` | No caching, as before; a `cache()` re-enabled below it stays in the partition |
1124
- | no `key` anywhere | The default key, or the store's `keyGenerator` result, as before |
1124
+ | no `key` anywhere | The default key, or the store's `keyGenerator` result, then the request kind when that result does not start with it |
1125
1125
 
1126
1126
  - No `key()` result, whatever it returns, equals a default key or a
1127
1127
  composed key, and no two composed keys are equal: a namespaced result
@@ -1150,10 +1150,15 @@ results first, outermost first:
1150
1150
  empty result there fails key resolution, so the request renders uncached,
1151
1151
  and the router warns once naming the store. On the same store the inner
1152
1152
  boundary's own default key already carries it (or its own `key()`
1153
- overrides it). Keep the default key in the result
1154
- (`` `${locale}:${defaultKey}` ``): its prefix is the request kind, and a
1155
- result without it gives a document request, a navigation and each
1156
- intercept navigation one record.
1153
+ overrides it). A `keyGenerator` result keeps the request kind: the
1154
+ default key starts with it (`doc:`, `partial:`, `intercept:<intercept>:`),
1155
+ and a result that does not gets it as a last part (`items` is stored as
1156
+ `items|doc` for a document request), so a document request, a navigation
1157
+ and each intercept navigation keep their own record whatever it returns.
1158
+ Build on the default key (`` `${defaultKey}|${locale}` ``) and the result
1159
+ is the key, byte for byte. A prefix before it
1160
+ (`` `${locale}:${defaultKey}` ``) keeps the kind too, but no longer starts
1161
+ with it, so the kind is appended (`en%3Adoc%3A…|doc`).
1157
1162
  - A response route's entry (`path.json()` and the other response routes) is
1158
1163
  keyed the same way, behind a `response:` prefix. A `ppr` route's shell is
1159
1164
  partitioned by the chain's `key()` results, namespaced the same way, since
@@ -278,7 +278,11 @@ the arguments and type of a `urls()` loader's (`ShouldRevalidateFn` from
278
278
  `actionUrl`, `actionResult`, `formData`, `method`,
279
279
  `routeName`/`fromRouteName`/`toRouteName`, and `stale`. Nothing runs in the
280
280
  browser before the request goes out; there is no client-computed decision and
281
- no hybrid.
281
+ no hybrid. A predicate that reads `currentParams` or `fromRouteName` and
282
+ keeps its segment shares a prefetch of that answer only between the pages
283
+ with equal values of what it read, and one that reads `currentUrl` makes it
284
+ page-only, as in `urls()` (`/links` → "Shared prefetches and
285
+ `prefetchKey`").
282
286
 
283
287
  You still write the predicate in the `"use client"` module, or in a
284
288
  component's `.use`. The rango Vite plugin hoists each `revalidate()` argument
@@ -400,7 +404,7 @@ Everything after that is decided on the server, while it handles that request:
400
404
  the action's `actionResult` and `formData` (above). A loader that holds keeps
401
405
  the data the page shows.
402
406
  - **The browser's caches.** When the response arrives the browser marks its
403
- history cache stale and drops its prefetches, unless the action called
407
+ history cache and its prefetches stale, unless the action called
404
408
  `keepClientCache()` (`/server-actions` → "Client Cache After an Action").
405
409
  - **A loader that fails on its rerun** reaches your `onError` on the server,
406
410
  and in the browser it throws at its read, below.
@@ -619,21 +623,23 @@ shell, such as a `useActionState` form (React suspends it once to encode its
619
623
  action), is in the first flush too: the router gives React up to three more
620
624
  turns before it, and none when the document is already complete.
621
625
 
622
- ## `prefetch: false` — keep a loader out of prefetches
626
+ ## `prefetch: false` — keep a loader out of speculative prefetches
623
627
 
624
628
  A `<Link>` prefetch of a group route runs its loaders. Flag the expensive one
625
- and the prefetch skips it:
629
+ and a viewport or render prefetch skips it. A hover prefetch, and
630
+ `useRouter().prefetch()`, is intent to click: it runs the loader, and the
631
+ click sends no fill (`/loader` → "`prefetch: false`").
626
632
 
627
633
  ```tsx
628
634
  path("/product/:slug", ProductPage, { name: "product" }, () => [
629
635
  loader(PriceLoader),
630
- loader(ReviewsLoader, { prefetch: false }), // kept out of prefetches
636
+ loader(ReviewsLoader, { prefetch: false }), // kept out of viewport prefetches
631
637
  loading(<ProductSkeleton />),
632
638
  ]),
633
639
  ```
634
640
 
635
- A click inside the group still shows the destination at once. With the
636
- prefetch received, the URL and the other loaders' data move with the click
641
+ A click inside the group still shows the destination at once. With a
642
+ viewport or render prefetch received, the URL and the other loaders' data move with the click
637
643
  too, `useOutlet().pending` ends with that commit, and one follow-up request
638
644
  (the fill) runs only the flagged loaders. Read the flagged loader inside its
639
645
  own `<Suspense>` to show the page and that fallback at once; with no boundary
@@ -654,10 +660,11 @@ rest of the destination; otherwise it is held. A same-route click
654
660
  - `loading()` in `clientUrls()` takes no options, and a flagged `loading()`
655
661
  on a server layout above the group defers nothing for the group's routes.
656
662
  - On an `intercept()` loader the flag works as in `urls()`: an in-group
657
- prefetch of the intercept's target defers it, and the click opens the modal
658
- at once with one fill for it.
659
- - With `ssr: false` on a `ppr` group route the flag is ignored (the bake
660
- lane), as on a server route.
663
+ viewport or render prefetch of the intercept's target defers it, and the
664
+ click opens the modal at once with one fill for it; a hover's runs it.
665
+ - With `ssr: false` on a `ppr` group route the flag works as on a server
666
+ route: deferred unless the prefetch serves the loader from a pin of a
667
+ document-captured shell (`/ppr` → "`prefetch: false` on a `ppr` route").
661
668
  - Test it with `serveShellRequest` in the Flight project: mount the module
662
669
  itself, and `rangoUseClientTransform()` installs the group's projection
663
670
  (`/testing` → `serve-shell-request.md`, "`clientUrls()` routes"). It also
@@ -558,8 +558,9 @@ analog but operates on loader/query reload, not RSC-segment render.
558
558
 
559
559
  `<Link prefetch="hover|viewport|render|adaptive|none">` (default: the router's
560
560
  `defaultPrefetch`, `"none"` in development and `"viewport"` in production), plus
561
- `prefetchKey` (`":source"` scopes a prefetch to the originating page for routes
562
- whose response branches on `currentUrl`). Intercepted plain `<a href>` elements
561
+ `prefetchKey` (`":source"` scopes a prefetch to the originating page; the
562
+ router does so itself for an intercept target and for a `revalidate()` that
563
+ read the source page and kept a segment). Intercepted plain `<a href>` elements
563
564
  inside the router basename follow the same default; common static-resource
564
565
  extensions are excluded unless `data-prefetch="true"` identifies an application
565
566
  route, and `data-prefetch="false"` or `"none"` opts out unsafe GET links. A
@@ -582,9 +583,11 @@ the bounded cache uses a configurable TTL. `Save-Data`,
582
583
  `prefers-reduced-data`, or `prefetchCacheTTL: false` disable speculation.
583
584
 
584
585
  Correctness is part of the prefetch contract. Keys include Rango state, destination
585
- URL, and the mounted segment set used to compute the diff. Server actions rotate
586
- state and abort stale speculative work; a generation check prevents a late result
587
- from repopulating an invalidated cache. Routes an intercept targets are automatically
586
+ URL, and the mounted segment set used to compute the diff. Server actions mark
587
+ every prefetch stale without aborting it: a click on a stale one shows it at once,
588
+ then revalidates the page in the background. `invalidateClientCache()` rotates
589
+ state and aborts speculative work; a generation check prevents a late result from
590
+ repopulating a flushed cache. Routes an intercept targets are automatically
588
591
  source-scoped, matched or not,
589
592
  and malformed, cross-origin, foreign-router, redirect, and reload responses are
590
593
  dropped rather than warmed. `useLinkStatus()` exposes `{ pending }` for the owning
@@ -594,7 +597,7 @@ link.
594
597
  session-cookie value shaped as `{documentVersion}:{invalidationTimestamp}`. Navigation
595
598
  and prefetch requests send it as `X-Rango-State`, and RSC responses vary on that
596
599
  header, so the browser HTTP cache and Rango's decoded prefetch map share one cache
597
- identity. A deployment that changes the router's code or client assets changes the version; a mutation rotates the timestamp. Old
600
+ identity. A deployment that changes the router's code or client assets changes the version; `invalidateClientCache()` rotates the timestamp. Old
598
601
  responses become unreachable under the retired identity instead of requiring every
599
602
  cache layer to delete the same entries successfully.
600
603
 
@@ -608,10 +611,13 @@ cookie. Across those two seats, it covers mutations initiated outside the action
608
611
  bridge: REST calls, client-observed WebSocket events, authentication changes, or
609
612
  middleware/loader decisions.
610
613
 
611
- Server Actions invalidate automatically after a response reaches the browser. If
612
- an action knows it changed nothing that any route renders, `keepClientCache()` is
613
- the explicit per-response escape hatch: it suppresses that automatic state
614
- rotation, prefetch flush, broadcast, and revalidation fetch. Together,
614
+ Server Actions mark the history and prefetch caches stale automatically after a
615
+ response reaches the browser, without rotating the state: the response bumps
616
+ only its write part, so the browser HTTP cache misses on responses from before
617
+ the write while the in-memory caches go stale. If an action knows it
618
+ changed nothing that any route renders, `keepClientCache()` is the explicit
619
+ per-response escape hatch: it suppresses that stale-marking, the broadcast, and
620
+ the revalidation fetch. Together,
615
621
  `invalidateClientCache()` and `keepClientCache()` give application code control in
616
622
  both directions — force a miss for mutations the router cannot observe, or preserve
617
623
  the current state for a known no-op.
@@ -217,6 +217,14 @@ suffixes are still appended.
217
217
  request that holds no segments (`router.refresh()`, a cross-tab refresh).
218
218
  Each renders at the origin and is not stored, because its data may predate
219
219
  a write and the cache cannot tell.
220
+ - A client navigation whose answer depends on the page it came from: a
221
+ `revalidate()` rule that read `currentParams`, `currentUrl` or
222
+ `fromRouteName` kept a segment, or an intercept targets the route. The key
223
+ names the segments the client holds, not its page, so `/c/tv` would get the
224
+ answer `/c/audio` got for `/c/audio?sort=x` and keep its own hero. It is sent
225
+ `private, no-cache` over the route's `Cache-Control` and is not stored. One
226
+ whose rule re-rendered the segment is right from every page and is stored as
227
+ usual.
220
228
  - Non-GET requests
221
229
  - Responses without an `s-maxage` `Cache-Control` directive
222
230
  - Responses whose `Cache-Control` also contains `private`, `no-store`, or an
@@ -228,6 +236,11 @@ suffixes are still appended.
228
236
  (`rango-state-fresh` by default), so it is not stored. A stale entry's
229
237
  background re-run sets no fresh-reads cookie (no visitor made that
230
238
  invalidation), so its invalidation does not keep the refreshed page out.
239
+ - A document that carries location state (`ctx.setLocationState()`, or the
240
+ one-shot cookie a document redirect left for it): it is the visitor's, so it
241
+ is not stored, leaves `private, no-store`, and a request that arrives with
242
+ that cookie, or whose earlier middleware set state, is not answered from a
243
+ stored entry (see `/hooks` `state.md`, "State on a document response")
231
244
  - Non-200 responses
232
245
  - A 200 whose render reported an error: a component that threw after the
233
246
  response started streaming, such as an async server component whose fetch
@@ -160,14 +160,14 @@ function NavigationControls() {
160
160
  }
161
161
  ```
162
162
 
163
- | Method | Behaviour |
164
- | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
165
- | `push(url, options?)` | Navigate and add a history entry. Returns a promise. |
166
- | `replace(url, options?)` | Navigate and replace the current history entry. Returns a promise. |
167
- | `refresh()` | Re-fetch the current route's server data, keeping client state. Returns a promise. |
168
- | `prefetch(url, opts?)` | Prefetch a target. `{ key: ":source" }` scopes the entry to the source page (parity with `<Link prefetchKey>`). |
169
- | `back()` | `history.back()`; on the first entry of the session it replaces to the app root instead of leaving the app. |
170
- | `forward()` | `history.forward()`. |
163
+ | Method | Behaviour |
164
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
165
+ | `push(url, options?)` | Navigate and add a history entry. Returns a promise. |
166
+ | `replace(url, options?)` | Navigate and replace the current history entry. Returns a promise. |
167
+ | `refresh()` | Re-fetch the current route's server data, keeping client state. Returns a promise. |
168
+ | `prefetch(url, opts?)` | Prefetch a target. `{ key: ":source" }` scopes the entry to the source page (parity with `<Link prefetchKey>`, see `/links`). |
169
+ | `back()` | `history.back()`; on the first entry of the session it replaces to the app root instead of leaving the app. |
170
+ | `forward()` | `history.forward()`. |
171
171
 
172
172
  `push` / `replace` options: `scroll` (`false` keeps the current scroll
173
173
  position), `revalidate` (default `true`, see below), `transition` (`false`
@@ -101,6 +101,7 @@ Back/forward and state the server sets on a navigation
101
101
  | `<Link state>`, `router.push()` / `router.replace()` | in the commit that shows the destination |
102
102
  | back/forward | in the commit that restores the entry (cached or refetched) |
103
103
  | the server, on a navigation (`setLocationState`, a redirect) | in the commit that shows the destination |
104
+ | the server, on a document load (a page load, a no-JS post) | once the page has hydrated, as stored state |
104
105
  | a navigation the server answers with nothing to re-render | with the new location: the page on screen is the entry's |
105
106
  | `router.push(url, { revalidate: false })` (no server fetch) | with the new location: the page does not change |
106
107
  | a server action's `setLocationState` (no navigation) | when the action's response arrives, added to what readers show |
@@ -279,8 +280,8 @@ import { FlashMessage } from "../location-states";
279
280
  ```
280
281
 
281
282
  Or via `ctx.setLocationState()` from a handler or middleware. It is delivered
282
- with a client navigation's or an action's response; a document response
283
- carries no location state (there is no history entry to write it to yet):
283
+ with a client navigation's or an action's response, and with a document
284
+ response too (see "State on a document response" below):
284
285
 
285
286
  ```tsx
286
287
  (ctx) => {
@@ -289,6 +290,121 @@ carries no location state (there is no history entry to write it to yet):
289
290
  };
290
291
  ```
291
292
 
293
+ ### Several writes to one definition
294
+
295
+ Entries accumulate across definitions, but one definition is one slot in
296
+ `history.state`: for the same definition the last write wins. Two
297
+ `ctx.setLocationState(Toasts(...))` calls in one response, or
298
+ `redirect(url, { state: [Toasts(a), Toasts(b)] })`, deliver only the later
299
+ value, without a warning. A server action, a handler and the middleware
300
+ around them all write to the same response, so a shared helper that writes a
301
+ definition replaces what an earlier call wrote.
302
+
303
+ To combine several messages, make the state an array and write it once, for
304
+ example by collecting them in a request variable and writing the definition
305
+ at the end of the action:
306
+
307
+ ```ts
308
+ export const Toasts = createLocationState<ToastMessage[]>({ flash: true });
309
+
310
+ // One write per response, with every toast in it.
311
+ getRequestContext().setLocationState(Toasts([...savedToasts, loginToast]));
312
+ ```
313
+
314
+ ### State on a document response
315
+
316
+ A document request has no history entry yet, so the router carries what the
317
+ server set to the client and writes it there when the client starts. This
318
+ covers the mutations that most need a flash message: a `<form action={fn}>`
319
+ posted before hydration or without JS, a payment provider redirecting the
320
+ shopper back, a link from an email.
321
+
322
+ | The document request | Where the state travels |
323
+ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------- |
324
+ | renders a page (`setLocationState` in a handler, middleware or no-JS action) | the document's bootstrap payload |
325
+ | redirects (`redirect(url, { state })`, or `setLocationState` and a redirect) | a one-shot cookie the target's document request reads and clears |
326
+ | redirects with `{ external: true }` or off-origin | nowhere: the cookie would not reach the target |
327
+
328
+ ```tsx
329
+ // A payment provider sends the shopper back to /checkout/callback.
330
+ path("/checkout/callback", async (ctx) => {
331
+ const ok = await completeOrder(ctx.searchParams.get("token"));
332
+ return ok
333
+ ? redirect("/orders/confirmed", { state: [Flash({ text: "Paid" })] })
334
+ : redirect("/checkout", { state: [Flash({ text: "Payment declined" })] });
335
+ });
336
+ ```
337
+
338
+ - **When a reader gets it.** `useLocationState` returns `undefined` while the
339
+ document streams and hydrates, then the value, at the moment it would get
340
+ state stored in the entry. A flash message shows just after hydration.
341
+ - **Without JS, no flash.** The state rides to the client, and only the
342
+ client writes it into the entry and hands it to readers. A visitor with JS
343
+ disabled gets the page as the server rendered it, where every reader of the
344
+ state shows `undefined`: the post and its redirect work, but the message
345
+ never shows. If such visitors must see it, render it from the server, for
346
+ example from a search param or a cookie your handler reads.
347
+ - **Flash and refresh.** Flash state is cleared once read, as always. The
348
+ cookie is cleared by the response that read it, so a refresh of the target
349
+ renders without it. A page whose handler sets state on every request
350
+ delivers it again on a refresh, because the handler ran again. A no-JS post
351
+ answered with a page (no redirect) is re-posted by the browser on refresh;
352
+ redirect after the post (`redirect(url, { status: 303, state })`) to avoid
353
+ that.
354
+ - **Caching.** A document that carries state, and the redirect carrying it
355
+ on, are sent `Cache-Control: private, no-store`, after every middleware.
356
+ The document cache neither stores such a document nor answers a request
357
+ that arrives with the cookie from a stored one. A shared PPR shell never
358
+ holds state: on a `ppr` route, state set before the render (by middleware,
359
+ or the cookie) rides the live payload of a shell HIT or MISS, and the shell
360
+ is still served and captured; state a handler sets keeps that route from
361
+ being captured, so it renders in full. `no-store` also keeps the
362
+ page out of the browser's back/forward cache (Chrome's bfcache), so Back to
363
+ it loads it again. That costs little for a one-off flash; a handler that
364
+ sets state on every request pays it on every page view.
365
+ - **`ppr` and cached routes.** A shell HIT and a `cache()` hit run no
366
+ handler, so a handler there cannot set state on that request. Set it from
367
+ middleware, which runs on every request; the HIT still serves, with the
368
+ state in its live payload. A HIT sends exactly the state set before its
369
+ render: its headers leave before its tail runs, so they decide then
370
+ whether the response is private.
371
+ - **Not from a loader.** A loader has no `setLocationState`, and calling
372
+ `getRequestContext().setLocationState()` from one is not supported: the
373
+ payload takes the request's state before loaders settle, so a loader's
374
+ write may arrive (a `ppr` MISS then renders in full) or be dropped, and a
375
+ shell HIT never sends it. Set state from middleware, the handler or an
376
+ action.
377
+ - **The cookie.** It is named after the router's state cookie
378
+ (`<stateCookieName>-ls`, `__Host-` prefixed on https), `HttpOnly`,
379
+ `SameSite=Lax`, `Path=/`, and lives 60 seconds. Only the next GET document
380
+ request of the redirect's target pathname that navigates reads it: where
381
+ the browser sends `Sec-Fetch-Dest`, it must be `document`, `iframe` or
382
+ `frame`, so a `<link rel=prefetch>` or a `fetch()` of that path leaves it
383
+ to the navigation. A form POST to it, a client navigation and a Flight
384
+ fetch leave it too, as does a request of another path. A
385
+ target that redirects again carries the state on to the next target. There
386
+ is one such cookie per router: two document redirects within a moment (two
387
+ tabs) keep only the later one's state. Without https there is no `__Host-`
388
+ prefix, so a sibling subdomain could set the cookie: treat state that
389
+ arrives this way as a message to show, not as a fact to act on. A `ppr`
390
+ shell HIT that read the cookie and then cannot finish its tail reloads the
391
+ page into a full render; its headers already cleared the cookie, so its
392
+ reload script sets it again first (from script, so without `HttpOnly`,
393
+ for the moment the reload takes), and the reload shows the state.
394
+ - **Known limit: a redirect from one router to another.** The cookie is
395
+ named after the redirecting router's state cookie, which holds its router
396
+ id (`<stateCookiePrefix>_<routerId>`), and a router reads only its own. On
397
+ one origin served by several routers (a host router, or several
398
+ `createRouter()` apps behind one worker), a document redirect from one
399
+ router to a path another serves loses its state, without a warning. Carry
400
+ such a message another way, such as a search param.
401
+ - **JSON only across a redirect.** The cookie holds JSON: a `Date` arrives as
402
+ a string, a `Map` or `Set` as `{}`, `undefined` is dropped. Development
403
+ warns, naming the slot. A value JSON cannot encode (a `bigint`), or state
404
+ over the cookie's 4000-byte limit, is dropped from the redirect with a
405
+ `console.warn`. Carry an id and load the rest on the target page.
406
+ Navigation and action payloads are not limited this way.
407
+
292
408
  ### State from another deploy
293
409
 
294
410
  History entries survive reloads and back/forward, so a tab left open across a
@@ -419,10 +535,10 @@ as the server rendered it:
419
535
  where it sits (a `<Suspense>` boundary that hydrates late included), or
420
536
  whether the definition's module is loaded at all. Other slots on the entry
421
537
  are left alone.
422
- - State the server sets is never dropped by this: a document response carries
423
- no location state, and `ctx.setLocationState()` / `redirect(url, { state })`
424
- reach `history.state` only through navigations and actions in the running
425
- app, after start-up.
538
+ - State the server sets is never dropped by this: the state a document
539
+ response carries is written after the `~r` keys are removed, so a
540
+ `clearOnReload` slot the server set for this load is applied on this load
541
+ (and removed by the next one). Navigations and actions write after start-up.
426
542
  - `.write()` stores under the same key, so it does not survive a refresh
427
543
  either.
428
544
  - Adding the option to an existing definition moves it to the `~r` key: state
@@ -503,9 +619,12 @@ from module-level callbacks too. Imported from the root entry `@rangojs/router`,
503
619
  it is selected by export conditions:
504
620
 
505
621
  - **Client** (client component or module): marks the history cache stale,
506
- flushes the prefetch cache, rotates the router state, and notifies other tabs
507
- — the same thing a completed server action does. During SSR of a client
508
- component it is a no-op (dev warning).
622
+ flushes the prefetch cache, rotates the router state, and notifies other
623
+ tabs, which adopt the new state and flush theirs. That is more than a
624
+ completed server action does: an action marks history and prefetches stale
625
+ and rotates nothing (its response bumps only the state's write part, so the
626
+ browser HTTP cache misses on responses from before the write). During SSR
627
+ of a client component it is a no-op (dev warning).
509
628
  - **Server** (handler, server component, middleware, loader, or action): writes
510
629
  a rotated state `Set-Cookie` for the responding client, which marks its
511
630
  caches stale when it next reads them. Idempotent within a request; throws
@@ -514,9 +633,10 @@ it is selected by export conditions:
514
633
  from handler code writes the cookie only on the renders that run it (a
515
634
  MISS); call it from middleware, which runs on every request, HITs included.
516
635
 
517
- Server actions already invalidate automatically; to suppress that for a no-op
518
- action, call `keepClientCache()` inside it (see `/server-actions` → "Client
519
- Cache After an Action").
636
+ Server actions already mark the caches stale automatically; to suppress that
637
+ for a no-op action, call `keepClientCache()` inside it, and for the full flush
638
+ and rotation, call `invalidateClientCache()` inside it (see `/server-actions` →
639
+ "Client Cache After an Action").
520
640
 
521
641
  ```tsx
522
642
  "use client";