@rangojs/router 0.0.0-experimental.29 → 0.0.0-experimental.2a0dea97

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 (156) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +78 -19
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +853 -435
  5. package/package.json +17 -16
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +45 -4
  9. package/skills/handler-use/SKILL.md +362 -0
  10. package/skills/hooks/SKILL.md +22 -4
  11. package/skills/intercept/SKILL.md +20 -0
  12. package/skills/layout/SKILL.md +22 -0
  13. package/skills/links/SKILL.md +3 -1
  14. package/skills/loader/SKILL.md +71 -21
  15. package/skills/middleware/SKILL.md +34 -3
  16. package/skills/migrate-nextjs/SKILL.md +560 -0
  17. package/skills/migrate-react-router/SKILL.md +764 -0
  18. package/skills/parallel/SKILL.md +185 -0
  19. package/skills/prerender/SKILL.md +110 -68
  20. package/skills/rango/SKILL.md +24 -22
  21. package/skills/route/SKILL.md +56 -2
  22. package/skills/router-setup/SKILL.md +87 -2
  23. package/skills/typesafety/SKILL.md +33 -21
  24. package/src/__internal.ts +92 -0
  25. package/src/browser/app-version.ts +14 -0
  26. package/src/browser/event-controller.ts +5 -0
  27. package/src/browser/link-interceptor.ts +4 -0
  28. package/src/browser/navigation-bridge.ts +125 -16
  29. package/src/browser/navigation-client.ts +142 -57
  30. package/src/browser/navigation-store.ts +43 -8
  31. package/src/browser/navigation-transaction.ts +11 -9
  32. package/src/browser/partial-update.ts +94 -17
  33. package/src/browser/prefetch/cache.ts +82 -12
  34. package/src/browser/prefetch/fetch.ts +98 -27
  35. package/src/browser/prefetch/policy.ts +6 -0
  36. package/src/browser/prefetch/queue.ts +92 -20
  37. package/src/browser/prefetch/resource-ready.ts +77 -0
  38. package/src/browser/react/Link.tsx +88 -9
  39. package/src/browser/react/NavigationProvider.tsx +40 -4
  40. package/src/browser/react/context.ts +7 -2
  41. package/src/browser/react/use-handle.ts +9 -58
  42. package/src/browser/react/use-router.ts +21 -8
  43. package/src/browser/rsc-router.tsx +134 -59
  44. package/src/browser/scroll-restoration.ts +41 -42
  45. package/src/browser/segment-reconciler.ts +72 -10
  46. package/src/browser/server-action-bridge.ts +8 -6
  47. package/src/browser/types.ts +55 -5
  48. package/src/build/generate-manifest.ts +6 -6
  49. package/src/build/generate-route-types.ts +3 -0
  50. package/src/build/route-trie.ts +50 -24
  51. package/src/build/route-types/include-resolution.ts +8 -1
  52. package/src/build/route-types/router-processing.ts +223 -74
  53. package/src/build/route-types/scan-filter.ts +8 -1
  54. package/src/cache/cache-runtime.ts +15 -11
  55. package/src/cache/cache-scope.ts +48 -7
  56. package/src/cache/cf/cf-cache-store.ts +453 -11
  57. package/src/cache/cf/index.ts +5 -1
  58. package/src/cache/document-cache.ts +17 -7
  59. package/src/cache/index.ts +1 -0
  60. package/src/cache/taint.ts +55 -0
  61. package/src/client.rsc.tsx +2 -0
  62. package/src/client.tsx +6 -66
  63. package/src/context-var.ts +72 -2
  64. package/src/debug.ts +2 -2
  65. package/src/handle.ts +40 -0
  66. package/src/handles/breadcrumbs.ts +66 -0
  67. package/src/handles/index.ts +1 -0
  68. package/src/index.rsc.ts +6 -36
  69. package/src/index.ts +50 -43
  70. package/src/prerender/store.ts +5 -4
  71. package/src/prerender.ts +138 -77
  72. package/src/reverse.ts +25 -1
  73. package/src/route-definition/dsl-helpers.ts +224 -37
  74. package/src/route-definition/helpers-types.ts +67 -19
  75. package/src/route-definition/index.ts +3 -0
  76. package/src/route-definition/redirect.ts +11 -3
  77. package/src/route-definition/resolve-handler-use.ts +149 -0
  78. package/src/route-map-builder.ts +7 -1
  79. package/src/route-types.ts +11 -0
  80. package/src/router/content-negotiation.ts +100 -1
  81. package/src/router/find-match.ts +4 -2
  82. package/src/router/handler-context.ts +111 -25
  83. package/src/router/intercept-resolution.ts +11 -4
  84. package/src/router/lazy-includes.ts +4 -1
  85. package/src/router/loader-resolution.ts +156 -21
  86. package/src/router/logging.ts +5 -2
  87. package/src/router/manifest.ts +9 -3
  88. package/src/router/match-api.ts +125 -190
  89. package/src/router/match-middleware/background-revalidation.ts +30 -2
  90. package/src/router/match-middleware/cache-lookup.ts +94 -17
  91. package/src/router/match-middleware/cache-store.ts +53 -10
  92. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  93. package/src/router/match-middleware/segment-resolution.ts +61 -5
  94. package/src/router/match-result.ts +104 -10
  95. package/src/router/metrics.ts +6 -1
  96. package/src/router/middleware-types.ts +16 -22
  97. package/src/router/middleware.ts +24 -30
  98. package/src/router/navigation-snapshot.ts +182 -0
  99. package/src/router/prerender-match.ts +114 -10
  100. package/src/router/preview-match.ts +30 -102
  101. package/src/router/request-classification.ts +310 -0
  102. package/src/router/route-snapshot.ts +245 -0
  103. package/src/router/router-context.ts +6 -1
  104. package/src/router/router-interfaces.ts +36 -4
  105. package/src/router/router-options.ts +37 -11
  106. package/src/router/segment-resolution/fresh.ts +198 -20
  107. package/src/router/segment-resolution/helpers.ts +30 -25
  108. package/src/router/segment-resolution/loader-cache.ts +1 -0
  109. package/src/router/segment-resolution/revalidation.ts +438 -300
  110. package/src/router/segment-wrappers.ts +2 -0
  111. package/src/router/types.ts +1 -0
  112. package/src/router.ts +59 -6
  113. package/src/rsc/handler.ts +472 -372
  114. package/src/rsc/loader-fetch.ts +23 -3
  115. package/src/rsc/manifest-init.ts +5 -1
  116. package/src/rsc/progressive-enhancement.ts +14 -2
  117. package/src/rsc/rsc-rendering.ts +12 -1
  118. package/src/rsc/server-action.ts +8 -0
  119. package/src/rsc/ssr-setup.ts +2 -2
  120. package/src/rsc/types.ts +9 -1
  121. package/src/segment-content-promise.ts +33 -0
  122. package/src/segment-system.tsx +164 -23
  123. package/src/server/context.ts +140 -14
  124. package/src/server/handle-store.ts +19 -0
  125. package/src/server/loader-registry.ts +9 -8
  126. package/src/server/request-context.ts +204 -28
  127. package/src/ssr/index.tsx +4 -0
  128. package/src/static-handler.ts +18 -6
  129. package/src/types/cache-types.ts +4 -4
  130. package/src/types/handler-context.ts +149 -49
  131. package/src/types/loader-types.ts +36 -9
  132. package/src/types/route-entry.ts +8 -1
  133. package/src/types/segments.ts +6 -0
  134. package/src/urls/path-helper-types.ts +39 -6
  135. package/src/urls/path-helper.ts +48 -13
  136. package/src/urls/pattern-types.ts +12 -0
  137. package/src/urls/response-types.ts +16 -6
  138. package/src/use-loader.tsx +77 -5
  139. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  140. package/src/vite/discovery/discover-routers.ts +5 -1
  141. package/src/vite/discovery/prerender-collection.ts +128 -74
  142. package/src/vite/discovery/state.ts +13 -6
  143. package/src/vite/index.ts +4 -0
  144. package/src/vite/plugin-types.ts +51 -79
  145. package/src/vite/plugins/expose-action-id.ts +1 -3
  146. package/src/vite/plugins/expose-id-utils.ts +12 -0
  147. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  148. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  149. package/src/vite/plugins/performance-tracks.ts +88 -0
  150. package/src/vite/plugins/refresh-cmd.ts +88 -26
  151. package/src/vite/plugins/version-plugin.ts +13 -1
  152. package/src/vite/rango.ts +163 -211
  153. package/src/vite/router-discovery.ts +178 -45
  154. package/src/vite/utils/banner.ts +3 -3
  155. package/src/vite/utils/prerender-utils.ts +37 -5
  156. package/src/vite/utils/shared-utils.ts +3 -2
