@rangojs/router 0.0.0-experimental.19 → 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 (160) hide show
  1. package/{CLAUDE.md → AGENTS.md} +4 -0
  2. package/README.md +122 -30
  3. package/dist/bin/rango.js +245 -63
  4. package/dist/vite/index.js +859 -418
  5. package/package.json +3 -3
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +49 -8
  9. package/skills/document-cache/SKILL.md +2 -2
  10. package/skills/hooks/SKILL.md +33 -31
  11. package/skills/host-router/SKILL.md +218 -0
  12. package/skills/links/SKILL.md +3 -1
  13. package/skills/loader/SKILL.md +72 -22
  14. package/skills/middleware/SKILL.md +2 -0
  15. package/skills/parallel/SKILL.md +126 -0
  16. package/skills/prerender/SKILL.md +112 -70
  17. package/skills/rango/SKILL.md +0 -1
  18. package/skills/route/SKILL.md +34 -4
  19. package/skills/router-setup/SKILL.md +95 -5
  20. package/skills/typesafety/SKILL.md +35 -23
  21. package/src/__internal.ts +92 -0
  22. package/src/bin/rango.ts +18 -0
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/event-controller.ts +5 -0
  25. package/src/browser/link-interceptor.ts +4 -0
  26. package/src/browser/navigation-bridge.ts +114 -18
  27. package/src/browser/navigation-client.ts +126 -44
  28. package/src/browser/navigation-store.ts +43 -8
  29. package/src/browser/navigation-transaction.ts +11 -9
  30. package/src/browser/partial-update.ts +80 -15
  31. package/src/browser/prefetch/cache.ts +166 -27
  32. package/src/browser/prefetch/fetch.ts +52 -39
  33. package/src/browser/prefetch/policy.ts +6 -0
  34. package/src/browser/prefetch/queue.ts +92 -20
  35. package/src/browser/prefetch/resource-ready.ts +77 -0
  36. package/src/browser/react/Link.tsx +70 -14
  37. package/src/browser/react/NavigationProvider.tsx +40 -4
  38. package/src/browser/react/context.ts +7 -2
  39. package/src/browser/react/use-handle.ts +9 -58
  40. package/src/browser/react/use-router.ts +21 -8
  41. package/src/browser/rsc-router.tsx +143 -59
  42. package/src/browser/scroll-restoration.ts +41 -42
  43. package/src/browser/segment-reconciler.ts +6 -1
  44. package/src/browser/server-action-bridge.ts +454 -436
  45. package/src/browser/types.ts +60 -5
  46. package/src/build/generate-manifest.ts +6 -6
  47. package/src/build/generate-route-types.ts +5 -0
  48. package/src/build/route-trie.ts +19 -3
  49. package/src/build/route-types/include-resolution.ts +8 -1
  50. package/src/build/route-types/router-processing.ts +346 -87
  51. package/src/build/route-types/scan-filter.ts +8 -1
  52. package/src/cache/cache-runtime.ts +15 -11
  53. package/src/cache/cache-scope.ts +48 -7
  54. package/src/cache/cf/cf-cache-store.ts +453 -11
  55. package/src/cache/cf/index.ts +5 -1
  56. package/src/cache/document-cache.ts +17 -7
  57. package/src/cache/index.ts +1 -0
  58. package/src/cache/taint.ts +55 -0
  59. package/src/client.rsc.tsx +2 -1
  60. package/src/client.tsx +3 -102
  61. package/src/context-var.ts +72 -2
  62. package/src/debug.ts +2 -2
  63. package/src/handle.ts +40 -0
  64. package/src/handles/breadcrumbs.ts +66 -0
  65. package/src/handles/index.ts +1 -0
  66. package/src/host/index.ts +0 -3
  67. package/src/index.rsc.ts +8 -37
  68. package/src/index.ts +40 -66
  69. package/src/prerender/store.ts +57 -15
  70. package/src/prerender.ts +138 -77
  71. package/src/reverse.ts +22 -1
  72. package/src/route-definition/dsl-helpers.ts +73 -25
  73. package/src/route-definition/helpers-types.ts +10 -6
  74. package/src/route-definition/index.ts +3 -3
  75. package/src/route-definition/redirect.ts +11 -3
  76. package/src/route-definition/resolve-handler-use.ts +149 -0
  77. package/src/route-map-builder.ts +7 -1
  78. package/src/route-types.ts +11 -0
  79. package/src/router/content-negotiation.ts +100 -1
  80. package/src/router/find-match.ts +4 -2
  81. package/src/router/handler-context.ts +108 -25
  82. package/src/router/intercept-resolution.ts +11 -4
  83. package/src/router/lazy-includes.ts +4 -1
  84. package/src/router/loader-resolution.ts +123 -11
  85. package/src/router/logging.ts +5 -2
  86. package/src/router/manifest.ts +9 -3
  87. package/src/router/match-api.ts +125 -190
  88. package/src/router/match-middleware/background-revalidation.ts +30 -2
  89. package/src/router/match-middleware/cache-lookup.ts +88 -16
  90. package/src/router/match-middleware/cache-store.ts +53 -10
  91. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  92. package/src/router/match-middleware/segment-resolution.ts +61 -5
  93. package/src/router/match-result.ts +22 -15
  94. package/src/router/metrics.ts +238 -13
  95. package/src/router/middleware-types.ts +53 -12
  96. package/src/router/middleware.ts +172 -85
  97. package/src/router/navigation-snapshot.ts +182 -0
  98. package/src/router/pattern-matching.ts +20 -5
  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/revalidation.ts +27 -7
  103. package/src/router/route-snapshot.ts +245 -0
  104. package/src/router/router-context.ts +6 -1
  105. package/src/router/router-interfaces.ts +50 -5
  106. package/src/router/router-options.ts +50 -19
  107. package/src/router/segment-resolution/fresh.ts +200 -19
  108. package/src/router/segment-resolution/helpers.ts +30 -25
  109. package/src/router/segment-resolution/loader-cache.ts +1 -0
  110. package/src/router/segment-resolution/revalidation.ts +429 -301
  111. package/src/router/segment-wrappers.ts +2 -0
  112. package/src/router/trie-matching.ts +20 -2
  113. package/src/router/types.ts +1 -0
  114. package/src/router.ts +88 -15
  115. package/src/rsc/handler.ts +546 -359
  116. package/src/rsc/index.ts +0 -20
  117. package/src/rsc/manifest-init.ts +5 -1
  118. package/src/rsc/progressive-enhancement.ts +25 -8
  119. package/src/rsc/rsc-rendering.ts +35 -43
  120. package/src/rsc/server-action.ts +16 -10
  121. package/src/rsc/ssr-setup.ts +128 -0
  122. package/src/rsc/types.ts +10 -1
  123. package/src/search-params.ts +16 -13
  124. package/src/segment-system.tsx +140 -4
  125. package/src/server/context.ts +148 -16
  126. package/src/server/loader-registry.ts +9 -8
  127. package/src/server/request-context.ts +182 -34
  128. package/src/server.ts +6 -0
  129. package/src/ssr/index.tsx +4 -0
  130. package/src/static-handler.ts +18 -6
  131. package/src/theme/index.ts +4 -13
  132. package/src/types/cache-types.ts +4 -4
  133. package/src/types/handler-context.ts +149 -49
  134. package/src/types/loader-types.ts +36 -9
  135. package/src/types/route-config.ts +17 -8
  136. package/src/types/route-entry.ts +8 -1
  137. package/src/types/segments.ts +2 -5
  138. package/src/urls/path-helper-types.ts +9 -2
  139. package/src/urls/path-helper.ts +48 -13
  140. package/src/urls/pattern-types.ts +12 -0
  141. package/src/urls/response-types.ts +16 -6
  142. package/src/use-loader.tsx +73 -4
  143. package/src/vite/discovery/bundle-postprocess.ts +61 -89
  144. package/src/vite/discovery/discover-routers.ts +23 -5
  145. package/src/vite/discovery/prerender-collection.ts +48 -15
  146. package/src/vite/discovery/state.ts +17 -13
  147. package/src/vite/index.ts +8 -3
  148. package/src/vite/plugin-types.ts +51 -79
  149. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  150. package/src/vite/plugins/expose-action-id.ts +1 -3
  151. package/src/vite/plugins/performance-tracks.ts +88 -0
  152. package/src/vite/plugins/refresh-cmd.ts +127 -0
  153. package/src/vite/plugins/version-plugin.ts +13 -1
  154. package/src/vite/rango.ts +174 -211
  155. package/src/vite/router-discovery.ts +169 -42
  156. package/src/vite/utils/banner.ts +3 -3
  157. package/src/vite/utils/prerender-utils.ts +78 -0
  158. package/src/vite/utils/shared-utils.ts +3 -2
  159. package/skills/testing/SKILL.md +0 -226
  160. package/src/route-definition/route-function.ts +0 -119
