@rangojs/router 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10173
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-CZ2QHT4Q.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-QRR6HD6T.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  19. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  20. package/dist/types/browser/navigation-client.d.ts +19 -0
  21. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  22. package/dist/types/browser/navigation-store.d.ts +2 -2
  23. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  24. package/dist/types/browser/partial-update.d.ts +13 -0
  25. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  26. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  27. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  28. package/dist/types/browser/rango-state.d.ts +34 -5
  29. package/dist/types/browser/react/Link.d.ts +15 -6
  30. package/dist/types/browser/types.d.ts +25 -0
  31. package/dist/types/build/generate-route-types.d.ts +1 -1
  32. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  33. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  34. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  35. package/dist/types/cache/cache-scope.d.ts +4 -2
  36. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  37. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  38. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  39. package/dist/types/cache/segment-codec.d.ts +2 -2
  40. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  41. package/dist/types/cache/types.d.ts +12 -7
  42. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  43. package/dist/types/client-urls/types.d.ts +7 -5
  44. package/dist/types/prefetch-markers.d.ts +9 -0
  45. package/dist/types/prefetch-source-scope.d.ts +66 -0
  46. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  47. package/dist/types/router/intercept-resolution.d.ts +25 -11
  48. package/dist/types/router/match-context.d.ts +1 -0
  49. package/dist/types/router/match-handlers.d.ts +1 -1
  50. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  51. package/dist/types/router/router-context.d.ts +1 -1
  52. package/dist/types/router/router-options.d.ts +6 -3
  53. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  54. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  55. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  56. package/dist/types/router/source-reads.d.ts +53 -0
  57. package/dist/types/router/types.d.ts +1 -1
  58. package/dist/types/rsc/helpers.d.ts +5 -2
  59. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  60. package/dist/types/rsc/request-stages.d.ts +2 -0
  61. package/dist/types/rsc/shell-serve.d.ts +6 -1
  62. package/dist/types/server/context.d.ts +23 -6
  63. package/dist/types/server/cookie-store.d.ts +15 -1
  64. package/dist/types/server/document-location-state.d.ts +5 -2
  65. package/dist/types/server/handle-store.d.ts +2 -0
  66. package/dist/types/server/request-context.d.ts +43 -7
  67. package/dist/types/testing/e2e/server.d.ts +16 -1
  68. package/dist/types/testing/render-route.d.ts +66 -2
  69. package/dist/types/testing/serve-shell-request.d.ts +6 -3
  70. package/dist/types/types/handler-context.d.ts +7 -5
  71. package/dist/types/types/loader-types.d.ts +7 -4
  72. package/dist/types/types/segments.d.ts +15 -4
  73. package/dist/types/urls/path-helper-types.d.ts +7 -5
  74. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  75. package/dist/vite/index.js +449 -189
  76. package/package.json +2 -2
  77. package/skills/app-structure/SKILL.md +74 -0
  78. package/skills/app-structure/e-commerce.md +10 -9
  79. package/skills/app-structure/large-app.md +15 -2
  80. package/skills/caching/SKILL.md +10 -5
  81. package/skills/client-urls/SKILL.md +18 -11
  82. package/skills/comparison/references/framework-comparison.md +13 -8
  83. package/skills/document-cache/SKILL.md +8 -0
  84. package/skills/hooks/navigation.md +8 -8
  85. package/skills/hooks/state.md +24 -8
  86. package/skills/intercept/SKILL.md +33 -12
  87. package/skills/layout/SKILL.md +11 -0
  88. package/skills/links/SKILL.md +101 -4
  89. package/skills/loader/SKILL.md +49 -22
  90. package/skills/migrate-nextjs/SKILL.md +8 -2
  91. package/skills/migrate-react-router/data-and-actions.md +5 -0
  92. package/skills/ppr/SKILL.md +26 -5
  93. package/skills/prerender/SKILL.md +4 -1
  94. package/skills/route/SKILL.md +6 -6
  95. package/skills/router-setup/SKILL.md +2 -1
  96. package/skills/server-actions/SKILL.md +41 -8
  97. package/skills/testing/client-components.md +29 -13
  98. package/skills/testing/serve-shell-request.md +7 -7
  99. package/skills/testing/server-actions.md +8 -0
  100. package/skills/ux-architecture/SKILL.md +67 -18
  101. package/src/bin/rango.ts +39 -13
  102. package/src/browser/action-fence.ts +72 -0
  103. package/src/browser/cookie-name.ts +82 -14
  104. package/src/browser/invalidate-client-cache.ts +9 -7
  105. package/src/browser/navigation-bridge.ts +164 -32
  106. package/src/browser/navigation-client.ts +95 -20
  107. package/src/browser/navigation-store-handle.ts +67 -5
  108. package/src/browser/navigation-store.ts +86 -17
  109. package/src/browser/navigation-transaction.ts +0 -6
  110. package/src/browser/partial-update.ts +31 -12
  111. package/src/browser/prefetch/cache.ts +188 -11
  112. package/src/browser/prefetch/fetch.ts +211 -51
  113. package/src/browser/prefetch/queue.ts +3 -2
  114. package/src/browser/rango-state.ts +50 -8
  115. package/src/browser/react/Link.tsx +20 -9
  116. package/src/browser/react/use-navigation.ts +1 -1
  117. package/src/browser/rsc-router.tsx +8 -1
  118. package/src/browser/server-action-bridge.ts +3 -3
  119. package/src/browser/types.ts +25 -0
  120. package/src/build/generate-route-types.ts +1 -0
  121. package/src/build/route-types/include-resolution.ts +40 -3
  122. package/src/build/route-types/router-processing.ts +170 -25
  123. package/src/build/route-types/scan-filter.ts +1 -0
  124. package/src/cache/cache-key-utils.ts +12 -8
  125. package/src/cache/cache-runtime.ts +5 -1
  126. package/src/cache/cache-scope.ts +28 -7
  127. package/src/cache/cf/cf-cache-types.ts +9 -5
  128. package/src/cache/document-cache-policy.ts +27 -0
  129. package/src/cache/document-cache.ts +5 -0
  130. package/src/cache/memory-segment-store.ts +3 -2
  131. package/src/cache/segment-codec.ts +2 -2
  132. package/src/cache/shell-snapshot.ts +1 -1
  133. package/src/cache/types.ts +12 -7
  134. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  135. package/src/client-urls/types.ts +7 -5
  136. package/src/prefetch-markers.ts +10 -0
  137. package/src/prefetch-source-scope.ts +137 -0
  138. package/src/route-definition/helpers-types.ts +8 -5
  139. package/src/router/intercept-resolution.ts +94 -50
  140. package/src/router/loader-resolution.ts +8 -6
  141. package/src/router/match-api.ts +33 -29
  142. package/src/router/match-context.ts +5 -0
  143. package/src/router/match-handlers.ts +2 -1
  144. package/src/router/match-middleware/cache-lookup.ts +21 -13
  145. package/src/router/match-result.ts +39 -87
  146. package/src/router/navigation-snapshot.ts +18 -3
  147. package/src/router/prefetch-cache-ttl.ts +15 -3
  148. package/src/router/revalidation.ts +53 -23
  149. package/src/router/router-context.ts +2 -1
  150. package/src/router/router-options.ts +6 -3
  151. package/src/router/segment-resolution/loader-cache.ts +21 -3
  152. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  153. package/src/router/segment-resolution/revalidation.ts +40 -12
  154. package/src/router/source-reads.ts +122 -0
  155. package/src/router/types.ts +2 -1
  156. package/src/router.ts +11 -12
  157. package/src/rsc/helpers.ts +8 -3
  158. package/src/rsc/prefetch-render-error.ts +7 -2
  159. package/src/rsc/progressive-enhancement.ts +3 -2
  160. package/src/rsc/request-stages.ts +59 -0
  161. package/src/rsc/rsc-rendering.ts +49 -33
  162. package/src/rsc/server-action.ts +2 -1
  163. package/src/rsc/shell-capture.ts +4 -5
  164. package/src/rsc/shell-serve.ts +12 -1
  165. package/src/segment-system.tsx +1 -1
  166. package/src/server/context.ts +37 -6
  167. package/src/server/cookie-store.ts +22 -1
  168. package/src/server/document-location-state.ts +23 -2
  169. package/src/server/handle-store.ts +2 -0
  170. package/src/server/request-context.ts +113 -28
  171. package/src/testing/dispatch.ts +3 -0
  172. package/src/testing/e2e/fixture.ts +4 -12
  173. package/src/testing/e2e/server.ts +91 -4
  174. package/src/testing/render-route.tsx +269 -4
  175. package/src/testing/serve-shell-request.ts +6 -3
  176. package/src/types/handler-context.ts +7 -5
  177. package/src/types/loader-types.ts +7 -4
  178. package/src/types/segments.ts +15 -4
  179. package/src/urls/path-helper-types.ts +7 -5
  180. package/src/vite/temp-rsc-server.ts +2 -2