@@ -19,6 +19,14 @@ import type { BoundTransaction } from "./navigation-transaction.js";
19
19
  import { ServerRedirect } from "../errors.js";
20
20
  import { debugLog } from "./logging.js";
21
21
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
22
+ import type { NavigationUpdate } from "./types.js";
23
+
24
+ /** Build a scroll payload from the commit's scroll option */
25
+ function toScrollPayload(
26
+ scroll: boolean | undefined,
27
+ ): NonNullable<NavigationUpdate["scroll"]> {
28
+ return { enabled: scroll !== false ? scroll : false };
29
+ }
22
30
 
23
31
  /**
24
32
  * Configuration for creating a partial updater
@@ -31,8 +39,8 @@ export interface PartialUpdateConfig {
31
39
  segments: ResolvedSegment[],
32
40
  options?: RenderSegmentsOptions,
33
41
  ) => Promise<ReactNode> | ReactNode;
34
- /** RSC version received from server (from initial payload metadata) */
35
- version?: string;
42
+ /** RSC version getter returns the current version (may change after HMR) */
43
+ getVersion?: () => string | undefined;
36
44
  }
37
45
 
38
46
  /**
@@ -96,7 +104,13 @@ export type PartialUpdater = (
96
104
  export function createPartialUpdater(
97
105
  config: PartialUpdateConfig,
98
106
  ): PartialUpdater {
99
- const { store, client, onUpdate, renderSegments, version } = config;
107
+ const {
108
+ store,
109
+ client,
110
+ onUpdate,
111
+ renderSegments,
112
+ getVersion = () => undefined,
113
+ } = config;
100
114
 
101
115
  /**
102
116
  * Get current page's cached segments as an array
@@ -153,9 +167,16 @@ export function createPartialUpdater(
153
167
  segments = segmentIds ?? segmentState.currentSegmentIds;
154
168
  }
155
169
 
156
- // For intercept revalidation, use the intercept source URL as previousUrl
170
+ // For intercept revalidation, use the intercept source URL as previousUrl.
171
+ // For leave-intercept, tx.currentUrl captures window.location.href at tx
172
+ // creation, which on popstate is already the destination URL and would
173
+ // tell the server "from == to". segmentState.currentUrl still points at
174
+ // the URL the cached segments render (the intercept URL), which is the
175
+ // correct "from" for the server's diff computation.
157
176
  const previousUrl =
158
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
177
+ mode.type === "leave-intercept"
178
+ ? segmentState.currentUrl || tx.currentUrl
179
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
159
180
 
160
181
  debugLog(`\n[Browser] >>> NAVIGATION`);
161
182
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -174,6 +195,11 @@ export function createPartialUpdater(
174
195
  targetCache && targetCache.length > 0
175
196
  ? targetCache
176
197
  : getCurrentCachedSegments();
198
+ const cachedSegsSource =
199
+ targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
200
+ debugLog(
201
+ `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
202
+ );
177
203
 
178
204
  // Fetch partial payload (no abort signal - RSC doesn't support it well)
179
205
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
@@ -185,7 +211,8 @@ export function createPartialUpdater(
185
211
  // (action redirect sends empty segments for a fresh render).
186
212
  staleRevalidation:
187
213
  mode.type === "stale-revalidation" || segments.length === 0,
188
- version,
214
+ version: getVersion(),
215
+ routerId: store.getRouterId?.(),
189
216
  });
190
217
  // Mark navigation as streaming (response received, now parsing RSC).
191
218
  // Called after fetchPartial so pendingUrl stays set during the network wait,
@@ -198,6 +225,21 @@ export function createPartialUpdater(
198
225
  streamingToken.end();
199
226
  });
200
227
 
228
+ // Detect app switch: if routerId changed, the navigation crossed into
229
+ // a different router (e.g., via host router path mount). Downgrade
230
+ // partial to full so the entire tree is replaced without reconciliation
231
+ // against stale segments from the previous app.
232
+ if (payload.metadata?.routerId) {
233
+ const prevRouterId = store.getRouterId?.();
234
+ if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
235
+ debugLog(
236
+ `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
237
+ );
238
+ payload.metadata.isPartial = false;
239
+ }
240
+ store.setRouterId?.(payload.metadata.routerId);
241
+ }
242
+
201
243
  // Handle server-side redirect with state
202
244
  if (payload.metadata?.redirect) {
203
245
  if (signal?.aborted) {
@@ -246,7 +288,21 @@ export function createPartialUpdater(
246
288
  forceAwait: true,
247
289
  });
248
290
 
249
- tx.commit(matchedIds, existingSegments);
291
+ const { scroll: commitScroll } = tx.commit(
292
+ matchedIds,
293
+ existingSegments,
294
+ );
295
+
296
+ // tx.commit() cached the source page's handleData because
297
+ // eventController hasn't been updated yet. Overwrite with the
298
+ // correct cached handleData to prevent cache corruption on
299
+ // subsequent navigations to this same URL.
300
+ if (mode.targetCacheHandleData) {
301
+ store.updateCacheHandleData(
302
+ store.getHistoryKey(),
303
+ mode.targetCacheHandleData,
304
+ );
305
+ }
250
306
 
251
307
  // Include cachedHandleData in metadata so NavigationProvider can restore
252
308
  // breadcrumbs and other handle data from cache.
@@ -260,6 +316,7 @@ export function createPartialUpdater(
260
316
  ...metadataWithoutHandles,
261
317
  cachedHandleData: mode.targetCacheHandleData,
262
318
  },
319
+ scroll: toScrollPayload(commitScroll),
263
320
  };
264
321
 
265
322
  const cachedHasTransition = existingSegments.some(
@@ -290,11 +347,15 @@ export function createPartialUpdater(
290
347
  forceAwait: true,
291
348
  });
292
349
 
293
- tx.commit(matchedIds, existingSegments);
350
+ const { scroll: leaveScroll } = tx.commit(
351
+ matchedIds,
352
+ existingSegments,
353
+ );
294
354
 
295
355
  onUpdate({
296
356
  root: newTree,
297
357
  metadata: payload.metadata,
358
+ scroll: toScrollPayload(leaveScroll),
298
359
  });
299
360
 
300
361
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -411,8 +472,10 @@ export function createPartialUpdater(
411
472
  }
412
473
  }
413
474
 
414
- // Commit navigation - transaction handles all store mutations atomically
415
- const allSegmentIds = reconciled.segments.map((s) => s.id);
475
+ // Commit navigation - use server's matched as the authoritative segment ID list.
476
+ // reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
477
+ // but the server's matched always includes all expected segment IDs.
478
+ const allSegmentIds = matchedIds;
416
479
  const serverLocationState = payload.metadata?.locationState;
417
480
  const overrides: CommitOverrides | undefined = isInterceptResponse
418
481
  ? {
@@ -424,7 +487,11 @@ export function createPartialUpdater(
424
487
  : serverLocationState
425
488
  ? { serverState: serverLocationState }
426
489
  : undefined;
427
- tx.commit(allSegmentIds, reconciled.segments, overrides);
490
+ const { scroll: navScroll } = tx.commit(
491
+ allSegmentIds,
492
+ reconciled.segments,
493
+ overrides,
494
+ );
428
495
 
429
496
  // For stale revalidation: verify history key hasn't changed before updating UI
430
497
  if (mode.type === "stale-revalidation") {
@@ -439,8 +506,10 @@ export function createPartialUpdater(
439
506
 
440
507
  debugLog("[partial-update] updating document");
441
508
 
442
- // Emit update to trigger React render
509
+ // Emit update to trigger React render.
510
+ // Scroll info is included so NavigationProvider applies it after React commits.
443
511
  const hasTransition = reconciled.mainSegments.some((s) => s.transition);
512
+ const scrollPayload = toScrollPayload(navScroll);
444
513
 
445
514
  if (mode.type === "action" || mode.type === "stale-revalidation") {
446
515
  startTransition(() => {
@@ -450,6 +519,7 @@ export function createPartialUpdater(
450
519
  onUpdate({
451
520
  root: newTree,
452
521
  metadata: payload.metadata!,
522
+ scroll: scrollPayload,
453
523
  });
454
524
  });
455
525
  } else if (hasTransition) {
@@ -460,12 +530,14 @@ export function createPartialUpdater(
460
530
  onUpdate({
461
531
  root: newTree,
462
532
  metadata: payload.metadata!,
533
+ scroll: scrollPayload,
463
534
  });
464
535
  });
465
536
  } else {
466
537
  onUpdate({
467
538
  root: newTree,
468
539
  metadata: payload.metadata!,
540
+ scroll: scrollPayload,
469
541
  });
470
542
  }
471
543
 
@@ -492,15 +564,16 @@ export function createPartialUpdater(
492
564
  }
493
565
 
494
566
  const fullUpdateServerState = payload.metadata?.locationState;
495
- if (fullUpdateServerState) {
496
- tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
497
- } else {
498
- tx.commit(segmentIds, segments);
499
- }
567
+ const { scroll: fullScroll } = fullUpdateServerState
568
+ ? tx.commit(segmentIds, segments, {
569
+ serverState: fullUpdateServerState,
570
+ })
571
+ : tx.commit(segmentIds, segments);
500
572
 
501
573
  const fullHasTransition = segments.some(
502
574
  (s: ResolvedSegment) => s.transition,
503
575
  );
576
+ const fullScrollPayload = toScrollPayload(fullScroll);
504
577
 
505
578
  if (mode.type === "stale-revalidation") {
506
579
  await rawStreamComplete;
@@ -511,6 +584,7 @@ export function createPartialUpdater(
511
584
  onUpdate({
512
585
  root: newTree,
513
586
  metadata: payload.metadata!,
587
+ scroll: fullScrollPayload,
514
588
  });
515
589
  });
516
590
  } else if (mode.type === "action") {
@@ -521,6 +595,7 @@ export function createPartialUpdater(
521
595
  onUpdate({
522
596
  root: newTree,
523
597
  metadata: payload.metadata!,
598
+ scroll: fullScrollPayload,
524
599
  });
525
600
  });
526
601
  } else if (fullHasTransition) {
@@ -531,12 +606,14 @@ export function createPartialUpdater(
531
606
  onUpdate({
532
607
  root: newTree,
533
608
  metadata: payload.metadata!,
609
+ scroll: fullScrollPayload,
534
610
  });
535
611
  });
536
612
  } else {
537
613
  onUpdate({
538
614
  root: newTree,
539
615
  metadata: payload.metadata!,
616
+ scroll: fullScrollPayload,
540
617
  });
541
618
  }
542
619
 
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * Prefetch Cache
3
3
  *
4
- * In-memory cache storing prefetch Response objects for instant cache hits
4
+ * In-memory cache storing prefetched Response objects for instant cache hits
5
5
  * on subsequent navigation. Cache key is source-dependent (includes the
6
6
  * current page URL) because the server's diff-based response depends on
7
7
  * where the user navigates from.
8
8
  *
9
+ * Also tracks in-flight prefetch promises. Each promise resolves to the
10
+ * navigation branch of a tee'd Response, allowing navigation to adopt a
11
+ * still-downloading prefetch without reparsing or buffering the body.
12
+ *
9
13
  * Replaces the previous browser HTTP cache approach which was unreliable
10
14
  * due to response draining race conditions and browser inconsistencies.
11
15
  */