@@ -1,67 +1,206 @@
1
1
  /**
2
- * Prefetch Tracking
2
+ * Prefetch Cache
3
3
  *
4
- * Tracks in-flight and completed prefetches for deduplication.
5
- * The actual response caching is handled by the browser's HTTP cache
6
- * via Vary: X-Rango-State.
4
+ * In-memory cache storing prefetched Response objects for instant cache hits
5
+ * on subsequent navigation. Cache key is source-dependent (includes the
6
+ * current page URL) because the server's diff-based response depends on
7
+ * where the user navigates from.
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
+ *
13
+ * Replaces the previous browser HTTP cache approach which was unreliable
14
+ * due to response draining race conditions and browser inconsistencies.
7
15
  */
8
16
 
9
- import { cancelAllPrefetches } from "./queue.js";
17
+ import { abortAllPrefetches } from "./queue.js";
10
18
  import { invalidateRangoState } from "../rango-state.js";
11
19
 
20
+ // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
21
+ // the server-configured prefetchCacheTTL from router options.
22
+ // 0 disables the in-memory cache entirely.
23
+ let cacheTTL = 300_000;
24
+
25
+ /**
26
+ * Initialize the prefetch cache with the configured TTL.
27
+ * Called once at app startup with the value from server metadata.
28
+ * A TTL of 0 disables the in-memory cache and all prefetching.
29
+ */
30
+ export function initPrefetchCache(ttlMs: number): void {
31
+ cacheTTL = ttlMs;
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
+ }
41
+ const MAX_PREFETCH_CACHE_SIZE = 50;
42
+
43
+ interface PrefetchCacheEntry {
44
+ response: Response;
45
+ timestamp: number;
46
+ }
47
+
48
+ const cache = new Map<string, PrefetchCacheEntry>();
12
49
  const inflight = new Set<string>();
