@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
@@ -16,28 +16,39 @@
16
16
  */
17
17
 
18
18
  import {
19
+ buildParamsKey,
19
20
  buildPrefetchKey,
20
21
  buildSourceKey,
21
22
  hasPrefetch,
23
+ paramsCandidateKeys,
24
+ rememberParamsShape,
22
25
  markPrefetchInflight,
23
26
  setInflightPromiseWithAliases,
24
27
  storePrefetch,
25
28
  removePrefetch,
26
29
  clearPrefetchInflight,
27
30
  currentGeneration,
31
+ currentPrefetchEpoch,
28
32
  isFragmentPassthroughEnabled,
33
+ isPrefetchStale,
29
34
  type DecodedPrefetch,
35
+ type PrefetchScope,
30
36
  } from "./cache.js";
37
+ import { paramsScopeLabel } from "../../prefetch-source-scope.js";
31
38
  import { getRangoState } from "../rango-state.js";
39
+ import { stateEpoch } from "../cookie-name.js";
32
40
  import { isActionFenceActive } from "../action-fence.js";
33
41
  import { enqueuePrefetch } from "./queue.js";
34
42
  import { shouldPrefetch } from "./policy.js";
35
43
  import { debugLog, IS_BROWSER_DEBUG } from "../logging.js";
36
44
  import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
37
45
  import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../../segment-fragments.js";
38
- import { PREFETCH_HEADER } from "../../prefetch-markers.js";
46
+ import { PREFETCH_HEADER, PREFETCH_INTENT } from "../../prefetch-markers.js";
39
47
  import type { RscPayload } from "../types.js";
40
- import { getRegisteredStore } from "../navigation-store-handle.js";
48
+ import {
49
+ getCommittedPage,
50
+ getRegisteredStore,
51
+ } from "../navigation-store-handle.js";
41
52
  import { writePartialParams } from "../wire-protocol.js";
42
53
 
43
54
  /**
@@ -87,7 +98,8 @@ export function setPrefetchDecoder(fn: PrefetchDecoder): void {
87
98
  function makeRespawn(
88
99
  storageKey: string,
89
100
  reserve: ReadableStream<Uint8Array>,
90
- scope: "source" | "wildcard",
101
+ slot: PrefetchSlot,
102
+ epoch: number,
91
103
  ): () => DecodedPrefetch {
92
104
  return () => {
93
105
  const [body, nextReserve] = reserve.tee();
@@ -98,7 +110,9 @@ function makeRespawn(
98
110
  const entry: DecodedPrefetch = {
99
111
  payload,
100
112
  streamComplete: Promise.resolve(),
101
- scope,
113
+ scope: slot.scope,
114
+ paramsLabel: slot.paramsLabel,
115
+ epoch,
102
116
  complete: true,
103
117
  dispose: () => {
104
118
  // Rejects (caught) instead of throwing if the branch is already
@@ -107,7 +121,7 @@ function makeRespawn(
107
121
  nextReserve.cancel().catch(() => {});
108
122
  },
109
123
  };
110
- entry.respawn = makeRespawn(storageKey, nextReserve, scope);
124
+ entry.respawn = makeRespawn(storageKey, nextReserve, slot, epoch);
111
125
  // Parity with the original path: a rejected decode must not stay
112
126
  // consumable. Clean bytes should never fail to decode, but if they do,
113
127
  // evict (identity-guarded) so navigation refetches instead.
@@ -117,17 +131,36 @@ function makeRespawn(
117
131
  }
118
132
 
119
133
  /**
120
- * Check if a URL resolves to the current page (same pathname + search).
134
+ * The page a prefetch is sent from: the URL the router store committed, the
135
+ * page whose segment ids every caller sends. Not window.location: a popstate
136
+ * moves it to the popped entry before that entry's fetch commits, and a
137
+ * prefetch sent from there with the page on screen's ids was judged against
138
+ * the wrong page, so a layout kept for one page showed the other's copy.
139
+ * Absolute, as navigation-client.ts makes the URL navigation sends
140
+ * (partial-update.ts previousUrl), so the source keys agree.
141
+ */
142
+ function committedHref(): string {
143
+ const committed = getRegisteredStore()?.getSegmentState().currentUrl;
144
+ return committed
145
+ ? new URL(committed, window.location.origin).href
146
+ : window.location.href;
147
+ }
148
+
149
+ function withoutHash(href: string): string {
150
+ const hash = href.indexOf("#");
151
+ return hash === -1 ? href : href.slice(0, hash);
152
+ }
153
+
154
+ /**
155
+ * Check if a URL resolves to the committed page (same pathname + search).
121
156
  * Used to prevent same-page prefetching, which produces a trivial diff
122
157
  * that would corrupt the (default wildcard) prefetch cache entry.
123
158
  */
