@rangojs/router 0.0.0-experimental.1b930379 → 0.0.0-experimental.1fa245e2

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 (136) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +76 -18
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +558 -319
  5. package/package.json +16 -15
  6. package/skills/cache-guide/SKILL.md +32 -0
  7. package/skills/caching/SKILL.md +45 -4
  8. package/skills/links/SKILL.md +3 -1
  9. package/skills/loader/SKILL.md +53 -43
  10. package/skills/middleware/SKILL.md +2 -0
  11. package/skills/parallel/SKILL.md +126 -0
  12. package/skills/prerender/SKILL.md +110 -68
  13. package/skills/route/SKILL.md +31 -0
  14. package/skills/router-setup/SKILL.md +87 -2
  15. package/skills/typesafety/SKILL.md +10 -0
  16. package/src/__internal.ts +1 -1
  17. package/src/browser/app-version.ts +14 -0
  18. package/src/browser/event-controller.ts +5 -0
  19. package/src/browser/navigation-bridge.ts +19 -13
  20. package/src/browser/navigation-client.ts +115 -58
  21. package/src/browser/navigation-store.ts +43 -8
  22. package/src/browser/navigation-transaction.ts +11 -9
  23. package/src/browser/partial-update.ts +80 -15
  24. package/src/browser/prefetch/cache.ts +57 -5
  25. package/src/browser/prefetch/fetch.ts +38 -23
  26. package/src/browser/prefetch/queue.ts +92 -20
  27. package/src/browser/prefetch/resource-ready.ts +77 -0
  28. package/src/browser/react/Link.tsx +53 -9
  29. package/src/browser/react/NavigationProvider.tsx +40 -4
  30. package/src/browser/react/context.ts +7 -2
  31. package/src/browser/react/use-handle.ts +9 -58
  32. package/src/browser/react/use-router.ts +21 -8
  33. package/src/browser/rsc-router.tsx +134 -59
  34. package/src/browser/scroll-restoration.ts +41 -42
  35. package/src/browser/segment-reconciler.ts +6 -1
  36. package/src/browser/server-action-bridge.ts +8 -6
  37. package/src/browser/types.ts +36 -5
  38. package/src/build/generate-manifest.ts +6 -6
  39. package/src/build/generate-route-types.ts +3 -0
  40. package/src/build/route-types/include-resolution.ts +8 -1
  41. package/src/build/route-types/router-processing.ts +223 -74
  42. package/src/build/route-types/scan-filter.ts +8 -1
  43. package/src/cache/cache-runtime.ts +15 -11
  44. package/src/cache/cache-scope.ts +48 -7
  45. package/src/cache/cf/cf-cache-store.ts +453 -11
  46. package/src/cache/cf/index.ts +5 -1
  47. package/src/cache/document-cache.ts +17 -7
  48. package/src/cache/index.ts +1 -0
  49. package/src/cache/taint.ts +55 -0
  50. package/src/client.tsx +2 -56
  51. package/src/context-var.ts +72 -2
  52. package/src/debug.ts +2 -2
  53. package/src/handle.ts +40 -0
  54. package/src/index.rsc.ts +3 -1
  55. package/src/index.ts +8 -0
  56. package/src/prerender/store.ts +5 -4
  57. package/src/prerender.ts +138 -77
  58. package/src/reverse.ts +22 -1
  59. package/src/route-definition/dsl-helpers.ts +73 -25
  60. package/src/route-definition/helpers-types.ts +10 -6
  61. package/src/route-definition/index.ts +3 -0
  62. package/src/route-definition/redirect.ts +11 -3
  63. package/src/route-definition/resolve-handler-use.ts +149 -0
  64. package/src/route-map-builder.ts +7 -1
  65. package/src/route-types.ts +11 -0
  66. package/src/router/content-negotiation.ts +100 -1
  67. package/src/router/find-match.ts +4 -2
  68. package/src/router/handler-context.ts +79 -23
  69. package/src/router/intercept-resolution.ts +11 -4
  70. package/src/router/lazy-includes.ts +4 -1
  71. package/src/router/loader-resolution.ts +122 -10
  72. package/src/router/logging.ts +5 -2
  73. package/src/router/manifest.ts +9 -3
  74. package/src/router/match-api.ts +124 -189
  75. package/src/router/match-middleware/background-revalidation.ts +30 -2
  76. package/src/router/match-middleware/cache-lookup.ts +88 -16
  77. package/src/router/match-middleware/cache-store.ts +53 -10
  78. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  79. package/src/router/match-middleware/segment-resolution.ts +61 -5
  80. package/src/router/match-result.ts +22 -6
  81. package/src/router/metrics.ts +6 -1
  82. package/src/router/middleware-types.ts +6 -8
  83. package/src/router/middleware.ts +4 -6
  84. package/src/router/navigation-snapshot.ts +182 -0
  85. package/src/router/prerender-match.ts +110 -10
  86. package/src/router/preview-match.ts +30 -102
  87. package/src/router/request-classification.ts +310 -0
  88. package/src/router/route-snapshot.ts +245 -0
  89. package/src/router/router-context.ts +6 -1
  90. package/src/router/router-interfaces.ts +36 -4
  91. package/src/router/router-options.ts +37 -11
  92. package/src/router/segment-resolution/fresh.ts +183 -20
  93. package/src/router/segment-resolution/helpers.ts +29 -24
  94. package/src/router/segment-resolution/loader-cache.ts +1 -0
  95. package/src/router/segment-resolution/revalidation.ts +412 -297
  96. package/src/router/segment-wrappers.ts +2 -0
  97. package/src/router/types.ts +1 -0
  98. package/src/router.ts +59 -6
  99. package/src/rsc/handler.ts +460 -368
  100. package/src/rsc/manifest-init.ts +5 -1
  101. package/src/rsc/progressive-enhancement.ts +4 -0
  102. package/src/rsc/rsc-rendering.ts +5 -0
  103. package/src/rsc/server-action.ts +2 -0
  104. package/src/rsc/ssr-setup.ts +2 -2
  105. package/src/rsc/types.ts +8 -1
  106. package/src/segment-system.tsx +140 -4
  107. package/src/server/context.ts +140 -14
  108. package/src/server/loader-registry.ts +9 -8
  109. package/src/server/request-context.ts +144 -18
  110. package/src/ssr/index.tsx +4 -0
  111. package/src/static-handler.ts +18 -6
  112. package/src/types/cache-types.ts +4 -4
  113. package/src/types/handler-context.ts +137 -33
  114. package/src/types/loader-types.ts +36 -9
  115. package/src/types/route-entry.ts +8 -1
  116. package/src/types/segments.ts +2 -0
  117. package/src/urls/path-helper-types.ts +9 -2
  118. package/src/urls/path-helper.ts +48 -13
  119. package/src/urls/pattern-types.ts +12 -0
  120. package/src/urls/response-types.ts +16 -6
  121. package/src/use-loader.tsx +73 -4
  122. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  123. package/src/vite/discovery/discover-routers.ts +5 -1
  124. package/src/vite/discovery/prerender-collection.ts +14 -1
  125. package/src/vite/discovery/state.ts +13 -6
  126. package/src/vite/index.ts +4 -0
  127. package/src/vite/plugin-types.ts +51 -79
  128. package/src/vite/plugins/expose-action-id.ts +1 -3
  129. package/src/vite/plugins/performance-tracks.ts +88 -0
  130. package/src/vite/plugins/refresh-cmd.ts +88 -26
  131. package/src/vite/plugins/version-plugin.ts +13 -1
  132. package/src/vite/rango.ts +163 -211
  133. package/src/vite/router-discovery.ts +153 -42
  134. package/src/vite/utils/banner.ts +3 -3
  135. package/src/vite/utils/prerender-utils.ts +18 -0
  136. 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
