@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.
- package/README.md +46 -12
- package/dist/bin/rango.js +109 -15
- package/dist/vite/index.js +323 -121
- package/package.json +15 -16
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/caching/SKILL.md +4 -4
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/hooks/SKILL.md +33 -31
- package/skills/host-router/SKILL.md +218 -0
- package/skills/loader/SKILL.md +55 -15
- package/skills/prerender/SKILL.md +2 -2
- package/skills/rango/SKILL.md +0 -1
- package/skills/route/SKILL.md +3 -4
- package/skills/router-setup/SKILL.md +8 -3
- package/skills/typesafety/SKILL.md +25 -23
- package/src/__internal.ts +92 -0
- package/src/bin/rango.ts +18 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +95 -5
- package/src/browser/navigation-client.ts +97 -72
- package/src/browser/prefetch/cache.ts +112 -25
- package/src/browser/prefetch/fetch.ts +28 -30
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/react/Link.tsx +19 -7
- package/src/browser/rsc-router.tsx +11 -2
- package/src/browser/server-action-bridge.ts +448 -432
- package/src/browser/types.ts +24 -0
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-trie.ts +19 -3
- package/src/build/route-types/router-processing.ts +125 -15
- package/src/client.rsc.tsx +2 -1
- package/src/client.tsx +1 -46
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/host/index.ts +0 -3
- package/src/index.rsc.ts +5 -36
- package/src/index.ts +32 -66
- package/src/prerender/store.ts +56 -15
- package/src/route-definition/index.ts +0 -3
- package/src/router/handler-context.ts +30 -3
- package/src/router/loader-resolution.ts +1 -1
- package/src/router/match-api.ts +1 -1
- package/src/router/match-result.ts +0 -9
- package/src/router/metrics.ts +233 -13
- package/src/router/middleware-types.ts +53 -10
- package/src/router/middleware.ts +170 -81
- package/src/router/pattern-matching.ts +20 -5
- package/src/router/prerender-match.ts +4 -0
- package/src/router/revalidation.ts +27 -7
- package/src/router/router-interfaces.ts +14 -1
- package/src/router/router-options.ts +13 -8
- package/src/router/segment-resolution/fresh.ts +18 -0
- package/src/router/segment-resolution/helpers.ts +1 -1
- package/src/router/segment-resolution/revalidation.ts +22 -9
- package/src/router/trie-matching.ts +20 -2
- package/src/router.ts +29 -9
- package/src/rsc/handler.ts +106 -11
- package/src/rsc/index.ts +0 -20
- package/src/rsc/progressive-enhancement.ts +21 -8
- package/src/rsc/rsc-rendering.ts +30 -43
- package/src/rsc/server-action.ts +14 -10
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +2 -0
- package/src/search-params.ts +16 -13
- package/src/server/context.ts +8 -2
- package/src/server/request-context.ts +38 -16
- package/src/server.ts +6 -0
- package/src/theme/index.ts +4 -13
- package/src/types/handler-context.ts +12 -16
- package/src/types/route-config.ts +17 -8
- package/src/types/segments.ts +0 -5
- package/src/vite/discovery/bundle-postprocess.ts +31 -56
- package/src/vite/discovery/discover-routers.ts +18 -4
- package/src/vite/discovery/prerender-collection.ts +34 -14
- package/src/vite/discovery/state.ts +4 -7
- package/src/vite/index.ts +4 -3
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/refresh-cmd.ts +65 -0
- package/src/vite/rango.ts +11 -0
- package/src/vite/router-discovery.ts +16 -0
- package/src/vite/utils/prerender-utils.ts +60 -0
- package/skills/testing/SKILL.md +0 -226
- package/src/route-definition/route-function.ts +0 -119
- /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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
2
|
+
* Prefetch Cache
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
17
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
|
33
|
-
return
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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) =>
|