@rangojs/router 0.0.0-experimental.27 → 0.0.0-experimental.289231ba

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/AGENTS.md +4 -0
  2. package/README.md +78 -19
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +853 -435
  5. package/package.json +17 -16
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +45 -4
  9. package/skills/handler-use/SKILL.md +362 -0
  10. package/skills/hooks/SKILL.md +22 -4
  11. package/skills/intercept/SKILL.md +20 -0
  12. package/skills/layout/SKILL.md +22 -0
  13. package/skills/links/SKILL.md +3 -1
  14. package/skills/loader/SKILL.md +71 -21
  15. package/skills/middleware/SKILL.md +34 -3
  16. package/skills/migrate-nextjs/SKILL.md +560 -0
  17. package/skills/migrate-react-router/SKILL.md +764 -0
  18. package/skills/parallel/SKILL.md +185 -0
  19. package/skills/prerender/SKILL.md +110 -68
  20. package/skills/rango/SKILL.md +24 -22
  21. package/skills/route/SKILL.md +56 -2
  22. package/skills/router-setup/SKILL.md +92 -2
  23. package/skills/typesafety/SKILL.md +33 -21
  24. package/src/__internal.ts +92 -0
  25. package/src/browser/app-version.ts +14 -0
  26. package/src/browser/event-controller.ts +5 -0
  27. package/src/browser/link-interceptor.ts +4 -0
  28. package/src/browser/navigation-bridge.ts +125 -16
  29. package/src/browser/navigation-client.ts +154 -44
  30. package/src/browser/navigation-store.ts +43 -8
  31. package/src/browser/navigation-transaction.ts +11 -9
  32. package/src/browser/partial-update.ts +94 -17
  33. package/src/browser/prefetch/cache.ts +176 -27
  34. package/src/browser/prefetch/fetch.ts +110 -41
  35. package/src/browser/prefetch/policy.ts +6 -0
  36. package/src/browser/prefetch/queue.ts +92 -20
  37. package/src/browser/prefetch/resource-ready.ts +77 -0
  38. package/src/browser/react/Link.tsx +88 -9
  39. package/src/browser/react/NavigationProvider.tsx +40 -4
  40. package/src/browser/react/context.ts +7 -2
  41. package/src/browser/react/use-handle.ts +9 -58
  42. package/src/browser/react/use-navigation.ts +22 -2
  43. package/src/browser/react/use-router.ts +21 -8
  44. package/src/browser/rsc-router.tsx +143 -60
  45. package/src/browser/scroll-restoration.ts +41 -42
  46. package/src/browser/segment-reconciler.ts +36 -9
  47. package/src/browser/server-action-bridge.ts +8 -6
  48. package/src/browser/types.ts +60 -5
  49. package/src/build/generate-manifest.ts +6 -6
  50. package/src/build/generate-route-types.ts +3 -0
  51. package/src/build/route-trie.ts +50 -24
  52. package/src/build/route-types/include-resolution.ts +8 -1
  53. package/src/build/route-types/router-processing.ts +223 -74
  54. package/src/build/route-types/scan-filter.ts +8 -1
  55. package/src/cache/cache-runtime.ts +15 -11
  56. package/src/cache/cache-scope.ts +48 -7
  57. package/src/cache/cf/cf-cache-store.ts +453 -11
  58. package/src/cache/cf/index.ts +5 -1
  59. package/src/cache/document-cache.ts +17 -7
  60. package/src/cache/index.ts +1 -0
  61. package/src/cache/taint.ts +55 -0
  62. package/src/client.rsc.tsx +2 -0
  63. package/src/client.tsx +85 -230
  64. package/src/context-var.ts +72 -2
  65. package/src/debug.ts +2 -2
  66. package/src/handle.ts +40 -0
  67. package/src/handles/breadcrumbs.ts +66 -0
  68. package/src/handles/index.ts +1 -0
  69. package/src/index.rsc.ts +6 -36
  70. package/src/index.ts +50 -43
  71. package/src/prerender/store.ts +5 -4
  72. package/src/prerender.ts +138 -77
  73. package/src/reverse.ts +25 -1
  74. package/src/route-definition/dsl-helpers.ts +224 -37
  75. package/src/route-definition/helpers-types.ts +67 -19
  76. package/src/route-definition/index.ts +3 -0
  77. package/src/route-definition/redirect.ts +11 -3
  78. package/src/route-definition/resolve-handler-use.ts +149 -0
  79. package/src/route-map-builder.ts +7 -1
  80. package/src/route-types.ts +18 -0
  81. package/src/router/content-negotiation.ts +100 -1
  82. package/src/router/find-match.ts +4 -2
  83. package/src/router/handler-context.ts +111 -25
  84. package/src/router/intercept-resolution.ts +11 -4
  85. package/src/router/lazy-includes.ts +9 -6
  86. package/src/router/loader-resolution.ts +156 -21
  87. package/src/router/logging.ts +5 -2
  88. package/src/router/manifest.ts +31 -16
  89. package/src/router/match-api.ts +125 -190
  90. package/src/router/match-middleware/background-revalidation.ts +30 -2
  91. package/src/router/match-middleware/cache-lookup.ts +94 -17
  92. package/src/router/match-middleware/cache-store.ts +53 -10
  93. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  94. package/src/router/match-middleware/segment-resolution.ts +61 -5
  95. package/src/router/match-result.ts +104 -10
  96. package/src/router/metrics.ts +6 -1
  97. package/src/router/middleware-types.ts +40 -12
  98. package/src/router/middleware.ts +43 -79
  99. package/src/router/navigation-snapshot.ts +182 -0
  100. package/src/router/prerender-match.ts +114 -10
  101. package/src/router/preview-match.ts +30 -102
  102. package/src/router/request-classification.ts +310 -0
  103. package/src/router/revalidation.ts +21 -10
  104. package/src/router/route-snapshot.ts +245 -0
  105. package/src/router/router-context.ts +6 -1
  106. package/src/router/router-interfaces.ts +44 -5
  107. package/src/router/router-options.ts +49 -18
  108. package/src/router/segment-resolution/fresh.ts +198 -20
  109. package/src/router/segment-resolution/helpers.ts +30 -25
  110. package/src/router/segment-resolution/loader-cache.ts +1 -0
  111. package/src/router/segment-resolution/revalidation.ts +438 -300
  112. package/src/router/segment-wrappers.ts +2 -0
  113. package/src/router/types.ts +1 -0
  114. package/src/router.ts +73 -13
  115. package/src/rsc/handler.ts +472 -372
  116. package/src/rsc/loader-fetch.ts +23 -3
  117. package/src/rsc/manifest-init.ts +5 -1
  118. package/src/rsc/progressive-enhancement.ts +14 -2
  119. package/src/rsc/rsc-rendering.ts +13 -1
  120. package/src/rsc/server-action.ts +8 -0
  121. package/src/rsc/ssr-setup.ts +2 -2
  122. package/src/rsc/types.ts +11 -1
  123. package/src/segment-content-promise.ts +67 -0
  124. package/src/segment-loader-promise.ts +122 -0
  125. package/src/segment-system.tsx +109 -23
  126. package/src/server/context.ts +166 -17
  127. package/src/server/handle-store.ts +19 -0
  128. package/src/server/loader-registry.ts +9 -8
  129. package/src/server/request-context.ts +204 -28
  130. package/src/ssr/index.tsx +4 -0
  131. package/src/static-handler.ts +18 -6
  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-entry.ts +19 -1
  136. package/src/types/segments.ts +2 -0
  137. package/src/urls/include-helper.ts +24 -14
  138. package/src/urls/path-helper-types.ts +39 -6
  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 +77 -5
  143. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  144. package/src/vite/discovery/discover-routers.ts +5 -1
  145. package/src/vite/discovery/prerender-collection.ts +128 -74
  146. package/src/vite/discovery/state.ts +13 -6
  147. package/src/vite/index.ts +4 -0
  148. package/src/vite/plugin-types.ts +51 -79
  149. package/src/vite/plugins/expose-action-id.ts +1 -3
  150. package/src/vite/plugins/expose-id-utils.ts +12 -0
  151. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  152. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  153. package/src/vite/plugins/performance-tracks.ts +88 -0
  154. package/src/vite/plugins/refresh-cmd.ts +88 -26
  155. package/src/vite/plugins/version-plugin.ts +13 -1
  156. package/src/vite/rango.ts +163 -211
  157. package/src/vite/router-discovery.ts +178 -45
  158. package/src/vite/utils/banner.ts +3 -3
  159. package/src/vite/utils/prerender-utils.ts +37 -5
  160. package/src/vite/utils/shared-utils.ts +3 -2