13
- const prefetched = new Set<string>();
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>>();
14
57
 
15
58
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
16
- // started before a clear carry a stale generation and must not re-add their
17
- // key to the prefetched set (the browser HTTP cache entry is already invalid
18
- // due to Rango-State rotation).
59
+ // started before a clear carry a stale generation and must not store their
60
+ // response (the data may be stale due to a server action invalidation).
19
61
  let generation = 0;
20
62
 
21
63
  /**
22
- * Check if a prefetch is already in-flight or completed for the given key.
64
+ * Build a source-dependent cache key.
65
+ * Includes the source page href so the same target prefetched from
66
+ * different pages gets separate entries — the server response varies
67
+ * based on the source page context (diff-based rendering).
68
+ */
69
+ export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
70
+ return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
71
+ }
72
+
73
+ /**
74
+ * Check if a prefetch is already cached, in-flight, or queued for the given key.
23
75
  */
24
76
  export function hasPrefetch(key: string): boolean {
25
- return prefetched.has(key) || inflight.has(key);
77
+ if (inflight.has(key)) return true;
78
+ if (cacheTTL <= 0) return false;
79
+ const entry = cache.get(key);
80
+ if (!entry) return false;
81
+ if (Date.now() - entry.timestamp > cacheTTL) {
82
+ cache.delete(key);
83
+ return false;
84
+ }
85
+ return true;
26
86
  }
27
87
 
