@rangojs/router 0.0.0-experimental.39 → 0.0.0-experimental.3b1deca8

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 (89) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/vite/index.js +292 -204
  3. package/package.json +1 -1
  4. package/skills/cache-guide/SKILL.md +32 -0
  5. package/skills/caching/SKILL.md +45 -4
  6. package/skills/loader/SKILL.md +53 -43
  7. package/skills/parallel/SKILL.md +126 -0
  8. package/skills/route/SKILL.md +31 -0
  9. package/skills/router-setup/SKILL.md +52 -2
  10. package/skills/typesafety/SKILL.md +10 -0
  11. package/src/browser/debug-channel.ts +93 -0
  12. package/src/browser/event-controller.ts +5 -0
  13. package/src/browser/navigation-bridge.ts +1 -5
  14. package/src/browser/navigation-client.ts +84 -27
  15. package/src/browser/navigation-transaction.ts +11 -9
  16. package/src/browser/partial-update.ts +50 -9
  17. package/src/browser/prefetch/cache.ts +57 -5
  18. package/src/browser/prefetch/fetch.ts +30 -21
  19. package/src/browser/prefetch/queue.ts +92 -20
  20. package/src/browser/prefetch/resource-ready.ts +77 -0
  21. package/src/browser/react/Link.tsx +9 -1
  22. package/src/browser/react/NavigationProvider.tsx +32 -3
  23. package/src/browser/rsc-router.tsx +109 -57
  24. package/src/browser/scroll-restoration.ts +31 -34
  25. package/src/browser/segment-reconciler.ts +6 -1
  26. package/src/browser/server-action-bridge.ts +12 -0
  27. package/src/browser/types.ts +17 -1
  28. package/src/build/route-types/router-processing.ts +12 -2
  29. package/src/cache/cache-runtime.ts +15 -11
  30. package/src/cache/cache-scope.ts +48 -7
  31. package/src/cache/cf/cf-cache-store.ts +453 -11
  32. package/src/cache/cf/index.ts +5 -1
  33. package/src/cache/document-cache.ts +17 -7
  34. package/src/cache/index.ts +1 -0
  35. package/src/cache/taint.ts +55 -0
  36. package/src/context-var.ts +72 -2
  37. package/src/debug.ts +2 -2
  38. package/src/deps/browser.ts +1 -0
  39. package/src/route-definition/dsl-helpers.ts +32 -7
  40. package/src/route-definition/helpers-types.ts +6 -5
  41. package/src/route-definition/redirect.ts +2 -2
  42. package/src/route-map-builder.ts +7 -1
  43. package/src/router/find-match.ts +4 -2
  44. package/src/router/handler-context.ts +31 -8
  45. package/src/router/intercept-resolution.ts +2 -0
  46. package/src/router/lazy-includes.ts +4 -1
  47. package/src/router/loader-resolution.ts +7 -1
  48. package/src/router/logging.ts +5 -2
  49. package/src/router/manifest.ts +9 -3
  50. package/src/router/match-middleware/background-revalidation.ts +30 -2
  51. package/src/router/match-middleware/cache-lookup.ts +66 -9
  52. package/src/router/match-middleware/cache-store.ts +53 -10
  53. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  54. package/src/router/match-middleware/segment-resolution.ts +8 -5
  55. package/src/router/match-result.ts +22 -6
  56. package/src/router/metrics.ts +6 -1
  57. package/src/router/middleware-types.ts +6 -2
  58. package/src/router/middleware.ts +4 -3
  59. package/src/router/router-context.ts +6 -1
  60. package/src/router/segment-resolution/fresh.ts +130 -17
  61. package/src/router/segment-resolution/helpers.ts +29 -24
  62. package/src/router/segment-resolution/loader-cache.ts +1 -0
  63. package/src/router/segment-resolution/revalidation.ts +352 -290
  64. package/src/router/segment-wrappers.ts +2 -0
  65. package/src/router/types.ts +1 -0
  66. package/src/router.ts +6 -1
  67. package/src/rsc/handler.ts +28 -2
  68. package/src/rsc/loader-fetch.ts +7 -2
  69. package/src/rsc/progressive-enhancement.ts +4 -1
  70. package/src/rsc/rsc-rendering.ts +4 -1
  71. package/src/rsc/server-action.ts +2 -0
  72. package/src/rsc/types.ts +7 -1
  73. package/src/segment-system.tsx +140 -4
  74. package/src/server/context.ts +102 -13
  75. package/src/server/request-context.ts +59 -12
  76. package/src/ssr/index.tsx +1 -0
  77. package/src/types/handler-context.ts +120 -22
  78. package/src/types/loader-types.ts +4 -4
  79. package/src/types/route-entry.ts +7 -0
  80. package/src/types/segments.ts +2 -0
  81. package/src/urls/path-helper.ts +1 -1
  82. package/src/vite/discovery/state.ts +0 -2
  83. package/src/vite/plugin-types.ts +0 -83
  84. package/src/vite/plugins/expose-action-id.ts +1 -3
  85. package/src/vite/plugins/performance-tracks.ts +235 -0
  86. package/src/vite/plugins/version-plugin.ts +13 -1
  87. package/src/vite/rango.ts +148 -209
  88. package/src/vite/router-discovery.ts +0 -8
  89. package/src/vite/utils/banner.ts +3 -3
