@rangojs/router 0.32.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 (180) 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 -10173
  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/invalidate-client-cache.d.ts +8 -6
  19. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  20. package/dist/types/browser/navigation-client.d.ts +19 -0
  21. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  22. package/dist/types/browser/navigation-store.d.ts +2 -2
  23. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  24. package/dist/types/browser/partial-update.d.ts +13 -0
  25. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  26. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  27. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  28. package/dist/types/browser/rango-state.d.ts +34 -5
  29. package/dist/types/browser/react/Link.d.ts +15 -6
  30. package/dist/types/browser/types.d.ts +25 -0
  31. package/dist/types/build/generate-route-types.d.ts +1 -1
  32. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  33. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  34. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  35. package/dist/types/cache/cache-scope.d.ts +4 -2
  36. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  37. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  38. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  39. package/dist/types/cache/segment-codec.d.ts +2 -2
  40. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  41. package/dist/types/cache/types.d.ts +12 -7
  42. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  43. package/dist/types/client-urls/types.d.ts +7 -5
  44. package/dist/types/prefetch-markers.d.ts +9 -0
  45. package/dist/types/prefetch-source-scope.d.ts +66 -0
  46. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  47. package/dist/types/router/intercept-resolution.d.ts +25 -11
  48. package/dist/types/router/match-context.d.ts +1 -0
  49. package/dist/types/router/match-handlers.d.ts +1 -1
  50. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  51. package/dist/types/router/router-context.d.ts +1 -1
  52. package/dist/types/router/router-options.d.ts +6 -3
  53. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  54. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  55. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  56. package/dist/types/router/source-reads.d.ts +53 -0
  57. package/dist/types/router/types.d.ts +1 -1
  58. package/dist/types/rsc/helpers.d.ts +5 -2
  59. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  60. package/dist/types/rsc/request-stages.d.ts +2 -0
  61. package/dist/types/rsc/shell-serve.d.ts +6 -1
  62. package/dist/types/server/context.d.ts +23 -6
  63. package/dist/types/server/cookie-store.d.ts +15 -1
  64. package/dist/types/server/document-location-state.d.ts +5 -2
  65. package/dist/types/server/handle-store.d.ts +2 -0
  66. package/dist/types/server/request-context.d.ts +43 -7
  67. package/dist/types/testing/e2e/server.d.ts +16 -1
  68. package/dist/types/testing/render-route.d.ts +66 -2
  69. package/dist/types/testing/serve-shell-request.d.ts +6 -3
  70. package/dist/types/types/handler-context.d.ts +7 -5
  71. package/dist/types/types/loader-types.d.ts +7 -4
  72. package/dist/types/types/segments.d.ts +15 -4
  73. package/dist/types/urls/path-helper-types.d.ts +7 -5
  74. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  75. package/dist/vite/index.js +449 -189
  76. package/package.json +2 -2
  77. package/skills/app-structure/SKILL.md +74 -0
  78. package/skills/app-structure/e-commerce.md +10 -9
  79. package/skills/app-structure/large-app.md +15 -2
  80. package/skills/caching/SKILL.md +10 -5
  81. package/skills/client-urls/SKILL.md +18 -11
  82. package/skills/comparison/references/framework-comparison.md +13 -8
  83. package/skills/document-cache/SKILL.md +8 -0
  84. package/skills/hooks/navigation.md +8 -8
  85. package/skills/hooks/state.md +24 -8
  86. package/skills/intercept/SKILL.md +33 -12
  87. package/skills/layout/SKILL.md +11 -0
  88. package/skills/links/SKILL.md +101 -4
  89. package/skills/loader/SKILL.md +49 -22
  90. package/skills/migrate-nextjs/SKILL.md +8 -2
  91. package/skills/migrate-react-router/data-and-actions.md +5 -0
  92. package/skills/ppr/SKILL.md +26 -5
  93. package/skills/prerender/SKILL.md +4 -1
  94. package/skills/route/SKILL.md +6 -6
  95. package/skills/router-setup/SKILL.md +2 -1
  96. package/skills/server-actions/SKILL.md +41 -8
  97. package/skills/testing/client-components.md +29 -13
  98. package/skills/testing/serve-shell-request.md +7 -7
  99. package/skills/testing/server-actions.md +8 -0
  100. package/skills/ux-architecture/SKILL.md +67 -18
  101. package/src/bin/rango.ts +39 -13
  102. package/src/browser/action-fence.ts +72 -0
  103. package/src/browser/cookie-name.ts +82 -14
  104. package/src/browser/invalidate-client-cache.ts +9 -7
  105. package/src/browser/navigation-bridge.ts +164 -32
  106. package/src/browser/navigation-client.ts +95 -20
  107. package/src/browser/navigation-store-handle.ts +67 -5
  108. package/src/browser/navigation-store.ts +86 -17
  109. package/src/browser/navigation-transaction.ts +0 -6
  110. package/src/browser/partial-update.ts +31 -12
  111. package/src/browser/prefetch/cache.ts +188 -11
  112. package/src/browser/prefetch/fetch.ts +211 -51
  113. package/src/browser/prefetch/queue.ts +3 -2
  114. package/src/browser/rango-state.ts +50 -8
  115. package/src/browser/react/Link.tsx +20 -9
  116. package/src/browser/react/use-navigation.ts +1 -1
  117. package/src/browser/rsc-router.tsx +8 -1
  118. package/src/browser/server-action-bridge.ts +3 -3
  119. package/src/browser/types.ts +25 -0
  120. package/src/build/generate-route-types.ts +1 -0
  121. package/src/build/route-types/include-resolution.ts +40 -3
  122. package/src/build/route-types/router-processing.ts +170 -25
  123. package/src/build/route-types/scan-filter.ts +1 -0
  124. package/src/cache/cache-key-utils.ts +12 -8
  125. package/src/cache/cache-runtime.ts +5 -1
  126. package/src/cache/cache-scope.ts +28 -7
  127. package/src/cache/cf/cf-cache-types.ts +9 -5
  128. package/src/cache/document-cache-policy.ts +27 -0
  129. package/src/cache/document-cache.ts +5 -0
  130. package/src/cache/memory-segment-store.ts +3 -2
  131. package/src/cache/segment-codec.ts +2 -2
  132. package/src/cache/shell-snapshot.ts +1 -1
  133. package/src/cache/types.ts +12 -7
  134. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  135. package/src/client-urls/types.ts +7 -5
  136. package/src/prefetch-markers.ts +10 -0
  137. package/src/prefetch-source-scope.ts +137 -0
  138. package/src/route-definition/helpers-types.ts +8 -5
  139. package/src/router/intercept-resolution.ts +94 -50
  140. package/src/router/loader-resolution.ts +8 -6
  141. package/src/router/match-api.ts +33 -29
  142. package/src/router/match-context.ts +5 -0
  143. package/src/router/match-handlers.ts +2 -1
  144. package/src/router/match-middleware/cache-lookup.ts +21 -13
  145. package/src/router/match-result.ts +39 -87
  146. package/src/router/navigation-snapshot.ts +18 -3
  147. package/src/router/prefetch-cache-ttl.ts +15 -3
  148. package/src/router/revalidation.ts +53 -23
  149. package/src/router/router-context.ts +2 -1
  150. package/src/router/router-options.ts +6 -3
  151. package/src/router/segment-resolution/loader-cache.ts +21 -3
  152. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  153. package/src/router/segment-resolution/revalidation.ts +40 -12
  154. package/src/router/source-reads.ts +122 -0
  155. package/src/router/types.ts +2 -1
  156. package/src/router.ts +11 -12
  157. package/src/rsc/helpers.ts +8 -3
  158. package/src/rsc/prefetch-render-error.ts +7 -2
  159. package/src/rsc/progressive-enhancement.ts +3 -2
  160. package/src/rsc/request-stages.ts +59 -0
  161. package/src/rsc/rsc-rendering.ts +49 -33
  162. package/src/rsc/server-action.ts +2 -1
  163. package/src/rsc/shell-capture.ts +4 -5
  164. package/src/rsc/shell-serve.ts +12 -1
  165. package/src/segment-system.tsx +1 -1
  166. package/src/server/context.ts +37 -6
  167. package/src/server/cookie-store.ts +22 -1
  168. package/src/server/document-location-state.ts +23 -2
  169. package/src/server/handle-store.ts +2 -0
  170. package/src/server/request-context.ts +113 -28
  171. package/src/testing/dispatch.ts +3 -0
  172. package/src/testing/e2e/fixture.ts +4 -12
  173. package/src/testing/e2e/server.ts +91 -4
  174. package/src/testing/render-route.tsx +269 -4
  175. package/src/testing/serve-shell-request.ts +6 -3
  176. package/src/types/handler-context.ts +7 -5
  177. package/src/types/loader-types.ts +7 -4
  178. package/src/types/segments.ts +15 -4
  179. package/src/urls/path-helper-types.ts +7 -5
  180. package/src/vite/temp-rsc-server.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.32.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
