@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
@@ -7,11 +7,14 @@
7
7
  * stream resolves the route's client references, so the route's JS chunks are
8
8
  * imported during prefetch rather than on click. The decoded payload is reused
9
9
  * verbatim by navigation, so a prefetched click loads no new code. Two key
10
- * scopes are in play:
10
+ * scopes are in play. Wherever a key below says `rangoState`, callers pass the
11
+ * state's `{version}:{timestamp}` part (cookie-name.ts stateEpoch), not the
12
+ * whole value: a server action's write bump leaves the keys as they were.
11
13
  * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
12
14
  * shape `rangoState\0/target?...`. Shared across all source pages and
13
15
  * source segment trees; `_rsc_segments` is omitted from this key. Invalidated
14
- * automatically when Rango state bumps (deploy or server-action invalidation).
16
+ * automatically when Rango state bumps (deploy or invalidateClientCache()).
17
+ * A server action marks entries stale instead (the stale epoch, below).
15
18
  * - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
16
19
  * — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
17
20
  * (so rotation invalidates source-scoped entries too), the source href, and
@@ -20,6 +23,14 @@
20
23
  * targets, matched or not), OR when a Link opts in with
21
24
  * `prefetchKey=":source"` — in both cases so source-sensitive responses
22
25
  * cannot bleed into navigations from other pages.
26
+ * - Params-scoped: built by `buildParamsKey(rangoState, label, target)` —
27
+ * shape `rangoState\0p:<label>\0/target?...`. Populated when the server
28
+ * tags a response with `X-RSC-Prefetch-Scope: params; <label>`: a
29
+ * revalidate() rule read the source's params or route name and kept a
30
+ * segment, so the answer is right for every page with equal values of
31
+ * what it read (prefetch-source-scope.ts). `paramsShapes` remembers, per
32
+ * target, what the stored labels read, so a lookup rebuilds the label of
33
+ * the page on screen (paramsCandidateKeys).
23
34
  *
24
35
  * Also tracks in-flight prefetch promises. Each promise resolves to the
25
36
  * decoded prefetch entry (or null), letting navigation adopt a
@@ -44,6 +55,13 @@ import { notifyPrefetchCacheInvalidated } from "./invalidation.js";
44
55
  import { invalidateRangoState } from "../rango-state.js";
45
56
  import type { RscPayload } from "../types.js";
46
57
  import { WIRE } from "../../wire-params.js";
58
+ import {
59
+ formatSourceLabel,
60
+ parseSourceShape,
61
+ sourceShapeId,
62
+ type SourcePage,
63
+ type SourceShape,
64
+ } from "../../prefetch-source-scope.js";
47
65
 
48
66
  /**
49
67
  * A prefetch that has been fetched AND eagerly decoded. Storing the decoded
@@ -64,9 +82,23 @@ export interface DecodedPrefetch {
64
82
  * Prefetch scope as tagged by the server via `X-RSC-Prefetch-Scope`.
65
83
  * `"source"` means the response is source-page-sensitive and must not be
66
84
  * reused by a navigation from a different page — navigation enforces this
67
- * when it adopted an inflight entry through the wildcard key.
85
+ * when it adopted an inflight entry through the wildcard key. `"params"`
86
+ * means it serves the pages whose label equals `paramsLabel`.
68
87
  */
69
- scope: "source" | "wildcard";
88
+ scope: PrefetchScope;
89
+ /** The label of a `"params"` entry (prefetch-source-scope.ts). */
90
+ paramsLabel?: string;
91
+ /**
92
+ * The stale epoch (`currentPrefetchEpoch()`) the prefetch's fetch started
93
+ * under. A server action without a directive marks every prefetch stale
94
+ * (`markPrefetchesStale()` bumps the epoch) instead of flushing it: an entry
95
+ * from an older epoch is still served, and the navigation that adopts it
96
+ * revalidates its page once it settled (`isPrefetchStale`). A stale entry
97
+ * is served at most once, and only to a click (`consumePrefetch`'s
98
+ * `acceptStale`). A respawn keeps the original fetch's epoch, as it keeps
99
+ * its timestamp.
100
+ */
101
+ epoch: number;
70
102
  /**
71
103
  * Synchronously-readable flag, flipped to true when `streamComplete` resolves
72
104
  * (the entire RSC stream has drained). Navigation reads this at click time to
@@ -108,6 +140,9 @@ export interface DecodedPrefetch {
108
140
  dispose?: () => void;
109
141
  }
110
142
 
143
+ /** Where a prefetch is stored: see the module comment. */
144
+ export type PrefetchScope = "source" | "params" | "wildcard";
145
+
111
146
  let cacheTTL = 300_000;
