@rangojs/router 0.0.0-experimental.4518794d → 0.0.0-experimental.4ffb98de

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 (56) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/vite/index.js +139 -200
  3. package/package.json +1 -1
  4. package/skills/caching/SKILL.md +37 -4
  5. package/skills/parallel/SKILL.md +59 -0
  6. package/src/browser/event-controller.ts +5 -0
  7. package/src/browser/navigation-bridge.ts +1 -7
  8. package/src/browser/navigation-client.ts +60 -27
  9. package/src/browser/partial-update.ts +21 -8
  10. package/src/browser/prefetch/cache.ts +57 -5
  11. package/src/browser/prefetch/fetch.ts +30 -21
  12. package/src/browser/prefetch/queue.ts +53 -13
  13. package/src/browser/react/Link.tsx +9 -1
  14. package/src/browser/react/NavigationProvider.tsx +27 -0
  15. package/src/browser/rsc-router.tsx +109 -57
  16. package/src/browser/scroll-restoration.ts +11 -10
  17. package/src/browser/segment-reconciler.ts +6 -1
  18. package/src/browser/types.ts +9 -0
  19. package/src/build/route-types/router-processing.ts +12 -2
  20. package/src/cache/cache-scope.ts +2 -2
  21. package/src/cache/cf/cf-cache-store.ts +453 -11
  22. package/src/cache/cf/index.ts +5 -1
  23. package/src/cache/document-cache.ts +17 -7
  24. package/src/cache/index.ts +1 -0
  25. package/src/debug.ts +2 -2
  26. package/src/route-definition/dsl-helpers.ts +32 -7
  27. package/src/route-definition/redirect.ts +2 -2
  28. package/src/router/lazy-includes.ts +2 -1
  29. package/src/router/logging.ts +1 -1
  30. package/src/router/manifest.ts +6 -2
  31. package/src/router/match-middleware/background-revalidation.ts +18 -1
  32. package/src/router/match-middleware/cache-lookup.ts +20 -3
  33. package/src/router/match-middleware/cache-store.ts +32 -6
  34. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  35. package/src/router/match-middleware/segment-resolution.ts +7 -5
  36. package/src/router/match-result.ts +11 -1
  37. package/src/router/metrics.ts +6 -1
  38. package/src/router/middleware.ts +2 -1
  39. package/src/router/segment-resolution/fresh.ts +104 -14
  40. package/src/router/segment-resolution/loader-cache.ts +1 -0
  41. package/src/router/segment-resolution/revalidation.ts +307 -272
  42. package/src/router.ts +1 -1
  43. package/src/rsc/handler.ts +9 -0
  44. package/src/segment-system.tsx +140 -4
  45. package/src/server/context.ts +90 -13
  46. package/src/server/request-context.ts +10 -4
  47. package/src/ssr/index.tsx +1 -0
  48. package/src/types/segments.ts +2 -0
  49. package/src/urls/path-helper.ts +1 -1
  50. package/src/vite/discovery/state.ts +0 -2
  51. package/src/vite/plugin-types.ts +0 -83
  52. package/src/vite/plugins/expose-action-id.ts +1 -3
  53. package/src/vite/plugins/version-plugin.ts +13 -1
  54. package/src/vite/rango.ts +144 -209
  55. package/src/vite/router-discovery.ts +0 -8
  56. package/src/vite/utils/banner.ts +3 -3
@@ -109,6 +109,65 @@ parallel(
109
109
  )