12
16
 
13
- import { cancelAllPrefetches } from "./queue.js";
17
+ import { abortAllPrefetches } from "./queue.js";
14
18
  import { invalidateRangoState } from "../rango-state.js";
15
19
 
16
20
  // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
@@ -21,11 +25,19 @@ let cacheTTL = 300_000;
21
25
  /**
22
26
  * Initialize the prefetch cache with the configured TTL.
23
27
  * Called once at app startup with the value from server metadata.
24
- * A TTL of 0 disables the in-memory cache.
28
+ * A TTL of 0 disables the in-memory cache and all prefetching.
25
29
  */
26
30
  export function initPrefetchCache(ttlMs: number): void {
27
31
  cacheTTL = ttlMs;
28
32
  }
33
+
34
+ /**
35
+ * Check if the prefetch cache is disabled (TTL <= 0).
36
+ * When disabled, no prefetch requests should be issued.
37
+ */
38
+ export function isPrefetchCacheDisabled(): boolean {
39
+ return cacheTTL <= 0;
40
+ }
29
41
  const MAX_PREFETCH_CACHE_SIZE = 50;
30
42
 
31
43
  interface PrefetchCacheEntry {
@@ -36,19 +48,36 @@ interface PrefetchCacheEntry {
36
48
  const cache = new Map<string, PrefetchCacheEntry>();
37
49
  const inflight = new Set<string>();
38
50
 
51
+ /**
52
+ * In-flight promise map. When a prefetch fetch is in progress, its
53
+ * Promise<Response | null> is stored here so navigation can await
54
+ * it instead of starting a duplicate request.
55
+ */
56
+ const inflightPromises = new Map<string, Promise<Response | null>>();
57
+
39
58
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
40
59
  // started before a clear carry a stale generation and must not store their
41
60
  // response (the data may be stale due to a server action invalidation).
42
61
  let generation = 0;
43
62
 
44
63
  /**
45
- * Build a source-dependent cache key.
46
- * Includes the source page href so the same target prefetched from
47
- * different pages gets separate entries the server response varies
48
- * based on the source page context (diff-based rendering).
64
+ * Build a cache key for prefetched responses.
65
+ *
66
+ * By default the key includes the source page href so the same target
67
+ * prefetched from different pages gets separate entries (the server's
68
+ * diff response depends on the source page context).
69
+ *
70
+ * When `prefetchKey` is provided, the source portion is replaced with
71
+ * a `*` sentinel so all custom-keyed entries share one cache slot per
72
+ * target — enabling source-agnostic cache reuse.
49
73
  */
50
- export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
51
- return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
74
+ export function buildPrefetchKey(
75
+ sourceHref: string,
76
+ targetUrl: URL,
77
+ prefetchKey?: string | ((from: string) => string),
78
+ ): string {
79
+ const source = prefetchKey != null ? "*" : sourceHref;
80
+ return source + "\0" + targetUrl.pathname + targetUrl.search;
52
81
  }
53
82
 
54
83
  /**
@@ -70,6 +99,9 @@ export function hasPrefetch(key: string): boolean {
70
99
  * Consume a cached prefetch response. Returns null if not found or expired.
71
100
  * One-time consumption: the entry is deleted after retrieval.
72
101
  * Returns null when caching is disabled (TTL <= 0).
102
+ *
103
+ * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
104
+ * for that (returns a Promise instead of a Response).
73
105
  */
74
106
  export function consumePrefetch(key: string): Response | null {
75
107
  if (cacheTTL <= 0) return null;
@@ -83,10 +115,33 @@ export function consumePrefetch(key: string): Response | null {
83
115
  return entry.response;
84
116
  }
85
117
 
118
+ /**
119
+ * Consume an in-flight prefetch promise. Returns null if no prefetch is
120
+ * in-flight for this key. The returned Promise resolves to the buffered
121
+ * Response (or null if the fetch failed/was aborted).
122
+ *
123
+ * One-time consumption: the promise entry is removed so a second call
124
+ * returns null. The `inflight` set entry is intentionally kept so that
125
+ * hasPrefetch() continues to return true while the underlying fetch is
126
+ * still downloading — this prevents prefetchDirect() or other callers
127
+ * from starting a duplicate request during the handoff window. The
128
+ * inflight flag is cleaned up naturally by clearPrefetchInflight() in
129
+ * the fetch's .finally().
130
+ */
131
+ export function consumeInflightPrefetch(
132
+ key: string,
133
+ ): Promise<Response | null> | null {
134
+ const promise = inflightPromises.get(key);
135
+ if (!promise) return null;
136
+ // Remove the promise (one-time consumption) but keep the inflight flag.
137
+ inflightPromises.delete(key);
138
+ return promise;
139
+ }
140
+
86
141
  /**
87
142
  * Store a prefetch response in the in-memory cache.
88
- * The response body must be fully buffered (e.g. via arrayBuffer()) before
89
- * storing, so the cached Response is self-contained and network-independent.
143
+ * The response should be a clone() of the original so the caller can
144
+ * still consume the body. The clone's body streams independently.
90
145
  *
91
146
  * Skips storage if the generation has changed since the fetch started
92
147
  * (a server action invalidated the cache mid-flight).
@@ -128,19 +183,34 @@ export function markPrefetchInflight(key: string): void {
128
183
  inflight.add(key);
129
184
  }
130
185
 
186
+ /**
187
+ * Store the in-flight Promise for a prefetch so navigation can reuse it.
188
+ */
189
+ export function setInflightPromise(
190
+ key: string,
191
+ promise: Promise<Response | null>,
192
+ ): void {
193
+ inflightPromises.set(key, promise);
194
+ }
195
+
131
196
  export function clearPrefetchInflight(key: string): void {
132
197
  inflight.delete(key);
198
+ inflightPromises.delete(key);
133
199
  }
134
200
 
135
201
  /**
136
202
  * Invalidate all prefetch state. Called when server actions mutate data.
137
203
  * Clears the in-memory cache, cancels in-flight prefetches, and rotates
138
204
  * the Rango state key so CDN-cached responses are also invalidated.
205
+ *
206
+ * Uses abortAllPrefetches (hard cancel) because in-flight responses
207
+ * may contain stale data after a mutation.
139
208
  */
140
209
  export function clearPrefetchCache(): void {
141
210
  generation++;
142
211
  inflight.clear();
212
+ inflightPromises.clear();
143
213
  cache.clear();
144
- cancelAllPrefetches();
214
+ abortAllPrefetches();
145
215
  invalidateRangoState();
146
216
  }
@@ -6,12 +6,16 @@
6
6
  * real navigation so the server returns a proper diff. The Response is fully
7
7
  * buffered and stored in an in-memory cache for instant consumption on
8
8
  * subsequent navigation.
9
+ *
10
+ * In-flight promises are tracked in the cache so that navigation can reuse
11
+ * a prefetch that is still downloading instead of starting a duplicate request.
9
12
  */
10
13
 
11
14
  import {
12
15
  buildPrefetchKey,
13
16
  hasPrefetch,
14
17
  markPrefetchInflight,
18
+ setInflightPromise,
15
19
  storePrefetch,
16
20
  clearPrefetchInflight,
17
21
  currentGeneration,
@@ -19,6 +23,24 @@ import {
19
23
  import { getRangoState } from "../rango-state.js";
20
24
  import { enqueuePrefetch } from "./queue.js";
21
25
  import { shouldPrefetch } from "./policy.js";
26
+ import { debugLog } from "../logging.js";
27
+
28
+ /**
29
+ * Check if a URL resolves to the current page (same pathname + search).
30
+ * Used to prevent same-page prefetching with prefetchKey, which would
31
+ * produce a trivial diff that corrupts the wildcard cache.
32
+ */
33
+ function isSamePage(url: string): boolean {
34
+ try {
35
+ const target = new URL(url, window.location.origin);
36
+ return (
37
+ target.pathname + target.search ===
38
+ window.location.pathname + window.location.search
39
+ );
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
22
44
 
23
45
  /**
24
46
  * Build an RSC partial URL for prefetching.
@@ -30,6 +52,7 @@ function buildPrefetchUrl(
30
52
  url: string,
31
53
  segmentIds: string[],
32
54
  version?: string,
55
+ routerId?: string,
33
56
  ): URL | null {
34
57
  let targetUrl: URL;
35
58
  try {
@@ -47,23 +70,27 @@ function buildPrefetchUrl(
47
70
  if (version) {
48
71
  targetUrl.searchParams.set("_rsc_v", version);
49
72
  }
73
+ if (routerId) {
74
+ targetUrl.searchParams.set("_rsc_rid", routerId);
75
+ }
50
76
  return targetUrl;
51
77
  }
52
78
 
53
79
  /**
54
- * Core prefetch fetch logic. Fetches the response, fully buffers the body,
55
- * and stores it in the in-memory cache. Returns a Promise and accepts an
56
- * optional AbortSignal for cancellation by the prefetch queue.
80
+ * Core prefetch fetch logic. Fetches the response, tees the body, and stores
81
+ * one branch in the in-memory cache. The returned Promise resolves to the
82
+ * sibling navigation branch (or null on failure) so navigation can safely
83
+ * reuse an in-flight prefetch via consumeInflightPrefetch().
57
84
  */
58
85
  function executePrefetchFetch(
59
86
  key: string,
60
87
  fetchUrl: string,
61
88
  signal?: AbortSignal,
62
- ): Promise<void> {
89
+ ): Promise<Response | null> {
63
90
  const gen = currentGeneration();
64
91
  markPrefetchInflight(key);
65
92
 
66
- return fetch(fetchUrl, {
93
+ const promise: Promise<Response | null> = fetch(fetchUrl, {
67
94
  priority: "low" as RequestPriority,
68
95
  signal,
69
96
  headers: {
@@ -72,26 +99,27 @@ function executePrefetchFetch(
72
99
  "X-Rango-Prefetch": "1",
73
100
  },
74
101
  })
75
- .then(async (response) => {
76
- if (!response.ok) return;
77
- // Fully buffer the response body so the cached Response is
78
- // self-contained and doesn't depend on the network connection.
79
- // This eliminates the race condition where the user clicks before
80
- // the response body has been fully downloaded.
81
- const buffer = await response.arrayBuffer();
82
- const cachedResponse = new Response(buffer, {
102
+ .then((response) => {
103
+ if (!response.ok) return null;
104
+ // Don't buffer with arrayBuffer() that blocks until the entire
105
+ // body downloads, defeating streaming for slow loaders.
106
+ // Tee the body: one branch for navigation, one for cache storage.
107
+ const [navStream, cacheStream] = response.body!.tee();
108
+ const responseInit = {
83
109
  headers: response.headers,
84
110
  status: response.status,
85
111
  statusText: response.statusText,
86
- });
87
- storePrefetch(key, cachedResponse, gen);
88
- })
89
- .catch(() => {
90
- // Silently ignore prefetch failures (including abort)
112
+ };
113
+ storePrefetch(key, new Response(cacheStream, responseInit), gen);
114
+ return new Response(navStream, responseInit);
91
115
  })
116
+ .catch(() => null)
92
117
  .finally(() => {
93
118
  clearPrefetchInflight(key);
94
119
  });
120
+
121
+ setInflightPromise(key, promise);
122
+ return promise;
95
123
  }
96
124
 
97
125
  /**
@@ -102,13 +130,33 @@ export function prefetchDirect(
102
130
  url: string,
103
131
  segmentIds: string[],
104
132
  version?: string,
133
+ routerId?: string,
134
+ prefetchKey?: string | ((from: string) => string),
105
135
  ): void {
106
136
  if (!shouldPrefetch()) return;
107
137
 
108
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
138
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
109
139
  if (!targetUrl) return;
110
- const key = buildPrefetchKey(window.location.href, targetUrl);
111
- if (hasPrefetch(key)) return;
140
+ // Skip same-page prefetch with prefetchKey — a same-page diff is trivial
141
+ // and would corrupt the wildcard cache entry for cross-page navigation.
142
+ if (prefetchKey != null && isSamePage(url)) {
143
+ return;
144
+ }
145
+ const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
146
+ if (hasPrefetch(key)) {
147
+ debugLog("[prefetch] direct dedup (key already exists)", {
148
+ url,
149
+ key,
150
+ prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
151
+ });
152
+ return;
153
+ }
154
+ debugLog("[prefetch] direct fetch", {
155
+ url,
156
+ key,
157
+ source: window.location.href,
158
+ prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
159
+ });
112
160
  executePrefetchFetch(key, targetUrl.toString());
113
161
  }
114
162
 
@@ -121,15 +169,38 @@ export function prefetchQueued(
121
169
  url: string,
122
170
  segmentIds: string[],
123
171
  version?: string,
172
+ routerId?: string,
173
+ prefetchKey?: string | ((from: string) => string),
124
174
  ): string {
125
175
  if (!shouldPrefetch()) return "";
126
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
176
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
127
177
  if (!targetUrl) return "";
128
- const key = buildPrefetchKey(window.location.href, targetUrl);
129
- if (hasPrefetch(key)) return key;
178
+ // Skip same-page prefetch with prefetchKey — a same-page diff is trivial
179
+ // and would corrupt the wildcard cache entry for cross-page navigation.
180
+ if (prefetchKey != null && isSamePage(url)) {
181
+ return "";
182
+ }
183
+ const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
184
+ if (hasPrefetch(key)) {
185
+ debugLog("[prefetch] queued dedup (key already exists)", {
186
+ url,
187
+ key,
188
+ prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
189
+ });
190
+ return key;
191
+ }
130
192
  const fetchUrlStr = targetUrl.toString();
131
- enqueuePrefetch(key, (signal) =>
132
- executePrefetchFetch(key, fetchUrlStr, signal),
133
- );
193
+ enqueuePrefetch(key, (signal) => {
194
+ // Re-check at execution time: a hover-triggered prefetchDirect may
195
+ // have started or completed this key while the item sat in the queue.
196
+ if (hasPrefetch(key)) return Promise.resolve();
197
+ // By execution time, the user may have navigated to the target page.
198
+ // A same-page prefetch produces a trivial diff that would overwrite
199
+ // the useful cross-page entry in the wildcard cache.
200
+ if (prefetchKey != null && isSamePage(url)) {
201
+ return Promise.resolve();
202
+ }
203
+ return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
204
+ });
134
205
  return key;
135
206
  }
@@ -5,6 +5,8 @@
5
5
  * Honors browser reduced-data preferences when available.
6
6
  */
7
7
 
8
+ import { isPrefetchCacheDisabled } from "./cache.js";
9
+
8
10
  type NavigatorWithConnection = Navigator & {
9
11
  connection?: {
10
12
  saveData?: boolean;
@@ -18,6 +20,10 @@ type NavigatorWithConnection = Navigator & {
18
20
  export function shouldPrefetch(): boolean {
19
21
  if (typeof window === "undefined") return false;
20
22
 
23
+ // When prefetchCacheTTL is false/0, prefetching is fully disabled —
24
+ // no point issuing requests whose responses will be discarded.
25
+ if (isPrefetchCacheDisabled()) return false;
26
+
21
27
  const nav =
22
28
  typeof navigator !== "undefined"
23
29
  ? (navigator as NavigatorWithConnection)