112
147
  let fragmentPassthroughEnabled = true;
113
148
 
@@ -163,8 +198,25 @@ const inflightAliases = new Map<string, string[]>();
163
198
 
164
199
  const adoptedKeys = new Set<string>();
165
200
 
201
+ /**
202
+ * What the stored params-scoped labels of a target read, by
203
+ * `buildPrefetchKey("", target)`: the shapes paramsCandidateKeys rebuilds
204
+ * labels from. Bounded like the cache; cleared with it.
205
+ */
206
+ const paramsShapes = new Map<string, Map<string, SourceShape>>();
207
+
166
208
  let generation = 0;
167
209
 
210
+ /**
211
+ * The stale epoch. `markPrefetchesStale()` bumps it; an entry whose fetch
212
+ * started under an older one is stale (`isPrefetchStale`). Separate from
213
+ * `generation`: a stale mark keeps every entry and every in-flight fetch,
214
+ * where a generation bump drops them. So a Flight response that has already
215
+ * arrived is never aborted by an action (aborting it mid-stream errors
216
+ * the stream): an in-flight prefetch finishes and lands stale.
217
+ */
218
+ let staleEpoch = 0;
219
+
168
220
  /**
169
221
  * Build a cache key by combining a scope prefix with the target URL.
170
222
  *
@@ -172,7 +224,7 @@ let generation = 0;
172
224
  * one of:
173
225
  * - Wildcard (source-agnostic): prefix is the Rango state value from
174
226
  * `getRangoState()`. Shared across all source pages. Invalidated
175
- * automatically when Rango state bumps (deploy or server-action).
227
+ * automatically when Rango state bumps (deploy or invalidateClientCache()).
176
228
  * `_rsc_segments` is omitted so sibling payloads prefetched from one page
177
229
  * remain reusable after another prefetched navigation commits. Key shape:
178
230
  * `rangoState\0/target?...`.
@@ -218,6 +270,76 @@ export function buildSourceKey(
218
270
  );
219
271
  }
220
272
 
273
+ /**
274
+ * Build a params-scoped cache key. Key shape:
275
+ * `rangoState\0p:<label>\0/target?...`, the target normalized like the
276
+ * wildcard key's: the answer serves every page with this label, whatever
277
+ * segments it holds, as a wildcard answer serves every page.
278
+ */
279
+ export function buildParamsKey(
280
+ rangoState: string,
281
+ label: string,
282
+ targetUrl: URL,
283
+ ): string {
284
+ return buildPrefetchKey(rangoState + "\0p:" + label, targetUrl);
285
+ }
286
+
287
+ /**
288
+ * Remember that a params-scoped answer for `targetUrl` is stored under a
289
+ * label of this shape. Called where the response header is read, with the
290
+ * store (fetch.ts executePrefetchFetch).
291
+ */
292
+ export function rememberParamsShape(targetUrl: URL, label: string): void {
293
+ const target = buildPrefetchKey("", targetUrl);
294
+ const shape = parseSourceShape(label);
295
+ let shapes = paramsShapes.get(target);
296
+ if (!shapes) {
297
+ if (paramsShapes.size >= maxPrefetchCacheSize) {
298
+ const oldest = paramsShapes.keys().next().value;
299
+ if (oldest !== undefined) paramsShapes.delete(oldest);
300
+ }
301
+ shapes = new Map();
302
+ paramsShapes.set(target, shapes);
303
+ }
304
+ shapes.set(sourceShapeId(shape), shape);
305
+ }
306
+
307
+ /**
308
+ * The params-scoped keys a request for `targetUrl` from `page` may adopt:
309
+ * the page's label under each shape stored for the target, the shapes that
310
+ * read the most first. Empty without a page (before boot).
311
+ */
312
+ export function paramsCandidateKeys(
313
+ rangoState: string,
314
+ targetUrl: URL,
315
+ page: SourcePage | null,
316
+ ): string[] {
317
+ if (!page) return [];
318
+ const shapes = paramsShapes.get(buildPrefetchKey("", targetUrl));
319
+ if (!shapes) return [];
320
+ return [...shapes.values()]
321
+ .sort((a, b) => weightOf(b) - weightOf(a))
322
+ .map((shape) =>
323
+ buildParamsKey(rangoState, formatSourceLabel(shape, page), targetUrl),
324
+ );
325
+ }
326
+
327
+ const weightOf = (shape: SourceShape): number =>
328
+ shape.names.length + (shape.route ? 1 : 0) + (shape.keys ? 1 : 0);
329
+
330
+ /**
331
+ * Whether `page` is in the group a params-scoped entry serves: its label
332
+ * under the entry's shape equals the entry's.
333
+ */
334
+ export function pageMatchesParamsLabel(
335
+ label: string,
336
+ page: SourcePage | null,
337
+ ): boolean {
338
+ return (
339
+ page !== null && formatSourceLabel(parseSourceShape(label), page) === label
340
+ );
341
+ }
342
+
221
343
  /**
222
344
  * Walk an inflight key plus any sibling aliases registered via
223
345
  * `setInflightPromiseWithAliases`, invoking `fn` for each.
@@ -233,13 +355,19 @@ function forEachAlias(key: string, fn: (k: string) => void): void {
233
355
 
234
356
  /**
235
357
  * Check if a prefetch is already cached, in-flight, or queued for the given key.
358
+ *
359
+ * A stale stored entry (`isPrefetchStale`) counts as absent and is dropped, so
360
+ * a new hover or viewport trigger replaces it with a fresh prefetch, and a
361
+ * click made while that one is in flight adopts it rather than the stale one.
362
+ * An in-flight fetch counts whatever its epoch: it is never aborted, and a
363
+ * duplicate request for its key would race it for the slot.
236
364
  */
