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

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 (84) hide show
  1. package/README.md +46 -12
  2. package/dist/bin/rango.js +109 -15
  3. package/dist/vite/index.js +323 -121
  4. package/package.json +15 -16
  5. package/skills/breadcrumbs/SKILL.md +250 -0
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +33 -31
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/loader/SKILL.md +55 -15
  11. package/skills/prerender/SKILL.md +2 -2
  12. package/skills/rango/SKILL.md +0 -1
  13. package/skills/route/SKILL.md +3 -4
  14. package/skills/router-setup/SKILL.md +8 -3
  15. package/skills/typesafety/SKILL.md +25 -23
  16. package/src/__internal.ts +92 -0
  17. package/src/bin/rango.ts +18 -0
  18. package/src/browser/link-interceptor.ts +4 -0
  19. package/src/browser/navigation-bridge.ts +95 -5
  20. package/src/browser/navigation-client.ts +97 -72
  21. package/src/browser/prefetch/cache.ts +112 -25
  22. package/src/browser/prefetch/fetch.ts +28 -30
  23. package/src/browser/prefetch/policy.ts +6 -0
  24. package/src/browser/react/Link.tsx +19 -7
  25. package/src/browser/rsc-router.tsx +11 -2
  26. package/src/browser/server-action-bridge.ts +448 -432
  27. package/src/browser/types.ts +24 -0
  28. package/src/build/generate-route-types.ts +2 -0
  29. package/src/build/route-trie.ts +19 -3
  30. package/src/build/route-types/router-processing.ts +125 -15
  31. package/src/client.rsc.tsx +2 -1
  32. package/src/client.tsx +1 -46
  33. package/src/handles/breadcrumbs.ts +66 -0
  34. package/src/handles/index.ts +1 -0
  35. package/src/host/index.ts +0 -3
  36. package/src/index.rsc.ts +5 -36
  37. package/src/index.ts +32 -66
  38. package/src/prerender/store.ts +56 -15
  39. package/src/route-definition/index.ts +0 -3
  40. package/src/router/handler-context.ts +30 -3
  41. package/src/router/loader-resolution.ts +1 -1
  42. package/src/router/match-api.ts +1 -1
  43. package/src/router/match-result.ts +0 -9
  44. package/src/router/metrics.ts +233 -13
  45. package/src/router/middleware-types.ts +53 -10
  46. package/src/router/middleware.ts +170 -81
  47. package/src/router/pattern-matching.ts +20 -5
  48. package/src/router/prerender-match.ts +4 -0
  49. package/src/router/revalidation.ts +27 -7
  50. package/src/router/router-interfaces.ts +14 -1
  51. package/src/router/router-options.ts +13 -8
  52. package/src/router/segment-resolution/fresh.ts +18 -0
  53. package/src/router/segment-resolution/helpers.ts +1 -1
  54. package/src/router/segment-resolution/revalidation.ts +22 -9
  55. package/src/router/trie-matching.ts +20 -2
  56. package/src/router.ts +29 -9
  57. package/src/rsc/handler.ts +106 -11
  58. package/src/rsc/index.ts +0 -20
  59. package/src/rsc/progressive-enhancement.ts +21 -8
  60. package/src/rsc/rsc-rendering.ts +30 -43
  61. package/src/rsc/server-action.ts +14 -10
  62. package/src/rsc/ssr-setup.ts +128 -0
  63. package/src/rsc/types.ts +2 -0
  64. package/src/search-params.ts +16 -13
  65. package/src/server/context.ts +8 -2
  66. package/src/server/request-context.ts +38 -16
  67. package/src/server.ts +6 -0
  68. package/src/theme/index.ts +4 -13
  69. package/src/types/handler-context.ts +12 -16
  70. package/src/types/route-config.ts +17 -8
  71. package/src/types/segments.ts +0 -5
  72. package/src/vite/discovery/bundle-postprocess.ts +31 -56
  73. package/src/vite/discovery/discover-routers.ts +18 -4
  74. package/src/vite/discovery/prerender-collection.ts +34 -14
  75. package/src/vite/discovery/state.ts +4 -7
  76. package/src/vite/index.ts +4 -3
  77. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  78. package/src/vite/plugins/refresh-cmd.ts +65 -0
  79. package/src/vite/rango.ts +11 -0
  80. package/src/vite/router-discovery.ts +16 -0
  81. package/src/vite/utils/prerender-utils.ts +60 -0
  82. package/skills/testing/SKILL.md +0 -226
  83. package/src/route-definition/route-function.ts +0 -119
  84. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -10,6 +10,12 @@ import {
10
10
  createNavigationTransaction,
11
11
  resolveNavigationState,
12
12
  } from "./navigation-transaction.js";