124
- function isSamePage(url: string): boolean {
159
+ function isSamePage(url: string, sourceHref: string): boolean {
125
160
  try {
126
161
  const target = new URL(url, window.location.origin);
127
- return (
128
- target.pathname + target.search ===
129
- window.location.pathname + window.location.search
130
- );
162
+ const source = new URL(sourceHref, window.location.origin);
163
+ return target.pathname + target.search === source.pathname + source.search;
131
164
  } catch {
132
165
  return false;
133
166
  }
@@ -165,6 +198,41 @@ function buildPrefetchUrl(
165
198
  });
166
199
  }
167
200
 
201
+ /** Where a completed prefetch is stored, and the label of a params scope. */
202
+ export interface PrefetchSlot {
203
+ scope: PrefetchScope;
204
+ key: string;
205
+ paramsLabel?: string;
206
+ }
207
+
208
+ /**
209
+ * Where a completed prefetch is stored: under the source key when the Link
210
+ * forced it or the response says `X-RSC-Prefetch-Scope: source`; under the
211
+ * label's key when it says `params; <label>`, for the pages with equal values
212
+ * of what a revalidate() rule read; else under the key every source page
213
+ * shares (#1331). `stateKey` is the state's epoch the other keys were built
214
+ * with (cookie-name.ts stateEpoch).
215
+ */
216
+ export function prefetchStorageSlot(
217
+ forceSourceScope: boolean,
218
+ scopeHeader: string | null,
219
+ sourceKey: string,
220
+ wildcardKey: string,
221
+ stateKey: string,
222
+ targetUrl: URL,
223
+ ): PrefetchSlot {
224
+ if (forceSourceScope || scopeHeader === "source") {
225
+ return { scope: "source", key: sourceKey };
226
+ }
227
+ const label = paramsScopeLabel(scopeHeader);
228
+ if (label === undefined) return { scope: "wildcard", key: wildcardKey };
229
+ return {
230
+ scope: "params",
231
+ key: buildParamsKey(stateKey, label, targetUrl),
232
+ paramsLabel: label,
233
+ };
234
+ }
235
+
168
236
  /**
169
237
  * Core prefetch fetch logic. Fetches the response, eagerly decodes it, and
170
238
  * stores the decoded payload in the in-memory cache. The returned Promise
@@ -191,21 +259,38 @@ function buildPrefetchUrl(
191
259
  * `sourceKey` so same-source navigations adopt directly via their own
192
260
  * source key. Storage key is chosen at response time from the
193
261
  * `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (a route an
194
- * intercept targets), anything else → `wildcardKey`. The entry records its scope
195
- * so cross-source navigations that adopted via `wildcardKey` can bail out
196
- * in `navigation-client.ts` when the adopted entry turns out source-scoped.
262
+ * intercept targets), `"params; <label>"` → the label's key, anything else
263
+ * → `wildcardKey`. The entry records its scope (and label) so cross-source
264
+ * navigations that adopted via `wildcardKey` can bail out in
265
+ * `navigation-client.ts` when the adopted entry turns out source-scoped,
266
+ * or params-scoped to a group their page is not in.
197
267
  */