@@ -583,9 +583,11 @@ the bounded cache uses a configurable TTL. `Save-Data`,
583
583
  `prefers-reduced-data`, or `prefetchCacheTTL: false` disable speculation.
584
584
 
585
585
  Correctness is part of the prefetch contract. Keys include Rango state, destination
586
- URL, and the mounted segment set used to compute the diff. Server actions rotate
587
- state and abort stale speculative work; a generation check prevents a late result
588
- 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
589
591
  source-scoped, matched or not,
590
592
  and malformed, cross-origin, foreign-router, redirect, and reload responses are
591
593
  dropped rather than warmed. `useLinkStatus()` exposes `{ pending }` for the owning
@@ -595,7 +597,7 @@ link.
595
597
  session-cookie value shaped as `{documentVersion}:{invalidationTimestamp}`. Navigation
596
598
  and prefetch requests send it as `X-Rango-State`, and RSC responses vary on that
597
599
  header, so the browser HTTP cache and Rango's decoded prefetch map share one cache
598
- 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
599
601
  responses become unreachable under the retired identity instead of requiring every
600
602
  cache layer to delete the same entries successfully.
601
603
 
@@ -609,10 +611,13 @@ cookie. Across those two seats, it covers mutations initiated outside the action
609
611
  bridge: REST calls, client-observed WebSocket events, authentication changes, or
