@rangojs/router 0.0.0-experimental.78a48627 → 0.0.0-experimental.79
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 +76 -18
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +853 -435
- package/dist/vite/index.js.bak +5448 -0
- package/package.json +16 -17
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +45 -4
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +53 -43
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +185 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +55 -0
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/typesafety/SKILL.md +10 -0
- package/src/__internal.ts +1 -1
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +37 -5
- package/src/browser/navigation-client.ts +142 -57
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/partial-update.ts +63 -22
- package/src/browser/prefetch/cache.ts +73 -11
- package/src/browser/prefetch/fetch.ts +98 -27
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +76 -9
- package/src/browser/react/NavigationProvider.tsx +16 -7
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +134 -59
- package/src/browser/scroll-restoration.ts +21 -18
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +27 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- package/src/build/route-trie.ts +50 -24
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +223 -74
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +48 -7
- package/src/cache/cf/cf-cache-store.ts +453 -11
- package/src/cache/cf/index.ts +5 -1
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/taint.ts +55 -0
- package/src/client.tsx +84 -230
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +3 -1
- package/src/index.ts +46 -6
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +25 -1
- package/src/route-definition/dsl-helpers.ts +224 -37
- package/src/route-definition/helpers-types.ts +67 -19
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +11 -3
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +82 -23
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/lazy-includes.ts +7 -6
- package/src/router/loader-resolution.ts +156 -21
- package/src/router/logging.ts +1 -1
- package/src/router/manifest.ts +28 -15
- package/src/router/match-api.ts +124 -189
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +94 -17
- package/src/router/match-middleware/cache-store.ts +53 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +60 -5
- package/src/router/match-result.ts +104 -10
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +6 -8
- package/src/router/middleware.ts +4 -6
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +110 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +1 -0
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +198 -20
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +433 -296
- package/src/router/types.ts +1 -0
- package/src/router.ts +55 -6
- package/src/rsc/handler.ts +472 -372
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +14 -2
- package/src/rsc/rsc-rendering.ts +10 -1
- package/src/rsc/server-action.ts +8 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +9 -1
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +109 -23
- package/src/server/context.ts +166 -17
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +185 -19
- package/src/ssr/index.tsx +4 -0
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +137 -33
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +2 -0
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +48 -13
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/discover-routers.ts +5 -1
- package/src/vite/discovery/prerender-collection.ts +128 -74
- package/src/vite/discovery/state.ts +13 -6
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +51 -79
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +163 -211
- package/src/vite/router-discovery.ts +178 -45
- package/src/vite/utils/banner.ts +3 -3
- package/src/vite/utils/prerender-utils.ts +37 -5
- package/src/vite/utils/shared-utils.ts +3 -2
|
@@ -28,9 +28,15 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
|
|
|
28
28
|
// Maximum number of history entries to cache (URLs visited)
|
|
29
29
|
const HISTORY_CACHE_SIZE = 20;
|
|
30
30
|
|
|
31
|
-
// Cache entry: [url-key, segments, stale, handleData?]
|
|
31
|
+
// Cache entry: [url-key, segments, stale, handleData?, routerId?]
|
|
32
32
|
// stale=true means the data may be outdated and should be revalidated on access
|
|
33
|
-
type HistoryCacheEntry = [
|
|
33
|
+
type HistoryCacheEntry = [
|
|
34
|
+
string,
|
|
35
|
+
ResolvedSegment[],
|
|
36
|
+
boolean,
|
|
37
|
+
HandleData?,
|
|
38
|
+
string?,
|
|
39
|
+
];
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
42
|
* Shallow clone handleData to avoid reference sharing between cache entries.
|
|
@@ -258,6 +264,11 @@ export function createNavigationStore(
|
|
|
258
264
|
// Used to maintain intercept context during action revalidation
|
|
259
265
|
let interceptSourceUrl: string | null = null;
|
|
260
266
|
|
|
267
|
+
// Router identity - tracks which router is currently active.
|
|
268
|
+
// When this changes on a partial response, the client forces a full
|
|
269
|
+
// tree replacement instead of reconciling with stale segments.
|
|
270
|
+
let currentRouterId: string | undefined;
|
|
271
|
+
|
|
261
272
|
// Action state tracking (for useAction hook)
|
|
262
273
|
// Maps action function ID to its tracked state
|
|
263
274
|
const actionStates = new Map<string, TrackedActionState>();
|
|
@@ -571,10 +582,17 @@ export function createNavigationStore(
|
|
|
571
582
|
segments,
|
|
572
583
|
false,
|
|
573
584
|
clonedHandleData,
|
|
585
|
+
currentRouterId,
|
|
574
586
|
];
|
|
575
587
|
} else {
|
|
576
588
|
// Add new entry at the end (not stale)
|
|
577
|
-
historyCache.push([
|
|
589
|
+
historyCache.push([
|
|
590
|
+
historyKey,
|
|
591
|
+
segments,
|
|
592
|
+
false,
|
|
593
|
+
clonedHandleData,
|
|
594
|
+
currentRouterId,
|
|
595
|
+
]);
|
|
578
596
|
// Remove oldest entries if over limit
|
|
579
597
|
while (historyCache.length > cacheSize) {
|
|
580
598
|
historyCache.shift();
|
|
@@ -586,14 +604,22 @@ export function createNavigationStore(
|
|
|
586
604
|
* Get cached segments for a history entry
|
|
587
605
|
* Returns { segments, stale, handleData } or undefined if not cached
|
|
588
606
|
*/
|
|
589
|
-
getCachedSegments(
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
607
|
+
getCachedSegments(historyKey: string):
|
|
608
|
+
| {
|
|
609
|
+
segments: ResolvedSegment[];
|
|
610
|
+
stale: boolean;
|
|
611
|
+
handleData?: HandleData;
|
|
612
|
+
routerId?: string;
|
|
613
|
+
}
|
|
593
614
|
| undefined {
|
|
594
615
|
const entry = historyCache.find(([key]) => key === historyKey);
|
|
595
616
|
if (!entry) return undefined;
|
|
596
|
-
return {
|
|
617
|
+
return {
|
|
618
|
+
segments: entry[1],
|
|
619
|
+
stale: entry[2],
|
|
620
|
+
handleData: entry[3],
|
|
621
|
+
routerId: entry[4],
|
|
622
|
+
};
|
|
597
623
|
},
|
|
598
624
|
|
|
599
625
|
/**
|
|
@@ -621,6 +647,7 @@ export function createNavigationStore(
|
|
|
621
647
|
entry[1],
|
|
622
648
|
entry[2],
|
|
623
649
|
clonedHandleData,
|
|
650
|
+
entry[4], // preserve routerId
|
|
624
651
|
];
|
|
625
652
|
}
|
|
626
653
|
},
|
|
@@ -687,6 +714,14 @@ export function createNavigationStore(
|
|
|
687
714
|
interceptSourceUrl = url;
|
|
688
715
|
},
|
|
689
716
|
|
|
717
|
+
getRouterId(): string | undefined {
|
|
718
|
+
return currentRouterId;
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
setRouterId(id: string): void {
|
|
722
|
+
currentRouterId = id;
|
|
723
|
+
},
|
|
724
|
+
|
|
690
725
|
// ========================================================================
|
|
691
726
|
// UI Update Notifications
|
|
692
727
|
// ========================================================================
|
|
@@ -19,6 +19,14 @@ import type { BoundTransaction } from "./navigation-transaction.js";
|
|
|
19
19
|
import { ServerRedirect } from "../errors.js";
|
|
20
20
|
import { debugLog } from "./logging.js";
|
|
21
21
|
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
22
|
+
import type { NavigationUpdate } from "./types.js";
|
|
23
|
+
|
|
24
|
+
/** Build a scroll payload from the commit's scroll option */
|
|
25
|
+
function toScrollPayload(
|
|
26
|
+
scroll: boolean | undefined,
|
|
27
|
+
): NonNullable<NavigationUpdate["scroll"]> {
|
|
28
|
+
return { enabled: scroll !== false ? scroll : false };
|
|
29
|
+
}
|
|
22
30
|
|
|
23
31
|
/**
|
|
24
32
|
* Configuration for creating a partial updater
|
|
@@ -31,8 +39,8 @@ export interface PartialUpdateConfig {
|
|
|
31
39
|
segments: ResolvedSegment[],
|
|
32
40
|
options?: RenderSegmentsOptions,
|
|
33
41
|
) => Promise<ReactNode> | ReactNode;
|
|
34
|
-
/** RSC version
|
|
35
|
-
|
|
42
|
+
/** RSC version getter — returns the current version (may change after HMR) */
|
|
43
|
+
getVersion?: () => string | undefined;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/**
|
|
@@ -96,7 +104,13 @@ export type PartialUpdater = (
|
|
|
96
104
|
export function createPartialUpdater(
|
|
97
105
|
config: PartialUpdateConfig,
|
|
98
106
|
): PartialUpdater {
|
|
99
|
-
const {
|
|
107
|
+
const {
|
|
108
|
+
store,
|
|
109
|
+
client,
|
|
110
|
+
onUpdate,
|
|
111
|
+
renderSegments,
|
|
112
|
+
getVersion = () => undefined,
|
|
113
|
+
} = config;
|
|
100
114
|
|
|
101
115
|
/**
|
|
102
116
|
* Get current page's cached segments as an array
|
|
@@ -153,9 +167,16 @@ export function createPartialUpdater(
|
|
|
153
167
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
154
168
|
}
|
|
155
169
|
|
|
156
|
-
// For intercept revalidation, use the intercept source URL as previousUrl
|
|
170
|
+
// For intercept revalidation, use the intercept source URL as previousUrl.
|
|
171
|
+
// For leave-intercept, tx.currentUrl captures window.location.href at tx
|
|
172
|
+
// creation, which on popstate is already the destination URL and would
|
|
173
|
+
// tell the server "from == to". segmentState.currentUrl still points at
|
|
174
|
+
// the URL the cached segments render (the intercept URL), which is the
|
|
175
|
+
// correct "from" for the server's diff computation.
|
|
157
176
|
const previousUrl =
|
|
158
|
-
|
|
177
|
+
mode.type === "leave-intercept"
|
|
178
|
+
? segmentState.currentUrl || tx.currentUrl
|
|
179
|
+
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
159
180
|
|
|
160
181
|
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
161
182
|
debugLog(`[Browser] From: ${previousUrl}`);
|
|
@@ -174,6 +195,11 @@ export function createPartialUpdater(
|
|
|
174
195
|
targetCache && targetCache.length > 0
|
|
175
196
|
? targetCache
|
|
176
197
|
: getCurrentCachedSegments();
|
|
198
|
+
const cachedSegsSource =
|
|
199
|
+
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
200
|
+
debugLog(
|
|
201
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
202
|
+
);
|
|
177
203
|
|
|
178
204
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
179
205
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
@@ -185,7 +211,8 @@ export function createPartialUpdater(
|
|
|
185
211
|
// (action redirect sends empty segments for a fresh render).
|
|
186
212
|
staleRevalidation:
|
|
187
213
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
188
|
-
version,
|
|
214
|
+
version: getVersion(),
|
|
215
|
+
routerId: store.getRouterId?.(),
|
|
189
216
|
});
|
|
190
217
|
// Mark navigation as streaming (response received, now parsing RSC).
|
|
191
218
|
// Called after fetchPartial so pendingUrl stays set during the network wait,
|
|
@@ -198,6 +225,21 @@ export function createPartialUpdater(
|
|
|
198
225
|
streamingToken.end();
|
|
199
226
|
});
|
|
200
227
|
|
|
228
|
+
// Detect app switch: if routerId changed, the navigation crossed into
|
|
229
|
+
// a different router (e.g., via host router path mount). Downgrade
|
|
230
|
+
// partial to full so the entire tree is replaced without reconciliation
|
|
231
|
+
// against stale segments from the previous app.
|
|
232
|
+
if (payload.metadata?.routerId) {
|
|
233
|
+
const prevRouterId = store.getRouterId?.();
|
|
234
|
+
if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
|
|
235
|
+
debugLog(
|
|
236
|
+
`[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
|
|
237
|
+
);
|
|
238
|
+
payload.metadata.isPartial = false;
|
|
239
|
+
}
|
|
240
|
+
store.setRouterId?.(payload.metadata.routerId);
|
|
241
|
+
}
|
|
242
|
+
|
|
201
243
|
// Handle server-side redirect with state
|
|
202
244
|
if (payload.metadata?.redirect) {
|
|
203
245
|
if (signal?.aborted) {
|
|
@@ -251,6 +293,17 @@ export function createPartialUpdater(
|
|
|
251
293
|
existingSegments,
|
|
252
294
|
);
|
|
253
295
|
|
|
296
|
+
// tx.commit() cached the source page's handleData because
|
|
297
|
+
// eventController hasn't been updated yet. Overwrite with the
|
|
298
|
+
// correct cached handleData to prevent cache corruption on
|
|
299
|
+
// subsequent navigations to this same URL.
|
|
300
|
+
if (mode.targetCacheHandleData) {
|
|
301
|
+
store.updateCacheHandleData(
|
|
302
|
+
store.getHistoryKey(),
|
|
303
|
+
mode.targetCacheHandleData,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
254
307
|
// Include cachedHandleData in metadata so NavigationProvider can restore
|
|
255
308
|
// breadcrumbs and other handle data from cache.
|
|
256
309
|
// Remove `handles` from metadata to prevent NavigationProvider from
|
|
@@ -263,10 +316,7 @@ export function createPartialUpdater(
|
|
|
263
316
|
...metadataWithoutHandles,
|
|
264
317
|
cachedHandleData: mode.targetCacheHandleData,
|
|
265
318
|
},
|
|
266
|
-
scroll:
|
|
267
|
-
commitScroll !== false
|
|
268
|
-
? { enabled: commitScroll }
|
|
269
|
-
: { enabled: false },
|
|
319
|
+
scroll: toScrollPayload(commitScroll),
|
|
270
320
|
};
|
|
271
321
|
|
|
272
322
|
const cachedHasTransition = existingSegments.some(
|
|
@@ -305,10 +355,7 @@ export function createPartialUpdater(
|
|
|
305
355
|
onUpdate({
|
|
306
356
|
root: newTree,
|
|
307
357
|
metadata: payload.metadata,
|
|
308
|
-
scroll:
|
|
309
|
-
leaveScroll !== false
|
|
310
|
-
? { enabled: leaveScroll }
|
|
311
|
-
: { enabled: false },
|
|
358
|
+
scroll: toScrollPayload(leaveScroll),
|
|
312
359
|
});
|
|
313
360
|
|
|
314
361
|
debugLog("[Browser] Navigation complete (left intercept)");
|
|
@@ -462,10 +509,7 @@ export function createPartialUpdater(
|
|
|
462
509
|
// Emit update to trigger React render.
|
|
463
510
|
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
464
511
|
const hasTransition = reconciled.mainSegments.some((s) => s.transition);
|
|
465
|
-
const scrollPayload =
|
|
466
|
-
navScroll !== false
|
|
467
|
-
? { enabled: navScroll }
|
|
468
|
-
: { enabled: false as const };
|
|
512
|
+
const scrollPayload = toScrollPayload(navScroll);
|
|
469
513
|
|
|
470
514
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
471
515
|
startTransition(() => {
|
|
@@ -529,10 +573,7 @@ export function createPartialUpdater(
|
|
|
529
573
|
const fullHasTransition = segments.some(
|
|
530
574
|
(s: ResolvedSegment) => s.transition,
|
|
531
575
|
);
|
|
532
|
-
const fullScrollPayload =
|
|
533
|
-
fullScroll !== false
|
|
534
|
-
? { enabled: fullScroll }
|
|
535
|
-
: { enabled: false as const };
|
|
576
|
+
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
536
577
|
|
|
537
578
|
if (mode.type === "stale-revalidation") {
|
|
538
579
|
await rawStreamComplete;
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Prefetch Cache
|
|
3
3
|
*
|
|
4
|
-
* In-memory cache storing
|
|
4
|
+
* In-memory cache storing prefetched Response objects for instant cache hits
|
|
5
5
|
* on subsequent navigation. Cache key is source-dependent (includes the
|
|
6
6
|
* current page URL) because the server's diff-based response depends on
|
|
7
7
|
* where the user navigates from.
|
|
8
8
|
*
|
|
9
|
+
* Also tracks in-flight prefetch promises. Each promise resolves to the
|
|
10
|
+
* navigation branch of a tee'd Response, allowing navigation to adopt a
|
|
11
|
+
* still-downloading prefetch without reparsing or buffering the body.
|
|
12
|
+
*
|
|
9
13
|
* Replaces the previous browser HTTP cache approach which was unreliable
|
|
10
14
|
* due to response draining race conditions and browser inconsistencies.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
|
-
import {
|
|
17
|
+
import { abortAllPrefetches } from "./queue.js";
|
|
14
18
|
import { invalidateRangoState } from "../rango-state.js";
|
|
15
19
|
|
|
16
20
|
// Default TTL: 5 minutes. Overridden by initPrefetchCache() with
|
|
@@ -44,19 +48,36 @@ interface PrefetchCacheEntry {
|
|
|
44
48
|
const cache = new Map<string, PrefetchCacheEntry>();
|
|
45
49
|
const inflight = new Set<string>();
|
|
46
50
|
|
|
51
|
+
/**
|
|
52
|
+
* In-flight promise map. When a prefetch fetch is in progress, its
|
|
53
|
+
* Promise<Response | null> is stored here so navigation can await
|
|
54
|
+
* it instead of starting a duplicate request.
|
|
55
|
+
*/
|
|
56
|
+
const inflightPromises = new Map<string, Promise<Response | null>>();
|
|
57
|
+
|
|
47
58
|
// Generation counter incremented on each clearPrefetchCache(). Fetches that
|
|
48
59
|
// started before a clear carry a stale generation and must not store their
|
|
49
60
|
// response (the data may be stale due to a server action invalidation).
|
|
50
61
|
let generation = 0;
|
|
51
62
|
|
|
52
63
|
/**
|
|
53
|
-
* Build a
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
64
|
+
* Build a cache key for prefetched responses.
|
|
65
|
+
*
|
|
66
|
+
* By default the key includes the source page href so the same target
|
|
67
|
+
* prefetched from different pages gets separate entries (the server's
|
|
68
|
+
* diff response depends on the source page context).
|
|
69
|
+
*
|
|
70
|
+
* When `prefetchKey` is provided, the source portion is replaced with
|
|
71
|
+
* a `*` sentinel so all custom-keyed entries share one cache slot per
|
|
72
|
+
* target — enabling source-agnostic cache reuse.
|
|
57
73
|
*/
|
|
58
|
-
export function buildPrefetchKey(
|
|
59
|
-
|
|
74
|
+
export function buildPrefetchKey(
|
|
75
|
+
sourceHref: string,
|
|
76
|
+
targetUrl: URL,
|
|
77
|
+
prefetchKey?: string | ((from: string) => string),
|
|
78
|
+
): string {
|
|
79
|
+
const source = prefetchKey != null ? "*" : sourceHref;
|
|
80
|
+
return source + "\0" + targetUrl.pathname + targetUrl.search;
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
/**
|
|
@@ -78,6 +99,9 @@ export function hasPrefetch(key: string): boolean {
|
|
|
78
99
|
* Consume a cached prefetch response. Returns null if not found or expired.
|
|
79
100
|
* One-time consumption: the entry is deleted after retrieval.
|
|
80
101
|
* Returns null when caching is disabled (TTL <= 0).
|
|
102
|
+
*
|
|
103
|
+
* Does NOT check in-flight prefetches — use consumeInflightPrefetch()
|
|
104
|
+
* for that (returns a Promise instead of a Response).
|
|
81
105
|
*/
|
|
82
106
|
export function consumePrefetch(key: string): Response | null {
|
|
83
107
|
if (cacheTTL <= 0) return null;
|
|
@@ -91,10 +115,33 @@ export function consumePrefetch(key: string): Response | null {
|
|
|
91
115
|
return entry.response;
|
|
92
116
|
}
|
|
93
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Consume an in-flight prefetch promise. Returns null if no prefetch is
|
|
120
|
+
* in-flight for this key. The returned Promise resolves to the buffered
|
|
121
|
+
* Response (or null if the fetch failed/was aborted).
|
|
122
|
+
*
|
|
123
|
+
* One-time consumption: the promise entry is removed so a second call
|
|
124
|
+
* returns null. The `inflight` set entry is intentionally kept so that
|
|
125
|
+
* hasPrefetch() continues to return true while the underlying fetch is
|
|
126
|
+
* still downloading — this prevents prefetchDirect() or other callers
|
|
127
|
+
* from starting a duplicate request during the handoff window. The
|
|
128
|
+
* inflight flag is cleaned up naturally by clearPrefetchInflight() in
|
|
129
|
+
* the fetch's .finally().
|
|
130
|
+
*/
|
|
131
|
+
export function consumeInflightPrefetch(
|
|
132
|
+
key: string,
|
|
133
|
+
): Promise<Response | null> | null {
|
|
134
|
+
const promise = inflightPromises.get(key);
|
|
135
|
+
if (!promise) return null;
|
|
136
|
+
// Remove the promise (one-time consumption) but keep the inflight flag.
|
|
137
|
+
inflightPromises.delete(key);
|
|
138
|
+
return promise;
|
|
139
|
+
}
|
|
140
|
+
|
|
94
141
|
/**
|
|
95
142
|
* Store a prefetch response in the in-memory cache.
|
|
96
|
-
* The response
|
|
97
|
-
*
|
|
143
|
+
* The response should be a clone() of the original so the caller can
|
|
144
|
+
* still consume the body. The clone's body streams independently.
|
|
98
145
|
*
|
|
99
146
|
* Skips storage if the generation has changed since the fetch started
|
|
100
147
|
* (a server action invalidated the cache mid-flight).
|
|
@@ -136,19 +183,34 @@ export function markPrefetchInflight(key: string): void {
|
|
|
136
183
|
inflight.add(key);
|
|
137
184
|
}
|
|
138
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Store the in-flight Promise for a prefetch so navigation can reuse it.
|
|
188
|
+
*/
|
|
189
|
+
export function setInflightPromise(
|
|
190
|
+
key: string,
|
|
191
|
+
promise: Promise<Response | null>,
|
|
192
|
+
): void {
|
|
193
|
+
inflightPromises.set(key, promise);
|
|
194
|
+
}
|
|
195
|
+
|
|
139
196
|
export function clearPrefetchInflight(key: string): void {
|
|
140
197
|
inflight.delete(key);
|
|
198
|
+
inflightPromises.delete(key);
|
|
141
199
|
}
|
|
142
200
|
|
|
143
201
|
/**
|
|
144
202
|
* Invalidate all prefetch state. Called when server actions mutate data.
|
|
145
203
|
* Clears the in-memory cache, cancels in-flight prefetches, and rotates
|
|
146
204
|
* the Rango state key so CDN-cached responses are also invalidated.
|
|
205
|
+
*
|
|
206
|
+
* Uses abortAllPrefetches (hard cancel) because in-flight responses
|
|
207
|
+
* may contain stale data after a mutation.
|
|
147
208
|
*/
|
|
148
209
|
export function clearPrefetchCache(): void {
|
|
149
210
|
generation++;
|
|
150
211
|
inflight.clear();
|
|
212
|
+
inflightPromises.clear();
|
|
151
213
|
cache.clear();
|
|
152
|
-
|
|
214
|
+
abortAllPrefetches();
|
|
153
215
|
invalidateRangoState();
|
|
154
216
|
}
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
* real navigation so the server returns a proper diff. The Response is fully
|
|
7
7
|
* buffered and stored in an in-memory cache for instant consumption on
|
|
8
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.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import {
|
|
12
15
|
buildPrefetchKey,
|
|
13
16
|
hasPrefetch,
|
|
14
17
|
markPrefetchInflight,
|
|
18
|
+
setInflightPromise,
|
|
15
19
|
storePrefetch,
|
|
16
20
|
clearPrefetchInflight,
|
|
17
21
|
currentGeneration,
|
|
@@ -19,6 +23,24 @@ import {
|
|
|
19
23
|
import { getRangoState } from "../rango-state.js";
|
|
20
24
|
import { enqueuePrefetch } from "./queue.js";
|
|
21
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
|
+
}
|
|
22
44
|
|
|
23
45
|
/**
|
|
24
46
|
* Build an RSC partial URL for prefetching.
|
|
@@ -30,6 +52,7 @@ function buildPrefetchUrl(
|
|
|
30
52
|
url: string,
|
|
31
53
|
segmentIds: string[],
|
|
32
54
|
version?: string,
|
|
55
|
+
routerId?: string,
|
|
33
56
|
): URL | null {
|
|
34
57
|
let targetUrl: URL;
|
|
35
58
|
try {
|
|
@@ -47,23 +70,27 @@ function buildPrefetchUrl(
|
|
|
47
70
|
if (version) {
|
|
48
71
|
targetUrl.searchParams.set("_rsc_v", version);
|
|
49
72
|
}
|
|
73
|
+
if (routerId) {
|
|
74
|
+
targetUrl.searchParams.set("_rsc_rid", routerId);
|
|
75
|
+
}
|
|
50
76
|
return targetUrl;
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
/**
|
|
54
|
-
* Core prefetch fetch logic. Fetches the response,
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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().
|
|
57
84
|
*/
|
|
58
85
|
function executePrefetchFetch(
|
|
59
86
|
key: string,
|
|
60
87
|
fetchUrl: string,
|
|
61
88
|
signal?: AbortSignal,
|
|
62
|
-
): Promise<
|
|
89
|
+
): Promise<Response | null> {
|
|
63
90
|
const gen = currentGeneration();
|
|
64
91
|
markPrefetchInflight(key);
|
|
65
92
|
|
|
66
|
-
|
|
93
|
+
const promise: Promise<Response | null> = fetch(fetchUrl, {
|
|
67
94
|
priority: "low" as RequestPriority,
|
|
68
95
|
signal,
|
|
69
96
|
headers: {
|
|
@@ -72,26 +99,27 @@ function executePrefetchFetch(
|
|
|
72
99
|
"X-Rango-Prefetch": "1",
|
|
73
100
|
},
|
|
74
101
|
})
|
|
75
|
-
.then(
|
|
76
|
-
if (!response.ok) return;
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const cachedResponse = new Response(buffer, {
|
|
102
|
+
.then((response) => {
|
|
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 = {
|
|
83
109
|
headers: response.headers,
|
|
84
110
|
status: response.status,
|
|
85
111
|
statusText: response.statusText,
|
|
86
|
-
}
|
|
87
|
-
storePrefetch(key,
|
|
88
|
-
|
|
89
|
-
.catch(() => {
|
|
90
|
-
// Silently ignore prefetch failures (including abort)
|
|
112
|
+
};
|
|
113
|
+
storePrefetch(key, new Response(cacheStream, responseInit), gen);
|
|
114
|
+
return new Response(navStream, responseInit);
|
|
91
115
|
})
|
|
116
|
+
.catch(() => null)
|
|
92
117
|
.finally(() => {
|
|
93
118
|
clearPrefetchInflight(key);
|
|
94
119
|
});
|
|
120
|
+
|
|
121
|
+
setInflightPromise(key, promise);
|
|
122
|
+
return promise;
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
/**
|
|
@@ -102,13 +130,33 @@ export function prefetchDirect(
|
|
|
102
130
|
url: string,
|
|
103
131
|
segmentIds: string[],
|
|
104
132
|
version?: string,
|
|
133
|
+
routerId?: string,
|
|
134
|
+
prefetchKey?: string | ((from: string) => string),
|
|
105
135
|
): void {
|
|
106
136
|
if (!shouldPrefetch()) return;
|
|
107
137
|
|
|
108
|
-
const targetUrl = buildPrefetchUrl(url, segmentIds, version);
|
|
138
|
+
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
109
139
|
if (!targetUrl) return;
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
});
|
|
112
160
|
executePrefetchFetch(key, targetUrl.toString());
|
|
113
161
|
}
|
|
114
162
|
|
|
@@ -121,15 +169,38 @@ export function prefetchQueued(
|
|
|
121
169
|
url: string,
|
|
122
170
|
segmentIds: string[],
|
|
123
171
|
version?: string,
|
|
172
|
+
routerId?: string,
|
|
173
|
+
prefetchKey?: string | ((from: string) => string),
|
|
124
174
|
): string {
|
|
125
175
|
if (!shouldPrefetch()) return "";
|
|
126
|
-
const targetUrl = buildPrefetchUrl(url, segmentIds, version);
|
|
176
|
+
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
127
177
|
if (!targetUrl) return "";
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
|
130
192
|
const fetchUrlStr = targetUrl.toString();
|
|
131
|
-
enqueuePrefetch(key, (signal) =>
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
});
|
|
134
205
|
return key;
|
|
135
206
|
}
|