198
268
  function executePrefetchFetch(
199
269
  wildcardKey: string,
200
270
  sourceKey: string,
201
- fetchUrl: string,
271
+ /** The committed page sent as the source, the one `sourceKey` names. */
272
+ sourceHref: string,
273
+ targetUrl: URL,
202
274
  forceSourceScope: boolean,
203
- /** Rango state captured once by the caller (keys + header share one read). */
275
+ /**
276
+ * Rango state captured once by the caller (keys + header share one read).
277
+ * The header sends it whole; the keys hold its epoch.
278
+ */
204
279
  rangoState: string,
280
+ /**
281
+ * An intent prefetch (prefetchDirect: hover, `prefetch()`): sent as
282
+ * PREFETCH_INTENT so the server defers nothing, as in the navigation it
283
+ * starts early. A speculative one (prefetchQueued) sends `1` and lets the
284
+ * server defer `prefetch: false` work (docs/design/prefetch-false.md, R16).
285
+ */
286
+ intent: boolean,
205
287
  expectedRouterId?: string,
206
288
  signal?: AbortSignal,
207
289
  ): Promise<DecodedPrefetch | null> {
208
290
  const gen = currentGeneration();
291
+ // An action that lands while this fetch is out marks it stale without
292
+ // aborting it: it finishes and is stored stale (cache.ts isPrefetchStale).
293
+ const epoch = currentPrefetchEpoch();
209
294
  const inflightKeys = forceSourceScope
210
295
  ? [sourceKey]
211
296
  : [wildcardKey, sourceKey];
@@ -253,7 +338,7 @@ function executePrefetchFetch(
253
338
  });
254
339
  }
255
340
 