610
612
  middleware/loader decisions.
611
613
 
612
- Server Actions invalidate automatically after a response reaches the browser. If
613
- an action knows it changed nothing that any route renders, `keepClientCache()` is
614
- the explicit per-response escape hatch: it suppresses that automatic state
615
- 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,
616
621
  `invalidateClientCache()` and `keepClientCache()` give application code control in
617
622
  both directions — force a miss for mutations the router cannot observe, or preserve
618
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
@@ -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`
@@ -365,7 +365,15 @@ path("/checkout/callback", async (ctx) => {
365
365
  - **`ppr` and cached routes.** A shell HIT and a `cache()` hit run no
366
366
  handler, so a handler there cannot set state on that request. Set it from
367
367
  middleware, which runs on every request; the HIT still serves, with the
368
- state in its live payload.
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.
369
377
  - **The cookie.** It is named after the router's state cookie
370
378
  (`<stateCookieName>-ls`, `__Host-` prefixed on https), `HttpOnly`,
371
379
  `SameSite=Lax`, `Path=/`, and lives 60 seconds. Only the next GET document
@@ -378,7 +386,11 @@ path("/checkout/callback", async (ctx) => {
378
386
  is one such cookie per router: two document redirects within a moment (two
379
387
  tabs) keep only the later one's state. Without https there is no `__Host-`
380
388
  prefix, so a sibling subdomain could set the cookie: treat state that
381
- arrives this way as a message to show, not as a fact to act on.
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.
382
394
  - **Known limit: a redirect from one router to another.** The cookie is
383
395
  named after the redirecting router's state cookie, which holds its router
384
396
  id (`<stateCookiePrefix>_<routerId>`), and a router reads only its own. On
@@ -607,9 +619,12 @@ from module-level callbacks too. Imported from the root entry `@rangojs/router`,
607
619
  it is selected by export conditions:
608
620
 
609
621
  - **Client** (client component or module): marks the history cache stale,
610
- flushes the prefetch cache, rotates the router state, and notifies other tabs
611
- — the same thing a completed server action does. During SSR of a client
612
- 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).
613
628
  - **Server** (handler, server component, middleware, loader, or action): writes
614
629
  a rotated state `Set-Cookie` for the responding client, which marks its
615
630
  caches stale when it next reads them. Idempotent within a request; throws
@@ -618,9 +633,10 @@ it is selected by export conditions:
618
633
  from handler code writes the cookie only on the renders that run it (a
619
634
  MISS); call it from middleware, which runs on every request, HITs included.
620
635
 
621
- Server actions already invalidate automatically; to suppress that for a no-op
622
- action, call `keepClientCache()` inside it (see `/server-actions` → "Client
623
- 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").
624
640
 
625
641
  ```tsx
626
642
  "use client";
@@ -278,9 +278,29 @@ gives `from.url.pathname === "/list/open"`, not `/item/1`. That differs from
278
278
  `transition({ when })`'s `from`, which is always the committed location on
279
279
  screen. `routeName` is set for named routes only. Pass an array of predicates
280
280
  for AND logic (all must return true). Omit `when` entirely and the intercept