@@ -2,32 +2,57 @@
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";
17
23
  import { getRangoState } from "../rango-state.js";
18
24
  import { enqueuePrefetch } from "./queue.js";
19
25
  import { shouldPrefetch } from "./policy.js";
26
+ import { debugLog } from "../logging.js";
27
+
28
+ /**
29
+ * Check if a URL resolves to the current page (same pathname + search).
30
+ * Used to prevent same-page prefetching with prefetchKey, which would
31
+ * produce a trivial diff that corrupts the wildcard cache.
32
+ */
33
+ function isSamePage(url: string): boolean {
34
+ try {
35
+ const target = new URL(url, window.location.origin);
36
+ return (
37
+ target.pathname + target.search ===
38
+ window.location.pathname + window.location.search
39
+ );
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
20
44
 
21
45
  /**
22
46
  * 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.
47
+ * Includes _rsc_segments so the server can diff against currently mounted
48
+ * segments, and _rsc_v for version mismatch detection.
49
+ * Returns null for malformed or cross-origin URLs.
26
50
  */
27
51
  function buildPrefetchUrl(
28
52
  url: string,
29
53
  segmentIds: string[],
30
54
  version?: string,
55
+ routerId?: string,
31
56
  ): URL | null {
32
57
  let targetUrl: URL;
33
58
  try {
@@ -45,32 +70,27 @@ function buildPrefetchUrl(
45
70
  if (version) {
46
71
  targetUrl.searchParams.set("_rsc_v", version);
47
72
  }
73
+ if (routerId) {
74
+ targetUrl.searchParams.set("_rsc_rid", routerId);
75
+ }
48
76
  return targetUrl;
49
77
  }
50
78
 
51
79
  /**
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.
80
+ * Core prefetch fetch logic. Fetches the response, tees the body, and stores
81
+ * one branch in the in-memory cache. The returned Promise resolves to the
82
+ * sibling navigation branch (or null on failure) so navigation can safely
83
+ * reuse an in-flight prefetch via consumeInflightPrefetch().
64
84
  */
65
85
  function executePrefetchFetch(
66
86
  key: string,
67
87
  fetchUrl: string,
68
88
  signal?: AbortSignal,
69
- ): Promise<void> {
89
+ ): Promise<Response | null> {
70
90
  const gen = currentGeneration();
71
91
  markPrefetchInflight(key);
72
92
 
73
- return fetch(fetchUrl, {
93
+ const promise: Promise<Response | null> = fetch(fetchUrl, {
74
94
  priority: "low" as RequestPriority,
75
95
  signal,
76
96
  headers: {
@@ -80,37 +100,63 @@ function executePrefetchFetch(
80
100
  },
81
101
  })
82
102
  .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)
103
+ if (!response.ok) return null;
104
+ // Don't buffer with arrayBuffer() that blocks until the entire
105
+ // body downloads, defeating streaming for slow loaders.
106
+ // Tee the body: one branch for navigation, one for cache storage.
107
+ const [navStream, cacheStream] = response.body!.tee();
108
+ const responseInit = {
109
+ headers: response.headers,
110
+ status: response.status,
111
+ statusText: response.statusText,
112
+ };
113
+ storePrefetch(key, new Response(cacheStream, responseInit), gen);
114
+ return new Response(navStream, responseInit);
93
115
  })
116
+ .catch(() => null)
94
117
  .finally(() => {
95
118
  clearPrefetchInflight(key);
96
119
  });
120
+
121
+ setInflightPromise(key, promise);
122
+ return promise;
97
123
  }
98
124
 
99
125
  /**
100
- * Prefetch (direct): fetch with low priority and store in browser HTTP cache.
126
+ * Prefetch (direct): fetch with low priority and store in in-memory cache.
101
127
  * Used by hover strategy -- fires immediately without queueing.
102
128
  */
103
129
  export function prefetchDirect(
104
130
  url: string,
105
131
  segmentIds: string[],
106
132
  version?: string,
133
+ routerId?: string,
134
+ prefetchKey?: string | ((from: string) => string),
107
135
  ): void {
108
136
  if (!shouldPrefetch()) return;
109
137
 
110
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
138
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
111
139
  if (!targetUrl) return;
112
- const key = buildPrefetchKey(targetUrl);
113
- if (hasPrefetch(key)) return;
140
+ // Skip same-page prefetch with prefetchKey — a same-page diff is trivial
141
+ // and would corrupt the wildcard cache entry for cross-page navigation.
142
+ if (prefetchKey != null && isSamePage(url)) {
143
+ return;
144
+ }
145
+ const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
146
+ if (hasPrefetch(key)) {
147
+ debugLog("[prefetch] direct dedup (key already exists)", {
148
+ url,
149
+ key,
150
+ prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
151
+ });
152
+ return;
153
+ }
154
+ debugLog("[prefetch] direct fetch", {
155
+ url,
156
+ key,
157
+ source: window.location.href,
158
+ prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
159
+ });
114
160
  executePrefetchFetch(key, targetUrl.toString());
115
161
  }
116
162
 
@@ -123,15 +169,38 @@ export function prefetchQueued(
123
169
  url: string,
124
170
  segmentIds: string[],
125
171
  version?: string,
172
+ routerId?: string,
173
+ prefetchKey?: string | ((from: string) => string),
126
174
  ): string {
127
175
  if (!shouldPrefetch()) return "";
128
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
176
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
129
177
  if (!targetUrl) return "";
130
- const key = buildPrefetchKey(targetUrl);
131
- if (hasPrefetch(key)) return key;
178
+ // Skip same-page prefetch with prefetchKey — a same-page diff is trivial
179
+ // and would corrupt the wildcard cache entry for cross-page navigation.
180
+ if (prefetchKey != null && isSamePage(url)) {
181
+ return "";
182
+ }
183
+ const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
184
+ if (hasPrefetch(key)) {
185
+ debugLog("[prefetch] queued dedup (key already exists)", {
186
+ url,
187
+ key,
188
+ prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
189
+ });
190
+ return key;
191
+ }
132
192
  const fetchUrlStr = targetUrl.toString();
133
- enqueuePrefetch(key, (signal) =>
134
- executePrefetchFetch(key, fetchUrlStr, signal),
135
- );
193
+ enqueuePrefetch(key, (signal) => {
194
+ // Re-check at execution time: a hover-triggered prefetchDirect may
195
+ // have started or completed this key while the item sat in the queue.
196
+ if (hasPrefetch(key)) return Promise.resolve();
197
+ // By execution time, the user may have navigated to the target page.
198
+ // A same-page prefetch produces a trivial diff that would overwrite
199
+ // the useful cross-page entry in the wildcard cache.
200
+ if (prefetchKey != null && isSamePage(url)) {
201
+ return Promise.resolve();
202
+ }
203
+ return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
204
+ });
136
205
  return key;
137
206
  }
@@ -5,6 +5,8 @@
5
5
  * Honors browser reduced-data preferences when available.
6
6
  */
7
7
 
8
+ import { isPrefetchCacheDisabled } from "./cache.js";
9
+
8
10
  type NavigatorWithConnection = Navigator & {
9
11
  connection?: {
10
12
  saveData?: boolean;
@@ -18,6 +20,10 @@ type NavigatorWithConnection = Navigator & {
18
20
  export function shouldPrefetch(): boolean {
19
21
  if (typeof window === "undefined") return false;
20
22
 
23
+ // When prefetchCacheTTL is false/0, prefetching is fully disabled —
24
+ // no point issuing requests whose responses will be discarded.
25
+ if (isPrefetchCacheDisabled()) return false;
26
+
21
27
  const nav =
22
28
  typeof navigator !== "undefined"
23
29
  ? (navigator as NavigatorWithConnection)
@@ -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
+ }
@@ -5,6 +5,7 @@ import React, {
5
5
  useCallback,
6
6
  useContext,
7
7
  useEffect,
8
+ useMemo,
8
9
  useRef,
9
10
  type ForwardRefExoticComponent,
10
11
  type RefAttributes,
@@ -32,6 +33,7 @@ export type LinkState =
32
33
  | StateOrGetter<Record<string, unknown>>;
33
34
 
34
35
  import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
36
+ import { getAppVersion } from "../app-version.js";
35
37
  import {
36
38
  observeForPrefetch,
37
39
  unobserveForPrefetch,
@@ -80,11 +82,41 @@ export interface LinkProps extends Omit<
80
82
  * Force full document navigation instead of SPA
81
83
  */
82
84
  reloadDocument?: boolean;
85
+ /**
86
+ * Whether to revalidate server data on navigation.
87
+ * Set to `false` to skip the RSC server fetch and only update the URL.
88
+ *
89
+ * Only takes effect when the pathname stays the same (search param / hash changes).
90
+ * If the pathname changes, this option is ignored and a full navigation occurs.
91
+ *
92
+ * @default true
93
+ */
94
+ revalidate?: boolean;
83
95
  /**
84
96
  * Prefetch strategy for the link destination
85
97
  * @default "none"
86
98
  */
87
99
  prefetch?: PrefetchStrategy;
100
+ /**
101
+ * Custom prefetch cache key for source-agnostic cache reuse.
102
+ * When set, prefetch responses are cached independently of the current
103
+ * page URL, so navigating to the same target from different source pages
104
+ * reuses the cached prefetch.
105
+ *
106
+ * - String: static group name (e.g., `"pages"`)
107
+ * - Function: receives current URL (`window.location.href`), returns a
108
+ * normalized source key
109
+ *
110
+ * @example
111
+ * ```tsx
112
+ * // Static group — all "pages" links share one cache entry per target
113
+ * <Link to="/page/3" prefetch="hover" prefetchKey="pages" />
114
+ *
115
+ * // Normalize — strip trailing page number from source URL
116
+ * <Link to="/page/3" prefetch="hover" prefetchKey={(from) => from.replace(/\/\d+$/, '')} />
117
+ * ```
118
+ */
119
+ prefetchKey?: string | ((from: string) => string);
88
120
  /**
89
121
  * State to pass to history.pushState/replaceState.
90
122
  * Accessible via useLocationState() hook.
@@ -170,7 +202,9 @@ export const Link: ForwardRefExoticComponent<
170
202
  replace = false,
171
203
  scroll = true,
172
204
  reloadDocument = false,
205
+ revalidate,
173
206
  prefetch = "none",
207
+ prefetchKey,
174
208
  state,
175
209
  children,
176
210
  onClick,
@@ -181,6 +215,16 @@ export const Link: ForwardRefExoticComponent<
181
215
  const ctx = useContext(NavigationStoreContext);
182
216
  const isExternal = isExternalUrl(to);
183
217
 
218
+ // Auto-prefix with basename for app-local paths.
219
+ // Skip if external, already prefixed, or not a root-relative path.
220
+ const resolvedTo = useMemo(() => {
221
+ if (isExternal) return to;
222
+ const bn = ctx?.basename;
223
+ if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
224
+ return to;
225
+ return to === "/" ? bn : bn + to;
226
+ }, [to, isExternal, ctx?.basename]);
227
+
184
228
  // Resolve adaptive: viewport on touch devices, hover on pointer devices
185
229
  const resolvedStrategy =
186
230
  prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
@@ -262,17 +306,45 @@ export const Link: ForwardRefExoticComponent<
262
306
  resolvedState = currentState;
263
307
  }
264
308
 
265
- ctx.navigate(to, { replace, scroll, state: resolvedState });
309
+ ctx.navigate(resolvedTo, {
310
+ replace,
311
+ scroll,
312
+ state: resolvedState,
313
+ revalidate,
314
+ });
266
315
  },
267
- [to, isExternal, reloadDocument, replace, scroll, ctx, onClick],
316
+ [
317
+ resolvedTo,
318
+ isExternal,
319
+ reloadDocument,
320
+ replace,
321
+ scroll,
322
+ revalidate,
323
+ ctx,
324
+ onClick,
325
+ ],
268
326
  );
269
327
 
270
328
  const handleMouseEnter = useCallback(() => {
271
- if (resolvedStrategy === "hover" && !isExternal && ctx?.store) {
329
+ if (
330
+ (resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
331
+ !isExternal &&
332
+ ctx?.store
333
+ ) {
334
+ // For "hover", this is the primary prefetch trigger.
335
+ // For "viewport", this upgrades/prioritizes a potentially queued
336
+ // prefetch — prefetchDirect bypasses the queue, and hasPrefetch
337
+ // deduplicates if the viewport prefetch already completed.
272
338
  const segmentState = ctx.store.getSegmentState();
273
- prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
339
+ prefetchDirect(
340
+ resolvedTo,
341
+ segmentState.currentSegmentIds,
342
+ getAppVersion(),
343
+ ctx.store.getRouterId?.(),
344
+ prefetchKey,
345
+ );
274
346
  }
275
- }, [resolvedStrategy, to, isExternal, ctx]);
347
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
276
348
 
277
349
  // Viewport/render prefetch: waits for idle before starting,
278
350
  // uses concurrency-limited queue to avoid flooding.
@@ -289,7 +361,13 @@ export const Link: ForwardRefExoticComponent<
289
361
  const triggerPrefetch = () => {
290
362
  if (cancelled) return;
291
363
  const segmentState = ctx.store.getSegmentState();
292
- prefetchQueued(to, segmentState.currentSegmentIds, ctx.version);
364
+ prefetchQueued(
365
+ resolvedTo,
366
+ segmentState.currentSegmentIds,
367
+ getAppVersion(),
368
+ ctx.store.getRouterId?.(),
369
+ prefetchKey,
370
+ );
293
371
  };
294
372
 
295
373
  // Schedule prefetch only when the app is idle (no navigation/streaming).
@@ -328,21 +406,22 @@ export const Link: ForwardRefExoticComponent<
328
406
  unobserveForPrefetch(observedElement);
329
407
  }
330
408
  };
331
- }, [resolvedStrategy, to, isExternal, ctx]);
409
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
332
410
 
333
411
  return (
334
412
  <a
335
413
  ref={setRef}
336
- href={to}
414
+ href={resolvedTo}
337
415
  onClick={handleClick}
338
416
  onMouseEnter={handleMouseEnter}
339
417
  data-link-component
340
418
  data-external={isExternal ? "" : undefined}
341
419
  data-scroll={scroll === false ? "false" : undefined}
342
420
  data-replace={replace ? "true" : undefined}
421
+ data-revalidate={revalidate === false ? "false" : undefined}
343
422
  {...props}
344
423
  >
345
- <LinkContext.Provider value={to}>{children}</LinkContext.Provider>
424
+ <LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
346
425
  </a>
347
426
  );
348
427
  });