256
- const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
341
+ const promise: Promise<DecodedPrefetch | null> = fetch(targetUrl.toString(), {
257
342
  priority: "low" as RequestPriority,
258
343
  // During an action's flight the state is not rotated, so the old
259
344
  // X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass it so
@@ -264,8 +349,8 @@ function executePrefetchFetch(
264
349
  signal: effectiveSignal,
265
350
  headers: {
266
351
  "X-Rango-State": rangoState,
267
- "X-RSC-Router-Client-Path": window.location.href,
268
- [PREFETCH_HEADER]: "1",
352
+ "X-RSC-Router-Client-Path": sourceHref,
353
+ [PREFETCH_HEADER]: intent ? PREFETCH_INTENT : "1",
269
354
  ...(isFragmentPassthroughEnabled() && {
270
355
  [SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
271
356
  }),
@@ -288,12 +373,18 @@ function executePrefetchFetch(
288
373
  return null;
289
374
  }
290
375
 
291
- const scope: "source" | "wildcard" =
292
- forceSourceScope ||
293
- response.headers.get("x-rsc-prefetch-scope") === "source"
294
- ? "source"
295
- : "wildcard";
296
- const storageKey = scope === "source" ? sourceKey : wildcardKey;
376
+ // The one place the storage key is decided: from the header, with the
377
+ // values the server read. A key computed when the fetch was scheduled
378
+ // and written again later would undo it (vercel/next.js#91488).
379
+ const slot = prefetchStorageSlot(
380
+ forceSourceScope,
381
+ response.headers.get("x-rsc-prefetch-scope"),
382
+ sourceKey,
383
+ wildcardKey,
384
+ stateEpoch(rangoState),
385
+ targetUrl,
386
+ );
387
+ const storageKey = slot.key;
297
388
 
298
389
  // Track stream completion off a tee so navigation's scroll/revalidation
299
390
  // gating matches the fresh-fetch path; decode the other branch. The
@@ -340,7 +431,9 @@ function executePrefetchFetch(
340
431
  const entry: DecodedPrefetch = {
341
432
  payload,
342
433
  streamComplete,
343
- scope,
434
+ scope: slot.scope,
435
+ paramsLabel: slot.paramsLabel,
436
+ epoch,
344
437
  complete: false,
345
438
  stallTimer: timeoutId,
346
439
  ...(reserve && {
@@ -349,7 +442,10 @@ function executePrefetchFetch(
349
442
  },
350
443
  }),
351
444
  };
352
- storePrefetch(storageKey, entry, gen);
445
+ storePrefetch(storageKey, entry, gen, inflightKeys[0]);
446
+ if (slot.paramsLabel !== undefined) {
447
+ rememberParamsShape(targetUrl, slot.paramsLabel);
448
+ }
353
449
  // The stall timeout now owns the body stream: arm eviction (publishedKey)
354
450
  // and clear the timer once the stream completes. The tee's finally resolves
355
451
  // streamComplete on normal completion AND on abort, so a healthy body pays
@@ -375,7 +471,7 @@ function executePrefetchFetch(
375
471
  if (decode.status === "fulfilled" && endedCleanly) {
376
472
  entry.complete = true;
377
473
  if (reserve) {
378
- entry.respawn = makeRespawn(storageKey, reserve, scope);
474
+ entry.respawn = makeRespawn(storageKey, reserve, slot, epoch);
379
475
  // Refill after an early adoption. A click that lands before this
380
476
  // point adopts the entry with respawn unarmed and leaves the slot
381
477
  // empty — via either path: pre-headers inflight adoption makes
@@ -386,7 +482,11 @@ function executePrefetchFetch(
386
482
  // live again (our own un-adopted entry, a fresh entry, or a new
387
483
  // in-flight prefetch), and storePrefetch's generation check drops
388
484
  // the sibling if the cache was invalidated since the fetch began.
389
- if (!hasPrefetch(storageKey)) {
485
+ // A stale entry is served at most once (cache.ts consumePrefetch),
486
+ // so an adopted stale one is not refilled: the next click fetches
487
+ // fresh. The isPrefetchStale check also keeps hasPrefetch from
488
+ // dropping (and disposing) our own un-adopted entry once stale.
489
+ if (!isPrefetchStale(entry) && !hasPrefetch(storageKey)) {
390
490
  storePrefetch(storageKey, entry.respawn(), gen);
391
491
  }
392
492
  }
@@ -409,7 +509,8 @@ function executePrefetchFetch(
409
509
  }
410
510
 
411
511
  /**
412
- * Dedup check for prefetch entry presence.
512
+ * Dedup check for prefetch entry presence: the wildcard key, the source key,
513
+ * or a params-scoped answer whose group the page on screen is in.
413
514
  *
414
515
  * Forced `:source` must NOT dedupe against a pre-existing wildcard entry —
415
516
  * otherwise the source slot would stay unpopulated and navigation from
@@ -420,15 +521,29 @@ function hasPrefetchHit(
420
521
  forceSourceScope: boolean,
421
522
  wildcardKey: string,
422
523
  sourceKey: string,
524
+ stateKey: string,
525
+ targetUrl: URL,
423
526
  ): boolean {
424
- return forceSourceScope
425
- ? hasPrefetch(sourceKey)
426
- : hasPrefetch(wildcardKey) || hasPrefetch(sourceKey);
527
+ if (forceSourceScope) return hasPrefetch(sourceKey);
528
+ return (
529
+ hasPrefetch(wildcardKey) ||
530
+ hasPrefetch(sourceKey) ||
531
+ paramsCandidateKeys(stateKey, targetUrl, getCommittedPage()).some(
532
+ hasPrefetch,
533
+ )
534
+ );
427
535
  }
428
536
 
429
537
  /**
430
538
  * Prefetch (direct): fetch with low priority and store in in-memory cache.
431
- * Used by hover strategy -- fires immediately without queueing.
539
+ * Used by hover (on hover and viewport links, and delegated anchors) and
540
+ * useRouter().prefetch() -- fires immediately without queueing.
541
+ *
542
+ * An intent prefetch: the visitor is about to click, so it asks for the full
543
+ * response the navigation would get, `prefetch: false` loaders and flagged
544
+ * `loading()` work included. It runs only on a miss: a cached prefetch or one
545
+ * in flight, even a speculative one that deferred work, is used as it is,
546
+ * and its click sends the fill as before (docs/design/prefetch-false.md, R16).
432
547
  *
433
548
  * By default the wildcard key (Rango-state-keyed) is used for inflight
434
549
  * dedup and for responses that are not source-sensitive; source-scoped
@@ -450,18 +565,27 @@ export function prefetchDirect(
450
565
  const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
451
566
  if (!targetUrl) return;
452
567
  const forceSourceScope = prefetchKey === ":source";
568
+ const sourceHref = committedHref();
453
569
  // Skip same-page prefetch — a same-page diff is trivial and would corrupt
454
570
  // the wildcard cache entry used for cross-page navigation.
455
571
  // When `:source` is forced the entry is source-scoped (single-aliased to
456
572
  // itself), so it cannot poison any shared slot — allow it.
457
- if (!forceSourceScope && isSamePage(url)) {
573
+ if (!forceSourceScope && isSamePage(url, sourceHref)) {
458
574
  return;
459
575
  }
460
- const sourceHref = window.location.href;
461
576
  const rangoState = getRangoState();
462
- const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
463
- const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
464
- if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
577
+ const stateKey = stateEpoch(rangoState);
578
+ const wildcardKey = buildPrefetchKey(stateKey, targetUrl);
579
+ const sourceKey = buildSourceKey(stateKey, sourceHref, targetUrl);
580
+ if (
581
+ hasPrefetchHit(
582
+ forceSourceScope,
583
+ wildcardKey,
584
+ sourceKey,
585
+ stateKey,
586
+ targetUrl,
587
+ )
588
+ ) {
465
589
  if (IS_BROWSER_DEBUG) {
466
590
  debugLog("[prefetch] direct dedup (key already exists)", {
467
591
  url,
@@ -484,16 +608,19 @@ export function prefetchDirect(
484
608
  executePrefetchFetch(
485
609
  wildcardKey,
486
610
  sourceKey,
487
- targetUrl.toString(),
611
+ sourceHref,
612
+ targetUrl,
488
613
  forceSourceScope,
489
614
  rangoState,
615
+ true,
490
616
  routerId,
491
617
  );
492
618
  }
493
619
 
494
620
  /**
495
621
  * Prefetch (queued): goes through the concurrency-limited queue.
496
- * Used by viewport/render strategies to avoid flooding the server.
622
+ * Used by viewport/render strategies to avoid flooding the server. A
623
+ * speculative prefetch: the server defers `prefetch: false` work in it.
497
624
  * Returns the inflight key (wildcard by default, source-scoped when
498
625
  * `prefetchKey=":source"` is passed).
499
626
  */
@@ -508,15 +635,24 @@ export function prefetchQueued(
508
635
  const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
509
636
  if (!targetUrl) return "";
510
637
  const forceSourceScope = prefetchKey === ":source";
511
- if (!forceSourceScope && isSamePage(url)) {
638
+ const sourceHref = committedHref();
639
+ if (!forceSourceScope && isSamePage(url, sourceHref)) {
512
640
  return "";
513
641
  }
514
- const sourceHref = window.location.href;
515
642
  const rangoState = getRangoState();
516
- const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
517
- const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
643
+ const stateKey = stateEpoch(rangoState);
644
+ const wildcardKey = buildPrefetchKey(stateKey, targetUrl);
645
+ const sourceKey = buildSourceKey(stateKey, sourceHref, targetUrl);
518
646
  const queueKey = forceSourceScope ? sourceKey : wildcardKey;
519
- if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
647
+ if (
648
+ hasPrefetchHit(
649
+ forceSourceScope,
650
+ wildcardKey,
651
+ sourceKey,
652
+ stateKey,
653
+ targetUrl,
654
+ )
655
+ ) {
520
656
  if (IS_BROWSER_DEBUG) {
521
657
  debugLog("[prefetch] queued dedup (key already exists)", {
522
658
  url,
@@ -527,22 +663,46 @@ export function prefetchQueued(
527
663
  }
528
664
  return queueKey;
529
665
  }
530
- const fetchUrlStr = targetUrl.toString();
531
666
  enqueuePrefetch(queueKey, (signal) => {
667
+ // The header sends the state as it is now: a server action that landed
668
+ // while this sat in the queue bumped the write part, and the value read
669
+ // when it was queued would be answered by the browser HTTP cache with
670
+ // pre-write bytes. A rotation since then made the keys obsolete. Read
671
+ // before the re-check below: a read that sees another tab's write marks
672
+ // the entries from before it stale, so they no longer count as a hit.
673
+ const liveState = getRangoState();
674
+ if (stateEpoch(liveState) !== stateKey) return Promise.resolve();
532
675
  // Re-check at execution time: a hover-triggered prefetchDirect may
533
676
  // have started or completed this key while the item sat in the queue.
534
- if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
677
+ if (
678
+ hasPrefetchHit(
679
+ forceSourceScope,
680
+ wildcardKey,
681
+ sourceKey,
682
+ stateKey,
683
+ targetUrl,
684
+ )
685
+ ) {
535
686
  return Promise.resolve();
536
687
  }
537
- if (!forceSourceScope && isSamePage(url)) {
688
+ // The segment ids and the source were read when it was queued: once the
689
+ // committed page has moved, they name a page no longer on screen.
690
+ // A hash-only change commits the same page, so the hash is not compared.
691
+ const committed = committedHref();
692
+ if (
693
+ withoutHash(committed) !== withoutHash(sourceHref) ||
694
+ (!forceSourceScope && isSamePage(url, committed))
695
+ ) {
538
696
  return Promise.resolve();
539
697
  }
540
698
  return executePrefetchFetch(
541
699
  wildcardKey,
542
700
  sourceKey,
543
- fetchUrlStr,
701
+ sourceHref,
702
+ targetUrl,
544
703
  forceSourceScope,
545
- rangoState,
704
+ liveState,
705
+ false,
546
706
  routerId,
547
707
  signal,
548
708
  ).then(() => {});
@@ -188,8 +188,9 @@ export function cancelAllPrefetches(keepUrl?: string | null): void {
188
188
 
189
189
  /**
190
190
  * Hard-cancel everything including in-flight prefetches.
191
- * Used by clearPrefetchCache (server action invalidation) where
192
- * in-flight responses would be stale.
191
+ * Used by clearPrefetchCache (invalidateClientCache(), a deploy) where
192
+ * in-flight responses would be stale. A server action never calls it: it
193
+ * marks prefetches stale and lets in-flight ones finish (cache.ts).
193
194
  */
194
195
  export function abortAllPrefetches(): void {
195
196
  for (const ac of abortControllers.values()) {
@@ -6,11 +6,25 @@
6
6
  * requests; the server responds with `Vary: X-Rango-State`, so the browser HTTP
7
7
  * cache keys responses by (URL, X-Rango-State value).
8
8
  *
9
- * Value format: `{buildVersion}:{invalidationTimestamp}`
9
+ * Value format: `{buildVersion}:{invalidationTimestamp}[:{write}]`
10
10
  * - Build version: the router's document version. It changes when a deploy
11
11
  * changes the router's server code or the client assets, busting all cached
12
12
  * prefetches at boot; a deploy that changes neither keeps them.
13
- * - Timestamp rotates on invalidation (server action, invalidateClientCache).
13
+ * - Timestamp rotates on invalidation (invalidateClientCache, either seat).
14
+ * - Write: bumped by the response of a server action without a directive (its
15
+ * `Set-Cookie`, server side, so a no-JS action bumps it too); left out while
16
+ * 0. It changes the `X-Rango-State` every tab and every reload sends, so
17
+ * responses the browser HTTP cache holds from before the write miss.
18
+ *
19
+ * The client's in-memory comparisons read only `{buildVersion}:{timestamp}`
20
+ * (`stateEpoch`): the prefetch cache keys, the action fence's rotation check,
21
+ * the external-rotation observer below and the cross-tab adopt. A write-only
22
+ * change is soft: prefetches and history entries are marked stale and nothing
23
+ * is flushed. The tab that ran the action marks its own when it lands
24
+ * (navigation-store.ts markStaleSoftAndBroadcast) and tells the tabs that
25
+ * share a segment with it. Any other tab marks its own when a read sees the
26
+ * write part change (the write observer below), so a tab that ignores the
27
+ * broadcast or has crossTabSync off does not serve a pre-write prefetch fresh.
14
28
  *
15
29
  * Storage is a session cookie named by the server-resolved name passed to
16
30
  * initRangoState (`{prefix}_{routerId}`, default prefix `rango-state`). The
@@ -32,6 +46,7 @@ import {
32
46
  getRawCookieValue,
33
47
  mintStateValue,
34
48
  serializeStateCookie,
49
+ stateEpoch,
35
50
  } from "./cookie-name.js";
36
51
 
37
52
  let cookieName: string = DEFAULT_STATE_COOKIE_PREFIX;
@@ -42,10 +57,13 @@ let mirror: string | null = null;
42
57
  let cookieBacked = false;
43
58
 
44
59
  let externalRotationObserver: ((value: string) => void) | null = null;
60
+ let externalWriteObserver: (() => void) | null = null;
45
61
 
46
62
  /**
47
63
  * Register the observer invoked when a read detects an EXTERNAL rotation (a
48
- * sibling tab, a server `Set-Cookie`, or a cookie clear). Self-rotations
64
+ * sibling tab, a server `Set-Cookie`, or a cookie clear) of the
65
+ * `{version}:{timestamp}` part. A change to the write part alone (a server
66
+ * action's write) goes to the write observer instead. Self-rotations
49
67
  * (invalidateRangoState) update the mirror synchronously and never fire it.
50
68
  */
51
69
  export function setRangoStateObserver(
@@ -54,6 +72,19 @@ export function setRangoStateObserver(
54
72
  externalRotationObserver = observer;
55
73
  }
56
74
 
75
+ /**
76
+ * Register the observer invoked when a read sees the write part change while
77
+ * the `{version}:{timestamp}` part stays: a server action's response, in this
78
+ * tab or another, bumped it in the shared jar since the last read. Every read
79
+ * updates the mirror, and a prefetch reads the state before it starts, so a
80
+ * prefetch sent with the new value is never one this observer sees as old.
81
+ */
82
+ export function setRangoStateWriteObserver(
83
+ observer: (() => void) | null,
84
+ ): void {
85
+ externalWriteObserver = observer;
86
+ }
87
+
57
88
  /** Return the resolved cookie name used to namespace cross-tab messages. */
58
89
  export function getRangoStateCookieName(): string {
59
90
  return cookieName;
@@ -146,11 +177,17 @@ export function getRangoState(): string {
146
177
 
147
178
  if (read.value !== null) {
148
179
  if (read.value !== mirror) {
149
- // External rotation (sibling tab / server Set-Cookie): adopt it. The
150
- // mirror update makes this idempotent across a burst of reads.
180
+ // External change (sibling tab / server Set-Cookie): adopt it. The
181
+ // mirror update makes this idempotent across a burst of reads. A
182
+ // rotation notifies the rotation observer; a write-only bump (a server
183
+ // action's response, or a sibling tab's) keeps the caches the epoch
184
+ // keys and notifies the write observer, which marks them stale.
185
+ const rotated =
186
+ mirror === null || stateEpoch(mirror) !== stateEpoch(read.value);
151
187
  mirror = read.value;
152
188
  cookieBacked = true;
153
- notifyExternalRotation(read.value);
189
+ if (rotated) notifyExternalRotation(read.value);
190
+ else externalWriteObserver?.();
154
191
  } else {
155
192
  cookieBacked = true;
156
193
  }
@@ -176,7 +213,7 @@ export function getRangoState(): string {
176
213
 
177
214
  /**
178
215
  * Invalidate the Rango state (self-rotation). Called when the client clears its
179
- * prefetch caches (e.g. via the server-action bridge). Rotates the timestamp,
216
+ * prefetch caches (e.g. invalidateClientCache()). Rotates the timestamp,
180
217
  * keeps the version, writes the cookie, and updates the mirror synchronously so
181
218
  * the external-rotation observer is NOT triggered by our own write.
182
219
  */
@@ -186,7 +223,12 @@ export function invalidateRangoState(): void {
186
223
  writeCookie(cookieName, mirror);
187
224
  }
188
225
 
189
- /** Adopt the state carried by a same-origin cache-invalidation broadcast. */
226
+ /**
227
+ * Adopt the state carried by a same-origin cache-invalidation broadcast when
228
+ * its timestamp is newer (a rotation). The write part is not compared: a soft
229
+ * broadcast's newer write is already in the shared jar, and a value that
230
+ * differs only there is no rotation to adopt.
231
+ */
190
232
  export function adoptRangoState(value: string): boolean {
191
233
  const incoming = decodeStateValue(value);
192
234
  if (!incoming || incoming.version !== currentVersion) return false;
@@ -110,23 +110,29 @@ export interface LinkProps extends Omit<
110
110
  * keyed on Rango state + target URL. This is correct for routes whose
111
111
  * response shape doesn't depend on where the user navigates from.
112
112
  *
113
- * Set `":source"` when this Link's response would legitimately differ
114
- * based on the source page — typically when the target route (or one
115
- * of its layouts) uses a custom `revalidate()` handler that reads
116
- * `currentUrl` / `currentParams`, and the wildcard entry would
117
- * therefore serve the wrong diff to a navigation from a different
118
- * source.
113
+ * Set `":source"` to scope every prefetch of this Link to the page that
114
+ * sent it, when its response would differ by source page in a way the
115
+ * server cannot see.
119
116
  *
120
- * A route an intercept targets is scoped to the source automatically
121
- * (server-side `x-rsc-prefetch-scope: source`), whether or not the
122
- * intercept applies from this page: the modal and the full page are
123
- * both source-specific. Every other route stays shared, so `":source"`
124
- * is only needed for custom revalidation logic.
117
+ * The server scopes a prefetch to its source automatically
118
+ * (`x-rsc-prefetch-scope: source`) when an intercept targets the route or
119
+ * the source page, whether or not it applies (the modal and the full page
120
+ * are both source-specific), when the answer defers a `prefetch: false`
121
+ * unit the source does not hold, when a `revalidate()` of the target read
122
+ * `currentUrl` and kept the segment, and when a kept answer sends no
123
+ * segment. A `revalidate()` that read `currentParams` or `fromRouteName`
124
+ * and kept the segment, or a prerendered target that keeps segments on
125
+ * equal params (`storedEntrySends`), shares the answer between the pages
126
+ * with equal values of what was read (`x-rsc-prefetch-scope: params`,
127
+ * #1331). Every other response stays shared. Use the key
128
+ * for what the server does not track: `context` in a `revalidate()`, or
129
+ * a `Referer` read.
130
+ *
131
+ * A source-scoped entry is keyed by the source page's full URL, query
132
+ * and hash included, so every page prefetches the link again.
125
133
  *
126
134
  * @example
127
135
  * ```tsx
128
- * // Route uses a `revalidate()` that branches on currentUrl — opt in
129
- * // so prefetches don't bleed across source pages.
130
136
  * <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
131
137
  * ```
132
138
  */
@@ -364,9 +370,11 @@ export const Link: ForwardRefExoticComponent<
364
370
  (!isHashOnlyNavigation(element) && !isPrefetchScopeDisabled(element)))
365
371
  ) {
366
372
  // For "hover", this is the primary prefetch trigger.
367
- // For "viewport", this upgrades/prioritizes a potentially queued
368
- // prefetch — prefetchDirect bypasses the queue, and hasPrefetch
369
- // deduplicates if the viewport prefetch already completed.
373
+ // For "viewport", this prioritizes a potentially queued prefetch:
374
+ // prefetchDirect bypasses the queue, and its dedupe skips it when the
375
+ // viewport prefetch is cached or in flight. Either way it is an intent
376
+ // prefetch, which defers no `prefetch: false` work
377
+ // (docs/design/prefetch-false.md, R16).
370
378
  const segmentState = ctx.store.getSegmentState();
371
379
  prefetchDirect(
372
380
  resolvedTo,
@@ -462,6 +462,12 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
462
462
  * The key is auto-injected by the Vite exposeInternalIds plugin as a property
463
463
  * based on file path and export name. No manual key required.
464
464
  *
465
+ * One definition is one slot in `history.state`. Entries of different
466
+ * definitions accumulate, but for the same definition the last write wins:
467
+ * two `ctx.setLocationState(Def(...))` calls in one response, or
468
+ * `redirect(url, { state: [Def(a), Def(b)] })`, deliver only the later
469
+ * value. To combine writes, make the state an array and write it once.
470
+ *
465
471
  * @param options Optional configuration
466
472
  * @returns A typed state definition for use with Link and useLocationState
467
473
  *
@@ -675,7 +681,8 @@ export function isLocationStateEntry(
675
681
  }
676
682
 
677
683
  /**
678
- * Resolve state entries into a flat object for history.state
684
+ * Resolve state entries into a flat object for history.state. One slot per
685
+ * definition: a later entry of the same definition replaces an earlier one.
679
686
  */
680
687
  export function resolveLocationStateEntries(
681
688
  entries: LocationStateEntry[],