@@ -12,12 +12,18 @@ import {
12
12
  startBrowserTransaction,
13
13
  } from "./logging.js";
14
14
  import { getRangoState } from "./rango-state.js";
15
+ import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
16
+ import { findSourceMapURL } from "../deps/browser.js";
15
17
  import {
16
18
  extractRscHeaderUrl,
17
19
  emptyResponse,
18
20
  teeWithCompletion,
19
21
  } from "./response-adapter.js";
20
- import { buildPrefetchKey, consumePrefetch } from "./prefetch/cache.js";
22
+ import {
23
+ buildPrefetchKey,
24
+ consumeInflightPrefetch,
25
+ consumePrefetch,
26
+ } from "./prefetch/cache.js";
21
27
 
22
28
  /**
23
29
  * Create a navigation client for fetching RSC payloads
@@ -85,49 +91,49 @@ export function createNavigationClient(
85
91
  fetchUrl.searchParams.set("_rsc_v", version);
86
92
  }
87
93
 
88
- // Check in-memory prefetch cache before making a network request.
94
+ // Check completed in-memory prefetch cache before making a network request.
89
95
  // The cache key includes the source URL (previousUrl) because the
90
96
  // server's diff response depends on the source page context.
91
97
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
92
98
  // fresh modules), and intercept contexts (source-dependent responses).
99
+ //
100
+ const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
93
101
  const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
94
- const cachedResponse =
95
- !staleRevalidation && !hmr && !interceptSourceUrl
96
- ? consumePrefetch(cacheKey)
97
- : null;
98
-
102
+ const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
103
+ const inflightResponsePromise = canUsePrefetch
104
+ ? consumeInflightPrefetch(cacheKey)
105
+ : null;
99
106
  // Track when the stream completes
100
107
  let resolveStreamComplete: () => void;
101
108
  const streamComplete = new Promise<void>((resolve) => {
102
109
  resolveStreamComplete = resolve;
103
110
  });
104
111
 
105
- let responsePromise: Promise<Response>;
112
+ // Dev-only: create debug channel for React Performance Tracks
113
+ const debugId = (import.meta as any).hot
114
+ ? crypto.randomUUID()
115
+ : undefined;
116
+ const debugChannel = debugId
117
+ ? createClientDebugChannel(debugId)
118
+ : undefined;
119
+ if (debugId) {
120
+ console.log(
121
+ "[perf-tracks] client: debugId =",
122
+ debugId,
123
+ "channel =",
124
+ debugChannel ? "created" : "null (no HMR)",
125
+ );
126
+ }
106
127
 
107
- if (cachedResponse) {
108
- if (tx) {
109
- browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
110
- }
111
- // Cached response body is already fully buffered (arrayBuffer),
112
- // so stream completion is immediate.
113
- responsePromise = Promise.resolve(cachedResponse).then((response) => {
114
- return teeWithCompletion(
115
- response,
116
- () => {
117
- if (tx) browserDebugLog(tx, "stream complete (from cache)");
118
- resolveStreamComplete();
119
- },
120
- signal,
121
- );
122
- });
123
- } else {
128
+ /** Start a fresh navigation fetch (no cache / inflight hit). */
129
+ const doFreshFetch = (): Promise<Response> => {
124
130
  if (tx) {
125
131
  browserDebugLog(tx, "fetching", {
126
132
  path: `${fetchUrl.pathname}${fetchUrl.search}`,
127
133
  });
128
134
  }
129
135
 
130
- responsePromise = fetch(fetchUrl, {
136
+ return fetch(fetchUrl, {
131
137
  headers: {
132
138
  "X-RSC-Router-Client-Path": previousUrl,
133
139
  "X-Rango-State": getRangoState(),
@@ -136,6 +142,7 @@ export function createNavigationClient(
136
142
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
137
143
  }),
138
144
  ...(hmr && { "X-RSC-HMR": "1" }),
145
+ ...(debugId && { [DEBUG_ID_HEADER]: debugId }),
139
146
  },
140
147
  signal,
141
148
  }).then((response) => {
@@ -183,11 +190,61 @@ export function createNavigationClient(
183
190
  signal,
184
191
  );
185
192
  });
193
+ };
194
+
195
+ let responsePromise: Promise<Response>;
196
+
197
+ if (cachedResponse) {
198
+ if (tx) {
199
+ browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
200
+ }
201
+ // Cached response body is already fully buffered (arrayBuffer),
202
+ // so stream completion is immediate.
203
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
204
+ return teeWithCompletion(
205
+ response,
206
+ () => {
207
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
208
+ resolveStreamComplete();
209
+ },
210
+ signal,
211
+ );
212
+ });
213
+ } else if (inflightResponsePromise) {
214
+ if (tx) {
215
+ browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
216
+ }
217
+ responsePromise = inflightResponsePromise.then(async (response) => {
218
+ if (!response) {
219
+ if (tx) {
220
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
221
+ }
222
+ return doFreshFetch();
223
+ }
224
+
225
+ return teeWithCompletion(
226
+ response,
227
+ () => {
228
+ if (tx) {
229
+ browserDebugLog(tx, "stream complete (from inflight prefetch)");
230
+ }
231
+ resolveStreamComplete();
232
+ },
233
+ signal,
234
+ );
235
+ });
236
+ } else {
237
+ responsePromise = doFreshFetch();
186
238
  }
