@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
@@ -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
@@ -512,6 +512,13 @@ full: a HIT runs no handler, so a handler's `ctx.setLocationState()` does not
512
512
  happen on a HIT. Set it from middleware (see `/hooks` `state.md`, "State on a
513
513
  document response").
514
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
+
515
522
  ### Opting out per request with `ctx.dynamic()`
516
523
 
517
524
  Middleware and handlers can call `ctx.dynamic()` to force this request onto a
@@ -1158,11 +1165,25 @@ A `ppr` route's handler output is stored, so a `loading()` flagged with
1158
1165
  and its partial replay are served as usual and only the live loaders behind
1159
1166
  the fallback are deferred. A live loader registered with `{ prefetch: false }` is
1160
1167
  skipped the same way, and the click that adopts the prefetch sends one fill
1161
- request for it. An `ssr: false` loader is the bake lane and is served from the
1162
- shell, so on a `ppr` route `prefetch: false` is ignored for it, and
1163
- development logs a warning that the loader "is registered with ssr: false
1164
- and prefetch: false on a ppr route"; drop one of the two options. See
1165
- `/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`".
1166
1187
 
1167
1188
  ### The layout-with-loaders playbook (the storefront case)
1168
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.
@@ -79,6 +79,21 @@ type RenderRouteResult = RenderResult & {
79
79
  // re-render the current location, as router.refresh() does:
80
80
  // a spec's when gets kind "revalidate" (`to` is `from`)
81
81
  refresh(): Promise<void>;
82
+ // keep `url` as a prefetch made now carries it (the seeds then, `loaders`
83
+ // over them); a later navigate(url) without `loaders` commits that data
84
+ prefetch(
85
+ url: string,
86
+ options?: {
87
+ loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
88
+ },
89
+ ): void;
90
+ // land a server action's response: `loaders` are the post-write values,
91
+ // the page on screen re-renders with them, and (without keepClientCache)
92
+ // every history entry and prefetch is marked stale
93
+ action(options?: {
94
+ loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
95
+ keepClientCache?: boolean;
96
+ }): Promise<void>;
82
97
  pathname(): string;
83
98
  params(): Record<string, string>;
84
99
  store: NavigationStore; // advanced
@@ -420,19 +435,20 @@ it("dims the held price while the next product streams", async () => {
420
435
  });
421
436
  ```
422
437
 
423
- | Pending state | Under `renderRoute` |
424
- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
425
- | `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
426
- | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
427
- | navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
428
- | `transition: { when }` returning false, or `navigate(url, { transition: false })` | Same as without `transition`: urgent, with production's decision (`kind: "push"`; `refresh()` is `"revalidate"`). `transition: false` calls no predicate. |
429
- | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
430
- | `useNavigation().state`, `useLinkStatus().pending` | Modeled: navigate() starts the navigation as a click does. `loading` from the call, `idle` in the commit, held with it; `pending` true for the destination's link until that commit, held with it too. |
431
- | `useAction().state` | Stays `idle`: navigate() runs no action. e2e. |
432
- | `useHandle()` on a held navigation, `navigate(url, { handles, loaders })` | Modeled: `handles` arrive on the response's handle stream, so readers keep the page being left's values while React holds the page and take the new ones with its commit. |
433
- | `useNavigation().isStreaming` past the commit, `navigate(url, { streamUntil })` | Modeled: the stream opens a task after the call (`pendingUrl` clears) and ends when the promise settles or a newer navigation aborts it; settle it inside `await act(async () => ...)`. |
434
- | A navigation started while React holds a page (a newer click, a `transition: false` commit) | `await router.navigate(...)` shows its page, with or without `act()`. A `<Link>` click goes inside `await act(async () => { ... })`: `fireEvent`'s synchronous `act()` drops React's retry of the held page. |
435
- | A back/forward restored from the history cache (`pop`) | Modeled for the mounted entry and one a navigate() wrote: the page and its handle values come back, decided by `transition({ when })` as kind `"pop"`. A stale entry's revalidation and a restore that fetches: e2e. |
438
+ | Pending state | Under `renderRoute` |
439
+ | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
440
+ | `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
441
+ | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
442
+ | navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
443
+ | `transition: { when }` returning false, or `navigate(url, { transition: false })` | Same as without `transition`: urgent, with production's decision (`kind: "push"`; `refresh()` is `"revalidate"`). `transition: false` calls no predicate. |
444
+ | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
445
+ | `useNavigation().state`, `useLinkStatus().pending` | Modeled: navigate() starts the navigation as a click does. `loading` from the call, `idle` in the commit, held with it; `pending` true for the destination's link until that commit, held with it too. |
446
+ | `useAction().state` | Stays `idle`: navigate() runs no action. e2e. |
447
+ | `useHandle()` on a held navigation, `navigate(url, { handles, loaders })` | Modeled: `handles` arrive on the response's handle stream, so readers keep the page being left's values while React holds the page and take the new ones with its commit. |
448
+ | `useNavigation().isStreaming` past the commit, `navigate(url, { streamUntil })` | Modeled: the stream opens a task after the call (`pendingUrl` clears) and ends when the promise settles or a newer navigation aborts it; settle it inside `await act(async () => ...)`. |
449
+ | A navigation started while React holds a page (a newer click, a `transition: false` commit) | `await router.navigate(...)` shows its page, with or without `act()`. A `<Link>` click goes inside `await act(async () => { ... })`: `fireEvent`'s synchronous `act()` drops React's retry of the held page. |
450
+ | A back/forward restored from the history cache (`pop`) | Modeled for the mounted entry and one a navigate() wrote: the page and its handle values come back, decided by `transition({ when })` as kind `"pop"`. A stale entry's revalidation and a restore that fetches: e2e. |
451
+ | A click on a prefetch made before an action, `prefetch(url)`, `action({ loaders })`, `navigate(url)` | Modeled: the click commits the prefetch's data at once; once it settled, the page revalidates in Back's lane with the post-write data (`useNavigation().state` stays `idle`, nothing remounts). `router.revalidations()` lists each revalidation request (its URL and held segment ids), decided by production's guards. `action({ keepClientCache: true })` keeps the prefetch fresh: no revalidation. The prefetch request itself: e2e. |
436
452
 
437
453
  - Per-navigation seeds merge over the render-time `loaders`/`loaderData` for THAT navigation only; a later `navigate()` without `loaders` falls back to the render-time data.
438
454
  - Pass settled data as a plain value, not `Promise.resolve(value)`: React has not observed a plain promise yet, so it counts as pending and renders one `stale` frame. (A Flight chunk that has already settled carries its status and is skipped, which a plain value mirrors.)