@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
@@ -35,6 +35,16 @@
35
35
  * not modeled). navigate(url, { streamUntil }) gives the navigation a
36
36
  * response stream that stays open past the commit (isStreaming).
37
37
  * useAction().state stays idle: no action runs.
38
+ * - prefetch(url) keeps a page as a prefetch made now carries it, and
39
+ * action() lands a server action's response: the page on screen
40
+ * re-renders with its post-write loader data, and, without
41
+ * `keepClientCache`, production's store marks every history entry and
42
+ * prefetch stale (browser/navigation-store.ts). A navigate(url) to a
43
+ * prefetched page commits the prefetch's data; when an action made it
44
+ * stale, the page then revalidates once the navigation settled, in the
45
+ * lane production's Back uses (useNavigation() idle), with the loader
46
+ * data current then. The prefetch request, its decode and the HTTP cache
47
+ * are e2e territory.
38
48
  * - A spec's transition({ when }) decides every commit with production's
39
49
  * browser decision: navigate() and useRouter().push model kind "push",
40
50
  * useRouter().replace kind "replace", refresh() kind "revalidate", and a
@@ -49,8 +59,8 @@
49
59
  * written there, and another page is on screen, its page is restored
50
60
  * from the history cache, with the handle values it had, as production's
51
61
  * restore is; otherwise the tree keeps its location. A stale entry's
52
- * revalidation, a restore that fetches, a server action's state, a
53
- * redirect's and an intercept's are e2e territory.
62
+ * revalidation on Back, a restore that fetches, a server action's
63
+ * location state, a redirect's and an intercept's are e2e territory.
54
64
  * What it DOES cover: client hooks that read NavigationProvider /
55
65
  * OutletContext — useParams, useReverse, useHref, useMount, useNavigation,
56
66
  * useRouter, usePathname, useSearchParams, Outlet/useOutlet nesting and seeded
@@ -116,6 +126,7 @@ import {
116
126
  } from "../browser/react/location-state-shared.js";
