@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
@@ -43,7 +43,7 @@ import {
43
43
  generateHistoryKey,
44
44
  liveHandleDataForCache,
45
45
  } from "./navigation-store.js";
46
- import type { EventController } from "./event-controller.js";
46
+ import type { EventController, NavigationEntry } from "./event-controller.js";
47
47
  import { isInterceptOnlyCache } from "./intercept-utils.js";
48
48
  import { actionBoundaryAt, idsAboveBoundary } from "./segment-reconciler.js";
49
49
  import {
@@ -140,6 +140,82 @@ function isRestorable(
140
140
  );
141
141
  }
142
142
 
143
+ /**
144
+ * The request a stale-prefetch revalidation makes (scheduleStalePrefetch-
145
+ * Revalidation): the ids it holds, and the intercept and action error
146
+ * boundary on screen.
147
+ */
148
+ export interface StalePrefetchRevalidation {
149
+ segmentIds: string[];
150
+ intercept: { isIntercept: boolean; sourceUrl?: string };
151
+ boundary: string | undefined;
152
+ }
153
+
154
+ /**
155
+ * A click that adopted a prefetch a server action marked stale
156
+ * (prefetch/cache.ts isPrefetchStale) committed the pre-write payload, as any
157
+ * prefetched click commits. Once that navigation settled
158
+ * (NavigationEntry.settled: its commit, any `prefetch: false` fill and its
159
+ * stream, so this never supersedes the click nor cuts a response
160
+ * that arrived short), `revalidate` runs the page's request in
161
+ * Back's lane (the bridge's revalidateInBackground), with the ids on screen
162
+ * and the action error boundary it shows held, as Back onto a stale entry
163
+ * does.
164
+ *
165
+ * The entry is marked stale first, so a Back onto it revalidates when this
166
+ * one never ran or was left. Nothing runs when the click did not commit, or
167
+ * when the page moved since (a newer navigation aborts the click's entry, a
168
+ * shallow one moves the history key) or another navigation is open. An
169
+ * action in flight does not hold it back, as it does not hold Back's: its
170
+ * follow-up, when it owes one, starts a navigation that supersedes this one.
171
+ *
172
+ * Exported so renderRoute (testing/render-route.tsx) drives these guards and
173
+ * this plan rather than a copy of them.
174
+ */
175
+ export function scheduleStalePrefetchRevalidation(
176
+ store: Pick<
177
+ NavigationStore,
178
+ "getCachedSegments" | "getHistoryKey" | "updateCacheHandleData"
179
+ >,
180
+ eventController: Pick<EventController, "getUnsettledNavigation">,
181
+ entry: Pick<NavigationEntry, "settled">,
182
+ historyKey: string,
183
+ revalidate: (request: StalePrefetchRevalidation) => void,
184
+ ): void {
185
+ void entry.settled.then((committed) => {
186
+ if (!committed) return;
187
+ const cached = store.getCachedSegments(historyKey);
188
+ if (!cached) return;
189
+ store.updateCacheHandleData(historyKey, cached.handleData ?? {}, true);
190
+ if (
191
+ store.getHistoryKey() !== historyKey ||
192
+ eventController.getUnsettledNavigation() ||
193
+ !isRestorable(cached.segments)
194
+ ) {
195
+ return;
196
+ }
197
+ const historyState = window.history.state;
198
+ const isIntercept = historyState?.intercept === true;
199
+ const boundaryAt = isIntercept ? -1 : actionBoundaryAt(cached.segments);
200
+ if (boundaryAt === 0) return;
201
+ const boundary = cached.segments[boundaryAt]?.id;
202
+ debugLog("[Browser] Stale prefetch committed, background revalidating");
203
+ // A deferred Meta still pending when its handles were cached: a whole
204
+ // render re-streams them (handlePopstate, handlesPending).
205
+ const segmentIds = cached.handlesPending
206
+ ? []
207
+ : idsAboveBoundary(
208
+ cached.segments.map((s) => s.id),
209
+ boundary,
210
+ );
211
+ revalidate({
212
+ segmentIds,
213
+ intercept: { isIntercept, sourceUrl: historyState?.sourceUrl },
214
+ boundary,
215
+ });
216
+ });
217
+ }
218
+
143
219
  /**
144
220
  * Create a navigation bridge for handling client-side navigation
145
221
  *
@@ -236,6 +312,53 @@ export function createNavigationBridge(
236
312
  });
237
313
  }
238
314
 
315
+ /**
316
+ * Back's stale-revalidation lane: a background request for the page on
317
+ * screen (skipLoadingState: useNavigation() stays idle, useLinkStatus shows
318
+ * nothing) that holds `segmentIds` and an action error boundary on screen,
319
+ * re-caches the entry (cacheOnly) and commits as kind "revalidate". Used by
320
+ * a Back/Forward restore of a stale entry (handlePopstate) and after a click
321
+ * on a stale prefetch (scheduleStalePrefetchRevalidation).
322
+ */
323
+ function revalidateInBackground(
324
+ url: string,
325
+ segmentIds: string[],
326
+ intercept: { isIntercept: boolean; sourceUrl?: string },
327
+ boundary: string | undefined,
328
+ ): void {
329
+ const tx = createNavigationTransaction(store, eventController, url, {
330
+ skipLoadingState: true,
331
+ replace: true,
332
+ });
333
+
334
+ fetchPartialUpdate(
335
+ url,
336
+ segmentIds,
337
+ false,
338
+ tx.handle.signal,
339
+ tx.with({
340
+ url,
341
+ replace: true,
342
+ scroll: false,
343
+ intercept: intercept.isIntercept,
344
+ interceptSourceUrl: intercept.sourceUrl,
345
+ cacheOnly: true,
346
+ }),
347
+ {
348
+ type: "stale-revalidation",
349
+ interceptSourceUrl: intercept.sourceUrl,
350
+ boundary,
351
+ },
352
+ )
353
+ .catch((error) => {
354
+ if (isBackgroundSuppressible(error)) return;
355
+ console.error("[Browser] Background revalidation failed:", error);
356
+ })
357
+ .finally(() => {
358
+ tx[Symbol.dispose]();
359
+ });
360
+ }
361
+
239
362
  const bridge: NavigationBridge = {
240
363
  /**
241
364
  * Navigate to a URL
@@ -387,6 +510,17 @@ export function createNavigationBridge(
387
510
  store.updateCacheHandleData(sourceHistoryKey, currentHandleData);
388
511
  }
389
512
 
513
+ // Read the rango state BEFORE the target entry's stale bit, as
514
+ // handlePopstate does. Another tab's write reaches this tab only through
515
+ // a read (rango-state.ts write observer) when its broadcast does not:
516
+ // the pages share no segment, or crossTabSync is off. The first read
517
+ // otherwise comes from the navigation client, after the held ids below
518
+ // are chosen, and the server keeps what the client says it holds when
519
+ // the default allows (router/revalidation.ts: a route whose params and
520
+ // search are the entry's, a layout loader with no revalidate()), so the
521
+ // entry's pre-write copy would commit as fresh.
522
+ getRangoState();
523
+
390
524
  // Check if we have cached segments for target URL
391
525
  const historyKey = generateHistoryKey(url);
392
526
  const cached = store.getCachedSegments(historyKey);
@@ -441,6 +575,9 @@ export function createNavigationBridge(
441
575
  state: resolvedState,
442
576
  skipLoadingState: false,
443
577
  });
578
+ // This navigation's entry when its response was a prefetch an action
579
+ // marked stale (scheduleStalePrefetchRevalidation).
580
+ let stalePrefetchEntry: NavigationEntry | null = null;
444
581
  // A cross-route clientUrls() navigation presents its destination
445
582
  // optimistically at once: transition({ when }) decides THERE (the first
446
583
  // presentation), over the destination route's predicate and those of
@@ -520,8 +657,30 @@ export function createNavigationBridge(
520
657
  ...(clientUrlPresentation && {
521
658
  presentation: clientUrlPresentation,
522
659
  }),
660
+ // The last response decides: a fresh refetch after a stale
661
+ // adoption (a fill's start-over) clears it.
662
+ onStalePrefetch: (stale: boolean) => {
663
+ const entry = eventController.getUnsettledNavigation();
664
+ stalePrefetchEntry =
665
+ stale && entry?.abort === tx.handle.abort ? entry : null;
666
+ },
523
667
  },
524
668
  );
669
+ if (stalePrefetchEntry && !tx.handle.signal.aborted) {
670
+ scheduleStalePrefetchRevalidation(
671
+ store,
672
+ eventController,
673
+ stalePrefetchEntry,
674
+ store.getHistoryKey(),
675
+ ({ segmentIds, intercept, boundary }) =>
676
+ revalidateInBackground(
677
+ window.location.href,
678
+ segmentIds,
679
+ intercept,
680
+ boundary,
681
+ ),
682
+ );
683
+ }
525
684
  } catch (error) {
526
685
  // Aborted, or superseded by a newer navigation. A superseded nav may
527
686
  // reject with a non-AbortError (e.g. a Flight decode that fails after its
@@ -846,39 +1005,12 @@ export function createNavigationBridge(
846
1005
  // the deferred-Meta-aborted case, so action/cross-tab SWR keeps the
847
1006
  // cheap diff path.
848
1007
  const segmentIds = cached?.handlesPending ? [] : heldIds;
849
-
850
- const tx = createNavigationTransaction(
851
- store,
852
- eventController,
853
- url,
854
- { skipLoadingState: true, replace: true },
855
- );
856
-
857
- fetchPartialUpdate(
1008
+ revalidateInBackground(
858
1009
  url,
859
1010
  segmentIds,
860
- false,
861
- tx.handle.signal,
862
- tx.with({
863
- url,
864
- replace: true,
865
- scroll: false,
866
- intercept: isIntercept,
867
- interceptSourceUrl,
868
- cacheOnly: true,
869
- }),
870
- { type: "stale-revalidation", interceptSourceUrl, boundary },
871
- )
872
- .catch((error) => {
873
- if (isBackgroundSuppressible(error)) return;
874
- console.error(
875
- "[Browser] Background revalidation failed:",
876
- error,
877
- );
878
- })
879
- .finally(() => {
880
- tx[Symbol.dispose]();
881
- });
1011
+ { isIntercept, sourceUrl: interceptSourceUrl },
1012
+ boundary,
1013
+ );
882
1014
  }
883
1015
  };
884
1016
  // React commits a transition started while window.event is the
@@ -13,6 +13,7 @@ import {
13
13
  startBrowserTransaction,
14
14
  } from "./logging.js";
15
15
  import { getRangoState } from "./rango-state.js";
16
+ import { stateEpoch } from "./cookie-name.js";
16
17
  import { isActionFenceActive } from "./action-fence.js";
17
18
  import {
18
19
  expandPayloadFragments,
@@ -35,11 +36,54 @@ import {
35
36
  consumePrefetch,
36
37
  disableFragmentPassthrough,
37
38
  isFragmentPassthroughEnabled,
39
+ isPrefetchStale,
40
+ pageMatchesParamsLabel,
41
+ paramsCandidateKeys,
38
42
  type DecodedPrefetch,
39
43
  } from "./prefetch/cache.js";
44
+ import { getCommittedPage } from "./navigation-store-handle.js";
45
+ import type { SourcePage } from "../prefetch-source-scope.js";
40
46
  import { cancelAllPrefetches } from "./prefetch/loader.js";
41
47
  import { writePartialParams } from "./wire-protocol.js";
42
48
 
49
+ /**
50
+ * The completed prefetch a navigation adopts: the one stored under its
51
+ * source key, else one stored for a group of source pages the page is in
52
+ * (`paramsKeys`, paramsCandidateKeys), else the one every source page
53
+ * shares. None when the navigation cannot use a prefetch. A stale one only
54
+ * when `acceptStale` (FetchPartialOptions.acceptStalePrefetch).
55
+ */
56
+ export function consumeStoredPrefetch(
57
+ canUsePrefetch: boolean,
58
+ sourceKey: string,
59
+ paramsKeys: readonly string[],
60
+ wildcardKey: string,
61
+ acceptStale = false,
62
+ ): { entry: DecodedPrefetch | null; hitKey: string | null } {
63
+ if (!canUsePrefetch) return { entry: null, hitKey: null };
64
+ for (const key of [sourceKey, ...paramsKeys, wildcardKey]) {
65
+ const entry = consumePrefetch(key, { acceptStale });
66
+ if (entry) return { entry, hitKey: key };
67
+ }
68
+ return { entry: null, hitKey: null };
69
+ }
70
+
71
+ /**
72
+ * Whether a navigation from `page` may serve an in-flight prefetch it
73
+ * adopted through the wildcard key: not one the server scoped to its own
74
+ * source page, nor one scoped to a group of source pages `page` is not in.
75
+ */
76
+ export function wildcardAdoptionFits(
77
+ entry: DecodedPrefetch,
78
+ page: SourcePage | null,
79
+ ): boolean {
80
+ if (entry.scope === "source") return false;
81
+ if (entry.scope === "params") {
82
+ return pageMatchesParamsLabel(entry.paramsLabel ?? "", page);
83
+ }
84
+ return true;
85
+ }
86
+
43
87
  /**
44
88
  * Create a navigation client for fetching RSC payloads
45
89
  *
@@ -88,8 +132,14 @@ export function createNavigationClient(
88
132
  routerId,
89
133
  hmr,
90
134
  fill,
135
+ acceptStalePrefetch = false,
91
136
  } = options;
92
137
 
138
+ // The page left, absolute: the router store keeps a committed URL as
139
+ // navigate() was given it, often a path. The header and the source
140
+ // prefetch key name it as prefetch/fetch.ts does, so they agree.
141
+ const sourceHref = new URL(previousUrl, window.location.origin).href;
142
+
93
143
  const debugEnabled = isBrowserDebugEnabled();
94
144
  const tx = debugEnabled
95
145
  ? startBrowserTransaction(staleRevalidation ? "revalidate" : "navigate")
@@ -135,8 +185,13 @@ export function createNavigationClient(
135
185
  // intercept targets, or when a Link opted in with
136
186
  // `prefetchKey=":source"`), then fall back to the wildcard slot shared
137
187
  // across source pages.
138
- // Both keys embed the Rango state, so state rotation (deploy or
139
- // server-action invalidation) auto-invalidates both scopes.
188
+ // Both keys embed the Rango state's epoch (`{version}:{timestamp}`,
189
+ // cookie-name.ts stateEpoch), so state rotation (deploy or
190
+ // invalidateClientCache()) auto-invalidates both scopes. A server
191
+ // action bumps only the write part, which the keys leave out: it marks
192
+ // entries stale instead, and one is still served to a click
193
+ // (acceptStalePrefetch), whose bridge revalidates its page after it
194
+ // settled (stalePrefetch); for any other caller it is a miss.
140
195
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
141
196
  // fresh modules), and intercept contexts (source-dependent responses).
142
197
  // A prefetch served during an action's flight holds pre-write data:
@@ -144,20 +199,22 @@ export function createNavigationClient(
144
199
  const canUsePrefetch =
145
200
  !staleRevalidation && !hmr && !fill && !interceptSourceUrl;
146
201
  const rangoState = getRangoState();
147
- const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
148
- const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
202
+ const stateKey = stateEpoch(rangoState);
203
+ const wildcardKey = buildPrefetchKey(stateKey, fetchUrl);
204
+ const cacheKey = buildSourceKey(stateKey, sourceHref, fetchUrl);
205
+ // The page being left, as the router store committed it: the group a
206
+ // params-scoped prefetch must have been answered for.
207
+ const sourcePage = canUsePrefetch ? getCommittedPage() : null;
149
208
 
150
- let cachedEntry: DecodedPrefetch | null = null;
151
- let hitKey: string | null = null;
152
- if (canUsePrefetch) {
153
- cachedEntry = consumePrefetch(cacheKey);
154
- if (cachedEntry) {
155
- hitKey = cacheKey;
156
- } else {
157
- cachedEntry = consumePrefetch(wildcardKey);
158
- if (cachedEntry) hitKey = wildcardKey;
159
- }
160
- }
209
+ const stored = consumeStoredPrefetch(
210
+ canUsePrefetch,
211
+ cacheKey,
212
+ paramsCandidateKeys(stateKey, fetchUrl, sourcePage),
213
+ wildcardKey,
214
+ acceptStalePrefetch,
215
+ );
216
+ const cachedEntry = stored.entry;
217
+ let hitKey = stored.hitKey;
161
218
 
162
219
  let inflightEntryPromise: Promise<DecodedPrefetch | null> | null = null;
163
220
  if (canUsePrefetch && !cachedEntry) {
@@ -262,7 +319,7 @@ export function createNavigationClient(
262
319
  cache: "no-store" as RequestCache,
263
320
  }),
264
321
  headers: {
265
- "X-RSC-Router-Client-Path": previousUrl,
322
+ "X-RSC-Router-Client-Path": sourceHref,
266
323
  // Reuse the single per-operation read (see rangoState above): the
267
324
  // cache-key lookup and this header must agree on one value, and the
268
325
  // cookie read has side effects (external-rotation notify) we do not
@@ -312,6 +369,9 @@ export function createNavigationClient(
312
369
  // (complete === true). A still-streaming hit and the fresh path stay false,
313
370
  // so only a fully-warmed prefetch commits in a transition (no fallback flash).
314
371
  let fullyPrefetched = false;
372
+ // A prefetch an action marked stale after it was fetched: committed
373
+ // like any other, then revalidated (FetchPartialResult.stalePrefetch).
374
+ let stalePrefetch = false;
315
375
 
316
376
  // The prefetch entry this navigation serves.
317
377
  let adopted = cachedEntry;
@@ -335,15 +395,27 @@ export function createNavigationClient(
335
395
  if (tx) {
336
396
  browserDebugLog(tx, "inflight prefetch unavailable, refetching");
337
397
  }
338
- } else if (adoptedViaWildcard && entry.scope === "source") {
339
- // A wildcard-adopted inflight that turned out source-scoped was
340
- // built for a different source page. Discard and refetch.
398
+ } else if (
399
+ adoptedViaWildcard &&
400
+ !wildcardAdoptionFits(entry, sourcePage)
401
+ ) {
402
+ // A wildcard-adopted inflight that turned out source-scoped, or
403
+ // params-scoped to a group this page is not in, was built for a
404
+ // different source page. Discard and refetch.
341
405
  if (tx) {
342
406
  browserDebugLog(
343
407
  tx,
344
- "wildcard inflight turned out source-scoped, refetching",
408
+ `wildcard inflight turned out ${entry.scope}-scoped, refetching`,
345
409
  );
346
410
  }
411
+ } else if (!acceptStalePrefetch && isPrefetchStale(entry)) {
412
+ // An action marked it stale, and this navigation never revalidates
413
+ // (not a click): drop it and fetch fresh. Its reserve is released;
414
+ // the decode it started is never read.
415
+ entry.dispose?.();
416
+ if (tx) {
417
+ browserDebugLog(tx, "inflight prefetch is stale, refetching");
418
+ }
347
419
  } else {
348
420
  adopted = entry;
349
421
  }
@@ -357,6 +429,7 @@ export function createNavigationClient(
357
429
  // prefetched"; a still-streaming one keeps streaming its fallbacks like
358
430
  // a cold load.
359
431
  fullyPrefetched = adopted.complete;
432
+ stalePrefetch = isPrefetchStale(adopted);
360
433
  } else {
361
434
  ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
362
435
  freshResult());
@@ -393,6 +466,7 @@ export function createNavigationClient(
393
466
  const recovery = freshResult(false, true);
394
467
  streamCompletePromise = recovery.streamComplete;
395
468
  fullyPrefetched = false;
469
+ stalePrefetch = false;
396
470
  payload = await recovery.payload;
397
471
  await expandPayloadFragments(payload, deps.createFromReadableStream);
398
472
  }
@@ -414,6 +488,7 @@ export function createNavigationClient(
414
488
  payload,
415
489
  streamComplete: streamCompletePromise,
416
490
  fullyPrefetched,
491
+ stalePrefetch,
417
492
  };
418
493
  } catch (error) {
419
494
  // Convert network-level errors to NetworkError for proper handling
@@ -7,13 +7,23 @@
7
7
  * receive it by argument: the jar-divergence observer (below) and the client
8
8
  * seat of `invalidateClientCache()` (added later).
9
9
  *
10
- * Dependency-light on purpose: it imports only `setRangoStateObserver` and the
11
- * store type, so pulling it into the default root entry does not drag the
12
- * navigation store into bundles that previously lacked it.
10
+ * Dependency-light on purpose: it imports only the action fence, the
11
+ * rango-state observers and the store type, so pulling it into the default
12
+ * root entry does not drag the navigation store into bundles that previously
13
+ * lacked it.
13
14
  */
14
15
 
15
- import { setRangoStateObserver } from "./rango-state.js";
16
+ import {
17
+ noteStateRotation,
18
+ noteStateWrite,
19
+ setUnlandedWriteHandler,
20
+ } from "./action-fence.js";
21
+ import {
22
+ setRangoStateObserver,
23
+ setRangoStateWriteObserver,
24
+ } from "./rango-state.js";
16
25
  import type { NavigationStore } from "./types.js";
26
+ import type { SourcePage } from "../prefetch-source-scope.js";
17
27
 
18
28
  let registeredStore: NavigationStore | null = null;
19
29
 
@@ -23,16 +33,68 @@ let registeredStore: NavigationStore | null = null;
23
33
  * sibling tab, a server `Set-Cookie`, or a cookie clear), mark this tab's
