@rangojs/router 0.0.0-experimental.1b930379 → 0.0.0-experimental.1fa245e2
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/AGENTS.md +4 -0
- package/README.md +76 -18
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +558 -319
- package/package.json +16 -15
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +45 -4
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +53 -43
- package/skills/middleware/SKILL.md +2 -0
- package/skills/parallel/SKILL.md +126 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/route/SKILL.md +31 -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 +19 -13
- package/src/browser/navigation-client.ts +115 -58
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +80 -15
- package/src/browser/prefetch/cache.ts +57 -5
- package/src/browser/prefetch/fetch.ts +38 -23
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +53 -9
- package/src/browser/react/NavigationProvider.tsx +40 -4
- 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 +41 -42
- package/src/browser/segment-reconciler.ts +6 -1
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +36 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- 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 +2 -56
- 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 +8 -0
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +22 -1
- package/src/route-definition/dsl-helpers.ts +73 -25
- package/src/route-definition/helpers-types.ts +10 -6
- 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-map-builder.ts +7 -1
- package/src/route-types.ts +11 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +79 -23
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +4 -1
- package/src/router/loader-resolution.ts +122 -10
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +9 -3
- 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 +88 -16
- 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 +61 -5
- package/src/router/match-result.ts +22 -6
- 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 +6 -1
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +183 -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 +412 -297
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/types.ts +1 -0
- package/src/router.ts +59 -6
- package/src/rsc/handler.ts +460 -368
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/rsc-rendering.ts +5 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +8 -1
- package/src/segment-system.tsx +140 -4
- package/src/server/context.ts +140 -14
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +144 -18
- 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 +8 -1
- package/src/types/segments.ts +2 -0
- package/src/urls/path-helper-types.ts +9 -2
- 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 +73 -4
- 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 +14 -1
- 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/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 +153 -42
- package/src/vite/utils/banner.ts +3 -3
- package/src/vite/utils/prerender-utils.ts +18 -0
- package/src/vite/utils/shared-utils.ts +3 -2
|
@@ -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
|
|
@@ -185,7 +199,8 @@ export function createPartialUpdater(
|
|
|
185
199
|
// (action redirect sends empty segments for a fresh render).
|
|
186
200
|
staleRevalidation:
|
|
187
201
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
188
|
-
version,
|
|
202
|
+
version: getVersion(),
|
|
203
|
+
routerId: store.getRouterId?.(),
|
|
189
204
|
});
|
|
190
205
|
// Mark navigation as streaming (response received, now parsing RSC).
|
|
191
206
|
// Called after fetchPartial so pendingUrl stays set during the network wait,
|
|
@@ -198,6 +213,21 @@ export function createPartialUpdater(
|
|
|
198
213
|
streamingToken.end();
|
|
199
214
|
});
|
|
200
215
|
|
|
216
|
+
// Detect app switch: if routerId changed, the navigation crossed into
|
|
217
|
+
// a different router (e.g., via host router path mount). Downgrade
|
|
218
|
+
// partial to full so the entire tree is replaced without reconciliation
|
|
219
|
+
// against stale segments from the previous app.
|
|
220
|
+
if (payload.metadata?.routerId) {
|
|
221
|
+
const prevRouterId = store.getRouterId?.();
|
|
222
|
+
if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
|
|
223
|
+
debugLog(
|
|
224
|
+
`[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
|
|
225
|
+
);
|
|
226
|
+
payload.metadata.isPartial = false;
|
|
227
|
+
}
|
|
228
|
+
store.setRouterId?.(payload.metadata.routerId);
|
|
229
|
+
}
|
|
230
|
+
|
|
201
231
|
// Handle server-side redirect with state
|
|
202
232
|
if (payload.metadata?.redirect) {
|
|
203
233
|
if (signal?.aborted) {
|
|
@@ -246,7 +276,21 @@ export function createPartialUpdater(
|
|
|
246
276
|
forceAwait: true,
|
|
247
277
|
});
|
|
248
278
|
|
|
249
|
-
tx.commit(
|
|
279
|
+
const { scroll: commitScroll } = tx.commit(
|
|
280
|
+
matchedIds,
|
|
281
|
+
existingSegments,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// tx.commit() cached the source page's handleData because
|
|
285
|
+
// eventController hasn't been updated yet. Overwrite with the
|
|
286
|
+
// correct cached handleData to prevent cache corruption on
|
|
287
|
+
// subsequent navigations to this same URL.
|
|
288
|
+
if (mode.targetCacheHandleData) {
|
|
289
|
+
store.updateCacheHandleData(
|
|
290
|
+
store.getHistoryKey(),
|
|
291
|
+
mode.targetCacheHandleData,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
250
294
|
|
|
251
295
|
// Include cachedHandleData in metadata so NavigationProvider can restore
|
|
252
296
|
// breadcrumbs and other handle data from cache.
|
|
@@ -260,6 +304,7 @@ export function createPartialUpdater(
|
|
|
260
304
|
...metadataWithoutHandles,
|
|
261
305
|
cachedHandleData: mode.targetCacheHandleData,
|
|
262
306
|
},
|
|
307
|
+
scroll: toScrollPayload(commitScroll),
|
|
263
308
|
};
|
|
264
309
|
|
|
265
310
|
const cachedHasTransition = existingSegments.some(
|
|
@@ -290,11 +335,15 @@ export function createPartialUpdater(
|
|
|
290
335
|
forceAwait: true,
|
|
291
336
|
});
|
|
292
337
|
|
|
293
|
-
tx.commit(
|
|
338
|
+
const { scroll: leaveScroll } = tx.commit(
|
|
339
|
+
matchedIds,
|
|
340
|
+
existingSegments,
|
|
341
|
+
);
|
|
294
342
|
|
|
295
343
|
onUpdate({
|
|
296
344
|
root: newTree,
|
|
297
345
|
metadata: payload.metadata,
|
|
346
|
+
scroll: toScrollPayload(leaveScroll),
|
|
298
347
|
});
|
|
299
348
|
|
|
300
349
|
debugLog("[Browser] Navigation complete (left intercept)");
|
|
@@ -411,8 +460,10 @@ export function createPartialUpdater(
|
|
|
411
460
|
}
|
|
412
461
|
}
|
|
413
462
|
|
|
414
|
-
// Commit navigation -
|
|
415
|
-
|
|
463
|
+
// Commit navigation - use server's matched as the authoritative segment ID list.
|
|
464
|
+
// reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
|
|
465
|
+
// but the server's matched always includes all expected segment IDs.
|
|
466
|
+
const allSegmentIds = matchedIds;
|
|
416
467
|
const serverLocationState = payload.metadata?.locationState;
|
|
417
468
|
const overrides: CommitOverrides | undefined = isInterceptResponse
|
|
418
469
|
? {
|
|
@@ -424,7 +475,11 @@ export function createPartialUpdater(
|
|
|
424
475
|
: serverLocationState
|
|
425
476
|
? { serverState: serverLocationState }
|
|
426
477
|
: undefined;
|
|
427
|
-
tx.commit(
|
|
478
|
+
const { scroll: navScroll } = tx.commit(
|
|
479
|
+
allSegmentIds,
|
|
480
|
+
reconciled.segments,
|
|
481
|
+
overrides,
|
|
482
|
+
);
|
|
428
483
|
|
|
429
484
|
// For stale revalidation: verify history key hasn't changed before updating UI
|
|
430
485
|
if (mode.type === "stale-revalidation") {
|
|
@@ -439,8 +494,10 @@ export function createPartialUpdater(
|
|
|
439
494
|
|
|
440
495
|
debugLog("[partial-update] updating document");
|
|
441
496
|
|
|
442
|
-
// Emit update to trigger React render
|
|
497
|
+
// Emit update to trigger React render.
|
|
498
|
+
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
443
499
|
const hasTransition = reconciled.mainSegments.some((s) => s.transition);
|
|
500
|
+
const scrollPayload = toScrollPayload(navScroll);
|
|
444
501
|
|
|
445
502
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
446
503
|
startTransition(() => {
|
|
@@ -450,6 +507,7 @@ export function createPartialUpdater(
|
|
|
450
507
|
onUpdate({
|
|
451
508
|
root: newTree,
|
|
452
509
|
metadata: payload.metadata!,
|
|
510
|
+
scroll: scrollPayload,
|
|
453
511
|
});
|
|
454
512
|
});
|
|
455
513
|
} else if (hasTransition) {
|
|
@@ -460,12 +518,14 @@ export function createPartialUpdater(
|
|
|
460
518
|
onUpdate({
|
|
461
519
|
root: newTree,
|
|
462
520
|
metadata: payload.metadata!,
|
|
521
|
+
scroll: scrollPayload,
|
|
463
522
|
});
|
|
464
523
|
});
|
|
465
524
|
} else {
|
|
466
525
|
onUpdate({
|
|
467
526
|
root: newTree,
|
|
468
527
|
metadata: payload.metadata!,
|
|
528
|
+
scroll: scrollPayload,
|
|
469
529
|
});
|
|
470
530
|
}
|
|
471
531
|
|
|
@@ -492,15 +552,16 @@ export function createPartialUpdater(
|
|
|
492
552
|
}
|
|
493
553
|
|
|
494
554
|
const fullUpdateServerState = payload.metadata?.locationState;
|
|
495
|
-
|
|
496
|
-
tx.commit(segmentIds, segments, {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
555
|
+
const { scroll: fullScroll } = fullUpdateServerState
|
|
556
|
+
? tx.commit(segmentIds, segments, {
|
|
557
|
+
serverState: fullUpdateServerState,
|
|
558
|
+
})
|
|
559
|
+
: tx.commit(segmentIds, segments);
|
|
500
560
|
|
|
501
561
|
const fullHasTransition = segments.some(
|
|
502
562
|
(s: ResolvedSegment) => s.transition,
|
|
503
563
|
);
|
|
564
|
+
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
504
565
|
|
|
505
566
|
if (mode.type === "stale-revalidation") {
|
|
506
567
|
await rawStreamComplete;
|
|
@@ -511,6 +572,7 @@ export function createPartialUpdater(
|
|
|
511
572
|
onUpdate({
|
|
512
573
|
root: newTree,
|
|
513
574
|
metadata: payload.metadata!,
|
|
575
|
+
scroll: fullScrollPayload,
|
|
514
576
|
});
|
|
515
577
|
});
|
|
516
578
|
} else if (mode.type === "action") {
|
|
@@ -521,6 +583,7 @@ export function createPartialUpdater(
|
|
|
521
583
|
onUpdate({
|
|
522
584
|
root: newTree,
|
|
523
585
|
metadata: payload.metadata!,
|
|
586
|
+
scroll: fullScrollPayload,
|
|
524
587
|
});
|
|
525
588
|
});
|
|
526
589
|
} else if (fullHasTransition) {
|
|
@@ -531,12 +594,14 @@ export function createPartialUpdater(
|
|
|
531
594
|
onUpdate({
|
|
532
595
|
root: newTree,
|
|
533
596
|
metadata: payload.metadata!,
|
|
597
|
+
scroll: fullScrollPayload,
|
|
534
598
|
});
|
|
535
599
|
});
|
|
536
600
|
} else {
|
|
537
601
|
onUpdate({
|
|
538
602
|
root: newTree,
|
|
539
603
|
metadata: payload.metadata!,
|
|
604
|
+
scroll: fullScrollPayload,
|
|
540
605
|
});
|
|
541
606
|
}
|
|
542
607
|
|
|
@@ -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,6 +48,13 @@ 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).
|
|
@@ -78,6 +89,9 @@ export function hasPrefetch(key: string): boolean {
|
|
|
78
89
|
* Consume a cached prefetch response. Returns null if not found or expired.
|
|
79
90
|
* One-time consumption: the entry is deleted after retrieval.
|
|
80
91
|
* Returns null when caching is disabled (TTL <= 0).
|
|
92
|
+
*
|
|
93
|
+
* Does NOT check in-flight prefetches — use consumeInflightPrefetch()
|
|
94
|
+
* for that (returns a Promise instead of a Response).
|
|
81
95
|
*/
|
|
82
96
|
export function consumePrefetch(key: string): Response | null {
|
|
83
97
|
if (cacheTTL <= 0) return null;
|
|
@@ -91,10 +105,33 @@ export function consumePrefetch(key: string): Response | null {
|
|
|
91
105
|
return entry.response;
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Consume an in-flight prefetch promise. Returns null if no prefetch is
|
|
110
|
+
* in-flight for this key. The returned Promise resolves to the buffered
|
|
111
|
+
* Response (or null if the fetch failed/was aborted).
|
|
112
|
+
*
|
|
113
|
+
* One-time consumption: the promise entry is removed so a second call
|
|
114
|
+
* returns null. The `inflight` set entry is intentionally kept so that
|
|
115
|
+
* hasPrefetch() continues to return true while the underlying fetch is
|
|
116
|
+
* still downloading — this prevents prefetchDirect() or other callers
|
|
117
|
+
* from starting a duplicate request during the handoff window. The
|
|
118
|
+
* inflight flag is cleaned up naturally by clearPrefetchInflight() in
|
|
119
|
+
* the fetch's .finally().
|
|
120
|
+
*/
|
|
121
|
+
export function consumeInflightPrefetch(
|
|
122
|
+
key: string,
|
|
123
|
+
): Promise<Response | null> | null {
|
|
124
|
+
const promise = inflightPromises.get(key);
|
|
125
|
+
if (!promise) return null;
|
|
126
|
+
// Remove the promise (one-time consumption) but keep the inflight flag.
|
|
127
|
+
inflightPromises.delete(key);
|
|
128
|
+
return promise;
|
|
129
|
+
}
|
|
130
|
+
|
|
94
131
|
/**
|
|
95
132
|
* Store a prefetch response in the in-memory cache.
|
|
96
|
-
* The response
|
|
97
|
-
*
|
|
133
|
+
* The response should be a clone() of the original so the caller can
|
|
134
|
+
* still consume the body. The clone's body streams independently.
|
|
98
135
|
*
|
|
99
136
|
* Skips storage if the generation has changed since the fetch started
|
|
100
137
|
* (a server action invalidated the cache mid-flight).
|
|
@@ -136,19 +173,34 @@ export function markPrefetchInflight(key: string): void {
|
|
|
136
173
|
inflight.add(key);
|
|
137
174
|
}
|
|
138
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Store the in-flight Promise for a prefetch so navigation can reuse it.
|
|
178
|
+
*/
|
|
179
|
+
export function setInflightPromise(
|
|
180
|
+
key: string,
|
|
181
|
+
promise: Promise<Response | null>,
|
|
182
|
+
): void {
|
|
183
|
+
inflightPromises.set(key, promise);
|
|
184
|
+
}
|
|
185
|
+
|
|
139
186
|
export function clearPrefetchInflight(key: string): void {
|
|
140
187
|
inflight.delete(key);
|
|
188
|
+
inflightPromises.delete(key);
|
|
141
189
|
}
|
|
142
190
|
|
|
143
191
|
/**
|
|
144
192
|
* Invalidate all prefetch state. Called when server actions mutate data.
|
|
145
193
|
* Clears the in-memory cache, cancels in-flight prefetches, and rotates
|
|
146
194
|
* the Rango state key so CDN-cached responses are also invalidated.
|
|
195
|
+
*
|
|
196
|
+
* Uses abortAllPrefetches (hard cancel) because in-flight responses
|
|
197
|
+
* may contain stale data after a mutation.
|
|
147
198
|
*/
|
|
148
199
|
export function clearPrefetchCache(): void {
|
|
149
200
|
generation++;
|
|
150
201
|
inflight.clear();
|
|
202
|
+
inflightPromises.clear();
|
|
151
203
|
cache.clear();
|
|
152
|
-
|
|
204
|
+
abortAllPrefetches();
|
|
153
205
|
invalidateRangoState();
|
|
154
206
|
}
|
|
@@ -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,
|
|
@@ -30,6 +34,7 @@ function buildPrefetchUrl(
|
|
|
30
34
|
url: string,
|
|
31
35
|
segmentIds: string[],
|
|
32
36
|
version?: string,
|
|
37
|
+
routerId?: string,
|
|
33
38
|
): URL | null {
|
|
34
39
|
let targetUrl: URL;
|
|
35
40
|
try {
|
|
@@ -47,23 +52,27 @@ function buildPrefetchUrl(
|
|
|
47
52
|
if (version) {
|
|
48
53
|
targetUrl.searchParams.set("_rsc_v", version);
|
|
49
54
|
}
|
|
55
|
+
if (routerId) {
|
|
56
|
+
targetUrl.searchParams.set("_rsc_rid", routerId);
|
|
57
|
+
}
|
|
50
58
|
return targetUrl;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
54
|
-
* Core prefetch fetch logic. Fetches the response,
|
|
55
|
-
*
|
|
56
|
-
*
|
|
62
|
+
* Core prefetch fetch logic. Fetches the response, tees the body, and stores
|
|
63
|
+
* one branch in the in-memory cache. The returned Promise resolves to the
|
|
64
|
+
* sibling navigation branch (or null on failure) so navigation can safely
|
|
65
|
+
* reuse an in-flight prefetch via consumeInflightPrefetch().
|
|
57
66
|
*/
|
|
58
67
|
function executePrefetchFetch(
|
|
59
68
|
key: string,
|
|
60
69
|
fetchUrl: string,
|
|
61
70
|
signal?: AbortSignal,
|
|
62
|
-
): Promise<
|
|
71
|
+
): Promise<Response | null> {
|
|
63
72
|
const gen = currentGeneration();
|
|
64
73
|
markPrefetchInflight(key);
|
|
65
74
|
|
|
66
|
-
|
|
75
|
+
const promise: Promise<Response | null> = fetch(fetchUrl, {
|
|
67
76
|
priority: "low" as RequestPriority,
|
|
68
77
|
signal,
|
|
69
78
|
headers: {
|
|
@@ -72,26 +81,27 @@ function executePrefetchFetch(
|
|
|
72
81
|
"X-Rango-Prefetch": "1",
|
|
73
82
|
},
|
|
74
83
|
})
|
|
75
|
-
.then(
|
|
76
|
-
if (!response.ok) return;
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const cachedResponse = new Response(buffer, {
|
|
84
|
+
.then((response) => {
|
|
85
|
+
if (!response.ok) return null;
|
|
86
|
+
// Don't buffer with arrayBuffer() — that blocks until the entire
|
|
87
|
+
// body downloads, defeating streaming for slow loaders.
|
|
88
|
+
// Tee the body: one branch for navigation, one for cache storage.
|
|
89
|
+
const [navStream, cacheStream] = response.body!.tee();
|
|
90
|
+
const responseInit = {
|
|
83
91
|
headers: response.headers,
|
|
84
92
|
status: response.status,
|
|
85
93
|
statusText: response.statusText,
|
|
86
|
-
}
|
|
87
|
-
storePrefetch(key,
|
|
88
|
-
|
|
89
|
-
.catch(() => {
|
|
90
|
-
// Silently ignore prefetch failures (including abort)
|
|
94
|
+
};
|
|
95
|
+
storePrefetch(key, new Response(cacheStream, responseInit), gen);
|
|
96
|
+
return new Response(navStream, responseInit);
|
|
91
97
|
})
|
|
98
|
+
.catch(() => null)
|
|
92
99
|
.finally(() => {
|
|
93
100
|
clearPrefetchInflight(key);
|
|
94
101
|
});
|
|
102
|
+
|
|
103
|
+
setInflightPromise(key, promise);
|
|
104
|
+
return promise;
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
/**
|
|
@@ -102,10 +112,11 @@ export function prefetchDirect(
|
|
|
102
112
|
url: string,
|
|
103
113
|
segmentIds: string[],
|
|
104
114
|
version?: string,
|
|
115
|
+
routerId?: string,
|
|
105
116
|
): void {
|
|
106
117
|
if (!shouldPrefetch()) return;
|
|
107
118
|
|
|
108
|
-
const targetUrl = buildPrefetchUrl(url, segmentIds, version);
|
|
119
|
+
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
109
120
|
if (!targetUrl) return;
|
|
110
121
|
const key = buildPrefetchKey(window.location.href, targetUrl);
|
|
111
122
|
if (hasPrefetch(key)) return;
|
|
@@ -121,15 +132,19 @@ export function prefetchQueued(
|
|
|
121
132
|
url: string,
|
|
122
133
|
segmentIds: string[],
|
|
123
134
|
version?: string,
|
|
135
|
+
routerId?: string,
|
|
124
136
|
): string {
|
|
125
137
|
if (!shouldPrefetch()) return "";
|
|
126
|
-
const targetUrl = buildPrefetchUrl(url, segmentIds, version);
|
|
138
|
+
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
127
139
|
if (!targetUrl) return "";
|
|
128
140
|
const key = buildPrefetchKey(window.location.href, targetUrl);
|
|
129
141
|
if (hasPrefetch(key)) return key;
|
|
130
142
|
const fetchUrlStr = targetUrl.toString();
|
|
131
|
-
enqueuePrefetch(key, (signal) =>
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
enqueuePrefetch(key, (signal) => {
|
|
144
|
+
// Re-check at execution time: a hover-triggered prefetchDirect may
|
|
145
|
+
// have started or completed this key while the item sat in the queue.
|
|
146
|
+
if (hasPrefetch(key)) return Promise.resolve();
|
|
147
|
+
return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
|
|
148
|
+
});
|
|
134
149
|
return key;
|
|
135
150
|
}
|
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
* Hover prefetches bypass this queue — they fire directly for immediate response
|
|
6
6
|
* to user intent.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Draining waits for an idle main-thread moment and for viewport images to
|
|
9
|
+
* finish loading, so prefetch fetch() calls never compete with critical
|
|
10
|
+
* resources for the browser's connection pool.
|
|
11
|
+
*
|
|
12
|
+
* When a navigation starts, queued prefetches are cancelled but executing ones
|
|
13
|
+
* are left running. Navigation can reuse their in-flight responses via the
|
|
14
|
+
* prefetch cache's inflight promise map, avoiding duplicate requests.
|
|
10
15
|
*/
|
|
11
16
|
|
|
17
|
+
import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
|
|
18
|
+
|
|
12
19
|
const MAX_CONCURRENT = 2;
|
|
20
|
+
const IMAGE_WAIT_TIMEOUT = 2000;
|
|
13
21
|
|
|
14
22
|
let active = 0;
|
|
15
23
|
const queue: Array<{
|
|
@@ -18,7 +26,9 @@ const queue: Array<{
|
|
|
18
26
|
}> = [];
|
|
19
27
|
const queued = new Set<string>();
|
|
20
28
|
const executing = new Set<string>();
|
|
21
|
-
|
|
29
|
+
const abortControllers = new Map<string, AbortController>();
|
|
30
|
+
let drainScheduled = false;
|
|
31
|
+
let drainGeneration = 0;
|
|
22
32
|
|
|
23
33
|
function startExecution(
|
|
24
34
|
key: string,
|
|
@@ -26,18 +36,49 @@ function startExecution(
|
|
|
26
36
|
): void {
|
|
27
37
|
active++;
|
|
28
38
|
executing.add(key);
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
const ac = new AbortController();
|
|
40
|
+
abortControllers.set(key, ac);
|
|
41
|
+
execute(ac.signal).finally(() => {
|
|
42
|
+
abortControllers.delete(key);
|
|
31
43
|
// Only decrement if this key wasn't already cleared by cancelAllPrefetches.
|
|
32
44
|
// Without this guard, cancelled tasks' .finally() would underflow active
|
|
33
45
|
// below zero, breaking the MAX_CONCURRENT guarantee.
|
|
34
46
|
if (executing.delete(key)) {
|
|
35
47
|
active--;
|
|
36
48
|
}
|
|
37
|
-
|
|
49
|
+
scheduleDrain();
|
|
38
50
|
});
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Schedule a drain after the browser is idle and viewport images are loaded.
|
|
55
|
+
* Coalesces multiple drain requests into a single deferred callback so
|
|
56
|
+
* batch completion doesn't schedule redundant waits.
|
|
57
|
+
*
|
|
58
|
+
* The two-step wait ensures prefetch fetch() calls don't compete with
|
|
59
|
+
* images for the browser's connection pool:
|
|
60
|
+
* 1. waitForIdle — yield until the main thread has a quiet moment
|
|
61
|
+
* 2. waitForViewportImages OR 2s timeout — yield until visible images
|
|
62
|
+
* finish loading, but don't let slow/broken images block indefinitely
|
|
63
|
+
*/
|
|
64
|
+
function scheduleDrain(): void {
|
|
65
|
+
if (drainScheduled) return;
|
|
66
|
+
if (active >= MAX_CONCURRENT || queue.length === 0) return;
|
|
67
|
+
drainScheduled = true;
|
|
68
|
+
const gen = drainGeneration;
|
|
69
|
+
waitForIdle()
|
|
70
|
+
.then(() =>
|
|
71
|
+
Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
|
|
72
|
+
)
|
|
73
|
+
.then(() => {
|
|
74
|
+
drainScheduled = false;
|
|
75
|
+
// Stale drain: a cancel/abort happened while we were waiting.
|
|
76
|
+
// A fresh scheduleDrain will be called by whatever enqueues next.
|
|
77
|
+
if (gen !== drainGeneration) return;
|
|
78
|
+
if (queue.length > 0) drain();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
41
82
|
function drain(): void {
|
|
42
83
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
|
43
84
|
const item = queue.shift()!;
|
|
@@ -48,9 +89,10 @@ function drain(): void {
|
|
|
48
89
|
|
|
49
90
|
/**
|
|
50
91
|
* Enqueue a prefetch for concurrency-limited execution.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* Deduplicates by key — items already queued or executing
|
|
92
|
+
* Execution is deferred until the browser is idle and viewport images
|
|
93
|
+
* have finished loading, so prefetches never compete with critical
|
|
94
|
+
* resources. Deduplicates by key — items already queued or executing
|
|
95
|
+
* are skipped.
|
|
54
96
|
*
|
|
55
97
|
* The executor receives an AbortSignal that is aborted when
|
|
56
98
|
* cancelAllPrefetches() is called (e.g. on navigation start).
|
|
@@ -61,22 +103,50 @@ export function enqueuePrefetch(
|
|
|
61
103
|
): void {
|
|
62
104
|
if (queued.has(key) || executing.has(key)) return;
|
|
63
105
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
106
|
+
queued.add(key);
|
|
107
|
+
queue.push({ key, execute });
|
|
108
|
+
scheduleDrain();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Cancel queued prefetches and abort in-flight ones that don't match
|
|
113
|
+
* the current navigation target. If `keepUrl` is provided, the
|
|
114
|
+
* executing prefetch whose key contains that URL is kept alive so
|
|
115
|
+
* navigation can reuse its response via consumeInflightPrefetch.
|
|
116
|
+
*
|
|
117
|
+
* Called when a navigation starts via the NavigationProvider's
|
|
118
|
+
* event controller subscription.
|
|
119
|
+
*/
|
|
120
|
+
export function cancelAllPrefetches(keepUrl?: string | null): void {
|
|
121
|
+
queue.length = 0;
|
|
122
|
+
queued.clear();
|
|
123
|
+
drainScheduled = false;
|
|
124
|
+
drainGeneration++;
|
|
125
|
+
|
|
126
|
+
// Abort in-flight prefetches that aren't for the navigation target.
|
|
127
|
+
// Keys use format "sourceHref\0targetPathname+search" — match the
|
|
128
|
+
// target portion (after \0) against keepUrl.
|
|
129
|
+
for (const [key, ac] of abortControllers) {
|
|
130
|
+
const target = key.split("\0")[1];
|
|
131
|
+
if (keepUrl && target && keepUrl.startsWith(target)) continue;
|
|
132
|
+
ac.abort();
|
|
133
|
+
abortControllers.delete(key);
|
|
134
|
+
if (executing.delete(key)) {
|
|
135
|
+
active--;
|
|
136
|
+
}
|
|
69
137
|
}
|
|
70
138
|
}
|
|
71
139
|
|
|
72
140
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
141
|
+
* Hard-cancel everything including in-flight prefetches.
|
|
142
|
+
* Used by clearPrefetchCache (server action invalidation) where
|
|
143
|
+
* in-flight responses would be stale.
|
|
76
144
|
*/
|
|
77
|
-
export function
|
|
78
|
-
|
|
79
|
-
|
|
145
|
+
export function abortAllPrefetches(): void {
|
|
146
|
+
for (const ac of abortControllers.values()) {
|
|
147
|
+
ac.abort();
|
|
148
|
+
}
|
|
149
|
+
abortControllers.clear();
|
|
80
150
|
|
|
81
151
|
queue.length = 0;
|
|
82
152
|
queued.clear();
|
|
@@ -85,4 +155,6 @@ export function cancelAllPrefetches(): void {
|
|
|
85
155
|
// so active settles at 0 without underflow.
|
|
86
156
|
executing.clear();
|
|
87
157
|
active = 0;
|
|
158
|
+
drainScheduled = false;
|
|
159
|
+
drainGeneration++;
|
|
88
160
|
}
|