@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
@@ -8,25 +8,26 @@
8
8
 
9
9
  ### Options — `RenderRouteOptions`
10
10
 
11
- | Field | Type | Meaning |
12
- | ----------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
- | `request` | `Request \| string` | Initial location. Only the URL is read (client render — headers/method ignored). Defaults to the leaf spec's static prefix or `"/"`. |
14
- | `loaderData` | `Record<string, unknown>` | Loader data keyed by loader `$$id`. `useLoader(L)` reads `loaderData[L.$$id]`. |
15
- | `outletPending` | `boolean` | Seed `useOutlet().pending` through each synthetic segment's production `OutletProvider`. Defaults to `false`; this is context seeding, not a simulated navigation/Suspense/action lifecycle. |
16
- | `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>` | Seed by REFERENCE: `[loader, data]` pairs. Robust for real `createLoader()` handles whose `$$id` is empty in a bare test. Prefer over `loaderData`. |
17
- | `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
18
- | `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; keys an unkeyed `def` (like `withLocationStateKey(def)`) and writes `value` to `history.state` under `def.__rsc_ls_key`. See [Location state](#location-state). |
19
- | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
20
- | `lateHandles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Handle values that arrive AFTER hydration, as a late loader push does: `[handle, valuesAfterTheUpdate[]]` replaces that handle's `handles` seed once the root has hydrated (or mounted). See [Hydration](#hydration). |
21
- | `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
22
- | `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
23
- | `basename` | `string` | `createRouter({ basename })` value. Wired into `NavigationProvider` so `useRouter().basename`, `<Link>` prefixing, `useMount`/`useHref` resolve against the mount. Normalized like `createRouter`. Defaults to root. |
24
- | `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
25
- | `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape) to wrap the tree in a `ThemeProvider`. Defaults to no provider. A component calling `useTheme()` REQUIRES one. |
26
- | `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
27
- | `defaultPrefetch` | `PrefetchStrategy` | Router default for `<Link>` and eligible plain anchors. `data-prefetch="false"`/`"none"` opts out one anchor; ancestor `data-prefetch-scope="false"`/`"none"` hard-disables the subtree; `"true"` permits routed resource suffixes elsewhere. |
28
- | `hydrate` | `boolean` | Render the same tree to HTML first (`renderToString`, no `window`/`document`), then hydrate that HTML instead of mounting fresh. The result gains `serverHtml`, `recoverableErrors` and `shellError`. See [Hydration](#hydration). |
29
- | `rewrite` | `string` | The path middleware rewrote `request` to (`throw rewrite()`): the leaf is matched against it for params, while the location (`usePathname`, `useSearchParams`) stays `request`, as a rewritten document renders and hydrates. |
11
+ | Field | Type | Meaning |
12
+ | ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
13
+ | `request` | `Request \| string` | Initial location. Only the URL is read (client render — headers/method ignored). Defaults to the leaf spec's static prefix or `"/"`. |
14
+ | `loaderData` | `Record<string, unknown>` | Loader data keyed by loader `$$id`. `useLoader(L)` reads `loaderData[L.$$id]`. |
15
+ | `outletPending` | `boolean` | Seed `useOutlet().pending` through each synthetic segment's production `OutletProvider`. Defaults to `false`; this is context seeding, not a simulated navigation/Suspense/action lifecycle. |
16
+ | `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>` | Seed by REFERENCE: `[loader, data]` pairs. Robust for real `createLoader()` handles whose `$$id` is empty in a bare test. Prefer over `loaderData`. |
17
+ | `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
18
+ | `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; keys an unkeyed `def` (like `withLocationStateKey(def)`) and writes `value` to `history.state` under `def.__rsc_ls_key`. See [Location state](#location-state). |
19
+ | `documentLocationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | The location state the document response carried (a handler, middleware or no-JS form action set it, or a document redirect's cookie brought it), as `[def, value]` pairs. Merged at start-up after `locationState` and the `clearOnReload` pass. See [State a document carried](#state-a-document-carried). |
20
+ | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
21
+ | `lateHandles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Handle values that arrive AFTER hydration, as a late loader push does: `[handle, valuesAfterTheUpdate[]]` replaces that handle's `handles` seed once the root has hydrated (or mounted). See [Hydration](#hydration). |
22
+ | `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
23
+ | `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
24
+ | `basename` | `string` | `createRouter({ basename })` value. Wired into `NavigationProvider` so `useRouter().basename`, `<Link>` prefixing, `useMount`/`useHref` resolve against the mount. Normalized like `createRouter`. Defaults to root. |
25
+ | `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
26
+ | `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape) to wrap the tree in a `ThemeProvider`. Defaults to no provider. A component calling `useTheme()` REQUIRES one. |
27
+ | `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
28
+ | `defaultPrefetch` | `PrefetchStrategy` | Router default for `<Link>` and eligible plain anchors. `data-prefetch="false"`/`"none"` opts out one anchor; ancestor `data-prefetch-scope="false"`/`"none"` hard-disables the subtree; `"true"` permits routed resource suffixes elsewhere. |
29
+ | `hydrate` | `boolean` | Render the same tree to HTML first (`renderToString`, no `window`/`document`), then hydrate that HTML instead of mounting fresh. The result gains `serverHtml`, `recoverableErrors` and `shellError`. See [Hydration](#hydration). |
30
+ | `rewrite` | `string` | The path middleware rewrote `request` to (`throw rewrite()`): the leaf is matched against it for params, while the location (`usePathname`, `useSearchParams`) stays `request`, as a rewritten document renders and hydrates. |
30
31
 
31
32
  `RenderRouteSpec = { path, Component, layout?, loaderIds?, name?, transition? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational. `transition` is the `transition()` config this node declares (`{}` for a bare `transition()`), attached to its segment exactly as the DSL does — see [Held navigation](#held-navigation).
32
33
 
@@ -78,6 +79,21 @@ type RenderRouteResult = RenderResult & {
78
79
  // re-render the current location, as router.refresh() does:
79
80
  // a spec's when gets kind "revalidate" (`to` is `from`)
80
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>;
81
97
  pathname(): string;
82
98
  params(): Record<string, string>;
83
99
  store: NavigationStore; // advanced
@@ -265,6 +281,30 @@ it("drops them on a refresh and carries again on the next navigation", async ()
265
281
  });
266
282
  ```
267
283
 
284
+ ### State a document carried
285
+
286
+ A page load, a no-JS form post that renders a page, and the target of a document redirect carry the location state the server set for them (see `/hooks`, state.md, "State on a document response"). Start-up merges it into the entry after the `clearOnReload` pass, so a reader gets it once the root has hydrated, `clearOnReload` slots included. Seed it with `documentLocationState`, not `locationState`: `locationState` is the entry's stored state, which `hydrate: true` passes through the `clearOnReload` pass first.
287
+
288
+ | To test | Do |
289
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
290
+ | a flash a document carried, as the page hydrates | `renderRoute(routes, { hydrate: true, documentLocationState })` |
291
+ | a document's `clearOnReload` state replacing a stale entry | `hydrate: true` with the stale value in `locationState`, the new one in `documentLocationState` |
292
+ | what the server put in the document | `serveShellRequest(router, url).readLocationState()` (see [`./serve-shell-request.md`](./serve-shell-request.md)) |
293
+
294
+ ```tsx
295
+ it("shows the flash the declined-payment redirect carried", async () => {
296
+ const { serverHtml, getByRole } = await renderRoute(
297
+ [{ path: "/checkout", Component: FlashBanner }],
298
+ {
299
+ hydrate: true,
300
+ documentLocationState: [[FlashMessage, { text: "Payment declined" }]],
301
+ },
302
+ );
303
+ expect(serverHtml).not.toContain("Payment declined"); // useLocationState is undefined until hydration
304
+ expect(getByRole("alert")).toHaveTextContent("Payment declined");
305
+ });
306
+ ```
307
+
268
308
  ## Hydration
269
309
 
270
310
  A plain `renderRoute` mounts with `createRoot`, so a component never runs its hydration render: `useSyncExternalStore` skips `getServerSnapshot`, and nothing compares the first client render with server HTML. `hydrate: true` runs the document-load sequence instead. renderRoute renders the same element (same providers, seeds, and RTL `reactStrictMode`) to HTML with `react-dom/server`'s `renderToString`, puts that HTML in the container, and hydrates it through RTL (`render(ui, { hydrate: true, onRecoverableError })`), so `cleanup()` unmounts it like any other render. `serverHtml` is what the server pass produced; `recoverableErrors` holds the message of every error React recovered from, which is where a hydration mismatch arrives.
@@ -395,19 +435,20 @@ it("dims the held price while the next product streams", async () => {
395
435
  });
396
436
  ```
397
437
 
398
- | Pending state | Under `renderRoute` |
399
- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
400
- | `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. |
401
- | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
402
- | 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. |
403
- | `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. |
404
- | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
405
- | `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. |
406
- | `useAction().state` | Stays `idle`: navigate() runs no action. e2e. |
407
- | `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. |
408
- | `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 () => ...)`. |
409
- | 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. |
410
- | 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. |
411
452
 
412
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.
413
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.)
@@ -20,29 +20,30 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
20
20
 
21
21
  ### Options — `ServeShellRequestOptions`
22
22
 
23
- | Field | Type | Meaning |
24
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
- | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
- | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
- | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error segment of a thrown action's boundary the page at `from` shows (`_rsc_boundary`), as the background revalidation an earlier action owes that page sends it, with `stale: true` and no `segments`: the response renders every segment above the boundary and nothing at or below it, claims the boundary in `matched` and sends no segment for it. The server reads it only beside `stale`. |
29
- | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
23
+ | Field | Type | Meaning |
24
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
+ | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
+ | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
+ | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends the speculative kind a viewport or render `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so `prefetch: false` work is deferred; add `headers: { "X-Rango-Prefetch": "intent" }` to send a hover's or `prefetch()`'s intent prefetch instead, which defers nothing. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error segment of a thrown action's boundary the page at `from` shows (`_rsc_boundary`), as the background revalidation an earlier action owes that page sends it, with `stale: true` and no `segments`: the response renders every segment above the boundary and nothing at or below it, claims the boundary in `matched` and sends no segment for it. The server reads it only beside `stale`. |
29
+ | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
30
30
 
31
31
  ### Returns — `ServeShellRequestResult`
32
32
 
33
- | Field | Type | Meaning |
34
- | -------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
- | `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
36
- | `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
37
- | `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
38
- | `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
39
- | `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
40
- | `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
41
- | `readHandles` | `() => Promise<ShellRequestHandles \| undefined>` | Decodes the response's handle data as the browser reads it: `{ hydration, late }` (see "Handles and loader data"). `undefined` when no Flight rendered. |
42
- | `readDeferred` | `() => Promise<string[] \| undefined>` | The ids of the segments the payload marks deferred (a prefetch skipped them; the fill renders them), in payload order. `[]` when nothing was deferred, `undefined` when no Flight rendered. |
43
- | `readAction` | `() => Promise<ShellRequestAction \| undefined>` | For an `action` request, the call's answer as the browser reads it: `{ ok, result, revalidationError }` (see "A server action called from the page"). `undefined` when no Flight rendered or the payload carries no action result. |
44
- | `response` | `Response` | Status and headers. Its body is already read. |
45
- | `body` | `string` | The body text. |
33
+ | Field | Type | Meaning |
34
+ | ------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
36
+ | `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
37
+ | `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
38
+ | `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
39
+ | `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
40
+ | `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
41
+ | `readHandles` | `() => Promise<ShellRequestHandles \| undefined>` | Decodes the response's handle data as the browser reads it: `{ hydration, late }` (see "Handles and loader data"). `undefined` when no Flight rendered. |
42
+ | `readDeferred` | `() => Promise<string[] \| undefined>` | The ids of the segments the payload marks deferred (a prefetch skipped them; the fill renders them), in payload order. `[]` when nothing was deferred, `undefined` when no Flight rendered. |
43
+ | `readAction` | `() => Promise<ShellRequestAction \| undefined>` | For an `action` request, the call's answer as the browser reads it: `{ ok, result, revalidationError }` (see "A server action called from the page"). `undefined` when no Flight rendered or the payload carries no action result. |
44
+ | `readLocationState` | `() => Promise<Record<string, unknown> \| undefined>` | The payload's location state as the browser reads it: `{ [Def.__rsc_ls_key]: value }` (see "Location state on a document"). `undefined` when it carries none or no Flight rendered. |
45
+ | `response` | `Response` | Status and headers. Its body is already read. |
46
+ | `body` | `string` | The body text. |
46
47
 
47
48
  ### `resetShellTestState(): Promise<void>`
48
49
 
@@ -415,6 +416,30 @@ expect(await result.readPayloadFormState()).toEqual(result.formState);
415
416
  - Request headers come from `headers`. No `Origin` is sent by default, which the origin guard allows.
416
417
  - Not with `partial`. The hydration itself (`hydrateRoot` taking the state, the hook not resetting) is e2e.
417
418
 
419
+ ## Location state on a document
420
+
421
+ A document carries the location state its handler, middleware or no-JS form action set, in its bootstrap payload; `readLocationState()` reads it as the browser does. A document request that redirects carries its state to the target in a one-shot cookie instead (`Set-Cookie`, `<stateCookieName>-ls`, `__Host-` on https): pass that cookie on the target's request, as the browser does when it follows the redirect, and read what the target's payload carries.
422
+
423
+ ```tsx
424
+ const redirected = await serveShellRequest(router, "/payment/callback");
425
+ const setCookie = redirected.response.headers
426
+ .getSetCookie()
427
+ .find((c) => /-ls=/.test(c.split(";")[0]!))!;
428
+
429
+ const target = await serveShellRequest(router, "/checkout", {
430
+ headers: { cookie: setCookie.split(";")[0]!, "sec-fetch-dest": "document" },
431
+ });
432
+ expect(await target.readLocationState()).toEqual({
433
+ [FlashMessage.__rsc_ls_key]: { text: "Payment declined" },
434
+ });
435
+ expect(target.response.headers.get("cache-control")).toBe("private, no-store");
436
+ ```
437
+
438
+ - Only a GET document request of the target's pathname reads the cookie, and clears it (`Max-Age=0` on its response). Where the request sends `Sec-Fetch-Dest`, it must be `document`, `iframe` or `frame`: a prefetch or `fetch()` (`empty`) leaves the cookie to the navigation. Without the header the request counts as a navigation.
439
+ - A document that carries state, and the redirect carrying it on, leave `Cache-Control: private, no-store`; the document cache neither stores nor serves them. On a `ppr` route, state set before its handler ran, by middleware or a redirect's cookie, rides the live payload: the request is still a `MISS` that captures or a `HIT`, and `prelude` holds none of it. State its handler sets keeps the route from being stored as a shell (`readEntry()` is null).
440
+ - For a `form` request, the re-rendered document carries what the action set; a redirecting action carries it in the cookie, as above.
441
+ - What a reader shows is client-side: seed it with `renderRoute({ hydrate: true, documentLocationState })` (see [`./client-components.md`](./client-components.md)).
442
+
418
443
  ## A server action called from the page
419
444
 
420
445
  `serveShellRequest(router, url, { action: { ref, id, args, segments } })` sends the request a JS server action call makes from the page at `url`, as `browser/server-action-bridge.ts` sends it: a POST of `args` with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments`. `ref` is registered under `id` (`<module id>#<export name>`, any unique string of that shape in a test) so the handler loads it by id. The response is the action's Flight payload: its return value and the segments it re-rendered, `flight` as for any other request. Pass the segment ids the page holds as `segments`, so the request has segments to revalidate; without them the server holds none. Read them from the `metadata.matched` of a `partial` request's payload (`flight`), as in "Testing `prefetch: false`" above.
@@ -53,6 +53,14 @@
53
53
  | `stateCookieName` | `string` | The resolved rango state cookie name this run seeded (default `rango-state_router_0`). Assert an `invalidateClientCache()` rotation against it without recomputing. |
54
54
  | `locationState` | `Record<string, unknown>` | The flash set via `ctx.setLocationState()` / `redirect({ state })`, as the flat `{ key: value }` the client reads. |
55
55
 
56
+ `runInRequestContext` runs your function, not the router's action handling,
57
+ so its `response` carries no write bump: the router sets that around every
58
+ action it runs (the state cookie's `{version}:{timestamp}:{write}`, write part
59
+ bumped unless the action called `keepClientCache()`). To assert it, send the
60
+ action through the router with `serveShellRequest(router, url, { action })`
61
+ (or `{ form }` with `createActionForm` for a post before hydration) and read
62
+ the state `Set-Cookie` on `result.response`.
63
+
56
64
  Low-level building blocks (all from `@rangojs/router/testing`):
57
65
 
58
66
  - `createTestRequestContext(opts)` builds the context WITHOUT entering it and returns `{ ctx, request, url, variables, stateCookieName }`. Its `ctx.reverse` accepts any name from your `routeMap`.
@@ -249,6 +249,13 @@ them right:
249
249
  server").
250
250
  - A prefetch runs the predicates too, so a loader that holds costs nothing in
251
251
  the prefetch of a tab switch.
252
+ - A predicate that reads `currentParams` or `fromRouteName` and keeps its
253
+ segment shares that prefetch only between the pages with equal values of
254
+ what it read; one that reads `currentUrl` makes it page-only (2.6).
255
+ `bySlug` keeps only on an unchanged slug, so a product's tabs share their
256
+ prefetches; `bySearch` reads the URL, so the cost is the links between one
257
+ query's tabs. A layout rule comparing a param most pages share (a locale,
258
+ an account) groups by that param and keeps the sharing.
252
259
  - Unit-test them with `runRevalidate` (`/testing`).
253
260
 
254
261
  ### 2.5 loading() or an inline Suspense
@@ -279,8 +286,14 @@ A prefetch is a navigation sent early: the server renders the destination's
279
286
  handlers and loaders for it, from the page you are on (`/links`, "What a
280
287
  prefetch renders"). A click that adopts a complete prefetch commits with no
281
288
  request. Entries live 300 s by default (`prefetchCacheTTL`) in the router's
282
- memory only (prefetch responses carry no `Cache-Control`), and an action
283
- drops them unless it calls `keepClientCache()`. A prefetch whose page failed
289
+ memory. An action marks them stale unless it calls `keepClientCache()`: a
290
+ click on one shows it at once, then revalidates the page in the background
291
+ (`/server-actions`, "Client Cache After an Action"). The browser HTTP cache
292
+ keeps a prefetch for as long only when nothing in it is live: a document cache
293
+ HIT, or an answer whose every sent segment came from a `cache()` record or a
294
+ Prerender entry and that runs no loader
295
+ (`Cache-Control: private, max-age=<prefetchCacheTTL>`). Any other prefetch
296
+ carries no `Cache-Control` of the router's. A prefetch whose page failed
284
297
  is never used: a 500 or 404, and a 200 whose render reported an error (a
285
298
  loader that threw, a `loading()` handler that fell back to its
286
299
  `errorBoundary()`, an async component that threw). The click sends its own
@@ -301,11 +314,16 @@ The rule: **prefetch broadly, and flag what is expensive.**
301
314
  already on screen whose `revalidate()` re-runs it for the destination is
302
315
  prefetched with it, so the click does not wait on a fill for data the page
303
316
  is already showing.
304
- - The trade-off, measured on a `clientUrls()` tab in a consumer app (dev, a
305
- loaded machine): with the tab's loader flagged, a hover then a click 150 ms
317
+ - `prefetch: false` applies to speculative prefetches: viewport and render.
318
+ A hover, or `useRouter().prefetch()`, is intent to click and runs the
319
+ loader, so its click needs no fill. A hover over a link a viewport prefetch
320
+ already answered sends nothing, and that click still fills.
321
+ - The trade-off of a deferring prefetch, measured on a `clientUrls()` tab in
322
+ a consumer app (dev, a loaded machine), before a hover's prefetch ran
323
+ flagged loaders: with the tab's loader flagged, a hover then a click 150 ms
306
324
  later showed the content in 843 to 918 ms; with the tab prefetched whole, in
307
325
  77 to 157 ms. The flagged click pays the fill request and then React's
308
- 300 ms on the tab's fallback.
326
+ 300 ms on the tab's fallback. A viewport-prefetched link still pays it.
309
327
  - A click that adopts a prefetch with a flagged loader shows the page at once
310
328
  and sends one fill request for the flagged loaders. Read a flagged loader
311
329
  under its own `<Suspense>`: with no boundary, React keeps the page being
@@ -313,9 +331,33 @@ The rule: **prefetch broadly, and flag what is expensive.**
313
331
  - On a server route, `loading(fallback, { prefetch: false })` keeps the whole
314
332
  entry behind that fallback out of the prefetch (`/route`, "`loading()`
315
333
  options"). A group's `loading()` takes no options.
316
- - When the target's `revalidate()` reads `currentUrl` or `currentParams`, give
317
- the Link `prefetchKey=":source"`: the default prefetch entry is shared by
318
- every source page (`src/browser/react/Link.tsx`, `prefetchKey`).
334
+ - One prefetch of a page serves every page that links to it, so a paginated list
335
+ or a grid does not prefetch each link again after every navigation. The server
336
+ makes an answer page-only (`x-rsc-prefetch-scope: source`) when sharing it
337
+ would be wrong: an intercept targets the route or the page sending it, the
338
+ answer defers a `prefetch: false` unit the page does not show yet, a
339
+ `revalidate()` on the target read `currentUrl` and kept its segment, a rule
340
+ on the route (or inside its `path()`) read the page being left and kept the
341
+ segment through the route's default, or a kept answer sends no segment at
342
+ all. Only a click from that exact URL, query
343
+ and hash included, uses a page-only answer, so every page prefetches it
344
+ again (`/links`, "Shared prefetches and `prefetchKey`").
345
+ - A `revalidate()` that read `currentParams` or `fromRouteName` and kept its
346
+ segment, or a prerendered target reached from a page with the same route
347
+ params, groups the answer (`x-rsc-prefetch-scope: params; <label>`): the
348
+ pages with equal values of what was read share it. A layout rule comparing
349
+ `currentParams.locale` shares each prefetch between the pages of one locale.
350
+ Spreading `currentParams` reads every param; read the ones the rule needs by
351
+ name. A page's own block (a category hero) still goes in its route or in a
352
+ layout inside its `path()`, and links that follow a mount param come from
353
+ `useReverse`; neither needs a rule (`/app-structure`, "Prefetches are
354
+ shared").
355
+ - `prefetchKey=":source"` on a Link, or
356
+ `router.prefetch(url, { key: ":source" })`, makes that link's prefetches
357
+ page-only. Use it when the target's answer depends on the page through
358
+ something the server does not track: `context` in a `revalidate()`, or a
359
+ `Referer` read. It costs the same requests, so set it per link, not
360
+ app-wide.
319
361
 
320
362
  ### 2.7 transition() and view transitions
321
363
 
@@ -372,16 +414,16 @@ The rule: **prefetch broadly, and flag what is expensive.**
372
414
  the error to the nearest error boundary, which replaces the page and its
373
415
  form (`/client-urls`, "A loader that fails on its rerun").
374
416
  - `keepClientCache()` in an action that changed nothing keeps the prefetches
375
- and the history cache.
417
+ and the history cache fresh: no revalidation after a click or on Back.
376
418
  - `useOptimistic` gives feedback before the action answers.
377
419
 
378
420
  ### 2.10 Updates you did not make: three channels
379
421
 
380
- | The change | The tool |
381
- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
382
- | Your own mutation | A server action plus `revalidate()` rules (`isAction(ref)`, `actionResult`). The server re-runs what the action touched in the same response; nothing else to call. |
383
- | Out of band: a push, polling, a REST call, a login | `useLoader(L).load()` (also `refetch`): a plain `load()` of a route-registered loader updates every reader of it. `refreshGroup` on reads plus `useRefreshLoaders()` refreshes several loaders at once. Both need a fetchable loader, `createLoader(fn, true)`; a plain one answers 403. `router.refresh()` re-renders the current route's server data and keeps client state. `invalidateClientCache()` marks the history cache stale and drops prefetches when the change makes them wrong. |
384
- | Shared server caches (other users' data) | Tags: `cache({ tags })` or `cacheTag()`, then `updateTag()` in the action (awaitable, the action's response reads fresh) or `revalidateTag()` from a webhook. `revalidate()` is not cache invalidation (`/cache-guide`). |
422
+ | The change | The tool |
423
+ | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
424
+ | Your own mutation | A server action plus `revalidate()` rules (`isAction(ref)`, `actionResult`). The server re-runs what the action touched in the same response; nothing else to call. |
425
+ | Out of band: a push, polling, a REST call, a login | `useLoader(L).load()` (also `refetch`): a plain `load()` of a route-registered loader updates every reader of it. `refreshGroup` on reads plus `useRefreshLoaders()` refreshes several loaders at once. Both need a fetchable loader, `createLoader(fn, true)`; a plain one answers 403. `router.refresh()` re-renders the current route's server data and keeps client state. `invalidateClientCache()` marks the history cache stale, drops prefetches and rotates the state when the change makes them wrong. |
426
+ | Shared server caches (other users' data) | Tags: `cache({ tags })` or `cacheTag()`, then `updateTag()` in the action (awaitable, the action's response reads fresh) or `revalidateTag()` from a webhook. `revalidate()` is not cache invalidation (`/cache-guide`). |
385
427
 
386
428
  ### 2.11 Handles: titles and breadcrumbs
387
429
 
@@ -445,7 +487,7 @@ ctx.reverse(".product", { slug: "b", locale: "de" }); // switch language
445
487
  take unchecked path strings: prefer the typed `useReverse` map.
446
488
  - `useReverse` with another module's map, or `useReverse(NamedRoutes)` under
447
489
  a mount, gives a wrong URL with no error. Build cross-module links on the
448
- server and pass them as props or loader data (dc-t57).
490
+ server and pass them as props or loader data.
449
491
  - A server layout stays held across navigations, its own `:projectId`
450
492
  changing included: partial rendering by design. Links it rendered keep the
451
493
  old params. Put param-dependent nav links in a client component with
@@ -926,6 +968,16 @@ Each entry: what the user sees, why, the fix.
926
968
  15. **An effect keyed on a location-state object.** In a group the object is
927
969
  the one the navigation passed until the commit, then the history entry's
928
970
  copy: the effect runs twice. Fix: key it on a primitive field.
971
+ 16. **A page's own block in a shared layout, kept by a rule that reads the
972
+ page being left** (a category hero in a template over the product pages,
973
+ re-rendered when `currentParams.category` changes). Nothing looks wrong,
974
+ but every prefetch under that layout is shared only between the pages of
975
+ one category (or with the same params, off the category routes): the
976
+ other pages prefetch their links again and the server renders them
977
+ again. Fix: put
978
+ the block in its route, or in a layout inside its `path()`; it
979
+ re-renders on its own params with no rule (`/app-structure`, "Prefetches
980
+ are shared").
929
981
 
930
982
  ## 5. Known gaps
931
983