24
34
  * history cache stale. The history cache is not state-keyed, so the value
25
35
  * rotation alone does not reach it. No broadcast, no prefetch clear, no
26
- * re-rotation — the value already changed externally.
36
+ * re-rotation — the value already changed externally. A rotation seen while
37
+ * an action is in flight is recorded on the fence: the action's landing then
38
+ * sends the full broadcast (navigation-store.ts markCacheAsStaleAndBroadcast).
39
+ *
40
+ * A read that sees only the write part change (a server action's response,
41
+ * in this tab or another) marks this tab's history entries and prefetches
42
+ * stale, as a soft broadcast's receiver does: a tab the broadcast does not
43
+ * reach would otherwise serve a prefetch made before the write as fresh.
44
+ * While an action is in flight the write is recorded on the fence instead:
45
+ * the action's landing marks every cache stale once (navigation-store.ts
46
+ * markStaleSoftAndBroadcast), and when no landing does, the fence's drop
47
+ * marks them.
27
48
  */
28
49
  export function registerNavigationStore(store: NavigationStore): void {
29
50
  registeredStore = store;
30
51
  setRangoStateObserver(() => {
52
+ noteStateRotation();
31
53
  registeredStore?.markHistoryCacheStale();
32
54
  });
55
+ const markCachesStale = () => registeredStore?.markCachesStaleAfterWrite();
56
+ setRangoStateWriteObserver(() => {
57
+ if (!noteStateWrite()) markCachesStale();
58
+ });
59
+ setUnlandedWriteHandler(markCachesStale);
33
60
  }