187
239
 
188
240
  try {
189
241
  // Deserialize RSC payload
190
- const payload = await deps.createFromFetch<RscPayload>(responsePromise);
242
+ const payload = await deps.createFromFetch<RscPayload>(
243
+ responsePromise,
244
+ {
245
+ ...(debugChannel && { debugChannel, findSourceMapURL }),
246
+ },
247
+ );
191
248
  if (tx) {
192
249
  browserDebugLog(tx, "response received", {
193
250
  isPartial: payload.metadata?.isPartial,
@@ -7,7 +7,6 @@ import type {
7
7
  import { generateHistoryKey } from "./navigation-store.js";
8
8
  import {
9
9
  handleNavigationStart,
10
- handleNavigationEnd,
11
10
  ensureHistoryKey,
12
11
  } from "./scroll-restoration.js";
13
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
@@ -81,11 +80,12 @@ export interface BoundTransaction {
81
80
  readonly currentUrl: string;
82
81
  /** Start streaming and get a token to end it when the stream completes */
83
82
  startStreaming(): StreamingToken;
83
+ /** Commit the navigation. Returns the effective scroll option for the caller to handle. */
84
84
  commit(
85
85
  segmentIds: string[],
86
86
  segments: ResolvedSegment[],
87
87
  overrides?: BoundCommitOverrides,
88
- ): void;
88
+ ): { scroll?: boolean };
89
89
  }
90
90
 
91
91
  /**
@@ -93,7 +93,7 @@ export interface BoundTransaction {
93
93
  * Uses the event controller handle for lifecycle management
94
94
  */
95
95
  interface NavigationTransaction extends Disposable {
96
- commit(options: CommitOptions): void;
96
+ commit(options: CommitOptions): { scroll?: boolean };
97
97
  with(
98
98
  options: Omit<CommitOptions, "segmentIds" | "segments">,
99
99
  ): BoundTransaction;
@@ -120,7 +120,7 @@ export function createNavigationTransaction(
120
120
  /**
121
121
  * Commit the navigation - updates store and URL atomically
122
122
  */
123
- function commit(opts: CommitOptions): void {
123
+ function commit(opts: CommitOptions): { scroll?: boolean } {
124
124
  committed = true;
125
125
 
126
126
  const {
@@ -150,7 +150,7 @@ export function createNavigationTransaction(
150
150
  // Without this, the entry lingers and weakens state-machine invariants.
151
151
  handle.complete(parsedUrl);
152
152
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
- return;
153
+ return { scroll: false };
154
154
  }
155
155
 
156
156
  // Save current scroll position before navigating
@@ -172,7 +172,7 @@ export function createNavigationTransaction(
172
172
  debugLog("[Browser] Store updated (action)");
173
173
  // Complete navigation to clear loading state
174
174
  handle.complete(parsedUrl);
175
- return;
175
+ return { scroll: false };
176
176
  }
177
177
 
178
178
  // Build history state - include user state, intercept info, and server-set state
@@ -205,14 +205,16 @@ export function createNavigationTransaction(
205
205
  // Complete the navigation in event controller (sets idle state, updates location)
206
206
  handle.complete(parsedUrl);
207
207
 
208
- // Handle scroll after navigation
209
- handleNavigationEnd({ scroll });
208
+ // NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
209
+ // scroll AFTER onUpdate() so React has the new content before we scroll.
210
210
 
211
211
  debugLog(
212
212
  "[Browser] Navigation committed, historyKey:",
213
213
  historyKey,
214
214
  intercept ? "(intercept)" : "",
215
215
  );
216
+
217
+ return { scroll };
216
218
  }
217
219
 
218
220
  return {
@@ -263,7 +265,7 @@ export function createNavigationTransaction(
263
265
  overrides?.state !== undefined ? overrides.state : opts.state;
264
266
  // Server-set location state: only from overrides (set by partial-update)
265
267
  const serverState = overrides?.serverState;
266
- commit({
268
+ return commit({
267
269
  ...opts,
268
270
  segmentIds,
269
271
  segments,
@@ -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
@@ -246,7 +254,21 @@ export function createPartialUpdater(
246
254
  forceAwait: true,
247
255
  });
248
256
 
249
- tx.commit(matchedIds, existingSegments);
257
+ const { scroll: commitScroll } = tx.commit(
258
+ matchedIds,
259
+ existingSegments,
260
+ );
261
+
262
+ // Fix: tx.commit() cached the source page's handleData because
263
+ // eventController hasn't been updated yet. Overwrite with the
264
+ // correct cached handleData to prevent cache corruption on
265
+ // subsequent navigations to this same URL.
266
+ if (mode.targetCacheHandleData) {
267
+ store.updateCacheHandleData(
268
+ store.getHistoryKey(),
269
+ mode.targetCacheHandleData,
270
+ );
271
+ }
250
272
 
251
273
  // Include cachedHandleData in metadata so NavigationProvider can restore
252
274
  // breadcrumbs and other handle data from cache.
@@ -260,6 +282,7 @@ export function createPartialUpdater(
260
282
  ...metadataWithoutHandles,
261
283
  cachedHandleData: mode.targetCacheHandleData,
262
284
  },
285
+ scroll: toScrollPayload(commitScroll),
263
286
  };
264
287
 
265
288
  const cachedHasTransition = existingSegments.some(
@@ -290,11 +313,15 @@ export function createPartialUpdater(
290
313
  forceAwait: true,
291
314
  });
292
315
 
293
- tx.commit(matchedIds, existingSegments);
316
+ const { scroll: leaveScroll } = tx.commit(
317
+ matchedIds,
318
+ existingSegments,
319
+ );
294
320
 
295
321
  onUpdate({
296
322
  root: newTree,
297
323
  metadata: payload.metadata,
324
+ scroll: toScrollPayload(leaveScroll),
298
325
  });
299
326
 
300
327
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -426,7 +453,11 @@ export function createPartialUpdater(
426
453
  : serverLocationState
427
454
  ? { serverState: serverLocationState }
428
455
  : undefined;
429
- tx.commit(allSegmentIds, reconciled.segments, overrides);
456
+ const { scroll: navScroll } = tx.commit(
457
+ allSegmentIds,
458
+ reconciled.segments,
459
+ overrides,
460
+ );
430
461
 
431
462
  // For stale revalidation: verify history key hasn't changed before updating UI
432
463
  if (mode.type === "stale-revalidation") {
@@ -441,8 +472,10 @@ export function createPartialUpdater(
441
472
 
442
473
  debugLog("[partial-update] updating document");
443
474
 
444
- // Emit update to trigger React render
475
+ // Emit update to trigger React render.
476
+ // Scroll info is included so NavigationProvider applies it after React commits.
445
477
  const hasTransition = reconciled.mainSegments.some((s) => s.transition);
478
+ const scrollPayload = toScrollPayload(navScroll);
446
479
 
447
480
  if (mode.type === "action" || mode.type === "stale-revalidation") {
448
481
  startTransition(() => {
@@ -452,6 +485,7 @@ export function createPartialUpdater(
452
485
  onUpdate({
453
486
  root: newTree,
454
487
  metadata: payload.metadata!,
488
+ scroll: scrollPayload,
455
489
  });
456
490
  });
457
491
  } else if (hasTransition) {
@@ -462,12 +496,14 @@ export function createPartialUpdater(
462
496
  onUpdate({
463
497
  root: newTree,
464
498
  metadata: payload.metadata!,
499
+ scroll: scrollPayload,
465
500
  });
466
501
  });
467
502
  } else {
468
503
  onUpdate({
469
504
  root: newTree,
470
505
  metadata: payload.metadata!,
506
+ scroll: scrollPayload,
471
507
  });
472
508
  }
473
509
 
@@ -494,15 +530,16 @@ export function createPartialUpdater(
494
530
  }
495
531
 
496
532
  const fullUpdateServerState = payload.metadata?.locationState;
497
- if (fullUpdateServerState) {
498
- tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
499
- } else {
500
- tx.commit(segmentIds, segments);
501
- }
533
+ const { scroll: fullScroll } = fullUpdateServerState
534
+ ? tx.commit(segmentIds, segments, {
535
+ serverState: fullUpdateServerState,
536
+ })
537
+ : tx.commit(segmentIds, segments);
502
538
 
503
539
  const fullHasTransition = segments.some(
504
540
  (s: ResolvedSegment) => s.transition,
505
541
  );
542
+ const fullScrollPayload = toScrollPayload(fullScroll);
506
543
 
507
544
  if (mode.type === "stale-revalidation") {
508
545
  await rawStreamComplete;
@@ -513,6 +550,7 @@ export function createPartialUpdater(
513
550
  onUpdate({
514
551
  root: newTree,
515
552
  metadata: payload.metadata!,
553
+ scroll: fullScrollPayload,
516
554
  });
517
555
  });
518
556
  } else if (mode.type === "action") {
@@ -523,6 +561,7 @@ export function createPartialUpdater(
523
561
  onUpdate({
524
562
  root: newTree,
525
563
  metadata: payload.metadata!,
564
+ scroll: fullScrollPayload,
526
565
  });
527
566
  });
528
567
  } else if (fullHasTransition) {
@@ -533,12 +572,14 @@ export function createPartialUpdater(
533
572
  onUpdate({
534
573
  root: newTree,
535
574
  metadata: payload.metadata!,
575
+ scroll: fullScrollPayload,
536
576
  });
537
577
  });
538
578
  } else {
539
579
  onUpdate({
540
580
  root: newTree,
541
581
  metadata: payload.metadata!,
582
+ scroll: fullScrollPayload,
542
583
  });
543
584
  }
544
585
 
@@ -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
@@ -44,6 +48,13 @@ interface PrefetchCacheEntry {
44
48
  const cache = new Map<string, PrefetchCacheEntry>();
45
49
  const inflight = new Set<string>();
46
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
+
47
58
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
48
59
  // started before a clear carry a stale generation and must not store their
49
60
  // response (the data may be stale due to a server action invalidation).
@@ -78,6 +89,9 @@ export function hasPrefetch(key: string): boolean {
78
89
  * Consume a cached prefetch response. Returns null if not found or expired.
79
90
  * One-time consumption: the entry is deleted after retrieval.
80
91
  * Returns null when caching is disabled (TTL <= 0).
92
+ *
93
+ * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
94
+ * for that (returns a Promise instead of a Response).
81
95
  */
82
96
  export function consumePrefetch(key: string): Response | null {
83
97
  if (cacheTTL <= 0) return null;
@@ -91,10 +105,33 @@ export function consumePrefetch(key: string): Response | null {
91
105
  return entry.response;
92
106
  }
93
107
 
108
+ /**
109
+ * Consume an in-flight prefetch promise. Returns null if no prefetch is
110
+ * in-flight for this key. The returned Promise resolves to the buffered
111
+ * Response (or null if the fetch failed/was aborted).
112
+ *
113
+ * One-time consumption: the promise entry is removed so a second call
114
+ * returns null. The `inflight` set entry is intentionally kept so that
115
+ * hasPrefetch() continues to return true while the underlying fetch is
116
+ * still downloading — this prevents prefetchDirect() or other callers
117
+ * from starting a duplicate request during the handoff window. The
118
+ * inflight flag is cleaned up naturally by clearPrefetchInflight() in
119
+ * the fetch's .finally().
120
+ */
121
+ export function consumeInflightPrefetch(
122
+ key: string,
123
+ ): Promise<Response | null> | null {
124
+ const promise = inflightPromises.get(key);
125
+ if (!promise) return null;
126
+ // Remove the promise (one-time consumption) but keep the inflight flag.
127
+ inflightPromises.delete(key);
128
+ return promise;
129
+ }
130
+
94
131
  /**
95
132
  * Store a prefetch response in the in-memory cache.
96
- * The response body must be fully buffered (e.g. via arrayBuffer()) before
97
- * storing, so the cached Response is self-contained and network-independent.
133
+ * The response should be a clone() of the original so the caller can
134
+ * still consume the body. The clone's body streams independently.
98
135
  *
99
136
  * Skips storage if the generation has changed since the fetch started
100
137
  * (a server action invalidated the cache mid-flight).
@@ -136,19 +173,34 @@ export function markPrefetchInflight(key: string): void {
136
173
  inflight.add(key);
137
174
  }
138
175
 
176
+ /**
177
+ * Store the in-flight Promise for a prefetch so navigation can reuse it.
178
+ */
179
+ export function setInflightPromise(
180
+ key: string,
181
+ promise: Promise<Response | null>,
182
+ ): void {
183
+ inflightPromises.set(key, promise);
184
+ }
185
+
139
186
  export function clearPrefetchInflight(key: string): void {
140
187
  inflight.delete(key);
188
+ inflightPromises.delete(key);
141
189
  }
142
190
 
143
191
  /**
144
192
  * Invalidate all prefetch state. Called when server actions mutate data.
145
193
  * Clears the in-memory cache, cancels in-flight prefetches, and rotates
146
194
  * the Rango state key so CDN-cached responses are also invalidated.
195
+ *
196
+ * Uses abortAllPrefetches (hard cancel) because in-flight responses
197
+ * may contain stale data after a mutation.
147
198
  */
148
199
  export function clearPrefetchCache(): void {
149
200
  generation++;
150
201
  inflight.clear();
202
+ inflightPromises.clear();
151
203
  cache.clear();
152
- cancelAllPrefetches();
204
+ abortAllPrefetches();
153
205
  invalidateRangoState();
154
206
  }
@@ -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,
@@ -51,19 +55,20 @@ function buildPrefetchUrl(
51
55
  }
52
56
 
53
57
  /**
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.
58
+ * Core prefetch fetch logic. Fetches the response, tees the body, and stores
59
+ * one branch in the in-memory cache. The returned Promise resolves to the
60
+ * sibling navigation branch (or null on failure) so navigation can safely
61
+ * reuse an in-flight prefetch via consumeInflightPrefetch().
57
62
  */
58
63
  function executePrefetchFetch(
59
64
  key: string,
60
65
  fetchUrl: string,
61
66
  signal?: AbortSignal,
62
- ): Promise<void> {
67
+ ): Promise<Response | null> {
63
68
  const gen = currentGeneration();
64
69
  markPrefetchInflight(key);
65
70
 
66
- return fetch(fetchUrl, {
71
+ const promise: Promise<Response | null> = fetch(fetchUrl, {
67
72
  priority: "low" as RequestPriority,
68
73
  signal,
69
74
  headers: {
@@ -72,26 +77,27 @@ function executePrefetchFetch(
72
77
  "X-Rango-Prefetch": "1",
73
78
  },
74
79
  })
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, {
80
+ .then((response) => {
81
+ if (!response.ok) return null;
82
+ // Don't buffer with arrayBuffer() that blocks until the entire
83
+ // body downloads, defeating streaming for slow loaders.
84
+ // Tee the body: one branch for navigation, one for cache storage.
85
+ const [navStream, cacheStream] = response.body!.tee();
86
+ const responseInit = {
83
87
  headers: response.headers,
84
88
  status: response.status,
85
89
  statusText: response.statusText,
86
- });
87
- storePrefetch(key, cachedResponse, gen);
88
- })
89
- .catch(() => {
90
- // Silently ignore prefetch failures (including abort)
90
+ };
91
+ storePrefetch(key, new Response(cacheStream, responseInit), gen);
92
+ return new Response(navStream, responseInit);
91
93
  })
94
+ .catch(() => null)
92
95
  .finally(() => {
93
96
  clearPrefetchInflight(key);
94
97
  });
98
+
99
+ setInflightPromise(key, promise);
100
+ return promise;
95
101
  }
96
102
 
97
103
  /**
@@ -128,8 +134,11 @@ export function prefetchQueued(
128
134
  const key = buildPrefetchKey(window.location.href, targetUrl);
129
135
  if (hasPrefetch(key)) return key;
130
136
  const fetchUrlStr = targetUrl.toString();
131
- enqueuePrefetch(key, (signal) =>
132
- executePrefetchFetch(key, fetchUrlStr, signal),
133
- );
137
+ enqueuePrefetch(key, (signal) => {
138
+ // Re-check at execution time: a hover-triggered prefetchDirect may
139
+ // have started or completed this key while the item sat in the queue.
140
+ if (hasPrefetch(key)) return Promise.resolve();
141
+ return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
142
+ });
134
143
  return key;
135
144
  }