110
110
  ```
111
111
 
112
+ ### Streaming Behavior
113
+
114
+ Parallels with `loading()` are **independent streaming units**. They don't
115
+ block the parent layout or sibling routes during SSR:
116
+
117
+ - **With `loading()`**: The skeleton renders immediately. The loader runs
118
+ in the background and streams data to the client when ready. The rest
119
+ of the page (layout, route content, other parallels) renders without
120
+ waiting.
121
+ - **Without `loading()`**: The parallel's loaders block the parent layout's
122
+ rendering. Use this when the data must be available before the page
123
+ paints (e.g., critical above-the-fold content).
124
+ - **SPA navigation**: Parallel loaders resolve in the background. The
125
+ existing parallel UI stays visible — no skeleton flash on route changes
126
+ within the same layout.
127
+
128
+ ```typescript
129
+ // Sidebar streams independently — page renders immediately
130
+ parallel(
131
+ { "@sidebar": () => <Sidebar /> },
132
+ () => [loader(SlowSidebarLoader), loading(<SidebarSkeleton />)]
133
+ )
134
+
135
+ // Cart data blocks layout — must be ready before paint
136
+ parallel(
137
+ { "@cartBadge": () => <CartBadge /> },
138
+ () => [loader(CartCountLoader)] // No loading() = awaited
139
+ )
140
+ ```
141
+
142
+ ## Slot Override Semantics
143
+
144
+ When multiple `parallel()` calls define the same slot name, **the last
145
+ definition wins**. Earlier definitions of that slot are removed. Other
146
+ slots from the earlier call are preserved.
147
+
148
+ This enables composition patterns where included routes override
149
+ parent-defined slots:
150
+
151
+ ```typescript
152
+ layout(DashboardLayout, () => [
153
+ // Base slots
154
+ parallel({
155
+ "@sidebar": () => <DefaultSidebar />,
156
+ "@footer": () => <Footer />,
157
+ }),
158
+
159
+ // Override just @sidebar — @footer is preserved
160
+ parallel({ "@sidebar": () => <CustomSidebar /> }),
161
+
162
+ path("/", DashboardIndex, { name: "index" }),
163
+ ])
164
+ ```
165
+
166
+ After resolution, the layout has two parallel entries:
167
+
168
+ - `{ "@footer": () => <Footer /> }` (first call, `@sidebar` removed)
169
+ - `{ "@sidebar": () => <CustomSidebar /> }` (second call, wins)
170
+
112
171
  ## Multiple Parallel Slots
113
172
 
114
173
  ```typescript
