@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
@@ -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,12 +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.
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).
320
342
 
321
343
  ```typescript
322
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` →
@@ -457,7 +457,7 @@ export const useAccountReverse = () => useReverse(accountRoutes);
457
457
  Components call `useAccountReverse()`; a renamed or moved module changes one
458
458
  file. The wrapper resolves like `useReverse`: against the nearest `include()`
459
459
  mount, so call a module's hook from components under that module's own mount.
460
- Inside a nested module it builds a wrong URL with no error (dc-t57); pass
460
+ Inside a nested module it builds a wrong URL with no error; pass
461
461
  cross-module links from the server there.
462
462
 
463
463
  ### How it resolves
@@ -542,7 +542,7 @@ Both happen synchronously during `reverse()` — wrap calls in try/catch (or an
542
542
 
543
543
  ### The leading dot is optional
544
544
 
545
- `reverse("post")` and `reverse(".post")` resolve **identically** — the leading dot is cosmetic. The map you import IS the scope, so there is no separate global namespace to disambiguate and the dot carries no meaning; it exists only as a readability convention and for parity with `ctx.reverse(".name")` on the server. A module's `routes` map is right only inside that module's own mount: it resolves against the current mount, so another module's map gives a wrong URL with no error, and `useReverse(NamedRoutes)` is correct only at the root mount (under a mount it double-prefixes). There is no client API for links that cross modules under a nested mount (dc-t57). Build them on the server, where global names resolve fully, and pass them down:
545
+ `reverse("post")` and `reverse(".post")` resolve **identically** — the leading dot is cosmetic. The map you import IS the scope, so there is no separate global namespace to disambiguate and the dot carries no meaning; it exists only as a readability convention and for parity with `ctx.reverse(".name")` on the server. A module's `routes` map is right only inside that module's own mount: it resolves against the current mount, so another module's map gives a wrong URL with no error, and `useReverse(NamedRoutes)` is correct only at the root mount (under a mount it double-prefixes). There is no client API for links that cross modules under a nested mount. Build them on the server, where global names resolve fully, and pass them down:
546
546
 
547
547
  ```tsx
548
548
  // server layout or handler
@@ -619,7 +619,8 @@ A prefetch is a navigation sent early (a partial request with an
619
619
  `X-Rango-Prefetch` header), and by default the server renders everything for
620
620
  it: every handler and every loader. That is the point, a click then commits
621
621
  without a request, but it is also forty renders for a grid of forty links.
622
- Keep the expensive part out of the prefetch on the route, not on the link:
622
+ Keep the expensive part out of speculative (viewport, render) prefetches on
623
+ the route, not on the link:
623
624
 
624
625
  ```tsx
625
626
  // skip one loader
@@ -632,11 +633,107 @@ A click that adopts such a prefetch commits what was prefetched, shows the
632
633
  fallback for the deferred part, and sends one extra request for it. Only what
633
634
  is new to the page is deferred: a segment already on screen (the layout of the
634
635
  section you are in, the route itself on `/product/a` to `/product/b`) is
635
- prefetched as usual. See
636
+ prefetched as usual.
637
+
638
+ A hover prefetch is treated as intent and runs everything, flagged work
639
+ included (`X-Rango-Prefetch: intent`, where a viewport or render prefetch
640
+ sends `1`); so does `useRouter().prefetch()`. Its click commits complete with
641
+ no extra request. A hover sends that request only when the link has no
642
+ prefetch cached or in flight: on a viewport link whose prefetch has already
643
+ answered, it sends nothing, and the click fetches the deferred part as above.
644
+ See
636
645
  `/loader` → "`prefetch: false`" and `/route` → "`loading()` options". To stop
637
646
  a link or a whole section from prefetching at all, use the `prefetch` prop or
638
647
  `data-prefetch-scope` above.
639
648
 
649
+ ### Shared prefetches and `prefetchKey`
650
+
651
+ A prefetch is stored for its target URL and shared by every page. A link to
652
+ `/products/42` prefetched on the home page is not fetched again on the search
653
+ page or on page 2 of a list, and a click from any of them adopts that one
654
+ entry. This is deliberate: without it, a paginated list or a product grid
655
+ would prefetch every visible link again after each navigation.
656
+
657
+ The answer is a diff against the page that asked. The server leaves out the
658
+ segments that page already shows, and the click fills them from the copies on
659
+ screen. A shared answer is right on every page as long as that diff does not
660
+ depend on which page asked. With the defaults it does not: a standalone layout
661
+ is held from every page that shows it, and a route, with any layout inside its
662
+ `path()`, re-renders whenever its params or search change.
663
+
664
+ When the diff does depend on the page, the server scopes the answer. A
665
+ click from a page outside the scope then sends its own request.
666
+
667
+ The answer is page-only (`x-rsc-prefetch-scope: source`) when:
668
+
669
+ - An intercept targets the route, or the page that sent the prefetch,
670
+ whether or not it applies here.
671
+ - The answer defers a `prefetch: false` loader or `loading()` that the page
672
+ does not show yet (a viewport or render prefetch; a hover's defers nothing).
673
+ - A `revalidate()` on the target read `currentUrl` and kept its segment.
674
+ - A `revalidate()` on the route, or on a segment inside its `path()`, read
675
+ the page being left and kept the segment through the route's default
676
+ (it returned `undefined`, or the `defaultShouldRevalidate` it was given).
677
+ That default compares the target's params and search with the page's,
678
+ which no read records.
679
+ - The answer kept a segment for the page that asked and sends no segment at
680
+ all: a page that adopted it would keep its whole tree.
681
+
682
+ The answer is shared between a group of pages
683
+ (`x-rsc-prefetch-scope: params; <label>`) when:
684
+
685
+ - A `revalidate()` on the target read `currentParams` or `fromRouteName`
686
+ and kept its segment. That keep was decided from what the rule read, so it
687
+ is right on every page with equal values of those reads, and only there:
688
+ another page holds a copy rendered for other params. The server records
689
+ each param a rule reads by name (a spread, `JSON.stringify` or
690
+ `structuredClone` reads them all), the route name for `fromRouteName`, and
691
+ the page's set of params, and answers with the values; the browser stores
692
+ the answer under them and looks it up with the params of the page on
693
+ screen. The locale rule (`currentParams.locale !== nextParams.locale`)
694
+ shares each prefetch between the pages of one locale.
695
+ - The target is prerendered and the page that asked has the same route
696
+ params: the stored answer keeps a layout that has `revalidate()` rules
697
+ without running them, which is right for the pages with the same params.
698
+
699
+ A scoped answer, page-only or grouped, is sent `cache-control: private,
700
+ no-cache` over the route's own header, and the document cache does not store
701
+ it: a shared cache keys a navigation by its URL, not by the page that sent it
702
+ (`/document-cache` → "What's NOT Cached").
703
+
704
+ Page-only entries are keyed by the page's full URL, query string and hash
705
+ included, so each page prefetches those targets again: `?page=2` and `?page=3`
706
+ each make their own. Grouped entries are shared by `?page=2` and `?page=3`
707
+ when the rule did not read the page. The answer stays correct either way;
708
+ the cost is the sharing. Read the params a rule needs by name and avoid
709
+ `currentUrl` in a rule on a layout above most of the app.
710
+ `/app-structure` → "Prefetches are shared" lists the structures that need no
711
+ such rule.
712
+
713
+ `prefetchKey=":source"` asks for the same scope on one link, for a target
714
+ whose answer depends on the page that sent it in a way the server cannot
715
+ see:
716
+
717
+ ```tsx
718
+ <Link to={href} prefetchKey=":source">
719
+ Back to results
720
+ </Link>;
721
+ // The same for a programmatic prefetch
722
+ router.prefetch(href, { key: ":source" });
723
+ ```
724
+
725
+ The server tracks only the three fields above. Use the key when the
726
+ dependency runs through anything else:
727
+
728
+ - `context` in a `revalidate()` (the handler context: the request, its
729
+ headers and cookies, and middleware variables);
730
+ - a handler, loader or middleware that reads the request's `Referer`, which
731
+ the browser sets to the page that sent the prefetch (unless the page's
732
+ `Referrer-Policy` strips it).
733
+
734
+ `":source"` is the only value. It costs the same as a page-only entry, so put
735
+ it on the links that need it, never on every link of an app.
736
+
640
737
  ## When to use what
641
738
 
642
739
  | Context | API | Resolves | Use for |
@@ -218,7 +218,11 @@ Loaders receive a request-scoped context that shares its read surface with
218
218
  route handlers (`params`, `request`, `url`, `env`, `get`, `use`, `reverse`).
219
219
  It has no handler-only writers: there is no `ctx.set`, `ctx.headers`, or
220
220
  `ctx.setLocationState` (a loader can stream after the response has started,
221
- so response-shaping belongs in middleware or the handler).
221
+ so response-shaping belongs in middleware or the handler). Calling
222
+ `getRequestContext().setLocationState()` from a loader is not supported
223
+ either: the payload takes the request's location state before loaders
224
+ settle, so such a write may arrive or may be dropped, and a `ppr` shell HIT
225
+ never sends it (see `/hooks` `state.md`, "State on a document response").
222
226
 
223
227
  ### Full field surface
224
228
 
@@ -490,7 +494,11 @@ the same `$id ?? $$id` precedence the router uses to derive `actionId`, so it
490
494
  matches in both dev and production. The raw `actionId` string stays available on
491
495
  the same context as an escape hatch, alongside `actionResult`, `formData`,
492
496
  `actionUrl`, `currentParams`/`nextParams`, `currentUrl`/`nextUrl`,
493
- `fromRouteName`/`toRouteName`, and the full handler `context`.
497
+ `fromRouteName`/`toRouteName`, and the full handler `context`. Reading
498
+ `currentParams` or `fromRouteName` and keeping the segment shares a prefetch
499
+ of that answer only between the pages with equal values of what was read;
500
+ reading `currentUrl` makes it page-only (`/links` → "Shared prefetches and
501
+ `prefetchKey`"); `context` is not tracked.
494
502
 
495
503
  ### Revalidation Contracts for Loader Dependencies
496
504
 
@@ -1183,20 +1191,20 @@ The costs and constraints:
1183
1191
  Also available in `clientUrls()` route groups (`/client-urls`), where the
1184
1192
  loader-heavy shape makes it most useful.
1185
1193
 
1186
- ## `prefetch: false` — Keep a Loader Out of Prefetches
1194
+ ## `prefetch: false` — Keep a Loader Out of Speculative Prefetches
1187
1195
 
1188
1196
  A `<Link>` prefetch is a navigation sent early: the server renders every
1189
1197
  handler and every loader for it. With the default `"viewport"` strategy that
1190
1198
  means a grid of forty product links runs forty copies of your slowest loader
1191
1199
  for pages most visitors never open. Pass `prefetch: false` to keep one loader
1192
- out:
1200
+ out of those speculative (viewport and render) prefetches:
1193
1201
 
1194
1202
  ```tsx
1195
1203
  import { Suspense } from "react";
1196
1204
 
1197
1205
  path("/product/:slug", ProductPage, { name: "product" }, () => [
1198
1206
  loader(PriceLoader), // cheap: still runs in the prefetch
1199
- loader(ReviewsLoader, { prefetch: false }), // expensive: skipped in a prefetch
1207
+ loader(ReviewsLoader, { prefetch: false }), // expensive: skipped in a viewport prefetch
1200
1208
  loading(<ProductSkeleton />),
1201
1209
  ]),
1202
1210
  ```
@@ -1204,11 +1212,22 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
1204
1212
  (`prefetch` sits beside `ssr` in `LoaderOptions`; combine them as
1205
1213
  `{ ssr: false, prefetch: false }` if you need both.)
1206
1214
 
1207
- What happens:
1208
-
1209
- - A **prefetch** of the route does not run the loader and does not read its
1210
- `cache()`; the payload marks it deferred. The rest of the page prefetches as
1211
- usual.
1215
+ A hover is different: the visitor is about to click. A hover prefetch (on a
1216
+ `"hover"`, `"viewport"` or `"adaptive"` link, or a delegated plain anchor) and
1217
+ `useRouter().prefetch()` send `X-Rango-Prefetch: intent` and run the loader
1218
+ with the rest of the page, so the click commits complete and sends no second
1219
+ request. A hover sends its prefetch only when the link has none cached or in
1220
+ flight: on a link a viewport prefetch already answered, the hover sends
1221
+ nothing and the click fills the deferred loader as below. The cost is plain:
1222
+ each hover that misses the prefetch cache runs the loader, so a pointer
1223
+ passing over a list of `"hover"` links runs it once per link. Touch devices
1224
+ have no hover (`"adaptive"` resolves to viewport there) and keep the fill.
1225
+
1226
+ What happens in a speculative prefetch:
1227
+
1228
+ - A **viewport or render prefetch** of the route does not run the loader and
1229
+ does not read its `cache()`; the payload marks it deferred. The rest of the
1230
+ page prefetches as usual.
1212
1231
  - Only on a loader new to the page. A loader already on screen is never
1213
1232
  deferred: if its revalidation (its `revalidate()` or the default) re-runs
1214
1233
  it, it runs in the prefetch with the rest of the destination, exactly as
@@ -1258,16 +1277,18 @@ function Reviews() {
1258
1277
 
1259
1278
  The limits:
1260
1279
 
1261
- - **A click before its prefetch has answered waits for it, then fetches the
1262
- deferred part.** The flag is there to keep expensive or slow work out of
1263
- every prefetch, so that an API that is costly to call is not called for
1264
- every link a visitor might click. A click that comes while the link's
1265
- prefetch is still in flight reuses that prefetch, as it does without the
1280
+ - **A click before its viewport prefetch has answered waits for it, then
1281
+ fetches the deferred part.** The flag is there to keep expensive or slow
1282
+ work out of every speculative prefetch, so that an API that is costly to
1283
+ call is not called for every link a visitor might click. A click that comes
1284
+ while the link's viewport or render prefetch is still in flight reuses that
1285
+ prefetch, as it does without the
1266
1286
  flag, and sends the fill when the prefetch answers. The page shows no later
1267
1287
  than with no prefetch; the deferred part shows its fallback until the fill
1268
1288
  lands, and can complete later than a click with no prefetch, by up to what
1269
1289
  was left of the prefetch. That is the price of keeping the work out of the
1270
- prefetch.
1290
+ prefetch. A click while a hover's prefetch is in flight adopts it with no
1291
+ fill: the loader is already running in it.
1271
1292
  - **A navigation started before the fill answers cancels it.** It is
1272
1293
  cancelled when the newer navigation starts, not when that one commits. If
1273
1294
  the newer navigation then ends without replacing the page (a redirect the
@@ -1360,8 +1381,9 @@ The limits:
1360
1381
  data with the URL, and one fill runs the flagged loaders (see
1361
1382
  `/client-urls` → "`prefetch: false`"). A flagged `loading()` defers nothing
1362
1383
  on a group route, from the group or from a server layout above it.
1363
- - **Inside `intercept()`.** In `urls()` and in `clientUrls()`, a prefetch of
1364
- the intercept's target from its source page defers the flagged loader: the
1384
+ - **Inside `intercept()`.** In `urls()` and in `clientUrls()`, a viewport or
1385
+ render prefetch of the intercept's target from its source page defers the
1386
+ flagged loader (a hover's runs it, and its click sends no fill): the
1365
1387
  click opens the modal at once, with its `loading()`, and one fill, sent
1366
1388
  from the source, runs the loader. The page under the modal stays. With no
1367
1389
  `loading()` the slot is empty until the fill answers, as on a plain click
@@ -1371,16 +1393,21 @@ The limits:
1371
1393
  `revalidate()`, so an action from the modal re-runs only those that say so
1372
1394
  (see `/intercept`).
1373
1395
  - **`ppr` and `ssr: false`.** On a `ppr` route an `ssr: false` loader is the
1374
- bake lane and is served from the shell, so `prefetch: false` is ignored for
1375
- it and development logs a warning. Under `cache()` and `Prerender` the flag
1376
- works as above (see `/caching`, `/prerender`, `/ppr`).
1396
+ bake lane. A prefetch that replays a shell a document request captured
1397
+ serves it from its pin: it is not deferred, does not run, and the click
1398
+ sends nothing. Every other prefetch (a navigation-only shell, a cold page,
1399
+ an explicit `cache()` hit) defers it as above, and the click sends one
1400
+ fill. Under `cache()` and `Prerender` the flag works as above (see
1401
+ `/caching`, `/prerender`, `/ppr` → "`prefetch: false` on a `ppr` route").
1377
1402
 
1378
1403
  To skip a handler along with its loaders, flag the `loading()` instead (see
1379
1404
  `/route` → "`loading()` options"). `loader(Def, { prefetch: false })` accepts
1380
1405
  only a boolean; anything else throws `loader() prefetch must be a boolean`.
1381
1406
 
1382
1407
  Test it with `serveShellRequest(router, url, { partial: { prefetch: true } })`
1383
- and `result.readDeferred()` (see `/testing`).
1408
+ and `result.readDeferred()` (see `/testing`). That is a speculative prefetch;
1409
+ add `headers: { "X-Rango-Prefetch": "intent" }` to serve a hover's, which
1410
+ defers nothing.
1384
1411
 
1385
1412
  ## Fetchable Loaders
1386
1413
 
@@ -649,6 +649,11 @@ parallel({ "@sidebar": BlogSidebar }, () => [
649
649
  ]);
650
650
  ```
651
651
 
652
+ The sidebar rule reads `currentParams` and keeps the sidebar on an unchanged
653
+ `:slug`, so a prefetch of the same post (another tab, `?sort=`) is shared by
654
+ the pages of that post: the rule read its slug. Links to other posts stay
655
+ shared. `/links` → "Shared prefetches and `prefetchKey`".
656
+
652
657
  **Server-side caching** — `cache()` DSL, loader-level `cache()`, and `"use cache"`
653
658
  control what gets cached and for how long. This is separate from `revalidate()`:
654
659
 
@@ -833,8 +838,9 @@ router cache — map to Rango's prefetch system: per-Link
833
838
  option) warms the target's partial RSC payload before the click, and a click
834
839
  on a warmed link commits the prefetched payload as a whole — the complete
835
840
  page lands instantly, no fetch waterfall. Prefetched entries survive being
836
- used (they re-arm in place) and expire by `prefetchCacheTTL`; actions and
837
- `invalidateClientCache()` flush them so a stale payload is never committed.
841
+ used (they re-arm in place) and expire by `prefetchCacheTTL`.
842
+ `invalidateClientCache()` flushes them; a server action marks them stale, so
843
+ a click on one shows it and then revalidates the page in the background.
838
844
 
839
845
  ```tsx
840
846
  <Link to="/product/widget" prefetch="viewport">
@@ -245,6 +245,11 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
245
245
  ]);
246
246
  ```
247
247
 
248
+ On an unchanged `:slug` this rule keeps the route after reading `currentParams`,
249
+ so a prefetch of the same product (a tab, `?sort=price`) is shared by the
250
+ pages of that product: the rule read its slug. Links to other products stay
251
+ shared. `/links` → "Shared prefetches and `prefetchKey`".
252
+
248
253
  Note: RR7's `shouldRevalidate` controls client-side loader re-fetching. Rango's
249
254
  `revalidate()` controls which segments re-run during partial rendering after
250
255
  navigation or actions. The intent is the same — skip unnecessary work — but
@@ -500,6 +500,25 @@ Rango warns with the shell key and backs capture off instead of rendering the
500
500
  same doomed generation on every request. Move a deterministic self-invalidation
501
501
  out of render code if you want the shell to persist.
502
502
 
503
+ ### Location state on a ppr route
504
+
505
+ A shared shell never holds location state: the capture builds its own
506
+ payload, without it. State set before the render (from middleware, or the
507
+ one-shot cookie a document redirect left for this request) rides the live
508
+ payload: a HIT serves the shell with the state in its tail's payload, a MISS
509
+ carries it and still captures. Either response leaves `private, no-store`.
510
+ State a handler sets skips the capture of that MISS, so that route renders in
511
+ full: a HIT runs no handler, so a handler's `ctx.setLocationState()` does not
512
+ happen on a HIT. Set it from middleware (see `/hooks` `state.md`, "State on a
513
+ document response").
514
+
515
+ A HIT sends exactly the state set before its render: its headers, which
516
+ make it `private, no-store`, leave before the tail runs, so a write during
517
+ the tail (a loader calling `getRequestContext().setLocationState()`, which
518
+ is not supported) is not sent. A HIT that read a document redirect's cookie
519
+ and then degrades into a reload sets the cookie again from its reload
520
+ script, so the forced MISS still carries the state.
521
+
503
522
  ### Opting out per request with `ctx.dynamic()`
504
523
 
505
524
  Middleware and handlers can call `ctx.dynamic()` to force this request onto a
@@ -1146,11 +1165,25 @@ A `ppr` route's handler output is stored, so a `loading()` flagged with
1146
1165
  and its partial replay are served as usual and only the live loaders behind
1147
1166
  the fallback are deferred. A live loader registered with `{ prefetch: false }` is
1148
1167
  skipped the same way, and the click that adopts the prefetch sends one fill
1149
- request for it. An `ssr: false` loader is the bake lane and is served from the
1150
- shell, so on a `ppr` route `prefetch: false` is ignored for it, and
1151
- development logs a warning that the loader "is registered with ssr: false
1152
- and prefetch: false on a ppr route"; drop one of the two options. See
1153
- `/loader` → "`prefetch: false`".
1168
+ request for it.
1169
+
1170
+ An `ssr: false` loader (the bake lane) registered with `{ prefetch: false }`
1171
+ is deferred too, unless the prefetch serves it from a pin of a shell a
1172
+ document request captured:
1173
+
1174
+ | The prefetch | The flagged `ssr: false` loader |
1175
+ | ------------------------------------------------------ | --------------------------------------------------------------------- |
1176
+ | Replays a shell a document request captured (`HIT`) | Served from its pin, not deferred, never run; the click sends nothing |
1177
+ | Replays a navigation-only shell, or misses (cold page) | Deferred: the click sends one fill that runs it |
1178
+ | Serves an explicit `cache()` hit | Deferred: the click sends one fill that runs it |
1179
+ | Hover prefetch (`X-Rango-Prefetch: intent`) | Never deferred |
1180
+
1181
+ A cold prefetch still schedules the background capture that stores the
1182
+ page's navigation shell, and that capture runs the loader once. Once a
1183
+ document request has captured the page's shell, prefetches replay it and
1184
+ serve the loader from its pin, as without the flag. Since such a loader can be deferred, it must not
1185
+ `await ctx.rendered()` (it throws on every request, as any deferrable loader
1186
+ does). See `/loader` → "`prefetch: false`".
1154
1187
 
1155
1188
  ### The layout-with-loaders playbook (the storefront case)
1156
1189
 
@@ -1020,7 +1020,10 @@ in dev. A removed page is the exception: its marker answers 404 in dev too.
1020
1020
  that only a refresh produced.
1021
1021
  - **Connected clients can lag.** A refresh has no response to reach open tabs:
1022
1022
  a client that prefetched the page keeps its copy for `prefetchCacheTTL`
1023
- (default 300s, in memory only). Lower it if the window matters.
1023
+ (default 300s): in memory, and in the browser's HTTP cache too when the
1024
+ route runs no loader (the prefetch then carries
1025
+ `Cache-Control: private, max-age=<prefetchCacheTTL>`). Lower it if the
1026
+ window matters.
1024
1027
  - **Entries are per deploy.** Keys carry the router's data version, so a deploy
1025
1028
  that changes server code starts from the build entries again; a client-only
1026
1029
  deploy keeps refreshed pages.
@@ -242,10 +242,10 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
242
242
 
243
243
  `loading(fallback, options?)` takes two options:
244
244
 
245
- | Option | Effect |
246
- | ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
247
- | `ssr: false` | Skip the fallback on document requests; it shows on client navigations only. |
248
- | `prefetch: false` | Keep everything behind this fallback out of `<Link>` prefetches; the click shows the fallback and fetches that part itself. |
245
+ | Option | Effect |
246
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
247
+ | `ssr: false` | Skip the fallback on document requests; it shows on client navigations only. |
248
+ | `prefetch: false` | Keep everything behind this fallback out of viewport and render prefetches (a hover prefetch runs it); the click shows the fallback and fetches that part itself. |
249
249
 
250
250
  ```tsx
251
251
  path("/orders", OrdersPage, { name: "orders" }, () => [
@@ -255,8 +255,8 @@ path("/orders", OrdersPage, { name: "orders" }, () => [
255
255
  ```
256
256
 
257
257
  **What `prefetch: false` defers.** The flag applies when the entry is new to
258
- the browser: the page the prefetch is sent from does not show it. A prefetch
259
- then skips the entry's handler, its loaders, its orphan layouts and its
258
+ the browser: the page the prefetch is sent from does not show it. A viewport
259
+ or render prefetch then skips the entry's handler, its loaders, its orphan layouts and its
260
260
  parallel slots. On a `layout()` the fallback covers the whole outlet, so every
261
261
  deeper entry is skipped with it. A parallel slot with its own flagged
262
262
  `loading()` is a separate unit: the route beside it still renders in the
@@ -250,7 +250,8 @@ Notes:
250
250
  - `discovery: { include?, exclude? }` filters which files the route-discovery
251
251
  scan reads, as root-relative globs (e.g. `include: ["src/routes/**"]`).
252
252
  `exclude` replaces the default excludes (`__tests__`, `__mocks__`, `dist`,
253
- `coverage`, `*.test.*`, `*.spec.*`) rather than adding to them.
253
+ `coverage`, `*.test.*`, `*.rsc-test.*`, `*.spec.*`) rather than adding to
254
+ them.
254
255
  - `progressiveChunkSize` sets React's Fizz `progressiveChunkSize` for document
255
256
  renders and PPR shell capture. Unset, documents whose route has a
256
257
  `loader(Def, { ssr: false })` raise it automatically so that content stays
@@ -531,11 +531,42 @@ See `/middleware` for the full cross-segment revalidation contract.
531
531
  ## Client Cache After an Action
532
532
 
533
533
  `revalidate()` decides what the server re-renders **in the action's response**.
534
- Separately, the browser invalidates its own caches once the action's response
535
- arrives: it marks the history cache stale (Back renders the cached entry, then
536
- revalidates), flushes the prefetch cache, rotates the router's state cookie,
537
- and tells other open tabs to do the same. This happens for every action that
538
- reached the server, including ones that threw. No setup is needed.
534
+ Separately, the browser marks its own caches stale once the action's response
535
+ arrives: the history cache (Back renders the cached entry, then revalidates)
536
+ and every prefetch (a click on a link prefetched before the action shows that
537
+ page at once, then revalidates it in the background), and it tells other open
538
+ tabs that share a segment with the page to do the same and refresh. Nothing is
539
+ flushed. The action's response bumps only the write part of the router's state
540
+ cookie (`{build}:{invalidation}:{write}`), which every prefetch and navigation
541
+ sends as `X-Rango-State`: responses vary on it, so a partial response a route
542
+ caches with its own `Cache-Control: max-age` is not answered from the
543
+ browser's HTTP cache after the write, in this tab, after a reload, or in
544
+ another tab. The client's in-memory caches read only
545
+ `{build}:{invalidation}`, so the write marks them stale and drops nothing.
546
+ This happens for every action that reached the server, including ones that
547
+ threw or redirected, and for a form posted before hydration. No setup is
548
+ needed.
549
+
550
+ ```text
551
+ /product/1: /cart prefetched (cart:2), click "Add to cart"
552
+ the action's response (header: cart:3)
553
+ click the /cart link /cart shows at once (cart:2)
554
+ one background revalidation (cart:3)
555
+ ```
556
+
557
+ The pre-write values show for one round trip, a layout loader that re-runs
558
+ on navigation included. `useNavigation().state` stays `"idle"` during the
559
+ revalidation, and nothing remounts. A new hover on the link replaces the
560
+ stale prefetch with a fresh one. A stale prefetch answers one click only: a
561
+ second click on the same link fetches the page, and Back/Forward onto a page
562
+ the history cache no longer holds never uses it. Visible links do not
563
+ prefetch again after an action. A tab that shares no segment with the page
564
+ (or an app with `crossTabSync: false`) gets no message and does not refresh;
565
+ its next prefetch or navigation sees the bumped state and marks its own
566
+ history and prefetches stale, so a click on a link it prefetched before the
567
+ write shows that page, then revalidates it. Call `invalidateClientCache()` in the action
568
+ for the full flush: it drops every prefetch, rotates the state's invalidation
569
+ part, and other tabs adopt it.
539
570
 
540
571
  ### When the user navigates while the action runs
541
572
 
@@ -596,9 +627,11 @@ export async function saveDraft(formData: FormData) {
596
627
  ```
597
628
 
598
629
  What it suppresses, for this action only: the stale-marking of the history
599
- cache, the prefetch flush, the state-cookie rotation, the cross-tab broadcast,
600
- and the background revalidation the client runs when the user navigated while
601
- the action was in flight (above). What it does not change:
630
+ cache and of the prefetches, the state cookie's write bump, the cross-tab
631
+ broadcast, and the background
632
+ revalidation the client runs when the user navigated while the action was in
633
+ flight (above). A click on a prefetch made before it shows that prefetch with
634
+ no revalidation. What it does not change:
602
635
 
603
636
  - The action's own response still carries the revalidation render your
604
637
  `revalidate()` rules select, and the client applies it.
@@ -645,6 +678,14 @@ Redirects from actions render the **target** route tree's matched segments
645
678
  source page's — the target is what the user sees next. See `/hooks
646
679
  useLocationState` for reading flash state on the target page.
647
680
 
681
+ The same holds for a form posted without JS (or before hydration): an action
682
+ that redirects with `state` carries it to the target in a one-shot cookie,
683
+ and one that sets it with `getRequestContext().setLocationState()` and does
684
+ not redirect carries it in the re-rendered page. Either way the target reads
685
+ it with `useLocationState` once it has hydrated. Prefer a `303` redirect, so a
686
+ refresh does not post the form again. State that crosses a no-JS redirect
687
+ travels as JSON (see `/hooks` `state.md`, "State on a document response").
688
+
648
689
  Build the target by name rather than a literal path:
649
690
  `throw redirect(getRequestContext().reverse("account.settings"))`. It fills
650
691
  mount params (`:accountId`, `:locale`) from the page URL the action was called