281
- always activates. `when` is not re-evaluated during action revalidation, so
282
- an open modal stays open after an action. A selector that throws does not
283
- intercept: the full page renders and the error is logged with the route name.
281
+ always activates. A selector that throws does not intercept: the full page
282
+ renders and the error is logged with the route name.
283
+
284
+ A server action from an open intercept re-renders that intercept, the one
285
+ whose slot the page holds, and opens no other intercept that targets the
286
+ route. Its `when` is not evaluated again, so the intercept stays open after
287
+ the action even when its `when` would now reject. The page can hold the slots
288
+ of more than one of the route's intercepts: two intercepts on one layout that
289
+ fill the same slot share it, and a `parallel()` of an intercept's slot name
290
+ holds that slot whether or not an intercept is open there. Whether they are
291
+ declared for the route's name or its local name inside `include()`, the
292
+ action sets aside the intercepts whose slot a `parallel()` fills on their
293
+ layout and renders the one that is left, its `when` not evaluated. When
294
+ several are left (the same-slot pair), the action evaluates their `when`
295
+ again, with `from` the intercept's source page, and the first that passes
296
+ renders; when none passes, the first of them renders. When a `parallel()`
297
+ fills every held slot, that rule applies to all of them.
298
+
299
+ `when` selects an intercept; it does not authorize one. An action request
300
+ that names an intercept's slot renders that intercept without its `when`,
301
+ and a navigation request can claim any source page. Guard data in the target
302
+ route's middleware, which runs for every intercept render, or in the
303
+ intercept's loaders.
284
304
 
285
305
  The server learns `from` from the `X-RSC-Router-Client-Path` header, which the
286
306
  rango client sends on every navigation. A partial request without it falls
@@ -291,12 +311,13 @@ intercepted: it gets the full page.
291
311
  ### `prefetch: false` on an intercept's loaders
292
312
 
293
313
  `loader(Def, { prefetch: false })` inside `intercept()` keeps that loader out
294
- of a prefetch of the target from the source page:
314
+ of a viewport or render prefetch of the target from the source page (a hover
315
+ prefetch runs it, and its click opens the modal complete with no fill):
295
316
 
296
317
  ```typescript
297
318
  intercept("@modal", "product", <ProductModal />, () => [
298
319
  loader(ProductLoader),
299
- loader(ReviewsLoader, { prefetch: false }), // skipped in the prefetch
320
+ loader(ReviewsLoader, { prefetch: false }), // skipped in a viewport prefetch
300
321
  loading(<ModalSkeleton />),
301
322
  ]);
302
323
  ```
@@ -311,13 +332,13 @@ flagged loader with `ctx.use()` before its first `await` (or one with no
311
332
  `loading()`) gets it run in the prefetch instead: one run, no fill for it.
312
333
  `loading(…, { prefetch: false })` inside `intercept()` defers nothing.
313
334
 
314
- A prefetch of a route an intercept targets is stored per source page, whether
315
- or not the intercept applies from that page (`rsc-rendering.ts`
316
- `x-rsc-prefetch-scope: source`, set from `MatchResult.interceptTargeted`). A
317
- full page prefetched from a page where `when` is false never serves a click
318
- from a page where it is true. Routes no intercept targets keep one shared
319
- prefetch entry, unless a `revalidate()` that read the source page kept a
320
- segment (`MatchResult.sourceHeld`, same header).
335
+ A prefetch of a route an intercept targets, or one sent from such a route, is
336
+ stored per source page, whether or not the intercept applies from that page
337
+ (`rsc-rendering.ts` `x-rsc-prefetch-scope: source`, set from
338
+ `MatchResult.interceptTargeted`). A full page prefetched from a page where
339
+ `when` is false never serves a click from a page where it is true. Other
340
+ prefetches keep one shared entry, unless a `revalidate()` that read the source
341
+ page kept a segment (`MatchResult.sourceHeld`, same header).
321
342
 
322
343
  ```typescript
323
344
  // Intercept only when opened from the shop index
@@ -267,6 +267,17 @@ layout(LocaleLayout, () => [
267
267
  ])
268
268
  ```
269
269
 
270
+ A rule that reads `currentParams` or `fromRouteName` and keeps the layout
271
+ shares a prefetch between the pages with equal values of what it read: the
272
+ locale rule above keeps the layout on every `/en` to `/en` navigation, so one
273
+ prefetch of a link serves every `/en` page, and a French page prefetches its
274
+ own. Spreading `currentParams` reads every param, so read the ones the rule
275
+ needs by name; a rule that reads `currentUrl` makes the prefetch page-only
276
+ (`/links` → "Shared prefetches and `prefetchKey`").
277
+ Write it for chrome that must follow the param; a block that belongs to one
278
+ page goes in its route or in an orphan layout inside its `path()` (above),
279
+ which re-renders with the route and needs no rule.
280
+
270
281
  `revalidate(false)` on a standalone layout only restates the default.
271
282
  The return shapes (hard boolean, soft `{ defaultShouldRevalidate }`, or defer)
272
283
  and the `revalidate(true | false)` shorthand are covered in `/loader` →