@@ -79,6 +79,8 @@ export interface DerivedNavigationState {
79
79
  state: "idle" | "loading";
80
80
  /** Whether any operation is streaming */
81
81
  isStreaming: boolean;
82
+ /** Whether a navigation is active (fetching or streaming, before commit) */
83
+ isNavigating: boolean;
82
84
  /** Current committed location */
83
85
  location: NavigationLocation;
84
86
  /** URL being navigated to (null if idle) */
@@ -389,6 +391,9 @@ export function createEventController(
389
391
  return {
390
392
  state,
391
393
  isStreaming,
394
+ // True when a navigation is active (fetching or streaming, before
395
+ // commit). Broader than pendingUrl which clears during streaming.
396
+ isNavigating: currentNavigation !== null,
392
397
  location,
393
398
  // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
394
399
  // Background revalidations (skipLoadingState) don't expose a pending URL.
@@ -461,10 +461,6 @@ export function createNavigationBridge(
461
461
  }
462
462
  eventController.setParams(cachedParams);
463
463
 
464
- // Scroll to top immediately to avoid flicker from the previous
465
- // page's scroll position while React paints the cached content.
466
- window.scrollTo(0, 0);
467
-
468
464
  const popstateUpdate = {
469
465
  root,
470
466
  metadata: {
@@ -476,6 +472,7 @@ export function createNavigationBridge(
476
472
  cachedHandleData,
477
473
  params: cachedParams,
478
474
  },
475
+ scroll: { restore: true, isStreaming },
479
476
  };
480
477
  const hasTransition = cachedSegments.some((s) => s.transition);
481
478
  if (hasTransition) {
@@ -489,9 +486,6 @@ export function createNavigationBridge(
489
486
  onUpdate(popstateUpdate);
490
487
  }
491
488
 
492
- // Restore the actual saved scroll position after React paints.
493
- handleNavigationEnd({ restore: true, isStreaming });
494
-
495
489
  // SWR: If stale, trigger background revalidation
496
490
  if (isStale) {
497
491
  debugLog("[Browser] Cache is stale, background revalidating...");
@@ -17,7 +17,11 @@ import {
17
17
  emptyResponse,
18
18
  teeWithCompletion,
19
19
  } from "./response-adapter.js";
20
- import { buildPrefetchKey, consumePrefetch } from "./prefetch/cache.js";
20
+ import {
21
+ buildPrefetchKey,
22
+ consumeInflightPrefetch,
23
+ consumePrefetch,
24
+ } from "./prefetch/cache.js";
21
25
 
22
26
  /**
23
27
  * Create a navigation client for fetching RSC payloads
@@ -85,49 +89,33 @@ export function createNavigationClient(
85
89
  fetchUrl.searchParams.set("_rsc_v", version);
86
90
  }
87
91
 
88
- // Check in-memory prefetch cache before making a network request.
92
+ // Check completed in-memory prefetch cache before making a network request.
89
93
  // The cache key includes the source URL (previousUrl) because the
90
94
  // server's diff response depends on the source page context.
91
95
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
92
96
  // fresh modules), and intercept contexts (source-dependent responses).
97
+ //
98
+ const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
93
99
  const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
94
- const cachedResponse =
95
- !staleRevalidation && !hmr && !interceptSourceUrl
96
- ? consumePrefetch(cacheKey)
97
- : null;
98
-
100
+ const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
101
+ const inflightResponsePromise = canUsePrefetch
102
+ ? consumeInflightPrefetch(cacheKey)
103
+ : null;
99
104
  // Track when the stream completes
100
105
  let resolveStreamComplete: () => void;
101
106
  const streamComplete = new Promise<void>((resolve) => {
102
107
  resolveStreamComplete = resolve;
103
108
  });
104
109
 
105
- let responsePromise: Promise<Response>;
106
-
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 {
110
+ /** Start a fresh navigation fetch (no cache / inflight hit). */
111
+ const doFreshFetch = (): Promise<Response> => {
124
112
  if (tx) {
125
113
  browserDebugLog(tx, "fetching", {
126
114
  path: `${fetchUrl.pathname}${fetchUrl.search}`,
127
115
  });
128
116
  }
129
117
 
130
- responsePromise = fetch(fetchUrl, {
118
+ return fetch(fetchUrl, {
131
119
  headers: {
132
120
  "X-RSC-Router-Client-Path": previousUrl,
133
121
  "X-Rango-State": getRangoState(),
@@ -183,6 +171,51 @@ export function createNavigationClient(
183
171
  signal,
184
172
  );
185
173
  });
174
+ };
175
+
176
+ let responsePromise: Promise<Response>;
177
+
178
+ if (cachedResponse) {
179
+ if (tx) {
180
+ browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
181
+ }
182
+ // Cached response body is already fully buffered (arrayBuffer),
183
+ // so stream completion is immediate.
184
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
185
+ return teeWithCompletion(
186
+ response,
187
+ () => {
188
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
189
+ resolveStreamComplete();
190
+ },
191
+ signal,
192
+ );
193
+ });
194
+ } else if (inflightResponsePromise) {
195
+ if (tx) {
196
+ browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
197
+ }
198
+ responsePromise = inflightResponsePromise.then(async (response) => {
199
+ if (!response) {
200
+ if (tx) {
201
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
202
+ }
203
+ return doFreshFetch();
204
+ }
205
+
206
+ return teeWithCompletion(
207
+ response,
208
+ () => {
209
+ if (tx) {
210
+ browserDebugLog(tx, "stream complete (from inflight prefetch)");
211
+ }
212
+ resolveStreamComplete();
213
+ },
214
+ signal,
215
+ );
216
+ });
217
+ } else {
218
+ responsePromise = doFreshFetch();
186
219
  }
187
220
 
188
221
  try {
@@ -19,7 +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 { handleNavigationEnd } from "./scroll-restoration.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
+ }
23
30
 
24
31
  /**
25
32
  * Configuration for creating a partial updater
@@ -264,6 +271,7 @@ export function createPartialUpdater(
264
271
  ...metadataWithoutHandles,
265
272
  cachedHandleData: mode.targetCacheHandleData,
266
273
  },
274
+ scroll: toScrollPayload(commitScroll),
267
275
  };
268
276
 
269
277
  const cachedHasTransition = existingSegments.some(
@@ -280,7 +288,6 @@ export function createPartialUpdater(
280
288
  onUpdate(cachedUpdate);
281
289
  }
282
290
 
283
- handleNavigationEnd({ scroll: commitScroll });
284
291
  debugLog("[Browser] Navigation complete (rendered from cache)");
285
292
  return;
286
293
  }
@@ -303,9 +310,9 @@ export function createPartialUpdater(
303
310
  onUpdate({
304
311
  root: newTree,
305
312
  metadata: payload.metadata,
313
+ scroll: toScrollPayload(leaveScroll),
306
314
  });
307
315
 
308
- handleNavigationEnd({ scroll: leaveScroll });
309
316
  debugLog("[Browser] Navigation complete (left intercept)");
310
317
  return;
311
318
  }
@@ -454,8 +461,10 @@ export function createPartialUpdater(
454
461
 
455
462
  debugLog("[partial-update] updating document");
456
463
 
457
- // Emit update to trigger React render
464
+ // Emit update to trigger React render.
465
+ // Scroll info is included so NavigationProvider applies it after React commits.
458
466
  const hasTransition = reconciled.mainSegments.some((s) => s.transition);
467
+ const scrollPayload = toScrollPayload(navScroll);
459
468
 
460
469
  if (mode.type === "action" || mode.type === "stale-revalidation") {
461
470
  startTransition(() => {
@@ -465,6 +474,7 @@ export function createPartialUpdater(
465
474
  onUpdate({
466
475
  root: newTree,
467
476
  metadata: payload.metadata!,
477
+ scroll: scrollPayload,
468
478
  });
469
479
  });
470
480
  } else if (hasTransition) {
@@ -475,18 +485,17 @@ export function createPartialUpdater(
475
485
  onUpdate({
476
486
  root: newTree,
477
487
  metadata: payload.metadata!,
488
+ scroll: scrollPayload,
478
489
  });
479
490
  });
480
491
  } else {
481
492
  onUpdate({
482
493
  root: newTree,
483
494
  metadata: payload.metadata!,
495
+ scroll: scrollPayload,
484
496
  });
485
497
  }
486
498
 
487
- // Scroll after onUpdate so React has the new content before we scroll
488
- handleNavigationEnd({ scroll: navScroll });
489
-
490
499
  debugLog("[Browser] Navigation complete");
491
500
  return;
492
501
  } else {
@@ -519,6 +528,7 @@ export function createPartialUpdater(
519
528
  const fullHasTransition = segments.some(
520
529
  (s: ResolvedSegment) => s.transition,
521
530
  );
531
+ const fullScrollPayload = toScrollPayload(fullScroll);
522
532
 
523
533
  if (mode.type === "stale-revalidation") {
524
534
  await rawStreamComplete;
@@ -529,6 +539,7 @@ export function createPartialUpdater(
529
539
  onUpdate({
530
540
  root: newTree,
531
541
  metadata: payload.metadata!,
542
+ scroll: fullScrollPayload,
532
543
  });
533
544
  });
534
545
  } else if (mode.type === "action") {
@@ -539,6 +550,7 @@ export function createPartialUpdater(
539
550
  onUpdate({
540
551
  root: newTree,
541
552
  metadata: payload.metadata!,
553
+ scroll: fullScrollPayload,
542
554
  });
543
555
  });
544
556
  } else if (fullHasTransition) {
@@ -549,16 +561,17 @@ export function createPartialUpdater(
549
561
  onUpdate({
550
562
  root: newTree,
551
563
  metadata: payload.metadata!,
564
+ scroll: fullScrollPayload,
552
565
  });
553
566
  });
554
567
  } else {
555
568
  onUpdate({
556
569
  root: newTree,
557
570
  metadata: payload.metadata!,
571
+ scroll: fullScrollPayload,
558
572
  });
559
573
  }
560
574
 
561
- handleNavigationEnd({ scroll: fullScroll });
562
575
  return;
563
576
  }
564
577
  }
@@ -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
  }