237
365
  export function hasPrefetch(key: string): boolean {
238
366
  if (inflight.has(key)) return true;
239
367
  if (cacheTTL <= 0) return false;
240
368
  const entry = cache.get(key);
241
369
  if (!entry) return false;
242
- if (Date.now() - entry.timestamp > cacheTTL) {
370
+ if (Date.now() - entry.timestamp > cacheTTL || isPrefetchStale(entry.entry)) {
243
371
  entry.entry.dispose?.();
244
372
  cache.delete(key);
245
373
  return false;
@@ -258,10 +386,20 @@ export function hasPrefetch(key: string): boolean {
258
386
  * TTL bounds the age of the data, not of the latest adoption. Without
259
387
  * `respawn` the entry is deleted (legacy one-shot behavior).
260
388
  *
389
+ * A stale entry (`isPrefetchStale`) is served only to a caller that passes
390
+ * `acceptStale` (a click, which revalidates its page once it settled,
391
+ * navigation-bridge.ts): for any other caller it is a miss and stays in its
392
+ * slot. It is served at most once: its slot is not re-armed, so a second
393
+ * click on the same link fetches fresh rather than showing pre-write data
394
+ * older than what the first click's revalidation showed.
395
+ *
261
396
  * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
262
397
  * for that (returns a Promise instead of a resolved entry).
263
398
  */
264
- export function consumePrefetch(key: string): DecodedPrefetch | null {
399
+ export function consumePrefetch(
400
+ key: string,
401
+ options?: { acceptStale?: boolean },
402
+ ): DecodedPrefetch | null {
265
403
  if (cacheTTL <= 0) return null;
266
404
  const cached = cache.get(key);
267
405
  if (!cached) return null;
@@ -271,10 +409,15 @@ export function consumePrefetch(key: string): DecodedPrefetch | null {
271
409
  return null;
272
410
  }
273
411
  const entry = cached.entry;
274
- if (entry.respawn) {
412
+ const stale = isPrefetchStale(entry);
413
+ if (stale && !options?.acceptStale) return null;
414
+ if (entry.respawn && !stale) {
275
415
  cached.entry = entry.respawn();
276
416
  } else {
277
417
  cache.delete(key);
418
+ // The buffered reserve (fetch.ts) is never replayed: release it. It is a
419
+ // separate tee branch, so the payload the caller adopts keeps streaming.
420
+ if (stale) entry.dispose?.();
278
421
  }
279
422
  return entry;
280
423
  }
@@ -318,12 +461,18 @@ export function consumeInflightPrefetch(
318
461
  * Store an eagerly-decoded prefetch in the in-memory cache.
319
462
  *
320
463
  * Skips storage if the generation has changed since the fetch started
321
- * (a server action invalidated the cache mid-flight).
464
+ * (the cache was flushed mid-flight). An entry from an older stale epoch (an
465
+ * action marked prefetches stale mid-flight) is stored, and stays stale.
466
+ *
467
+ * `inflightKey` is the key the prefetch was in flight under, when the
468
+ * storage key is not one of its aliases (a params-scoped answer): a
469
+ * navigation that adopted it marked the aliases.
322
470
  */
323
471
  export function storePrefetch(
324
472
  key: string,
325
473
  entry: DecodedPrefetch,
326
474
  fetchGeneration: number,
475
+ inflightKey: string = key,
327
476
  ): void {
328
477
  if (cacheTTL <= 0) return;
329
478
  if (fetchGeneration !== generation) return;
@@ -333,8 +482,8 @@ export function storePrefetch(
333
482
  // publish it to the cache map, or a later navigation would be served the
334
483
  // exhausted entry and lose that route's handles. Clear the marker (under all
335
484
  // aliases) now that the decision is made.
336
- if (adoptedKeys.has(key)) {
337
- forEachAlias(key, (k) => adoptedKeys.delete(k));
485
+ if (adoptedKeys.has(inflightKey)) {
486
+ forEachAlias(inflightKey, (k) => adoptedKeys.delete(k));
338
487
  return;
339
488
  }
340
489
 
@@ -398,6 +547,33 @@ export function currentGeneration(): number {
398
547
  return generation;
399
548
  }
400
549
 
550
+ /**
551
+ * Capture the current stale epoch. A prefetch fetch stamps its entry with the
552
+ * value read when it started (`DecodedPrefetch.epoch`).
553
+ */
554
+ export function currentPrefetchEpoch(): number {
555
+ return staleEpoch;
556
+ }
557
+
558
+ /** Whether an action marked prefetches stale after this entry's fetch began. */
559
+ export function isPrefetchStale(
560
+ entry: Pick<DecodedPrefetch, "epoch">,
561
+ ): boolean {
562
+ return entry.epoch < staleEpoch;
563
+ }
564
+
565
+ /**
566
+ * Mark every stored and in-flight prefetch stale: what a server action
567
+ * without a directive does (navigation-store.ts markCacheAsStaleAndBroadcast).
568
+ * Only bumps the epoch. Nothing is dropped, aborted or rotated, and visible
569
+ * links are not told (no `notifyPrefetchCacheInvalidated()`), so they do not
570
+ * prefetch again. A click on a stale entry commits it at once; the navigation
571
+ * then revalidates its page (navigation-bridge.ts navigate).
572
+ */
573
+ export function markPrefetchesStale(): void {
574
+ staleEpoch++;
575
+ }
576
+
401
577
  export function markPrefetchInflight(key: string): void {
402
578
  inflight.add(key);
403
579
  }
@@ -457,6 +633,7 @@ export function clearPrefetchCache(rotateRangoState = true): void {
457
633
  inflightPromises.clear();
458
634
  inflightAliases.clear();
459
635
  adoptedKeys.clear();
636
+ paramsShapes.clear();
460
637
  for (const cached of cache.values()) cached.entry.dispose?.();
461
638
  cache.clear();
462
639
  earliestTimestamp = Infinity;