@@ -10,7 +10,11 @@ import type {
10
10
  ShownEntry,
11
11
  } from "./types.js";
12
12
  import type { EventController } from "./event-controller.js";
13
- import { clearPrefetchCache } from "./prefetch/cache.js";
13
+ import { clearPrefetchCache, markPrefetchesStale } from "./prefetch/cache.js";
14
+ import {
15
+ takeStateRotationInActionFence,
16
+ takeStateWriteInActionFence,
17
+ } from "./action-fence.js";
14
18
  import { LoaderStore } from "../loader-store.js";
15
19
  import {
16
20
  adoptRangoState,
@@ -172,8 +176,8 @@ export interface NavigationStoreConfig {
172
176
 
173
177
  /**
174
178
  * Enable cross-tab cache invalidation via BroadcastChannel (default: true)
175
- * When cache is cleared (via server actions or invalidateClientCache()),
176
- * other tabs will also clear their cache
179
+ * After a server action other tabs that share a segment mark their caches
180
+ * stale; after invalidateClientCache() they adopt its state and flush
177
181
  */
178
182
  crossTabSync?: boolean;
179
183
 
@@ -332,22 +336,57 @@ export function createNavigationStore(
332
336
  */
333
337
  function clearCacheAndBroadcast(): void {
334
338
  clearCacheInternal();
335
- broadcastInvalidation();
339
+ broadcastInvalidation(false);
336
340
  }
337
341
 
338
342
  /**
339
- * Mark cache as stale and broadcast to other tabs
343
+ * Mark cache as stale, flush the prefetch caches (rotating the state), and
344
+ * broadcast to other tabs: the client seat of `invalidateClientCache()`.
340
345
  */
341
346
  function markStaleAndBroadcast(): void {
342
347
  markCacheAsStaleInternal();
343
- broadcastInvalidation();
348
+ broadcastInvalidation(false);
349
+ }
350
+
351
+ /**
352
+ * What a server action without a directive does once its response arrived
353
+ * (server-action-bridge.ts finalizeAction). History entries and prefetches
354
+ * are marked stale, nothing is flushed or rotated, and other tabs get a
355
+ * `soft` broadcast: they mark theirs stale and refresh.
356
+ *
357
+ * When the state rotated during the action (the action called the server
358
+ * seat of `invalidateClientCache()`, whose `Set-Cookie` reached the jar),
359
+ * it does what every action did before #1329: flush the prefetch map,
360
+ * rotate the state once more and send the full broadcast. The second
361
+ * rotation is what lets other tabs adopt: one that already read the
362
+ * server's value from the shared jar would refuse an equal one and rotate
363
+ * on its own, stranding this tab's new prefetches.
364
+ */
365
+ function markStaleSoftAndBroadcast(): void {
366
+ markHistoryStale();
367
+ // The read reports an external rotation or write the jar holds to the
368
+ // fence. Either branch below marks every cache stale, so it covers a
369
+ // write seen so far.
370
+ const rangoState = getRangoState();
371
+ takeStateWriteInActionFence();
372
+ if (takeStateRotationInActionFence()) {
373
+ clearPrefetchCache();
374
+ broadcastInvalidation(false);
375
+ return;
376
+ }
377
+ markPrefetchesStale();
378
+ broadcastInvalidation(true, rangoState);
344
379
  }
345
380
 
346
381
  /**
347
- * Broadcast cache invalidation to other tabs without clearing local cache
348
- * Used after consolidation fetch where local cache has fresh data
382
+ * Broadcast cache invalidation to other tabs. `soft` marks a server action
383
+ * that rotated nothing: a receiver that cannot adopt a newer state marks its
384
+ * caches stale instead of flushing them and rotating.
349
385
  */
350
- function broadcastInvalidation(): void {
386
+ function broadcastInvalidation(
387
+ soft: boolean,
388
+ rangoState: string = getRangoState(),
389
+ ): void {
351
390
  // Only broadcast if cross-tab sync is enabled
352
391
  if (!crossTabSync) return;
353
392
 
@@ -360,8 +399,9 @@ export function createNavigationStore(
360
399
  type: "invalidate",
361
400
  path: currentPath,
362
401
  segmentIds: currentSegmentIds,
363
- rangoState: getRangoState(),
402
+ rangoState,
364
403
  stateCookieName: getRangoStateCookieName(),
404
+ ...(soft && { soft: true }),
365
405
  });
366
406
  }
367
407
  }
@@ -394,12 +434,20 @@ export function createNavigationStore(
394
434
  typeof stateCookieName === "string"
395
435
  ) {
396
436
  if (stateCookieName !== getRangoStateCookieName()) return;
397
- // The sender already rotated the same-origin cookie. Adopt that
398
- // value before clearing locally so tabs cannot ping-pong rotations
399
- // and obsolete each other's post-invalidation prefetches.
400
- const adopted = adoptRangoState(rangoState);
401
437
  markHistoryStale();
402
- clearPrefetchCache(!adopted);
438
+ if (adoptRangoState(rangoState)) {
439
+ // The sender carries a newer state (it rotated the shared
440
+ // cookie: invalidateClientCache() on either seat). Adopt it
441
+ // before flushing locally, whether or not the message is soft,
442
+ // so tabs cannot ping-pong rotations and obsolete each other's
443
+ // post-invalidation prefetches.
444
+ clearPrefetchCache(false);
445
+ } else if (event.data.soft === true) {
446
+ // A server action that rotated nothing: mark, never flush.
447
+ markPrefetchesStale();
448
+ } else {
449
+ clearPrefetchCache(true);
450
+ }
403
451
  } else {
404
452
  // Compatibility with an already-open tab running an older sender.
405
453
  markCacheAsStaleInternal();
@@ -679,6 +727,18 @@ export function createNavigationStore(
679
727
  markHistoryStale();
680
728
  },
681
729
 
730
+ /**
731
+ * Mark history entries and prefetches stale, telling no other tab: a
732
+ * cookie read saw a server action's write part change (rango-state.ts
733
+ * write observer). What a soft broadcast's receiver does, for a tab the
734
+ * broadcast does not reach: one whose page shares no segment with the
735
+ * acting page, or one with crossTabSync off.
736
+ */
737
+ markCachesStaleAfterWrite(): void {
738
+ markHistoryStale();
739
+ markPrefetchesStale();
740
+ },
741
+
682
742
  /**
683
743
  * Clear the history cache and broadcast to other tabs
684
744
  * Use this for hard invalidation when data is definitely stale
@@ -688,10 +748,19 @@ export function createNavigationStore(
688
748
  },
689
749
 
690
750
  /**
691
- * Mark cache as stale and broadcast to other tabs
692
- * Called after server actions - allows SWR pattern for popstate
751
+ * Mark history entries and prefetches stale and tell other tabs. Called
752
+ * after a server action without a directive: Back and a click on a
753
+ * prefetch made before it show the cached page and revalidate it.
693
754
  */
694
755
  markCacheAsStaleAndBroadcast(): void {
756
+ markStaleSoftAndBroadcast();
757
+ },
758
+
759
+ /**
760
+ * Mark history stale, flush the prefetch caches, rotate the state and
761
+ * tell other tabs: the client seat of invalidateClientCache().
762
+ */
763
+ invalidateCacheAndBroadcast(): void {
695
764
  markStaleAndBroadcast();
696
765
  },
697
766
 
@@ -114,7 +114,6 @@ export interface CommitResult {
114
114
  * Bound transaction with pre-configured commit options (without segmentIds/segments)
115
115
  */
116
116
  export interface BoundTransaction {
117
- readonly currentUrl: string;
118
117
  /** Bound `replace` option (history replace instead of push). */
119
118
  readonly replace?: boolean;
120
119
  /** Bound `traversal` option: a back/forward navigation. */
@@ -156,8 +155,6 @@ export function createNavigationTransaction(
156
155
  url: string,
157
156
  options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
158
157
  ): NavigationTransaction {
159
- const currentUrl = window.location.href;
160
-
161
158
  const handle = eventController.startNavigation(url, options);
162
159
 
163
160
  /**
@@ -260,9 +257,6 @@ export function createNavigationTransaction(
260
257
  opts: Omit<CommitOptions, "segmentIds" | "segments">,
261
258
  ): BoundTransaction {
262
259
  return {
263
- get currentUrl() {
264
- return currentUrl;
265
- },
266
260
  replace: opts.replace,
267
261
  traversal: opts.traversal,
268
262
  state: opts.state,
@@ -238,6 +238,19 @@ export type UpdateMode =
238
238
  refresh?: boolean;
239
239
  /** The cross-tab refresh's held boundary (see `action`). */
240
240
  boundary?: string;
241
+ /**
242
+ * A click's: told of each response this navigation fetches, true when
243
+ * it is a prefetch a server action marked stale
244
+ * (FetchPartialResult.stalePrefetch), false otherwise, so a fresh
245
+ * refetch after it (a fill's start-over, an HMR retry) clears it. Its
246
+ * presence is what lets a stale prefetch answer
247
+ * (FetchPartialOptions.acceptStalePrefetch): only a navigation that
248
+ * revalidates afterwards takes one. The click commits it as it commits
249
+ * any prefetch; the bridge revalidates the page once the navigation
250
+ * settled (navigation-bridge.ts navigate). Nothing here changes how or
251
+ * when this commit lands.
252
+ */
253
+ onStalePrefetch?: (stale: boolean) => void;
241
254
  }
242
255
  | { type: "leave-intercept"; interceptSourceUrl?: string; boundary?: never }
243
256
  | {
@@ -601,16 +614,18 @@ export function createPartialUpdater(
601
614
  segments = segmentIds ?? segmentState.currentSegmentIds;
602
615
  }
603
616
 
604
- // The page the client is on, which the server diffs the target against.
605
- // tx.currentUrl is window.location when the transaction was created: on
606
- // a back/forward the popstate event had already moved it to the target,
607
- // and a server told the target is also the source revalidates nothing on
608
- // the same route, so the page being left stayed on screen (#1030). The
609
- // store's URL is the committed entry's until tx.commit().
617
+ // The page the client is on, which the server diffs the target against:
618
+ // the committed entry (the store's URL until tx.commit()), whose segment
619
+ // ids are sent above, or the intercept's source. Never window.location:
620
+ // a popstate has already moved it to the popped entry before that
621
+ // entry's fetch commits. Sent from there, a back/forward was told the
622
+ // target is also the source and kept the page being left (#1030), and a
623
+ // click during a back/forward that missed the history cache was judged
624
+ // against the popped entry while holding the page on screen's copies, so
625
+ // a layout kept for one page showed the other's.
610
626
  const previousUrl =
611
- mode.type === "leave-intercept" || (tx.traversal && !interceptSourceUrl)
612
- ? segmentState.currentUrl || tx.currentUrl
613
- : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
627
+ (mode.type !== "leave-intercept" && interceptSourceUrl) ||
628
+ segmentState.currentUrl;
614
629
 
615
630
  if (IS_BROWSER_DEBUG) {
616
631
  debugLog(`\n[Browser] >>> NAVIGATION`);
@@ -625,8 +640,7 @@ export function createPartialUpdater(
625
640
  // transition({ when }). Every decision below runs before tx.commit()
626
641
  // moves the store, so `from` is still the location being left:
627
642
  // segmentState.currentUrl is the committed URL even on a popstate cache
628
- // miss, where tx.currentUrl and window.location already point at the
629
- // target.
643
+ // miss, where window.location already points at the target.
630
644
  const whenKind: TransitionWhenKind =
631
645
  mode.type === "action"
632
646
  ? "action"
@@ -707,6 +721,8 @@ export function createPartialUpdater(
707
721
  boundary,
708
722
  version: getVersion(),
709
723
  routerId: store.getRouterId?.(),
724
+ acceptStalePrefetch:
725
+ mode.type === "navigate" && mode.onStalePrefetch !== undefined,
710
726
  });
711
727
  const streamingToken = tx.startStreaming();
712
728
  const {
@@ -714,6 +730,9 @@ export function createPartialUpdater(
714
730
  streamComplete: rawStreamComplete,
715
731
  fullyPrefetched,
716
732
  } = fetchResult;
733
+ if (mode.type === "navigate") {
734
+ mode.onStalePrefetch?.(fetchResult.stalePrefetch === true);
735
+ }
717
736
  debugLog("payload.metadata", payload.metadata);
718
737
 
719
738
  // Side effect only: end the streaming token once the stream settles.
@@ -1093,7 +1112,7 @@ export function createPartialUpdater(
1093
1112
 
1094
1113
  // A background refresh keeps the stale page on screen when a loader it
1095
1114
  // re-ran threw (its entries settled in the build): nothing is committed,
1096
- // and navigation-bridge logs the rejection (dc-t65). Every other lane
1115
+ // and navigation-bridge logs the rejection. Every other lane
1097
1116
  // throws it at the read. Before the abort check: the await yields.
1098
1117
  if (mode.type === "stale-revalidation") {
1099
1118
  const failed = await revalidationErrorOf(serverSegments);
@@ -7,11 +7,14 @@
7
7
  * stream resolves the route's client references, so the route's JS chunks are
8
8
  * imported during prefetch rather than on click. The decoded payload is reused
9
9
  * verbatim by navigation, so a prefetched click loads no new code. Two key
10
- * scopes are in play:
10
+ * scopes are in play. Wherever a key below says `rangoState`, callers pass the
11
+ * state's `{version}:{timestamp}` part (cookie-name.ts stateEpoch), not the
12
+ * whole value: a server action's write bump leaves the keys as they were.
11
13
  * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
12
14
  * shape `rangoState\0/target?...`. Shared across all source pages and
13
15
  * source segment trees; `_rsc_segments` is omitted from this key. Invalidated
14
- * automatically when Rango state bumps (deploy or server-action invalidation).
16
+ * automatically when Rango state bumps (deploy or invalidateClientCache()).
17
+ * A server action marks entries stale instead (the stale epoch, below).
15
18
  * - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
16
19
  * — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
17
20
  * (so rotation invalidates source-scoped entries too), the source href, and
@@ -20,6 +23,14 @@
20
23
  * targets, matched or not), OR when a Link opts in with
21
24
  * `prefetchKey=":source"` — in both cases so source-sensitive responses
22
25
  * cannot bleed into navigations from other pages.
26
+ * - Params-scoped: built by `buildParamsKey(rangoState, label, target)` —
27
+ * shape `rangoState\0p:<label>\0/target?...`. Populated when the server
28
+ * tags a response with `X-RSC-Prefetch-Scope: params; <label>`: a
29
+ * revalidate() rule read the source's params or route name and kept a
30
+ * segment, so the answer is right for every page with equal values of
31
+ * what it read (prefetch-source-scope.ts). `paramsShapes` remembers, per
32
+ * target, what the stored labels read, so a lookup rebuilds the label of
33
+ * the page on screen (paramsCandidateKeys).
23
34
  *
24
35
  * Also tracks in-flight prefetch promises. Each promise resolves to the
25
36
  * decoded prefetch entry (or null), letting navigation adopt a
@@ -44,6 +55,13 @@ import { notifyPrefetchCacheInvalidated } from "./invalidation.js";
44
55
  import { invalidateRangoState } from "../rango-state.js";
45
56
  import type { RscPayload } from "../types.js";
46
57
  import { WIRE } from "../../wire-params.js";
58
+ import {
59
+ formatSourceLabel,
60
+ parseSourceShape,
61
+ sourceShapeId,
62
+ type SourcePage,
63
+ type SourceShape,
64
+ } from "../../prefetch-source-scope.js";
47
65
 
48
66
  /**
49
67
  * A prefetch that has been fetched AND eagerly decoded. Storing the decoded
@@ -64,9 +82,23 @@ export interface DecodedPrefetch {
64
82
  * Prefetch scope as tagged by the server via `X-RSC-Prefetch-Scope`.
65
83
  * `"source"` means the response is source-page-sensitive and must not be
66
84
  * reused by a navigation from a different page — navigation enforces this
67
- * when it adopted an inflight entry through the wildcard key.
85
+ * when it adopted an inflight entry through the wildcard key. `"params"`
86
+ * means it serves the pages whose label equals `paramsLabel`.
68
87
  */
69
- scope: "source" | "wildcard";
88
+ scope: PrefetchScope;
89
+ /** The label of a `"params"` entry (prefetch-source-scope.ts). */
90
+ paramsLabel?: string;
91
+ /**
92
+ * The stale epoch (`currentPrefetchEpoch()`) the prefetch's fetch started
93
+ * under. A server action without a directive marks every prefetch stale
94
+ * (`markPrefetchesStale()` bumps the epoch) instead of flushing it: an entry
95
+ * from an older epoch is still served, and the navigation that adopts it
96
+ * revalidates its page once it settled (`isPrefetchStale`). A stale entry
97
+ * is served at most once, and only to a click (`consumePrefetch`'s
98
+ * `acceptStale`). A respawn keeps the original fetch's epoch, as it keeps
99
+ * its timestamp.
100
+ */
101
+ epoch: number;
70
102
  /**
71
103
  * Synchronously-readable flag, flipped to true when `streamComplete` resolves
72
104
  * (the entire RSC stream has drained). Navigation reads this at click time to
@@ -108,6 +140,9 @@ export interface DecodedPrefetch {
108
140
  dispose?: () => void;
109
141
  }
110
142
 
143
+ /** Where a prefetch is stored: see the module comment. */
144
+ export type PrefetchScope = "source" | "params" | "wildcard";
145
+
111
146
  let cacheTTL = 300_000;
112
147
  let fragmentPassthroughEnabled = true;
113
148
 
@@ -163,8 +198,25 @@ const inflightAliases = new Map<string, string[]>();
163
198
 
164
199
  const adoptedKeys = new Set<string>();
165
200
 
201
+ /**
202
+ * What the stored params-scoped labels of a target read, by
203
+ * `buildPrefetchKey("", target)`: the shapes paramsCandidateKeys rebuilds
204
+ * labels from. Bounded like the cache; cleared with it.
205
+ */
206
+ const paramsShapes = new Map<string, Map<string, SourceShape>>();
207
+
166
208
  let generation = 0;
167
209
 
210
+ /**
211
+ * The stale epoch. `markPrefetchesStale()` bumps it; an entry whose fetch
212
+ * started under an older one is stale (`isPrefetchStale`). Separate from
213
+ * `generation`: a stale mark keeps every entry and every in-flight fetch,
214
+ * where a generation bump drops them. So a Flight response that has already
215
+ * arrived is never aborted by an action (aborting it mid-stream errors
216
+ * the stream): an in-flight prefetch finishes and lands stale.
217
+ */
218
+ let staleEpoch = 0;
219
+
168
220
  /**
169
221
  * Build a cache key by combining a scope prefix with the target URL.
170
222
  *
@@ -172,7 +224,7 @@ let generation = 0;
172
224
  * one of:
173
225
  * - Wildcard (source-agnostic): prefix is the Rango state value from
174
226
  * `getRangoState()`. Shared across all source pages. Invalidated
175
- * automatically when Rango state bumps (deploy or server-action).
227
+ * automatically when Rango state bumps (deploy or invalidateClientCache()).
176
228
  * `_rsc_segments` is omitted so sibling payloads prefetched from one page
177
229
  * remain reusable after another prefetched navigation commits. Key shape:
178
230
  * `rangoState\0/target?...`.
@@ -218,6 +270,76 @@ export function buildSourceKey(
218
270
  );
219
271
  }
220
272
 
273
+ /**
274
+ * Build a params-scoped cache key. Key shape:
275
+ * `rangoState\0p:<label>\0/target?...`, the target normalized like the
276
+ * wildcard key's: the answer serves every page with this label, whatever
277
+ * segments it holds, as a wildcard answer serves every page.
278
+ */
279
+ export function buildParamsKey(
280
+ rangoState: string,
281
+ label: string,
282
+ targetUrl: URL,
283
+ ): string {
284
+ return buildPrefetchKey(rangoState + "\0p:" + label, targetUrl);
285
+ }
286
+
287
+ /**
288
+ * Remember that a params-scoped answer for `targetUrl` is stored under a
289
+ * label of this shape. Called where the response header is read, with the
290
+ * store (fetch.ts executePrefetchFetch).
291
+ */
292
+ export function rememberParamsShape(targetUrl: URL, label: string): void {
293
+ const target = buildPrefetchKey("", targetUrl);
294
+ const shape = parseSourceShape(label);
295
+ let shapes = paramsShapes.get(target);
296
+ if (!shapes) {
297
+ if (paramsShapes.size >= maxPrefetchCacheSize) {
298
+ const oldest = paramsShapes.keys().next().value;
299
+ if (oldest !== undefined) paramsShapes.delete(oldest);
300
+ }
301
+ shapes = new Map();
302
+ paramsShapes.set(target, shapes);
303
+ }
304
+ shapes.set(sourceShapeId(shape), shape);
305
+ }
306
+
307
+ /**
308
+ * The params-scoped keys a request for `targetUrl` from `page` may adopt:
309
+ * the page's label under each shape stored for the target, the shapes that
310
+ * read the most first. Empty without a page (before boot).
311
+ */
312
+ export function paramsCandidateKeys(
313
+ rangoState: string,
314
+ targetUrl: URL,
315
+ page: SourcePage | null,
316
+ ): string[] {
317
+ if (!page) return [];
318
+ const shapes = paramsShapes.get(buildPrefetchKey("", targetUrl));
319
+ if (!shapes) return [];
320
+ return [...shapes.values()]
321
+ .sort((a, b) => weightOf(b) - weightOf(a))
322
+ .map((shape) =>
323
+ buildParamsKey(rangoState, formatSourceLabel(shape, page), targetUrl),
324
+ );
325
+ }
326
+
327
+ const weightOf = (shape: SourceShape): number =>
328
+ shape.names.length + (shape.route ? 1 : 0) + (shape.keys ? 1 : 0);
329
+
330
+ /**
331
+ * Whether `page` is in the group a params-scoped entry serves: its label
332
+ * under the entry's shape equals the entry's.
333
+ */
334
+ export function pageMatchesParamsLabel(
335
+ label: string,
336
+ page: SourcePage | null,
337
+ ): boolean {
338
+ return (
339
+ page !== null && formatSourceLabel(parseSourceShape(label), page) === label
340
+ );
341
+ }
342
+
221
343
  /**
222
344
  * Walk an inflight key plus any sibling aliases registered via
223
345
  * `setInflightPromiseWithAliases`, invoking `fn` for each.
@@ -233,13 +355,19 @@ function forEachAlias(key: string, fn: (k: string) => void): void {
233
355
 
234
356
  /**
235
357
  * Check if a prefetch is already cached, in-flight, or queued for the given key.
358
+ *
359
+ * A stale stored entry (`isPrefetchStale`) counts as absent and is dropped, so
360
+ * a new hover or viewport trigger replaces it with a fresh prefetch, and a
361
+ * click made while that one is in flight adopts it rather than the stale one.
362
+ * An in-flight fetch counts whatever its epoch: it is never aborted, and a
363
+ * duplicate request for its key would race it for the slot.
236
364
  */
237
365
  export function hasPrefetch(key: string): boolean {
238
366
  if (inflight.has(key)) return true;
239
367
  if (cacheTTL <= 0) return false;
240
368
  const entry = cache.get(key);
241
369
  if (!entry) return false;
242
- if (Date.now() - entry.timestamp > cacheTTL) {
370
+ if (Date.now() - entry.timestamp > cacheTTL || isPrefetchStale(entry.entry)) {
243
371
  entry.entry.dispose?.();
244
372
  cache.delete(key);
245
373
  return false;
@@ -258,10 +386,20 @@ export function hasPrefetch(key: string): boolean {
258
386
  * TTL bounds the age of the data, not of the latest adoption. Without
259
387
  * `respawn` the entry is deleted (legacy one-shot behavior).
260
388
  *
389
+ * A stale entry (`isPrefetchStale`) is served only to a caller that passes
390
+ * `acceptStale` (a click, which revalidates its page once it settled,
391
+ * navigation-bridge.ts): for any other caller it is a miss and stays in its
392
+ * slot. It is served at most once: its slot is not re-armed, so a second
393
+ * click on the same link fetches fresh rather than showing pre-write data
394
+ * older than what the first click's revalidation showed.
395
+ *
261
396
  * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
262
397
  * for that (returns a Promise instead of a resolved entry).
263
398
  */
264
- export function consumePrefetch(key: string): DecodedPrefetch | null {
399
+ export function consumePrefetch(
400
+ key: string,
401
+ options?: { acceptStale?: boolean },
402
+ ): DecodedPrefetch | null {
265
403
  if (cacheTTL <= 0) return null;
266
404
  const cached = cache.get(key);
267
405
  if (!cached) return null;
@@ -271,10 +409,15 @@ export function consumePrefetch(key: string): DecodedPrefetch | null {
271
409
  return null;
272
410
  }
273
411
  const entry = cached.entry;
274
- if (entry.respawn) {
412
+ const stale = isPrefetchStale(entry);
413
+ if (stale && !options?.acceptStale) return null;
414
+ if (entry.respawn && !stale) {
275
415
  cached.entry = entry.respawn();
276
416
  } else {
277
417
  cache.delete(key);
418
+ // The buffered reserve (fetch.ts) is never replayed: release it. It is a
419
+ // separate tee branch, so the payload the caller adopts keeps streaming.
420
+ if (stale) entry.dispose?.();
278
421
  }
279
422
  return entry;
280
423
  }
@@ -318,12 +461,18 @@ export function consumeInflightPrefetch(
318
461
  * Store an eagerly-decoded prefetch in the in-memory cache.
319
462
  *
320
463
  * Skips storage if the generation has changed since the fetch started
321
- * (a server action invalidated the cache mid-flight).
464
+ * (the cache was flushed mid-flight). An entry from an older stale epoch (an
465
+ * action marked prefetches stale mid-flight) is stored, and stays stale.
466
+ *
467
+ * `inflightKey` is the key the prefetch was in flight under, when the
468
+ * storage key is not one of its aliases (a params-scoped answer): a
469
+ * navigation that adopted it marked the aliases.
322
470
  */
323
471
  export function storePrefetch(
324
472
  key: string,
325
473
  entry: DecodedPrefetch,
326
474
  fetchGeneration: number,
475
+ inflightKey: string = key,
327
476
  ): void {
328
477
  if (cacheTTL <= 0) return;
329
478
  if (fetchGeneration !== generation) return;
@@ -333,8 +482,8 @@ export function storePrefetch(
333
482
  // publish it to the cache map, or a later navigation would be served the
334
483
  // exhausted entry and lose that route's handles. Clear the marker (under all
335
484
  // aliases) now that the decision is made.
336
- if (adoptedKeys.has(key)) {
337
- forEachAlias(key, (k) => adoptedKeys.delete(k));
485
+ if (adoptedKeys.has(inflightKey)) {
486
+ forEachAlias(inflightKey, (k) => adoptedKeys.delete(k));
338
487
  return;
339
488
  }
340
489
 
@@ -398,6 +547,33 @@ export function currentGeneration(): number {
398
547
  return generation;
399
548
  }
400
549
 
550
+ /**
551
+ * Capture the current stale epoch. A prefetch fetch stamps its entry with the
552
+ * value read when it started (`DecodedPrefetch.epoch`).
553
+ */
554
+ export function currentPrefetchEpoch(): number {
555
+ return staleEpoch;
556
+ }
557
+
558
+ /** Whether an action marked prefetches stale after this entry's fetch began. */
559
+ export function isPrefetchStale(
560
+ entry: Pick<DecodedPrefetch, "epoch">,
561
+ ): boolean {
562
+ return entry.epoch < staleEpoch;
563
+ }
564
+
565
+ /**
566
+ * Mark every stored and in-flight prefetch stale: what a server action
567
+ * without a directive does (navigation-store.ts markCacheAsStaleAndBroadcast).
568
+ * Only bumps the epoch. Nothing is dropped, aborted or rotated, and visible
569
+ * links are not told (no `notifyPrefetchCacheInvalidated()`), so they do not
570
+ * prefetch again. A click on a stale entry commits it at once; the navigation
571
+ * then revalidates its page (navigation-bridge.ts navigate).
572
+ */
573
+ export function markPrefetchesStale(): void {
574
+ staleEpoch++;
575
+ }
576
+
401
577
  export function markPrefetchInflight(key: string): void {
402
578
  inflight.add(key);
403
579
  }
@@ -457,6 +633,7 @@ export function clearPrefetchCache(rotateRangoState = true): void {
457
633
  inflightPromises.clear();
458
634
  inflightAliases.clear();
459
635
  adoptedKeys.clear();
636
+ paramsShapes.clear();
460
637
  for (const cached of cache.values()) cached.entry.dispose?.();
461
638
  cache.clear();
462
639
  earliestTimestamp = Infinity;