34
61
 
35
62
  /** The active navigation store, or null before boot has registered it. */
36
63
  export function getRegisteredStore(): NavigationStore | null {
37
64
  return registeredStore;
38
65
  }
66
+
67
+ let readCommittedPage: (() => SourcePage) | null = null;
68
+
69
+ /**
70
+ * Register the reader of the committed page's params and route name: the
71
+ * router store's own state, which useParams reads (the event controller's
72
+ * params, the displayed history entry's route name). Read by the prefetch
73
+ * cache outside render, at request time, to look up an answer shared by the
74
+ * pages with equal values of what a revalidate() rule read
75
+ * (prefetch-source-scope.ts). A reader, not a copy: there is no second
76
+ * source of truth for the params.
77
+ */
78
+ export function registerCommittedPageReader(read: () => SourcePage): void {
79
+ readCommittedPage = read;
80
+ }
81
+
82
+ /**
83
+ * The boot reader (rsc-router.tsx): the event controller's params, which
84
+ * useParams reads and only a commit sets (an optimistic clientUrls() frame
85
+ * sets React context, not them), and the displayed entry's route name.
86
+ */
87
+ export function committedPageOf(
88
+ controller: { getParams(): Record<string, string> },
89
+ store: Pick<NavigationStore, "getHistoryEntryMemory">,
90
+ ): () => SourcePage {
91
+ return () => ({
92
+ params: controller.getParams(),
93
+ routeName: store.getHistoryEntryMemory()?.routeName,
94
+ });
95
+ }
96
+
97
+ /** The committed page, or null before boot has registered its reader. */
98
+ export function getCommittedPage(): SourcePage | null {
99
+ return readCommittedPage ? readCommittedPage() : null;
100
+ }