@@ -185,7 +199,8 @@ export function createPartialUpdater(
185
199
  // (action redirect sends empty segments for a fresh render).
186
200
  staleRevalidation:
187
201
  mode.type === "stale-revalidation" || segments.length === 0,
188
- version,
202
+ version: getVersion(),
203
+ routerId: store.getRouterId?.(),
189
204
  });
190
205
  // Mark navigation as streaming (response received, now parsing RSC).
191
206
  // Called after fetchPartial so pendingUrl stays set during the network wait,
@@ -198,6 +213,21 @@ export function createPartialUpdater(
198
213
  streamingToken.end();
199
214
  });
200
215
 
216
+ // Detect app switch: if routerId changed, the navigation crossed into
217
+ // a different router (e.g., via host router path mount). Downgrade
218
+ // partial to full so the entire tree is replaced without reconciliation
219
+ // against stale segments from the previous app.
220
+ if (payload.metadata?.routerId) {
221
+ const prevRouterId = store.getRouterId?.();
222
+ if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
223
+ debugLog(
224
+ `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
225
+ );
226
+ payload.metadata.isPartial = false;
227
+ }
228
+ store.setRouterId?.(payload.metadata.routerId);
229
+ }
230
+
201
231
  // Handle server-side redirect with state
202
232
  if (payload.metadata?.redirect) {
203
233
  if (signal?.aborted) {
@@ -246,7 +276,21 @@ export function createPartialUpdater(
246
276
  forceAwait: true,
247
277
  });
248
278
 
249
- tx.commit(matchedIds, existingSegments);
279
+ const { scroll: commitScroll } = tx.commit(
280
+ matchedIds,
281
+ existingSegments,
282
+ );
283
+
284
+ // tx.commit() cached the source page's handleData because
285
+ // eventController hasn't been updated yet. Overwrite with the
286
+ // correct cached handleData to prevent cache corruption on
287
+ // subsequent navigations to this same URL.
288
+ if (mode.targetCacheHandleData) {
289
+ store.updateCacheHandleData(
290
+ store.getHistoryKey(),
291
+ mode.targetCacheHandleData,
292
+ );
293
+ }
250
294
 
251
295
  // Include cachedHandleData in metadata so NavigationProvider can restore
252
296
  // breadcrumbs and other handle data from cache.
@@ -260,6 +304,7 @@ export function createPartialUpdater(
260
304
  ...metadataWithoutHandles,
261
305
  cachedHandleData: mode.targetCacheHandleData,
262
306
  },
307
+ scroll: toScrollPayload(commitScroll),
263
308
  };
264
309
 
265
310
  const cachedHasTransition = existingSegments.some(
@@ -290,11 +335,15 @@ export function createPartialUpdater(
290
335
  forceAwait: true,
291
336
  });
292
337
 
293
- tx.commit(matchedIds, existingSegments);
338
+ const { scroll: leaveScroll } = tx.commit(
339
+ matchedIds,
340
+ existingSegments,
341
+ );
294
342
 
295
343
  onUpdate({
296
344
  root: newTree,
297
345
  metadata: payload.metadata,
346
+ scroll: toScrollPayload(leaveScroll),
298
347
  });
299
348
 
300
349
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -411,8 +460,10 @@ export function createPartialUpdater(
411
460
  }
412
461
  }
413
462
 
414
- // Commit navigation - transaction handles all store mutations atomically
415
- const allSegmentIds = reconciled.segments.map((s) => s.id);
463
+ // Commit navigation - use server's matched as the authoritative segment ID list.
464
+ // reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
465
+ // but the server's matched always includes all expected segment IDs.
466
+ const allSegmentIds = matchedIds;
416
467
  const serverLocationState = payload.metadata?.locationState;
417
468
  const overrides: CommitOverrides | undefined = isInterceptResponse
418
469
  ? {
@@ -424,7 +475,11 @@ export function createPartialUpdater(
424
475
  : serverLocationState
425
476
  ? { serverState: serverLocationState }
426
477
  : undefined;
427
- tx.commit(allSegmentIds, reconciled.segments, overrides);
478
+ const { scroll: navScroll } = tx.commit(
479
+ allSegmentIds,
480
+ reconciled.segments,
481
+ overrides,
482
+ );
428
483
 
429
484
  // For stale revalidation: verify history key hasn't changed before updating UI
430
485
  if (mode.type === "stale-revalidation") {
@@ -439,8 +494,10 @@ export function createPartialUpdater(
439
494
 
440
495
  debugLog("[partial-update] updating document");
441
496
 
442
- // Emit update to trigger React render
497
+ // Emit update to trigger React render.
498
+ // Scroll info is included so NavigationProvider applies it after React commits.
443
499
  const hasTransition = reconciled.mainSegments.some((s) => s.transition);
500
+ const scrollPayload = toScrollPayload(navScroll);
444
501
 
445
502
  if (mode.type === "action" || mode.type === "stale-revalidation") {
446
503
  startTransition(() => {
@@ -450,6 +507,7 @@ export function createPartialUpdater(
450
507
  onUpdate({
451
508
  root: newTree,
452
509
  metadata: payload.metadata!,
510
+ scroll: scrollPayload,
453
511
  });
454
512
  });
455
513
  } else if (hasTransition) {
@@ -460,12 +518,14 @@ export function createPartialUpdater(
460
518
  onUpdate({
461
519
  root: newTree,
462
520
  metadata: payload.metadata!,
521
+ scroll: scrollPayload,
463
522
  });
464
523
  });
465
524
  } else {
466
525
  onUpdate({
467
526
  root: newTree,
468
527
  metadata: payload.metadata!,
528
+ scroll: scrollPayload,
469
529
  });
470
530
  }
471
531
 
@@ -492,15 +552,16 @@ export function createPartialUpdater(
492
552
  }
493
553
 
494
554
  const fullUpdateServerState = payload.metadata?.locationState;
495
- if (fullUpdateServerState) {
496
- tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
497
- } else {
498
- tx.commit(segmentIds, segments);
499
- }
555
+ const { scroll: fullScroll } = fullUpdateServerState
556
+ ? tx.commit(segmentIds, segments, {
557
+ serverState: fullUpdateServerState,
558
+ })
559
+ : tx.commit(segmentIds, segments);
500
560
 
501
561
  const fullHasTransition = segments.some(
502
562
  (s: ResolvedSegment) => s.transition,
503
563
  );
564
+ const fullScrollPayload = toScrollPayload(fullScroll);
504
565
 
505
566
  if (mode.type === "stale-revalidation") {
506
567
  await rawStreamComplete;
@@ -511,6 +572,7 @@ export function createPartialUpdater(
511
572
  onUpdate({
512
573
  root: newTree,
513
574
  metadata: payload.metadata!,
575
+ scroll: fullScrollPayload,
514
576
  });
515
577
  });
516
578
  } else if (mode.type === "action") {
@@ -521,6 +583,7 @@ export function createPartialUpdater(
521
583
  onUpdate({
522
584
  root: newTree,
523
585
  metadata: payload.metadata!,
586
+ scroll: fullScrollPayload,
524
587
  });
525
588
  });
526
589
  } else if (fullHasTransition) {
@@ -531,12 +594,14 @@ export function createPartialUpdater(
531
594
  onUpdate({
532
595
  root: newTree,
533
596
  metadata: payload.metadata!,
597
+ scroll: fullScrollPayload,
534
598
  });
535
599
  });
536
600
  } else {
537
601
  onUpdate({
538
602
  root: newTree,
539
603
  metadata: payload.metadata!,
604
+ scroll: fullScrollPayload,
540
605
  });
541
606
  }
542
607
 
@@ -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,
@@ -30,6 +34,7 @@ function buildPrefetchUrl(
30
34
  url: string,
31
35
  segmentIds: string[],
32
36
  version?: string,
37
+ routerId?: string,
33
38
  ): URL | null {
34
39
  let targetUrl: URL;
35
40
  try {
@@ -47,23 +52,27 @@ function buildPrefetchUrl(
47
52
  if (version) {
48
53
  targetUrl.searchParams.set("_rsc_v", version);
49
54
  }
55
+ if (routerId) {
56
+ targetUrl.searchParams.set("_rsc_rid", routerId);
57
+ }
50
58
  return targetUrl;
51
59
  }
52
60
 
53
61
  /**
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.
62
+ * Core prefetch fetch logic. Fetches the response, tees the body, and stores
63
+ * one branch in the in-memory cache. The returned Promise resolves to the
64
+ * sibling navigation branch (or null on failure) so navigation can safely
65
+ * reuse an in-flight prefetch via consumeInflightPrefetch().
57
66
  */
58
67
  function executePrefetchFetch(
59
68
  key: string,
60
69
  fetchUrl: string,
61
70
  signal?: AbortSignal,
62
- ): Promise<void> {
71
+ ): Promise<Response | null> {
63
72
  const gen = currentGeneration();
64
73
  markPrefetchInflight(key);
65
74
 
66
- return fetch(fetchUrl, {
75
+ const promise: Promise<Response | null> = fetch(fetchUrl, {
67
76
  priority: "low" as RequestPriority,
68
77
  signal,
69
78
  headers: {
@@ -72,26 +81,27 @@ function executePrefetchFetch(
72
81
  "X-Rango-Prefetch": "1",
73
82
  },
74
83
  })
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, {
84
+ .then((response) => {
85
+ if (!response.ok) return null;
86
+ // Don't buffer with arrayBuffer() that blocks until the entire
87
+ // body downloads, defeating streaming for slow loaders.
88
+ // Tee the body: one branch for navigation, one for cache storage.
89
+ const [navStream, cacheStream] = response.body!.tee();
90
+ const responseInit = {
83
91
  headers: response.headers,
84
92
  status: response.status,
85
93
  statusText: response.statusText,
86
- });
87
- storePrefetch(key, cachedResponse, gen);
88
- })
89
- .catch(() => {
90
- // Silently ignore prefetch failures (including abort)
94
+ };
95
+ storePrefetch(key, new Response(cacheStream, responseInit), gen);
96
+ return new Response(navStream, responseInit);
91
97
  })
98
+ .catch(() => null)
92
99
  .finally(() => {
93
100
  clearPrefetchInflight(key);
94
101
  });
102
+
103
+ setInflightPromise(key, promise);
104
+ return promise;
95
105
  }
96
106
 
97
107
  /**
@@ -102,10 +112,11 @@ export function prefetchDirect(
102
112
  url: string,
103
113
  segmentIds: string[],
104
114
  version?: string,
115
+ routerId?: string,
105
116
  ): void {
106
117
  if (!shouldPrefetch()) return;
107
118
 
108
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
119
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
109
120
  if (!targetUrl) return;
110
121
  const key = buildPrefetchKey(window.location.href, targetUrl);
111
122
  if (hasPrefetch(key)) return;
@@ -121,15 +132,19 @@ export function prefetchQueued(
121
132
  url: string,
122
133
  segmentIds: string[],
123
134
  version?: string,
135
+ routerId?: string,
124
136
  ): string {
125
137
  if (!shouldPrefetch()) return "";
126
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
138
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
127
139
  if (!targetUrl) return "";
128
140
  const key = buildPrefetchKey(window.location.href, targetUrl);
129
141
  if (hasPrefetch(key)) return key;
130
142
  const fetchUrlStr = targetUrl.toString();
131
- enqueuePrefetch(key, (signal) =>
132
- executePrefetchFetch(key, fetchUrlStr, signal),
133
- );
143
+ enqueuePrefetch(key, (signal) => {
144
+ // Re-check at execution time: a hover-triggered prefetchDirect may
145
+ // have started or completed this key while the item sat in the queue.
146
+ if (hasPrefetch(key)) return Promise.resolve();
147
+ return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
148
+ });
134
149
  return key;
135
150
  }
@@ -5,11 +5,19 @@
5
5
  * Hover prefetches bypass this queue — they fire directly for immediate response
6
6
  * to user intent.
7
7
  *
8
- * All queued/executing prefetches share a single AbortController so they can
9
- * be cancelled in bulk when a navigation starts.
8
+ * Draining waits for an idle main-thread moment and for viewport images to
9
+ * finish loading, so prefetch fetch() calls never compete with critical
10
+ * resources for the browser's connection pool.
11
+ *
12
+ * When a navigation starts, queued prefetches are cancelled but executing ones
13
+ * are left running. Navigation can reuse their in-flight responses via the
14
+ * prefetch cache's inflight promise map, avoiding duplicate requests.
10
15
  */
11
16
 
17
+ import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
18
+
12
19
  const MAX_CONCURRENT = 2;
20
+ const IMAGE_WAIT_TIMEOUT = 2000;
13
21
 
14
22
  let active = 0;
15
23
  const queue: Array<{
@@ -18,7 +26,9 @@ const queue: Array<{
18
26
  }> = [];
19
27
  const queued = new Set<string>();
20
28
  const executing = new Set<string>();
21
- let abortController: AbortController | null = null;
29
+ const abortControllers = new Map<string, AbortController>();
30
+ let drainScheduled = false;
31
+ let drainGeneration = 0;
22
32
 
23
33
  function startExecution(
24
34
  key: string,
@@ -26,18 +36,49 @@ function startExecution(
26
36
  ): void {
27
37
  active++;
28
38
  executing.add(key);
29
- abortController ??= new AbortController();
30
- execute(abortController.signal).finally(() => {
39
+ const ac = new AbortController();
40
+ abortControllers.set(key, ac);
41
+ execute(ac.signal).finally(() => {
42
+ abortControllers.delete(key);
31
43
  // Only decrement if this key wasn't already cleared by cancelAllPrefetches.
32
44
  // Without this guard, cancelled tasks' .finally() would underflow active
33
45
  // below zero, breaking the MAX_CONCURRENT guarantee.
34
46
  if (executing.delete(key)) {
35
47
  active--;
36
48
  }
37
- drain();
49
+ scheduleDrain();
38
50
  });
39
51
  }
40
52
 
53
+ /**
54
+ * Schedule a drain after the browser is idle and viewport images are loaded.
55
+ * Coalesces multiple drain requests into a single deferred callback so
56
+ * batch completion doesn't schedule redundant waits.
57
+ *
58
+ * The two-step wait ensures prefetch fetch() calls don't compete with
59
+ * images for the browser's connection pool:
60
+ * 1. waitForIdle — yield until the main thread has a quiet moment
61
+ * 2. waitForViewportImages OR 2s timeout — yield until visible images
62
+ * finish loading, but don't let slow/broken images block indefinitely
63
+ */
64
+ function scheduleDrain(): void {
65
+ if (drainScheduled) return;
66
+ if (active >= MAX_CONCURRENT || queue.length === 0) return;
67
+ drainScheduled = true;
68
+ const gen = drainGeneration;
69
+ waitForIdle()
70
+ .then(() =>
71
+ Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
72
+ )
73
+ .then(() => {
74
+ drainScheduled = false;
75
+ // Stale drain: a cancel/abort happened while we were waiting.
76
+ // A fresh scheduleDrain will be called by whatever enqueues next.
77
+ if (gen !== drainGeneration) return;
78
+ if (queue.length > 0) drain();
79
+ });
80
+ }
81
+
41
82
  function drain(): void {
42
83
  while (active < MAX_CONCURRENT && queue.length > 0) {
43
84
  const item = queue.shift()!;
@@ -48,9 +89,10 @@ function drain(): void {
48
89
 
49
90
  /**
50
91
  * Enqueue a prefetch for concurrency-limited execution.
51
- * If below the concurrency limit, executes immediately.
52
- * Otherwise queues for later execution.
53
- * Deduplicates by key — items already queued or executing are skipped.
92
+ * Execution is deferred until the browser is idle and viewport images
93
+ * have finished loading, so prefetches never compete with critical
94
+ * resources. Deduplicates by key — items already queued or executing
95
+ * are skipped.
54
96
  *
55
97
  * The executor receives an AbortSignal that is aborted when
56
98
  * cancelAllPrefetches() is called (e.g. on navigation start).
@@ -61,22 +103,50 @@ export function enqueuePrefetch(
61
103
  ): void {
62
104
  if (queued.has(key) || executing.has(key)) return;
63
105
 
64
- if (active < MAX_CONCURRENT) {
65
- startExecution(key, execute);
66
- } else {
67
- queued.add(key);
68
- queue.push({ key, execute });
106
+ queued.add(key);
107
+ queue.push({ key, execute });
108
+ scheduleDrain();
109
+ }
110
+
111
+ /**
112
+ * Cancel queued prefetches and abort in-flight ones that don't match
113
+ * the current navigation target. If `keepUrl` is provided, the
114
+ * executing prefetch whose key contains that URL is kept alive so
115
+ * navigation can reuse its response via consumeInflightPrefetch.
116
+ *
117
+ * Called when a navigation starts via the NavigationProvider's
118
+ * event controller subscription.
119
+ */
120
+ export function cancelAllPrefetches(keepUrl?: string | null): void {
121
+ queue.length = 0;
122
+ queued.clear();
123
+ drainScheduled = false;
124
+ drainGeneration++;
125
+
126
+ // Abort in-flight prefetches that aren't for the navigation target.
127
+ // Keys use format "sourceHref\0targetPathname+search" — match the
128
+ // target portion (after \0) against keepUrl.
129
+ for (const [key, ac] of abortControllers) {
130
+ const target = key.split("\0")[1];
131
+ if (keepUrl && target && keepUrl.startsWith(target)) continue;
132
+ ac.abort();
133
+ abortControllers.delete(key);
134
+ if (executing.delete(key)) {
135
+ active--;
136
+ }
69
137
  }
70
138
  }
71
139
 
72
140
  /**
73
- * Cancel all in-flight and queued prefetches.
74
- * Called when a navigation starts speculative prefetches should not
75
- * compete with navigation fetches for connection slots.
141
+ * Hard-cancel everything including in-flight prefetches.
142
+ * Used by clearPrefetchCache (server action invalidation) where
143
+ * in-flight responses would be stale.
76
144
  */
77
- export function cancelAllPrefetches(): void {
78
- abortController?.abort();
79
- abortController = null;
145
+ export function abortAllPrefetches(): void {
146
+ for (const ac of abortControllers.values()) {
147
+ ac.abort();
148
+ }
149
+ abortControllers.clear();
80
150
 
81
151
  queue.length = 0;
82
152
  queued.clear();
@@ -85,4 +155,6 @@ export function cancelAllPrefetches(): void {
85
155
  // so active settles at 0 without underflow.
86
156
  executing.clear();
87
157
  active = 0;
158
+ drainScheduled = false;
159
+ drainGeneration++;
88
160
  }