13
+ import { buildHistoryState } from "./history-state.js";
14
+ import {
15
+ handleNavigationStart,
16
+ handleNavigationEnd,
17
+ ensureHistoryKey,
18
+ } from "./scroll-restoration.js";
13
19
 
14
20
  // addTransitionType is only available in React experimental
15
21
  const addTransitionType: ((type: string) => void) | undefined =
@@ -18,7 +24,6 @@ const addTransitionType: ((type: string) => void) | undefined =
18
24
  import { setupLinkInterception } from "./link-interceptor.js";
19
25
  import { createPartialUpdater } from "./partial-update.js";
20
26
  import { generateHistoryKey } from "./navigation-store.js";
21
- import { handleNavigationEnd } from "./scroll-restoration.js";
22
27
  import type { EventController } from "./event-controller.js";
23
28
  import { isInterceptOnlyCache } from "./intercept-utils.js";
24
29
  import {
@@ -114,6 +119,85 @@ export function createNavigationBridge(
114
119
  return;
115
120
  }
116
121
 
122
+ // Shallow navigation: skip RSC fetch when revalidate is false
123
+ // and the pathname hasn't changed (search param / hash only change).
124
+ if (
125
+ options?.revalidate === false &&
126
+ targetUrl.pathname === new URL(window.location.href).pathname
127
+ ) {
128
+ // Preserve intercept context from the current history entry so that
129
+ // popstate uses the correct cache key (:intercept suffix) and restores
130
+ // the right full-page vs modal semantics.
131
+ const currentHistoryState = window.history.state;
132
+ const isIntercept = currentHistoryState?.intercept === true;
133
+ const interceptSourceUrl = isIntercept
134
+ ? currentHistoryState?.sourceUrl
135
+ : undefined;
136
+
137
+ const historyKey = generateHistoryKey(url, { intercept: isIntercept });
138
+
139
+ // Copy current segments to the new history key so back/forward restores instantly
140
+ const currentKey = store.getHistoryKey();
141
+ const currentCache = store.getCachedSegments(currentKey);
142
+ if (currentCache?.segments) {
143
+ const currentHandleData = eventController.getHandleState().data;
144
+ store.cacheSegmentsForHistory(
145
+ historyKey,
146
+ currentCache.segments,
147
+ currentHandleData,
148
+ );
149
+ }
150
+
151
+ // Save current scroll position before changing URL
152
+ handleNavigationStart();
153
+
154
+ // Snapshot old state before pushState/replaceState overwrites it
155
+ const oldState = window.history.state;
156
+
157
+ // Update browser URL (carry intercept context into history state)
158
+ const historyState = buildHistoryState(
159
+ resolvedState,
160
+ {
161
+ intercept: isIntercept || undefined,
162
+ sourceUrl: interceptSourceUrl,
163
+ },
164
+ {},
165
+ );
166
+ if (options.replace) {
167
+ window.history.replaceState(historyState, "", url);
168
+ } else {
169
+ window.history.pushState(historyState, "", url);
170
+ }
171
+
172
+ // Ensure new history entry has a scroll restoration key
173
+ ensureHistoryKey();
174
+
175
+ // Notify useLocationState() hooks when state changes
176
+ const hasOldState =
177
+ oldState &&
178
+ typeof oldState === "object" &&
179
+ ("state" in oldState ||
180
+ Object.keys(oldState).some((k) => k.startsWith("__rsc_ls_")));
181
+ const hasNewState =
182
+ historyState &&
183
+ ("state" in historyState ||
184
+ Object.keys(historyState).some((k) => k.startsWith("__rsc_ls_")));
185
+ if (hasOldState || hasNewState) {
186
+ window.dispatchEvent(new Event("__rsc_locationstate"));
187
+ }
188
+
189
+ // Update store history key so future navigations reference the right cache
190
+ store.setHistoryKey(historyKey);
191
+ store.setCurrentUrl(url);
192
+
193
+ // Notify hooks — location updates, state stays idle
194
+ eventController.setLocation(targetUrl);
195
+
196
+ // Handle post-navigation scroll
197
+ handleNavigationEnd({ scroll: options.scroll });
198
+ return;
199
+ }
200
+
117
201
  // Only abort pending requests when navigating to a different route
118
202
  // Same-route navigation (e.g., /todos -> /todos) should not cancel in-flight actions
119
203
  const currentPath = new URL(window.location.href).pathname;
@@ -189,7 +273,7 @@ export function createNavigationBridge(
189
273
  !isLeavingIntercept &&
190
274
  !options?._skipCache;
191
275
 
192
- using tx = createNavigationTransaction(store, eventController, url, {
276
+ const tx = createNavigationTransaction(store, eventController, url, {
193
277
  ...options,
194
278
  state: resolvedState,
195
279
  skipLoadingState: hasUsableCache,
@@ -224,7 +308,7 @@ export function createNavigationBridge(
224
308
  );
225
309
  } catch (error) {
226
310
  // Server-side redirect with location state: the current transaction's
227
- // `using` cleanup resets loading state. Re-navigate to the redirect
311
+ // cleanup resets loading state. Re-navigate to the redirect
228
312
  // target carrying the server-set state into history.pushState.
229
313
  if (error instanceof ServerRedirect) {
230
314
  const redirectUrl = validateRedirectOrigin(
@@ -260,6 +344,8 @@ export function createNavigationBridge(
260
344
  }
261
345
 
262
346
  throw error;
347
+ } finally {
348
+ tx[Symbol.dispose]();
263
349
  }
264
350
  },
265
351
 
@@ -269,7 +355,7 @@ export function createNavigationBridge(
269
355
  async refresh(): Promise<void> {
270
356
  eventController.abortNavigation();
271
357
 
272
- using tx = createNavigationTransaction(
358
+ const tx = createNavigationTransaction(
273
359
  store,
274
360
  eventController,
275
361
  window.location.href,
@@ -299,6 +385,8 @@ export function createNavigationBridge(
299
385
  return;
300
386
  }
301
387
  throw error;
388
+ } finally {
389
+ tx[Symbol.dispose]();
302
390
  }
303
391
  },
304
392
 
@@ -457,7 +545,7 @@ export function createNavigationBridge(
457
545
  }
458
546
 
459
547
  // Fetch if not cached
460
- using tx = createNavigationTransaction(store, eventController, url, {
548
+ const tx = createNavigationTransaction(store, eventController, url, {
461
549
  replace: true,
462
550
  });
463
551
 
@@ -498,6 +586,8 @@ export function createNavigationBridge(
498
586
  }
499
587
 
500
588
  throw error;
589
+ } finally {
590
+ tx[Symbol.dispose]();
501
591
  }
502
592
  },
503
593
 
@@ -17,6 +17,7 @@ import {
17
17
  emptyResponse,
18
18
  teeWithCompletion,
19
19
  } from "./response-adapter.js";
20
+ import { buildPrefetchKey, consumePrefetch } from "./prefetch/cache.js";
20
21
 
21
22
  /**
22
23
  * Create a navigation client for fetching RSC payloads
@@ -24,21 +25,12 @@ import {
24
25
  * The client handles building URLs with RSC parameters and
25
26
  * deserializing the response using the RSC runtime.
26
27
  *
28
+ * Checks the in-memory prefetch cache before making a network request.
29
+ * The cache key is source-dependent (includes the previous URL) so
30
+ * prefetch responses match the exact diff the server would produce.
31
+ *
27
32
  * @param deps - RSC browser dependencies (createFromFetch)
28
33
  * @returns NavigationClient instance
29
- *
30
- * @example
31
- * ```typescript
32
- * import { createFromFetch } from "@vitejs/plugin-rsc/browser";
33
- *
34
- * const client = createNavigationClient({ createFromFetch });
35
- *
36
- * const payload = await client.fetchPartial({
37
- * targetUrl: "/shop/products",
38
- * segmentIds: ["root", "shop"],
39
- * previousUrl: "/",
40
- * });
41
- * ```
42
34
  */
43
35
  export function createNavigationClient(
44
36
  deps: Pick<RscBrowserDependencies, "createFromFetch">,
@@ -47,8 +39,9 @@ export function createNavigationClient(
47
39
  /**
48
40
  * Fetch a partial RSC payload for navigation
49
41
  *
50
- * Sends current segment IDs to the server so it can determine
51
- * which segments need to be re-rendered (diff).
42
+ * First checks the in-memory prefetch cache for a matching entry.
43
+ * If found, uses the cached response instantly. Otherwise sends
44
+ * current segment IDs to the server for diff-based rendering.
52
45
  *
53
46
  * @param options - Fetch options
54
47
  * @returns RSC payload with segments and metadata, plus stream completion promise
@@ -80,7 +73,8 @@ export function createNavigationClient(
80
73
  });
81
74
  }
82
75
 
83
- // Build fetch URL with partial rendering params
76
+ // Build fetch URL with partial rendering params (used for both
77
+ // cache key lookup and actual fetch if cache misses)
84
78
  const fetchUrl = new URL(targetUrl, window.location.origin);
85
79
  fetchUrl.searchParams.set("_rsc_partial", "true");
86
80
  fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
@@ -90,11 +84,17 @@ export function createNavigationClient(
90
84
  if (version) {
91
85
  fetchUrl.searchParams.set("_rsc_v", version);
92
86
  }
93
- if (tx) {
94
- browserDebugLog(tx, "fetching", {
95
- path: `${fetchUrl.pathname}${fetchUrl.search}`,
96
- });
97
- }
87
+
88
+ // Check in-memory prefetch cache before making a network request.
89
+ // The cache key includes the source URL (previousUrl) because the
90
+ // server's diff response depends on the source page context.
91
+ // Skip cache for stale revalidation (needs fresh data), HMR (needs
92
+ // fresh modules), and intercept contexts (source-dependent responses).
93
+ const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
94
+ const cachedResponse =
95
+ !staleRevalidation && !hmr && !interceptSourceUrl
96
+ ? consumePrefetch(cacheKey)
97
+ : null;
98
98
 
99
99
  // Track when the stream completes
100
100
  let resolveStreamComplete: () => void;
@@ -102,63 +102,88 @@ export function createNavigationClient(
102
102
  resolveStreamComplete = resolve;
103
103
  });
104
104
 
105
- // Create a response promise that tracks stream completion
106
- const responsePromise = fetch(fetchUrl, {
107
- headers: {
108
- "X-RSC-Router-Client-Path": previousUrl,
109
- "X-Rango-State": getRangoState(),
110
- ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
111
- ...(interceptSourceUrl && {
112
- "X-RSC-Router-Intercept-Source": interceptSourceUrl,
113
- }),
114
- ...(hmr && { "X-RSC-HMR": "1" }),
115
- },
116
- signal,
117
- }).then((response) => {
118
- // Check for version mismatch - server wants us to reload
119
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
120
- if (reload === "blocked") {
121
- resolveStreamComplete();
122
- return emptyResponse();
123
- }
124
- if (reload) {
125
- if (tx) {
126
- browserDebugLog(tx, "version mismatch, reloading", {
127
- reloadUrl: reload.url,
128
- });
129
- }
130
- window.location.href = reload.url;
131
- return new Promise<Response>(() => {});
132
- }
105
+ let responsePromise: Promise<Response>;
133
106
 
134
- // Server-side redirect without state: the server returned 204 with
135
- // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
136
- // to a URL rendering full HTML). Throw ServerRedirect so the
137
- // navigation bridge catches it and re-navigates with _skipCache.
138
- const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
139
- if (redirect === "blocked") {
140
- resolveStreamComplete();
141
- return emptyResponse();
107
+ if (cachedResponse) {
108
+ if (tx) {
109
+ browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
142
110
  }
143
- if (redirect) {
144
- if (tx) {
145
- browserDebugLog(tx, "server redirect", {
146
- redirectUrl: redirect.url,
147
- });
148
- }
149
- resolveStreamComplete();
150
- throw new ServerRedirect(redirect.url, undefined);
111
+ // Cached response body is already fully buffered (arrayBuffer),
112
+ // so stream completion is immediate.
113
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
114
+ return teeWithCompletion(
115
+ response,
116
+ () => {
117
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
118
+ resolveStreamComplete();
119
+ },
120
+ signal,
121
+ );
122
+ });
123
+ } else {
124
+ if (tx) {
125
+ browserDebugLog(tx, "fetching", {
126
+ path: `${fetchUrl.pathname}${fetchUrl.search}`,
127
+ });
151
128
  }
152
129
 
153
- return teeWithCompletion(
154
- response,
155
- () => {
156
- if (tx) browserDebugLog(tx, "stream complete");
157
- resolveStreamComplete();
130
+ responsePromise = fetch(fetchUrl, {
131
+ headers: {
132
+ "X-RSC-Router-Client-Path": previousUrl,
133
+ "X-Rango-State": getRangoState(),
134
+ ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
135
+ ...(interceptSourceUrl && {
136
+ "X-RSC-Router-Intercept-Source": interceptSourceUrl,
137
+ }),
138
+ ...(hmr && { "X-RSC-HMR": "1" }),
158
139
  },
159
140
  signal,
160
- );
161
- });
141
+ }).then((response) => {
142
+ // Check for version mismatch - server wants us to reload
143
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
144
+ if (reload === "blocked") {
145
+ resolveStreamComplete();
146
+ return emptyResponse();
147
+ }
148
+ if (reload) {
149
+ if (tx) {
150
+ browserDebugLog(tx, "version mismatch, reloading", {
151
+ reloadUrl: reload.url,
152
+ });
153
+ }
154
+ window.location.href = reload.url;
155
+ return new Promise<Response>(() => {});
156
+ }
157
+
158
+ // Server-side redirect without state: the server returned 204 with
159
+ // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
160
+ // to a URL rendering full HTML). Throw ServerRedirect so the
161
+ // navigation bridge catches it and re-navigates with _skipCache.
162
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
163
+ if (redirect === "blocked") {
164
+ resolveStreamComplete();
165
+ return emptyResponse();
166
+ }
167
+ if (redirect) {
168
+ if (tx) {
169
+ browserDebugLog(tx, "server redirect", {
170
+ redirectUrl: redirect.url,
171
+ });
172
+ }
173
+ resolveStreamComplete();
174
+ throw new ServerRedirect(redirect.url, undefined);
175
+ }
176
+
177
+ return teeWithCompletion(
178
+ response,
179
+ () => {
180
+ if (tx) browserDebugLog(tx, "stream complete");
181
+ resolveStreamComplete();
182
+ },
183
+ signal,
184
+ );
185
+ });
186
+ }
162
187
 
163
188
  try {
164
189
  // Deserialize RSC payload
@@ -1,47 +1,135 @@
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 prefetch 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
+ * Replaces the previous browser HTTP cache approach which was unreliable
10
+ * due to response draining race conditions and browser inconsistencies.
7
11
  */
8
12
 
9
13
  import { cancelAllPrefetches } from "./queue.js";
10
14
  import { invalidateRangoState } from "../rango-state.js";
11
15
 
16
+ // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
17
+ // the server-configured prefetchCacheTTL from router options.
18
+ // 0 disables the in-memory cache entirely.
19
+ let cacheTTL = 300_000;
20
+
21
+ /**
22
+ * Initialize the prefetch cache with the configured TTL.
23
+ * Called once at app startup with the value from server metadata.
24
+ * A TTL of 0 disables the in-memory cache and all prefetching.
25
+ */
26
+ export function initPrefetchCache(ttlMs: number): void {
27
+ cacheTTL = ttlMs;
28
+ }
29
+
30
+ /**
31
+ * Check if the prefetch cache is disabled (TTL <= 0).
32
+ * When disabled, no prefetch requests should be issued.
33
+ */
34
+ export function isPrefetchCacheDisabled(): boolean {
35
+ return cacheTTL <= 0;
36
+ }
37
+ const MAX_PREFETCH_CACHE_SIZE = 50;
38
+
39
+ interface PrefetchCacheEntry {
40
+ response: Response;
41
+ timestamp: number;
42
+ }
43
+
44
+ const cache = new Map<string, PrefetchCacheEntry>();
12
45
  const inflight = new Set<string>();
13
- const prefetched = new Set<string>();
14
46
 
15
47
  // 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).
48
+ // started before a clear carry a stale generation and must not store their
49
+ // response (the data may be stale due to a server action invalidation).
19
50
  let generation = 0;
20
51
 
21
52
  /**
22
- * Check if a prefetch is already in-flight or completed for the given key.
53
+ * Build a source-dependent cache key.
54
+ * Includes the source page href so the same target prefetched from
55
+ * different pages gets separate entries — the server response varies
56
+ * based on the source page context (diff-based rendering).
57
+ */
58
+ export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
59
+ return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
60
+ }
61
+
62
+ /**
63
+ * Check if a prefetch is already cached, in-flight, or queued for the given key.
23
64
  */
24
65
  export function hasPrefetch(key: string): boolean {
25
- return prefetched.has(key) || inflight.has(key);
66
+ if (inflight.has(key)) return true;
67
+ if (cacheTTL <= 0) return false;
68
+ const entry = cache.get(key);
69
+ if (!entry) return false;
70
+ if (Date.now() - entry.timestamp > cacheTTL) {
71
+ cache.delete(key);
72
+ return false;
73
+ }
74
+ return true;
26
75
  }
27
76
 
28
77
  /**
29
- * Capture the current generation. The returned value is passed to
30
- * markPrefetched so it can detect stale completions.
78
+ * Consume a cached prefetch response. Returns null if not found or expired.
79
+ * One-time consumption: the entry is deleted after retrieval.
80
+ * Returns null when caching is disabled (TTL <= 0).
31
81
  */
32
- export function currentGeneration(): number {
33
- return generation;
82
+ export function consumePrefetch(key: string): Response | null {
83
+ if (cacheTTL <= 0) return null;
84
+ const entry = cache.get(key);
85
+ if (!entry) return null;
86
+ if (Date.now() - entry.timestamp > cacheTTL) {
87
+ cache.delete(key);
88
+ return null;
89
+ }
90
+ cache.delete(key);
91
+ return entry.response;
34
92
  }
35
93
 
36
94
  /**
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).
95
+ * 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.
98
+ *
99
+ * Skips storage if the generation has changed since the fetch started
100
+ * (a server action invalidated the cache mid-flight).
40
101
  */
41
- export function markPrefetched(key: string, fetchGeneration: number): void {
42
- if (fetchGeneration === generation) {
43
- prefetched.add(key);
102
+ export function storePrefetch(
103
+ key: string,
104
+ response: Response,
105
+ fetchGeneration: number,
106
+ ): void {
107
+ if (cacheTTL <= 0) return;
108
+ if (fetchGeneration !== generation) return;
109
+
110
+ // Evict expired entries
111
+ const now = Date.now();
112
+ for (const [k, entry] of cache) {
113
+ if (now - entry.timestamp > cacheTTL) {
114
+ cache.delete(k);
115
+ }
116
+ }
117
+
118
+ // FIFO eviction if at capacity
119
+ if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
120
+ const oldest = cache.keys().next().value;
121
+ if (oldest) cache.delete(oldest);
44
122
  }
123
+
124
+ cache.set(key, { response, timestamp: now });
125
+ }
126
+
127
+ /**
128
+ * Capture the current generation. The returned value is passed to
129
+ * storePrefetch so it can detect stale completions.
130
+ */
131
+ export function currentGeneration(): number {
132
+ return generation;
45
133
  }
46
134
 
47
135
  export function markPrefetchInflight(key: string): void {
@@ -53,15 +141,14 @@ export function clearPrefetchInflight(key: string): void {
53
141
  }
54
142
 
55
143
  /**
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.
144
+ * Invalidate all prefetch state. Called when server actions mutate data.
145
+ * Clears the in-memory cache, cancels in-flight prefetches, and rotates
146
+ * the Rango state key so CDN-cached responses are also invalidated.
60
147
  */
61
148
  export function clearPrefetchCache(): void {
62
149
  generation++;
63
150
  inflight.clear();
64
- prefetched.clear();
151
+ cache.clear();
65
152
  cancelAllPrefetches();
66
153
  invalidateRangoState();
67
154
  }
@@ -2,15 +2,17 @@
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.
8
9
  */
9
10
 
10
11
  import {
12
+ buildPrefetchKey,
11
13
  hasPrefetch,
12
14
  markPrefetchInflight,
13
- markPrefetched,
15
+ storePrefetch,
14
16
  clearPrefetchInflight,
15
17
  currentGeneration,
16
18
  } from "./cache.js";
@@ -20,9 +22,9 @@ import { shouldPrefetch } from "./policy.js";
20
22
 
21
23
  /**
22
24
  * 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.
25
+ * Includes _rsc_segments so the server can diff against currently mounted
26
+ * segments, and _rsc_v for version mismatch detection.
27
+ * Returns null for malformed or cross-origin URLs.
26
28
  */
27
29
  function buildPrefetchUrl(
28
30
  url: string,
@@ -49,18 +51,9 @@ function buildPrefetchUrl(
49
51
  }
50
52
 
51
53
  /**
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.
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.
64
57
  */
65
58
  function executePrefetchFetch(
66
59
  key: string,
@@ -79,14 +72,19 @@ function executePrefetchFetch(
79
72
  "X-Rango-Prefetch": "1",
80
73
  },
81
74
  })
82
- .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
- }
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, {
83
+ headers: response.headers,
84
+ status: response.status,
85
+ statusText: response.statusText,
86
+ });
87
+ storePrefetch(key, cachedResponse, gen);
90
88
  })
91
89
  .catch(() => {
92
90
  // Silently ignore prefetch failures (including abort)
@@ -97,7 +95,7 @@ function executePrefetchFetch(
97
95
  }
98
96
 
99
97
  /**
100
- * Prefetch (direct): fetch with low priority and store in browser HTTP cache.
98
+ * Prefetch (direct): fetch with low priority and store in in-memory cache.
101
99
  * Used by hover strategy -- fires immediately without queueing.
102
100
  */
103
101
  export function prefetchDirect(
@@ -109,7 +107,7 @@ export function prefetchDirect(
109
107
 
110
108
  const targetUrl = buildPrefetchUrl(url, segmentIds, version);
111
109
  if (!targetUrl) return;
112
- const key = buildPrefetchKey(targetUrl);
110
+ const key = buildPrefetchKey(window.location.href, targetUrl);
113
111
  if (hasPrefetch(key)) return;
114
112
  executePrefetchFetch(key, targetUrl.toString());
115
113
  }
@@ -127,7 +125,7 @@ export function prefetchQueued(
127
125
  if (!shouldPrefetch()) return "";
128
126
  const targetUrl = buildPrefetchUrl(url, segmentIds, version);
129
127
  if (!targetUrl) return "";
130
- const key = buildPrefetchKey(targetUrl);
128
+ const key = buildPrefetchKey(window.location.href, targetUrl);
131
129
  if (hasPrefetch(key)) return key;
132
130
  const fetchUrlStr = targetUrl.toString();
133
131
  enqueuePrefetch(key, (signal) =>