@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
@@ -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
+ }
@@ -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);