28
88
  /**
29
- * Capture the current generation. The returned value is passed to
30
- * markPrefetched so it can detect stale completions.
89
+ * Consume a cached prefetch response. Returns null if not found or expired.
90
+ * One-time consumption: the entry is deleted after retrieval.
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).
31
95
  */
32
- export function currentGeneration(): number {
33
- return generation;
96
+ export function consumePrefetch(key: string): Response | null {
97
+ if (cacheTTL <= 0) return null;
98
+ const entry = cache.get(key);
99
+ if (!entry) return null;
100
+ if (Date.now() - entry.timestamp > cacheTTL) {
101
+ cache.delete(key);
102
+ return null;
103
+ }
104
+ cache.delete(key);
105
+ return entry.response;
106
+ }
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;
34
129
  }
35
130
 
36
131
  /**
37
- * Mark a key as successfully prefetched (response is in browser HTTP cache).
38
- * Skips if the generation has changed since the fetch started (cache was
39
- * invalidated mid-flight, so the response uses a stale X-Rango-State).
132
+ * Store a prefetch response in the in-memory cache.
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.
135
+ *
136
+ * Skips storage if the generation has changed since the fetch started
137
+ * (a server action invalidated the cache mid-flight).
40
138
  */
41
- export function markPrefetched(key: string, fetchGeneration: number): void {
42
- if (fetchGeneration === generation) {
43
- prefetched.add(key);
139
+ export function storePrefetch(
140
+ key: string,
141
+ response: Response,
142
+ fetchGeneration: number,
143
+ ): void {
144
+ if (cacheTTL <= 0) return;
145
+ if (fetchGeneration !== generation) return;
146
+
147
+ // Evict expired entries
148
+ const now = Date.now();
149
+ for (const [k, entry] of cache) {
150
+ if (now - entry.timestamp > cacheTTL) {
151
+ cache.delete(k);
152
+ }
44
153
  }
154
+
155
+ // FIFO eviction if at capacity
156
+ if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
157
+ const oldest = cache.keys().next().value;
158
+ if (oldest) cache.delete(oldest);
159
+ }
160
+
161
+ cache.set(key, { response, timestamp: now });
162
+ }
163
+
164
+ /**
165
+ * Capture the current generation. The returned value is passed to
166
+ * storePrefetch so it can detect stale completions.
167
+ */
168
+ export function currentGeneration(): number {
169
+ return generation;
45
170
  }
46
171
 
47
172
  export function markPrefetchInflight(key: string): void {
48
173
  inflight.add(key);
49
174
  }
50
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
+
51
186
  export function clearPrefetchInflight(key: string): void {
52
187
  inflight.delete(key);
188
+ inflightPromises.delete(key);
53
189
  }
54
190
 
55
191
  /**
56
- * Invalidate prefetch state. Called when server actions mutate data.
57
- * Updates the localStorage state key so next fetch has a different
58
- * X-Rango-State value, causing Vary mismatch in browser HTTP cache.
59
- * Also cancels any in-flight or queued speculative prefetches.
192
+ * Invalidate all prefetch state. Called when server actions mutate data.
193
+ * Clears the in-memory cache, cancels in-flight prefetches, and rotates
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.
60
198
  */
61
199
  export function clearPrefetchCache(): void {
62
200
  generation++;
63
201
  inflight.clear();
64
- prefetched.clear();
65
- cancelAllPrefetches();
202
+ inflightPromises.clear();
203
+ cache.clear();
204
+ abortAllPrefetches();
66
205
  invalidateRangoState();
67
206
  }
@@ -2,15 +2,21 @@
2
2
  * Prefetch Fetch
3
3
  *
4
4
  * Fetch-based prefetch logic used by Link (hover/viewport/render strategies)
5
- * and useRouter().prefetch(). Sends low-priority fetch requests with
6
- * X-Rango-State and X-Rango-Prefetch headers so the browser HTTP cache
7
- * can serve the response on subsequent navigation.
5
+ * and useRouter().prefetch(). Sends the same headers and segment IDs as a
6
+ * real navigation so the server returns a proper diff. The Response is fully
7
+ * buffered and stored in an in-memory cache for instant consumption on
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.
8
12
  */
9
13
 
10
14
  import {
15
+ buildPrefetchKey,
11
16
  hasPrefetch,
12
17
  markPrefetchInflight,
13
- markPrefetched,
18
+ setInflightPromise,
19
+ storePrefetch,
14
20
  clearPrefetchInflight,
15
21
  currentGeneration,
16
22
  } from "./cache.js";
@@ -20,14 +26,15 @@ import { shouldPrefetch } from "./policy.js";
20
26
 
21
27
  /**
22
28
  * Build an RSC partial URL for prefetching.
23
- * Includes _rsc_v for version mismatch detection when available.
24
- * Returns null for malformed or cross-origin URLs to prevent
25
- * leaking router headers to external origins.
29
+ * Includes _rsc_segments so the server can diff against currently mounted
30
+ * segments, and _rsc_v for version mismatch detection.
31
+ * Returns null for malformed or cross-origin URLs.
26
32
  */
27
33
  function buildPrefetchUrl(
28
34
  url: string,
29
35
  segmentIds: string[],
30
36
  version?: string,
37
+ routerId?: string,
31
38
  ): URL | null {
32
39
  let targetUrl: URL;
33
40
  try {
@@ -45,32 +52,27 @@ function buildPrefetchUrl(
45
52
  if (version) {
46
53
  targetUrl.searchParams.set("_rsc_v", version);
47
54
  }
55
+ if (routerId) {
56
+ targetUrl.searchParams.set("_rsc_rid", routerId);
57
+ }
48
58
  return targetUrl;
49
59
  }
50
60
 
51
61
  /**
52
- * Build the dedup key for prefetch tracking.
53
- * Includes the source page pathname so the same target prefetched from
54
- * different pages gets separate entries the server response varies on
55
- * X-RSC-Router-Client-Path (source page context).
56
- */
57
- function buildPrefetchKey(targetUrl: URL): string {
58
- return window.location.href + "\0" + targetUrl.pathname + targetUrl.search;
59
- }
60
-
61
- /**
62
- * Core prefetch fetch logic. Returns a Promise and accepts an optional
63
- * 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().
64
66
  */
65
67
  function executePrefetchFetch(
66
68
  key: string,
67
69
  fetchUrl: string,
68
70
  signal?: AbortSignal,
69
- ): Promise<void> {
71
+ ): Promise<Response | null> {
70
72
  const gen = currentGeneration();
71
73
  markPrefetchInflight(key);
72
74
 
73
- return fetch(fetchUrl, {
75
+ const promise: Promise<Response | null> = fetch(fetchUrl, {
74
76
  priority: "low" as RequestPriority,
75
77
  signal,
76
78
  headers: {
@@ -80,36 +82,43 @@ function executePrefetchFetch(
80
82
  },
81
83
  })
82
84
  .then((response) => {
83
- // Drain body to ensure full download for browser HTTP cache.
84
- // pipeTo avoids decoding the stream into a JS string (unlike .text()).
85
- if (response.ok && response.body) {
86
- return response.body
87
- .pipeTo(new WritableStream())
88
- .then(() => markPrefetched(key, gen));
89
- }
90
- })
91
- .catch(() => {
92
- // Silently ignore prefetch failures (including abort)
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 = {
91
+ headers: response.headers,
92
+ status: response.status,
93
+ statusText: response.statusText,
94
+ };
95
+ storePrefetch(key, new Response(cacheStream, responseInit), gen);
96
+ return new Response(navStream, responseInit);
93
97
  })
98
+ .catch(() => null)
94
99
  .finally(() => {
95
100
  clearPrefetchInflight(key);
96
101
  });
102
+
103
+ setInflightPromise(key, promise);
104
+ return promise;
97
105
  }
98
106
 
99
107
  /**
100
- * Prefetch (direct): fetch with low priority and store in browser HTTP cache.
108
+ * Prefetch (direct): fetch with low priority and store in in-memory cache.
101
109
  * Used by hover strategy -- fires immediately without queueing.
102
110
  */
103
111
  export function prefetchDirect(
104
112
  url: string,
105
113
  segmentIds: string[],
106
114
  version?: string,
115
+ routerId?: string,
107
116
  ): void {
108
117
  if (!shouldPrefetch()) return;
109
118
 
110
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
119
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
111
120
  if (!targetUrl) return;
112
- const key = buildPrefetchKey(targetUrl);
121
+ const key = buildPrefetchKey(window.location.href, targetUrl);
113
122
  if (hasPrefetch(key)) return;
114
123
  executePrefetchFetch(key, targetUrl.toString());
115
124
  }
@@ -123,15 +132,19 @@ export function prefetchQueued(
123
132
  url: string,
124
133
  segmentIds: string[],
125
134
  version?: string,
135
+ routerId?: string,
126
136
  ): string {
127
137
  if (!shouldPrefetch()) return "";
128
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
138
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
129
139
  if (!targetUrl) return "";
130
- const key = buildPrefetchKey(targetUrl);
140
+ const key = buildPrefetchKey(window.location.href, targetUrl);
131
141
  if (hasPrefetch(key)) return key;
132
142
  const fetchUrlStr = targetUrl.toString();
133
- enqueuePrefetch(key, (signal) =>
134
- executePrefetchFetch(key, fetchUrlStr, signal),
135
- );
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
+ });
136
149
  return key;
137
150
  }
@@ -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)
@@ -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
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Resource Readiness
3
+ *
4
+ * Utilities to defer speculative prefetches until critical resources
5
+ * (viewport images) have finished loading. Prevents prefetch fetch()
6
+ * calls from competing with images for the browser's connection pool.
7
+ */
8
+
9
+ /**
10
+ * Resolve when all in-viewport images have finished loading.
11
+ * Returns immediately if no images are pending.
12
+ *
13
+ * Only checks images that exist at call time — does not observe
14
+ * dynamically added images. For SPA navigations where new images
15
+ * appear after render, call this after the navigation settles.
16
+ */
17
+ export function waitForViewportImages(): Promise<void> {
18
+ if (typeof document === "undefined") return Promise.resolve();
19
+
20
+ const pending = Array.from(document.querySelectorAll("img")).filter((img) => {
21
+ if (img.complete) return false;
22
+ const rect = img.getBoundingClientRect();
23
+ return (
24
+ rect.bottom > 0 &&
25
+ rect.right > 0 &&
26
+ rect.top < window.innerHeight &&
27
+ rect.left < window.innerWidth
28
+ );
29
+ });
30
+
31
+ if (pending.length === 0) return Promise.resolve();
32
+
33
+ return new Promise((resolve) => {
34
+ const settled = new Set<HTMLImageElement>();
35
+
36
+ const settle = (img: HTMLImageElement) => {
37
+ if (settled.has(img)) return;
38
+ settled.add(img);
39
+ if (settled.size >= pending.length) resolve();
40
+ };
41
+
42
+ for (const img of pending) {
43
+ img.addEventListener("load", () => settle(img), { once: true });
44
+ img.addEventListener("error", () => settle(img), { once: true });
45
+ // Re-check: image may have completed between the initial filter
46
+ // and listener attachment. settle() is idempotent per image, so
47
+ // a queued load event firing afterward is harmless.
48
+ if (img.complete) settle(img);
49
+ }
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Resolve after the given number of milliseconds.
55
+ */
56
+ export function wait(ms: number): Promise<void> {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+
60
+ /**
61
+ * Resolve when the browser has an idle main-thread moment.
62
+ * Uses requestIdleCallback where available, falls back to setTimeout.
63
+ *
64
+ * This is a scheduling hint, not an asset-loaded detector — combine
65
+ * with waitForViewportImages() for full resource readiness.
66
+ */
67
+ export function waitForIdle(timeout = 200): Promise<void> {
68
+ if (typeof window !== "undefined" && "requestIdleCallback" in window) {
69
+ return new Promise((resolve) => {
70
+ window.requestIdleCallback(() => resolve(), { timeout });
71
+ });
72
+ }
73
+
74
+ return new Promise((resolve) => {
75
+ setTimeout(resolve, 0);
76
+ });
77
+ }