@rangojs/router 0.0.0-experimental.28 → 0.0.0-experimental.29-prefetch-cache.29972e92

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.28",
1748
+ version: "0.0.0-experimental.29-prefetch-cache.29972e92",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.28",
3
+ "version": "0.0.0-experimental.29-prefetch-cache.29972e92",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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,111 @@
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
+ const PREFETCH_CACHE_TTL = 30_000; // 30 seconds
17
+ const MAX_PREFETCH_CACHE_SIZE = 50;
18
+
19
+ interface PrefetchCacheEntry {
20
+ response: Response;
21
+ timestamp: number;
22
+ }
23
+
24
+ const cache = new Map<string, PrefetchCacheEntry>();
12
25
  const inflight = new Set<string>();
13
- const prefetched = new Set<string>();
14
26
 
15
27
  // 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).
28
+ // started before a clear carry a stale generation and must not store their
29
+ // response (the data may be stale due to a server action invalidation).
19
30
  let generation = 0;
20
31
 
21
32
  /**
22
- * Check if a prefetch is already in-flight or completed for the given key.
33
+ * Build a source-dependent cache key.
34
+ * Includes the source page href so the same target prefetched from
35
+ * different pages gets separate entries — the server response varies
36
+ * based on the source page context (diff-based rendering).
37
+ */
38
+ export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
39
+ return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
40
+ }
41
+
42
+ /**
43
+ * Check if a prefetch is already cached, in-flight, or queued for the given key.
23
44
  */
24
45
  export function hasPrefetch(key: string): boolean {
25
- return prefetched.has(key) || inflight.has(key);
46
+ if (inflight.has(key)) return true;
47
+ const entry = cache.get(key);
48
+ if (!entry) return false;
49
+ if (Date.now() - entry.timestamp > PREFETCH_CACHE_TTL) {
50
+ cache.delete(key);
51
+ return false;
52
+ }
53
+ return true;
26
54
  }
27
55
 
28
56
  /**
29
- * Capture the current generation. The returned value is passed to
30
- * markPrefetched so it can detect stale completions.
57
+ * Consume a cached prefetch response. Returns null if not found or expired.
58
+ * One-time consumption: the entry is deleted after retrieval.
31
59
  */
32
- export function currentGeneration(): number {
33
- return generation;
60
+ export function consumePrefetch(key: string): Response | null {
61
+ const entry = cache.get(key);
62
+ if (!entry) return null;
63
+ if (Date.now() - entry.timestamp > PREFETCH_CACHE_TTL) {
64
+ cache.delete(key);
65
+ return null;
66
+ }
67
+ cache.delete(key);
68
+ return entry.response;
34
69
  }
35
70
 
36
71
  /**
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).
72
+ * Store a prefetch response in the in-memory cache.
73
+ * The response body must be fully buffered (e.g. via arrayBuffer()) before
74
+ * storing, so the cached Response is self-contained and network-independent.
75
+ *
76
+ * Skips storage if the generation has changed since the fetch started
77
+ * (a server action invalidated the cache mid-flight).
40
78
  */
41
- export function markPrefetched(key: string, fetchGeneration: number): void {
42
- if (fetchGeneration === generation) {
43
- prefetched.add(key);
79
+ export function storePrefetch(
80
+ key: string,
81
+ response: Response,
82
+ fetchGeneration: number,
83
+ ): void {
84
+ if (fetchGeneration !== generation) return;
85
+
86
+ // Evict expired entries
87
+ const now = Date.now();
88
+ for (const [k, entry] of cache) {
89
+ if (now - entry.timestamp > PREFETCH_CACHE_TTL) {
90
+ cache.delete(k);
91
+ }
92
+ }
93
+
94
+ // FIFO eviction if at capacity
95
+ if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
96
+ const oldest = cache.keys().next().value;
97
+ if (oldest) cache.delete(oldest);
44
98
  }
99
+
100
+ cache.set(key, { response, timestamp: now });
101
+ }
102
+
103
+ /**
104
+ * Capture the current generation. The returned value is passed to
105
+ * storePrefetch so it can detect stale completions.
106
+ */
107
+ export function currentGeneration(): number {
108
+ return generation;
45
109
  }
46
110
 
47
111
  export function markPrefetchInflight(key: string): void {
@@ -53,15 +117,14 @@ export function clearPrefetchInflight(key: string): void {
53
117
  }
54
118
 
55
119
  /**
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.
120
+ * Invalidate all prefetch state. Called when server actions mutate data.
121
+ * Clears the in-memory cache, cancels in-flight prefetches, and rotates
122
+ * the Rango state key so CDN-cached responses are also invalidated.
60
123
  */
61
124
  export function clearPrefetchCache(): void {
62
125
  generation++;
63
126
  inflight.clear();
64
- prefetched.clear();
127
+ cache.clear();
65
128
  cancelAllPrefetches();
66
129
  invalidateRangoState();
67
130
  }
@@ -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) =>