117
127
  import {
118
128
  buildHistoryState,
129
+ applyDocumentLocationState,
119
130
  clearLocationStateOnDocumentLoad,
120
131
  pushHistoryWithIdx,
121
132
  resolveNavigationState,
@@ -125,10 +136,17 @@ import type { Handle } from "../handle.js";
125
136
  import type { ThemeConfig } from "../theme/types.js";
126
137
  import { resolveThemeConfig } from "../theme/constants.js";
127
138
  import { isUnderTestRunner } from "../runtime-env.js";
128
- import { setupNavigationBridgeDelegatedPrefetch } from "../browser/navigation-bridge.js";
139
+ import {
140
+ scheduleStalePrefetchRevalidation,
141
+ setupNavigationBridgeDelegatedPrefetch,
142
+ } from "../browser/navigation-bridge.js";
129
143
  import { resetPrefetchObserverForTesting } from "../browser/prefetch/observer.js";
130
144
  import type { PrefetchStrategy } from "../router/prefetch-default.js";
131
145
  import { auditTreeCause, forgetSuspenseAudit } from "../suspense-audit.js";
146
+ import {
147
+ currentPrefetchEpoch,
148
+ isPrefetchStale,
149
+ } from "../browser/prefetch/cache.js";
132
150
 
133
151
  const TEST_ORIGIN = "http://localhost";
134
152
  let activePrefetchRegistrations = 0;
@@ -274,6 +292,25 @@ export interface RenderRouteOptions {
274
292
  locationState?: ReadonlyArray<
275
293
  readonly [LocationStateDefinition<any, any>, unknown]
276
294
  >;
295
+ /**
296
+ * Location state the document response carries, as `[def, value]` pairs:
297
+ * what a handler, middleware or no-JS form action set with
298
+ * `ctx.setLocationState()` (or a document redirect carried) for this page
299
+ * load. Merged into the entry at start-up as initBrowserApp merges the
300
+ * bootstrap payload's: after `locationState` and, with `hydrate: true`,
301
+ * after the `clearOnReload` pass, so a `clearOnReload` slot given here is
302
+ * applied. With `hydrate: true` a reader hydrates as `undefined` and gets
303
+ * the value once the root has hydrated.
304
+ *
305
+ * @example
306
+ * await renderRoute([{ path: "/", Component: FlashBanner }], {
307
+ * hydrate: true,
308
+ * documentLocationState: [[FlashMessage, { text: "Payment declined" }]],
309
+ * });
310
+ */
311
+ documentLocationState?: ReadonlyArray<
312
+ readonly [LocationStateDefinition<any, any>, unknown]
313
+ >;
277
314
  /**
278
315
  * Handles to seed by REFERENCE, for components that read handle output via
279
316
  * `useHandle(SomeHandle)` (e.g. a client `Breadcrumbs` trail). Each entry is
@@ -497,6 +534,59 @@ export interface TestRouterHandle {
497
534
  * transition({ when }) decides it with kind "revalidate" (`to` is `from`).
498
535
  */
499
536
  refresh(): Promise<void>;
537
+ /**
538
+ * Prefetch `url`, as a `<Link>` hover or viewport trigger does: the page is
539
+ * kept with the loader data its response would carry now (the render-time
540
+ * seeds, `options.loaders` merged over). A later `navigate(url)` without
541
+ * `loaders` commits that data, whatever the seeds are by then.
542
+ *
543
+ * The prefetch is stamped with production's stale epoch
544
+ * (browser/prefetch/cache.ts): an `action()` without `keepClientCache`
545
+ * makes it stale. A click on a stale prefetch commits it at once, then the
546
+ * page revalidates once the navigation settled (its `streamUntil` ended),
547
+ * in Back's stale-revalidation lane: `useNavigation().state` stays "idle"
548
+ * and `isNavigating` reads true while its request is open, `useLinkStatus`
549
+ * shows no pending state, and the commit runs through commitInTransition
550
+ * with kind "revalidate", reading the loader data current then. A newer
551
+ * navigation aborts it.
552
+ *
553
+ * @example
554
+ * router.prefetch("/cart"); // carries count 2
555
+ * await router.action({ loaders: [[BasketLoader, { count: 3 }]] });
556
+ * await router.navigate("/cart"); // count 2, at once
557
+ * await waitFor(() => expect(count()).toBe("3")); // revalidated
558
+ */
559
+ prefetch(url: string, options?: Pick<RenderRouteOptions, "loaders">): void;
560
+ /**
561
+ * Land a server action's response on the page on screen. `loaders` are the
562
+ * loader values after the action's write: they become the data every
563
+ * later response carries, and the page on screen re-renders with them in
564
+ * the action lane (commitInTransition, kind "action", unless
565
+ * transition({ when }) gates it off). Without `keepClientCache`,
566
+ * production's store then marks every history entry and every prefetch
567
+ * stale (`markCacheAsStaleAndBroadcast()`), as the action bridge does:
568
+ * nothing is flushed and the state cookie is not rotated.
569
+ * `keepClientCache: true` is the `keepClientCache()` directive: caches stay
570
+ * fresh. The action's own lifecycle (`useAction()`), its follow-up and its
571
+ * redirect are e2e territory.
572
+ */
573
+ action(
574
+ options?: Pick<RenderRouteOptions, "loaders"> & {
575
+ keepClientCache?: boolean;
576
+ },
577
+ ): Promise<void>;
578
+ /**
579
+ * The background revalidations a click on a stale prefetch started, in
580
+ * order: the page's pathname and search, and the segment ids the request
581
+ * holds, as production's bridge plans them
582
+ * (navigation-bridge.ts scheduleStalePrefetchRevalidation). A click on a
583
+ * fresh prefetch, or after a `keepClientCache` action, starts none.
584
+ *
585
+ * @example
586
+ * await router.navigate("/cart"); // a stale prefetch
587
+ * await waitFor(() => expect(router.revalidations()).toHaveLength(1));
588
+ */
589
+ revalidations(): ReadonlyArray<{ url: string; segmentIds: string[] }>;
500
590
  /** The current committed pathname. */
501
591
  pathname(): string;
502
592
  /** The current committed params. */
@@ -510,6 +600,19 @@ export interface TestRouterHandle {
510
600
  /** Result of renderRoute: RTL's render result plus the router handle. */
511
601
  export type RenderRouteResult = RenderResult & { router: TestRouterHandle };
512
602
 
603
+ /** `[def, value]` seeds as `history.state` slots, each def keyed. */
604
+ function locationStateSlots(
605
+ seeds:
606
+ | ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>
607
+ | undefined,
608
+ ): Record<string, unknown> {
609
+ const slots: Record<string, unknown> = {};
610
+ for (const [def, value] of seeds ?? []) {
611
+ slots[withLocationStateKey(def).__rsc_ls_key] = value;
612
+ }
613
+ return slots;
614
+ }
615
+
513
616
  /** Result of renderRoute with `hydrate: true`. */
514
617
  export type RenderRouteHydrateResult = RenderRouteResult & {
515
618
  /** The HTML the server pass produced, as placed in the container. */
@@ -743,14 +846,15 @@ export async function renderRoute(
743
846
  // The tree has no app version (its bridge reports none), so a seed needs
744
847
  // none either. Reset what an initBrowserApp in the same file may have left.
745
848
  setLocationStateVersion(undefined);
746
- const stateObj: Record<string, unknown> = {};
747
- for (const [def, value] of options.locationState ?? []) {
748
- stateObj[withLocationStateKey(def).__rsc_ls_key] = value;
749
- }
750
- window.history.replaceState(stateObj, "");
849
+ window.history.replaceState(locationStateSlots(options.locationState), "");
751
850
  // Hydrate mode is a document load of the seeded entry: run start-up's
752
- // step (initBrowserApp), not a copy of it.
851
+ // steps (initBrowserApp), not a copy of them.
753
852
  if (options.hydrate) clearLocationStateOnDocumentLoad();
853
+ if (options.documentLocationState) {
854
+ applyDocumentLocationState(
855
+ locationStateSlots(options.documentLocationState),
856
+ );
857
+ }
754
858
  }
755
859
 
756
860
  const resolve = (pathname: string): ResolvedMatch => {
@@ -857,6 +961,8 @@ export async function renderRoute(
857
961
  }
858
962
 
859
963
  type NavigateCall = Pick<RenderRouteOptions, "loaders" | "handles"> & {
964
+ /** The loader data the response carries: a prefetch's (prefetch()). */
965
+ seeds?: Record<string, unknown>;
860
966
  history?: Pick<NavigateOptionsInternal, "state" | "replace" | "transition">;
861
967
  kind?: TransitionWhenKind;
862
968
  transition?: boolean;
@@ -897,6 +1003,30 @@ export async function renderRoute(
897
1003
  const written = entries.get(entryIndex());
898
1004
  if (written) written.state = window.history.state;
899
1005
  });
1006
+ /**
1007
+ * The history-cache reads production's stale-prefetch guards make
1008
+ * (navigation-bridge.ts scheduleStalePrefetchRevalidation), over this
1009
+ * harness's history: the store's own history key never moves here, so the
1010
+ * key on screen is its location's, and the one cached page is the entry on
1011
+ * screen. Marking it stale writes nothing: a stale entry's revalidation on
1012
+ * Back is not modeled.
1013
+ */
1014
+ const historyView: Pick<
1015
+ NavigationStore,
1016
+ "getCachedSegments" | "getHistoryKey" | "updateCacheHandleData"
1017
+ > = {
1018
+ getHistoryKey: () => generateHistoryKey(eventController.getLocation().href),
1019
+ getCachedSegments: (key) => {
1020
+ const page = entries.get(entryOnScreen);
1021
+ if (!page || generateHistoryKey(page.url.href) !== key) return undefined;
1022
+ return {
1023
+ segments: page.segments,
1024
+ stale: false,
1025
+ handleData: page.handleData,
1026
+ };
1027
+ },
1028
+ updateCacheHandleData: () => {},
1029
+ };
900
1030
  /** The page being left keeps its pushes for a back/forward onto it. */
901
1031
  const leaveEntry = (): void => {
902
1032
  const left = entries.get(entryOnScreen);
@@ -911,9 +1041,38 @@ export async function renderRoute(
911
1041
  target: string,
912
1042
  navOptions?: NavigateCall,
913
1043
  ): Promise<void> => {
1044
+ // A click on a prefetched page commits the prefetch's data (prefetch());
1045
+ // refresh() and a navigation that seeds its own loaders fetch.
1046
+ const prefetchedPage =
1047
+ navOptions?.kind === undefined && navOptions?.loaders === undefined
1048
+ ? prefetchedPages.get(prefetchKeyOf(target))
1049
+ : undefined;
1050
+ if (prefetchedPage) {
1051
+ navOptions = { ...navOptions, seeds: prefetchedPage.loaderData };
1052
+ // A stale entry is served once (cache.ts consumePrefetch): the next
1053
+ // click on the link fetches fresh.
1054
+ if (isPrefetchStale(prefetchedPage)) {
1055
+ prefetchedPages.delete(prefetchKeyOf(target));
1056
+ }
1057
+ }
914
1058
  const handle = eventController.startNavigation(
915
1059
  new URL(target, TEST_ORIGIN).href,
916
1060
  );
1061
+ // An action made the prefetch stale: once this navigation settled (its
1062
+ // commit and its stream), the page revalidates. Production's own guards
1063
+ // and plan decide it (navigation-bridge.ts
1064
+ // scheduleStalePrefetchRevalidation), over the history this harness
1065
+ // keeps (historyView).
1066
+ const entry = eventController.getUnsettledNavigation();
1067
+ if (prefetchedPage && isPrefetchStale(prefetchedPage) && entry) {
1068
+ scheduleStalePrefetchRevalidation(
1069
+ historyView,
1070
+ eventController,
1071
+ entry,
1072
+ generateHistoryKey(new URL(target, TEST_ORIGIN).href),
1073
+ ({ segmentIds }) => void revalidateStalePrefetch(target, segmentIds),
1074
+ );
1075
+ }
917
1076
  // Runs in the caller's task, so a <Link> click's own act() takes it.
918
1077
  // Awaited: React retries a held page with this update, and an unawaited
919
1078
  // act() drops that suspended retry for good.
@@ -975,7 +1134,7 @@ export async function renderRoute(
975
1134
  const segments = buildSegments(
976
1135
  routes,
977
1136
  match.params,
978
- seedLoaders(loaderData, navOptions?.loaders),
1137
+ seedLoaders(navOptions?.seeds ?? loaderData, navOptions?.loaders),
979
1138
  mount,
980
1139
  );
981
1140
  const metadata = makeMetadata(nextUrl.pathname, segments, match.params);
@@ -1075,6 +1234,143 @@ export async function renderRoute(
1075
1234
  return navigate(current.pathname + current.search, { kind: "revalidate" });
1076
1235
  };
1077
1236
 
1237
+ // prefetch(): the pages prefetched so far, by pathname and search, each with
1238
+ // the loader data its response carried and the stale epoch it began under.
1239
+ interface PrefetchedPage {
1240
+ loaderData: Record<string, unknown>;
1241
+ epoch: number;
1242
+ }
1243
+ const prefetchedPages = new Map<string, PrefetchedPage>();
1244
+ const prefetchKeyOf = (target: string): string => {
1245
+ const parsed = new URL(target, TEST_ORIGIN);
1246
+ return parsed.pathname + parsed.search;
1247
+ };
1248
+ const prefetch = (
1249
+ target: string,
1250
+ prefetchOptions?: Pick<RenderRouteOptions, "loaders">,
1251
+ ): void => {
1252
+ prefetchedPages.set(prefetchKeyOf(target), {
1253
+ loaderData: seedLoaders(loaderData, prefetchOptions?.loaders),
1254
+ epoch: currentPrefetchEpoch(),
1255
+ });
1256
+ };
1257
+
1258
+ /**
1259
+ * Re-render the page at `url` with the loader data current now, decided by
1260
+ * transition({ when }) as `kind`. `commit()` hands it over as production's
1261
+ * action and stale-revalidation commits do (browser/partial-update.ts):
1262
+ * through commitInTransition unless gated off.
1263
+ */
1264
+ const renderPageAgain = async (
1265
+ url: URL,
1266
+ kind: "action" | "revalidate",
1267
+ renderOptions: { forceAwait?: boolean; isAction?: boolean },
1268
+ ): Promise<{ commit(): Promise<void> }> => {
1269
+ const match = resolve(url.pathname);
1270
+ const segments = buildSegments(routes, match.params, loaderData, mount);
1271
+ const metadata = makeMetadata(url.pathname, segments, match.params);
1272
+ const gatedOff = decideTransitionGatedOff(segments, () => ({
1273
+ kind,
1274
+ from: {
1275
+ url: eventController.getLocation().href,
1276
+ params: eventController.getParams(),
1277
+ routeName: leaf.name,
1278
+ state: window.history.state,
1279
+ },
1280
+ to: {
1281
+ url,
1282
+ params: match.params,
1283
+ routeName: leaf.name,
1284
+ state: window.history.state,
1285
+ },
1286
+ }));
1287
+ const root = await renderSegments(segments, {
1288
+ ...renderOptions,
1289
+ outletPending: options.outletPending,
1290
+ transitionGatedOff: gatedOff,
1291
+ });
1292
+ return {
1293
+ commit: async () => {
1294
+ store.setSegmentIds(segments.map((s) => s.id));
1295
+ const shown = entries.get(entryOnScreen);
1296
+ if (shown) shown.segments = segments;
1297
+ const emit: UpdateSubscriber = (update) => store.emitUpdate(update);
1298
+ await act(async () => {
1299
+ auditTreeCause(kind === "action" ? "action" : "stale-revalidation");
1300
+ if (gatedOff) emit({ root, metadata });
1301
+ else {
1302
+ commitInTransition(
1303
+ store.loaders,
1304
+ emit,
1305
+ segments,
1306
+ { root, metadata },
1307
+ kind,
1308
+ shouldStartViewTransition(segments, gatedOff) ? ["action"] : [],
1309
+ );
1310
+ }
1311
+ });
1312
+ },
1313
+ };
1314
+ };
1315
+
1316
+ /**
1317
+ * Back's stale-revalidation lane (navigation-bridge.ts revalidateInBackground),
1318
+ * after a click on a stale prefetch: a background navigation
1319
+ * (skipLoadingState) whose response arrives a task later, rendered with
1320
+ * the loader data current then, kind "revalidate". `segmentIds` are the
1321
+ * ids production's plan holds; the request is recorded (revalidations()).
1322
+ */
1323
+ const revalidations: Array<{ url: string; segmentIds: string[] }> = [];
1324
+ const revalidateStalePrefetch = async (
1325
+ target: string,
1326
+ segmentIds: string[],
1327
+ ): Promise<void> => {
1328
+ const nextUrl = new URL(target, TEST_ORIGIN);
1329
+ revalidations.push({
1330
+ url: nextUrl.pathname + nextUrl.search,
1331
+ segmentIds,
1332
+ });
1333
+ const handle = eventController.startNavigation(nextUrl.href, {
1334
+ skipLoadingState: true,
1335
+ });
1336
+ try {
1337
+ await act(() => eventController.flushRouteState());
1338
+ // Outside act(): a navigate() the test starts meanwhile runs its own.
1339
+ await new Promise((resolve) => setTimeout(resolve, 0));
1340
+ if (handle.signal.aborted) return;
1341
+ const page = await renderPageAgain(nextUrl, "revalidate", {
1342
+ forceAwait: true,
1343
+ });
1344
+ if (handle.signal.aborted) return;
1345
+ handle.complete(nextUrl);
1346
+ await page.commit();
1347
+ } finally {
1348
+ await act(() => {
1349
+ handle[Symbol.dispose]();
1350
+ eventController.flushRouteState();
1351
+ });
1352
+ }
1353
+ };
1354
+
1355
+ // action(): the response of a server action, landing on the page on screen.
1356
+ const action = async (
1357
+ actionOptions?: Pick<RenderRouteOptions, "loaders"> & {
1358
+ keepClientCache?: boolean;
1359
+ },
1360
+ ): Promise<void> => {
1361
+ for (const [loader, data] of actionOptions?.loaders ?? []) {
1362
+ loaderData[ensureSyntheticId(loader)] = data;
1363
+ }
1364
+ const page = await renderPageAgain(
1365
+ new URL(eventController.getLocation().href),
1366
+ "action",
1367
+ { isAction: true },
1368
+ );
1369
+ // The action bridge's landing (server-action-bridge.ts finalizeAction).
1370
+ if (!actionOptions?.keepClientCache) store.markCacheAsStaleAndBroadcast();
1371
+ await page.commit();
1372
+ };
1373
+
1078
1374
  // A back/forward onto the mounted entry or one a navigation wrote, whose
1079
1375
  // page is not the one on screen: its page is restored from the entry, as production's
1080
1376
  // history-cache restore does (navigation-bridge.ts handlePopstate). Not
@@ -1289,6 +1585,9 @@ export async function renderRoute(
1289
1585
  : undefined,
1290
1586
  }),
1291
1587
  refresh,
1588
+ prefetch,
1589
+ action,
1590
+ revalidations: () => [...revalidations],
1292
1591
  pathname: () => new URL(eventController.getLocation().href).pathname,
1293
1592
  params: () => eventController.getParams(),
1294
1593
  store,
@@ -121,7 +121,11 @@ export async function runRevalidate<TEnv = any>(
121
121
  belongsToRoute: opts.belongsToRoute ?? true,
122
122
  };
123
123
  const fns = Array.isArray(fn) ? fn : [fn];
124
- return runWithRequestContext(ctx as never, () =>
124
+ // evaluateRevalidation marks the context it decides on (_sourceHeld); a
125
+ // caller's own context leaves the run as it came in.
126
+ const own = opts.context as Record<string, unknown> | undefined;
127
+ const ownHadMark = own !== undefined && "_sourceHeld" in own;
128
+ const decision = runWithRequestContext(ctx as never, () =>
125
129
  evaluateRevalidation({
126
130
  segment,
127
131
  prevParams: currentParams,
@@ -146,4 +150,10 @@ export async function runRevalidate<TEnv = any>(
146
150
  stale: opts.stale ?? false,
147
151
  }),
148
152
  );
153
+ if (own === undefined || ownHadMark) return decision;
154
+ try {
155
+ return await decision;
156
+ } finally {
157
+ delete own._sourceHeld;
158
+ }
149
159
  }
@@ -99,9 +99,12 @@ export interface ServeShellRequestOptions<TEnv = any> {
99
99
  * (default none), advertising fragment passthrough. The replay decision is
100
100
  * `result.replayStatus`.
101
101
  *
102
- * `prefetch: true` sends it as a `<Link>` prefetch does (the
103
- * `X-Rango-Prefetch` header), so `prefetch: false` loaders and `loading()`
104
- * entries are deferred: `result.readDeferred()` lists them. `fill: true`
102
+ * `prefetch: true` sends the speculative kind a viewport or render
103
+ * `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so `prefetch: false`
104
+ * loaders and `loading()` entries are deferred: `result.readDeferred()`
105
+ * lists them. A hover's or `prefetch()`'s intent prefetch defers nothing;
106
+ * serve it with `prefetch: true` and `headers: { "X-Rango-Prefetch":
107
+ * "intent" }`, which `prefetch: true` leaves as given. `fill: true`
105
108
  * sends the request the browser follows an adopted prefetch with
106
109
  * (`_rsc_fill`): pass the ids the prefetch delivered, without the deferred
107
110
  * ones, as `segments`, and the same URL as `from`. `interceptSource` sends
@@ -269,6 +272,15 @@ export interface ServeShellRequestResult {
269
272
  * hydrates without it.
270
273
  */
271
274
  readPayloadFormState(): Promise<unknown>;
275
+ /**
276
+ * The payload's location state as the browser reads it: the slots
277
+ * (`Def.__rsc_ls_key` to value) a document's start-up merges into its
278
+ * entry (or a navigation's commit, for a `partial` request). Undefined when
279
+ * the payload carries none (or no Flight was rendered). A redirect carries
280
+ * a document request's state in a `Set-Cookie` instead: pass that cookie
281
+ * on the request of its target to read what the target's payload carries.
282
+ */
283
+ readLocationState(): Promise<Record<string, unknown> | undefined>;
272
284
  /**
273
285
  * For an `action` request, the call's answer as the browser reads it: its
274
286
  * result and the `revalidationError` its loaders' reruns report.
@@ -806,6 +818,13 @@ export async function serveShellRequest<TEnv = any>(
806
818
  }>(flight);
807
819
  return payload.formState;
808
820
  },
821
+ async readLocationState() {
822
+ if (!flight) return undefined;
823
+ const payload = await decodePayload<{
824
+ metadata?: { locationState?: Record<string, unknown> };
825
+ }>(flight);
826
+ return payload.metadata?.locationState;
827
+ },
809
828
  async readAction() {
810
829
  return flight ? decodeAction(flight) : undefined;
811
830
  },
@@ -381,9 +381,16 @@ export type HandlerContext<
381
381
  */
382
382
  setTheme?: (theme: Theme) => void;
383
383
  /**
384
- * Attach location state entries to this response.
385
- * State is delivered to the client via history.pushState and accessible
386
- * through the useLocationState() hook.
384
+ * Attach location state entries to this response, for the useLocationState()
385
+ * hook to read on the entry this response renders. A client navigation
386
+ * delivers it with its payload; a document load (a link from outside the
387
+ * app, a no-JS form post) with its bootstrap payload, once the page has
388
+ * hydrated; a redirect carries it to its target (in a one-shot cookie on a
389
+ * document request). A document carrying it is sent
390
+ * `Cache-Control: private, no-store`.
391
+ *
392
+ * Entries accumulate across definitions; for one definition the last write
393
+ * wins (an earlier entry of it is replaced, not merged).
387
394
  *
388
395
  * @example
389
396
  * ```typescript
@@ -510,6 +517,16 @@ export type InternalHandlerContext<
510
517
  * request is a fill. See router/segment-resolution/prefetch-deferral.ts.
511
518
  */
512
519
  _prefetchDeferral?: import("../router/segment-resolution/prefetch-deferral.js").PrefetchDeferral;
520
+ /**
521
+ * @internal A revalidate() predicate that read the source page
522
+ * (`currentParams`, `currentUrl`, `fromRouteName`) kept a held segment, so
523
+ * this match's diff is right only for pages with equal values of what the
524
+ * kept decisions read (#1331). Set by evaluateRevalidation
525
+ * (router/revalidation.ts) and the stored-entry keep
526
+ * (match-middleware/cache-lookup.ts); match-result.ts turns it into
527
+ * `sourceHeld`, which scopes a prefetch of the response to that group.
528
+ */
529
+ _sourceHeld?: import("../router/source-reads.js").SourceHeld;
513
530
  /**
514
531
  * @internal DSL loaders registered and not started, by `$$id`: a prefetch
515
532
  * deferred them, or a pass that skips loaders (`skipLoaders`) did not run
@@ -664,7 +681,7 @@ export type IsActionFn = (...actions: ActionRef[]) => boolean;
664
681
  export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
665
682
  /** Route params from the page being navigated away from. */
666
683
  currentParams: TParams;
667
- /** Full URL of the page being navigated away from. */
684
+ /** Full URL of the page being navigated away from, without its #fragment. */
668
685
  currentUrl: URL;
669
686
  /** Route params for the navigation target. */
670
687
  nextParams: TParams;
@@ -207,10 +207,13 @@ export type LoaderFn<
207
207
  export type LoaderOptions = {
208
208
  ssr?: boolean;
209
209
  /**
210
- * `prefetch: false` keeps this loader out of prefetches. A `<Link>`
211
- * prefetch (a partial request with `X-Rango-Prefetch`) does not execute it;
212
- * the payload marks it deferred. A document request and a navigation with
213
- * no prefetch to adopt run it as usual.
210
+ * `prefetch: false` keeps this loader out of speculative prefetches. A
211
+ * viewport or render `<Link>` prefetch (a partial request with
212
+ * `X-Rango-Prefetch: 1`) does not execute it; the payload marks it
213
+ * deferred. A hover prefetch and `useRouter().prefetch()`
214
+ * (`X-Rango-Prefetch: intent`) count as intent to click and run it, so a
215
+ * click after a hover needs no follow-up request. A document request and a
216
+ * navigation with no prefetch to adopt run it as usual.
214
217
  *
215
218
  * A deferred loader never blocks the navigation that adopts the prefetch:
216
219
  * it behaves like a loader that is still streaming. Its own `<Suspense>`
@@ -161,7 +161,6 @@ export interface ResolvedSegment {
161
161
  parallelName?: string; // For parallels: the parallel group name (used to match with revalidations)
162
162
  // Loader-specific fields
163
163
  loaderId?: string; // For loaders: the loader $$id identifier
164
- _inherited?: boolean; // For inherited loaders: dedup marker for buildMatchResult
165
164
  loaderData?: any; // For loaders: the resolved data from loader execution
166
165
  /**
167
166
  * True when this loader was awaited before first flush
@@ -299,6 +298,24 @@ export interface MatchResult {
299
298
  * source, so the RSC handler scopes its prefetch entry to it (#1007).
300
299
  */
301
300
  interceptTargeted?: true;
301
+ /**
302
+ * Set on a partial match when a revalidate() predicate read the source page
303
+ * and kept a held segment: the diff is right for the pages with equal values
304
+ * of what it read. The RSC handler scopes its prefetch entry to that group,
305
+ * or to the source page when the predicate read `currentUrl` (#1331).
306
+ */
307
+ sourceHeld?: import("../router/source-reads.js").SourceHeld;
308
+ /**
309
+ * Set on a partial match whose answer has nothing live in it: the cache
310
+ * lookup served it (a cache() record, a Prerender entry, a shell replay's
311
+ * record, not a stale cache() record or stale overlay entry), it sends a
312
+ * segment, every segment it
313
+ * sends is one that lookup decoded, and no loader ran. A stored segment
314
+ * cannot fail after the headers left, since every writer refuses a failed
315
+ * render; a live segment or a loader can. The RSC handler marks the request
316
+ * `_servedFromStorage` from it.
317
+ */
318
+ fromStorage?: true;
302
319
  /**
303
320
  * Redirect URL for trailing slash normalization.
304
321
  * When set, the RSC handler should return a 308 redirect to this URL
@@ -364,9 +364,10 @@ export type PathHelpers<TEnv> = {
364
364
  * freezes into the shell (nested promises stay live holes) — the pre-flush
365
365
  * promise applied to the prelude.
366
366
  *
367
- * Pass `{ prefetch: false }` to keep the loader out of prefetches: a
368
- * prefetch skips it and the click that adopts the prefetch fetches it in
369
- * one follow-up request (see {@link LoaderOptions}). Only on a segment the
367
+ * Pass `{ prefetch: false }` to keep the loader out of speculative
368
+ * prefetches (viewport, render): such a prefetch skips it and the click
369
+ * that adopts it fetches it in one follow-up request; a hover prefetch runs
370
+ * it (see {@link LoaderOptions}). Only on a segment the
370
371
  * client does not have yet: a loader the client already holds runs in the
371
372
  * prefetch when it revalidates.
372
373
  */
@@ -380,8 +381,9 @@ export type PathHelpers<TEnv> = {
380
381
  * Attach a loading component to the current route/layout.
381
382
  *
382
383
  * `{ ssr: false }` skips the fallback on document requests. `{ prefetch:
383
- * false }` keeps what the fallback covers out of prefetches, when this
384
- * entry's segment is new to the client: the entry's handler, its loaders,
384
+ * false }` keeps what the fallback covers out of viewport and render
385
+ * prefetches (a hover prefetch runs it), when this entry's segment is new
386
+ * to the client: the entry's handler, its loaders,
385
387
  * its orphan layouts and parallel slots and, for a layout, every deeper
386
388
  * entry. The click that adopts the prefetch shows the fallback and fetches
387
389
  * them in one follow-up request, which reads cache() and "use cache" as
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * Sits in src/vite/, not src/vite/discovery/: the loader hook URL below
9
9
  * resolves against this module's import.meta.url, and the published bundles
10
- * (dist/vite/index.js, dist/bin/rango.js) ship the hook at the same relative
11
- * path.
10
+ * (dist/vite/index.js, and the CLI's code-split chunks in dist/bin/) ship the
11
+ * hook at the same relative path.
12
12
  */
13
13
 
14
14
  import type { ViteDevServer } from "vite";