@rangojs/router 0.0.0-experimental.4518794d → 0.0.0-experimental.4ffb98de
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/dist/bin/rango.js +8 -3
- package/dist/vite/index.js +139 -200
- package/package.json +1 -1
- package/skills/caching/SKILL.md +37 -4
- package/skills/parallel/SKILL.md +59 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +1 -7
- package/src/browser/navigation-client.ts +60 -27
- package/src/browser/partial-update.ts +21 -8
- package/src/browser/prefetch/cache.ts +57 -5
- package/src/browser/prefetch/fetch.ts +30 -21
- package/src/browser/prefetch/queue.ts +53 -13
- package/src/browser/react/Link.tsx +9 -1
- package/src/browser/react/NavigationProvider.tsx +27 -0
- package/src/browser/rsc-router.tsx +109 -57
- package/src/browser/scroll-restoration.ts +11 -10
- package/src/browser/segment-reconciler.ts +6 -1
- package/src/browser/types.ts +9 -0
- package/src/build/route-types/router-processing.ts +12 -2
- package/src/cache/cache-scope.ts +2 -2
- 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/debug.ts +2 -2
- package/src/route-definition/dsl-helpers.ts +32 -7
- package/src/route-definition/redirect.ts +2 -2
- package/src/router/lazy-includes.ts +2 -1
- package/src/router/logging.ts +1 -1
- package/src/router/manifest.ts +6 -2
- package/src/router/match-middleware/background-revalidation.ts +18 -1
- package/src/router/match-middleware/cache-lookup.ts +20 -3
- package/src/router/match-middleware/cache-store.ts +32 -6
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +7 -5
- package/src/router/match-result.ts +11 -1
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware.ts +2 -1
- package/src/router/segment-resolution/fresh.ts +104 -14
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +307 -272
- package/src/router.ts +1 -1
- package/src/rsc/handler.ts +9 -0
- package/src/segment-system.tsx +140 -4
- package/src/server/context.ts +90 -13
- package/src/server/request-context.ts +10 -4
- package/src/ssr/index.tsx +1 -0
- package/src/types/segments.ts +2 -0
- package/src/urls/path-helper.ts +1 -1
- package/src/vite/discovery/state.ts +0 -2
- package/src/vite/plugin-types.ts +0 -83
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +144 -209
- package/src/vite/router-discovery.ts +0 -8
- package/src/vite/utils/banner.ts +3 -3
package/skills/parallel/SKILL.md
CHANGED
|
@@ -109,6 +109,65 @@ parallel(
|
|
|
109
109
|
)
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
+
### Streaming Behavior
|
|
113
|
+
|
|
114
|
+
Parallels with `loading()` are **independent streaming units**. They don't
|
|
115
|
+
block the parent layout or sibling routes during SSR:
|
|
116
|
+
|
|
117
|
+
- **With `loading()`**: The skeleton renders immediately. The loader runs
|
|
118
|
+
in the background and streams data to the client when ready. The rest
|
|
119
|
+
of the page (layout, route content, other parallels) renders without
|
|
120
|
+
waiting.
|
|
121
|
+
- **Without `loading()`**: The parallel's loaders block the parent layout's
|
|
122
|
+
rendering. Use this when the data must be available before the page
|
|
123
|
+
paints (e.g., critical above-the-fold content).
|
|
124
|
+
- **SPA navigation**: Parallel loaders resolve in the background. The
|
|
125
|
+
existing parallel UI stays visible — no skeleton flash on route changes
|
|
126
|
+
within the same layout.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// Sidebar streams independently — page renders immediately
|
|
130
|
+
parallel(
|
|
131
|
+
{ "@sidebar": () => <Sidebar /> },
|
|
132
|
+
() => [loader(SlowSidebarLoader), loading(<SidebarSkeleton />)]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
// Cart data blocks layout — must be ready before paint
|
|
136
|
+
parallel(
|
|
137
|
+
{ "@cartBadge": () => <CartBadge /> },
|
|
138
|
+
() => [loader(CartCountLoader)] // No loading() = awaited
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Slot Override Semantics
|
|
143
|
+
|
|
144
|
+
When multiple `parallel()` calls define the same slot name, **the last
|
|
145
|
+
definition wins**. Earlier definitions of that slot are removed. Other
|
|
146
|
+
slots from the earlier call are preserved.
|
|
147
|
+
|
|
148
|
+
This enables composition patterns where included routes override
|
|
149
|
+
parent-defined slots:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
layout(DashboardLayout, () => [
|
|
153
|
+
// Base slots
|
|
154
|
+
parallel({
|
|
155
|
+
"@sidebar": () => <DefaultSidebar />,
|
|
156
|
+
"@footer": () => <Footer />,
|
|
157
|
+
}),
|
|
158
|
+
|
|
159
|
+
// Override just @sidebar — @footer is preserved
|
|
160
|
+
parallel({ "@sidebar": () => <CustomSidebar /> }),
|
|
161
|
+
|
|
162
|
+
path("/", DashboardIndex, { name: "index" }),
|
|
163
|
+
])
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
After resolution, the layout has two parallel entries:
|
|
167
|
+
|
|
168
|
+
- `{ "@footer": () => <Footer /> }` (first call, `@sidebar` removed)
|
|
169
|
+
- `{ "@sidebar": () => <CustomSidebar /> }` (second call, wins)
|
|
170
|
+
|
|
112
171
|
## Multiple Parallel Slots
|
|
113
172
|
|
|
114
173
|
```typescript
|
|
@@ -79,6 +79,8 @@ export interface DerivedNavigationState {
|
|
|
79
79
|
state: "idle" | "loading";
|
|
80
80
|
/** Whether any operation is streaming */
|
|
81
81
|
isStreaming: boolean;
|
|
82
|
+
/** Whether a navigation is active (fetching or streaming, before commit) */
|
|
83
|
+
isNavigating: boolean;
|
|
82
84
|
/** Current committed location */
|
|
83
85
|
location: NavigationLocation;
|
|
84
86
|
/** URL being navigated to (null if idle) */
|
|
@@ -389,6 +391,9 @@ export function createEventController(
|
|
|
389
391
|
return {
|
|
390
392
|
state,
|
|
391
393
|
isStreaming,
|
|
394
|
+
// True when a navigation is active (fetching or streaming, before
|
|
395
|
+
// commit). Broader than pendingUrl which clears during streaming.
|
|
396
|
+
isNavigating: currentNavigation !== null,
|
|
392
397
|
location,
|
|
393
398
|
// pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
|
|
394
399
|
// Background revalidations (skipLoadingState) don't expose a pending URL.
|
|
@@ -461,10 +461,6 @@ export function createNavigationBridge(
|
|
|
461
461
|
}
|
|
462
462
|
eventController.setParams(cachedParams);
|
|
463
463
|
|
|
464
|
-
// Scroll to top immediately to avoid flicker from the previous
|
|
465
|
-
// page's scroll position while React paints the cached content.
|
|
466
|
-
window.scrollTo(0, 0);
|
|
467
|
-
|
|
468
464
|
const popstateUpdate = {
|
|
469
465
|
root,
|
|
470
466
|
metadata: {
|
|
@@ -476,6 +472,7 @@ export function createNavigationBridge(
|
|
|
476
472
|
cachedHandleData,
|
|
477
473
|
params: cachedParams,
|
|
478
474
|
},
|
|
475
|
+
scroll: { restore: true, isStreaming },
|
|
479
476
|
};
|
|
480
477
|
const hasTransition = cachedSegments.some((s) => s.transition);
|
|
481
478
|
if (hasTransition) {
|
|
@@ -489,9 +486,6 @@ export function createNavigationBridge(
|
|
|
489
486
|
onUpdate(popstateUpdate);
|
|
490
487
|
}
|
|
491
488
|
|
|
492
|
-
// Restore the actual saved scroll position after React paints.
|
|
493
|
-
handleNavigationEnd({ restore: true, isStreaming });
|
|
494
|
-
|
|
495
489
|
// SWR: If stale, trigger background revalidation
|
|
496
490
|
if (isStale) {
|
|
497
491
|
debugLog("[Browser] Cache is stale, background revalidating...");
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
emptyResponse,
|
|
18
18
|
teeWithCompletion,
|
|
19
19
|
} from "./response-adapter.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
buildPrefetchKey,
|
|
22
|
+
consumeInflightPrefetch,
|
|
23
|
+
consumePrefetch,
|
|
24
|
+
} from "./prefetch/cache.js";
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -85,49 +89,33 @@ export function createNavigationClient(
|
|
|
85
89
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
// Check in-memory prefetch cache before making a network request.
|
|
92
|
+
// Check completed in-memory prefetch cache before making a network request.
|
|
89
93
|
// The cache key includes the source URL (previousUrl) because the
|
|
90
94
|
// server's diff response depends on the source page context.
|
|
91
95
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
92
96
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
97
|
+
//
|
|
98
|
+
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
93
99
|
const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
|
|
94
|
-
const cachedResponse =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
|
|
101
|
+
const inflightResponsePromise = canUsePrefetch
|
|
102
|
+
? consumeInflightPrefetch(cacheKey)
|
|
103
|
+
: null;
|
|
99
104
|
// Track when the stream completes
|
|
100
105
|
let resolveStreamComplete: () => void;
|
|
101
106
|
const streamComplete = new Promise<void>((resolve) => {
|
|
102
107
|
resolveStreamComplete = resolve;
|
|
103
108
|
});
|
|
104
109
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (cachedResponse) {
|
|
108
|
-
if (tx) {
|
|
109
|
-
browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
|
|
110
|
-
}
|
|
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 {
|
|
110
|
+
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
111
|
+
const doFreshFetch = (): Promise<Response> => {
|
|
124
112
|
if (tx) {
|
|
125
113
|
browserDebugLog(tx, "fetching", {
|
|
126
114
|
path: `${fetchUrl.pathname}${fetchUrl.search}`,
|
|
127
115
|
});
|
|
128
116
|
}
|
|
129
117
|
|
|
130
|
-
|
|
118
|
+
return fetch(fetchUrl, {
|
|
131
119
|
headers: {
|
|
132
120
|
"X-RSC-Router-Client-Path": previousUrl,
|
|
133
121
|
"X-Rango-State": getRangoState(),
|
|
@@ -183,6 +171,51 @@ export function createNavigationClient(
|
|
|
183
171
|
signal,
|
|
184
172
|
);
|
|
185
173
|
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let responsePromise: Promise<Response>;
|
|
177
|
+
|
|
178
|
+
if (cachedResponse) {
|
|
179
|
+
if (tx) {
|
|
180
|
+
browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
|
|
181
|
+
}
|
|
182
|
+
// Cached response body is already fully buffered (arrayBuffer),
|
|
183
|
+
// so stream completion is immediate.
|
|
184
|
+
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
185
|
+
return teeWithCompletion(
|
|
186
|
+
response,
|
|
187
|
+
() => {
|
|
188
|
+
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
189
|
+
resolveStreamComplete();
|
|
190
|
+
},
|
|
191
|
+
signal,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
} else if (inflightResponsePromise) {
|
|
195
|
+
if (tx) {
|
|
196
|
+
browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
|
|
197
|
+
}
|
|
198
|
+
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
199
|
+
if (!response) {
|
|
200
|
+
if (tx) {
|
|
201
|
+
browserDebugLog(tx, "inflight prefetch unavailable, refetching");
|
|
202
|
+
}
|
|
203
|
+
return doFreshFetch();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return teeWithCompletion(
|
|
207
|
+
response,
|
|
208
|
+
() => {
|
|
209
|
+
if (tx) {
|
|
210
|
+
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
211
|
+
}
|
|
212
|
+
resolveStreamComplete();
|
|
213
|
+
},
|
|
214
|
+
signal,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
responsePromise = doFreshFetch();
|
|
186
219
|
}
|
|
187
220
|
|
|
188
221
|
try {
|
|
@@ -19,7 +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 {
|
|
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
|
+
}
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
32
|
* Configuration for creating a partial updater
|
|
@@ -264,6 +271,7 @@ export function createPartialUpdater(
|
|
|
264
271
|
...metadataWithoutHandles,
|
|
265
272
|
cachedHandleData: mode.targetCacheHandleData,
|
|
266
273
|
},
|
|
274
|
+
scroll: toScrollPayload(commitScroll),
|
|
267
275
|
};
|
|
268
276
|
|
|
269
277
|
const cachedHasTransition = existingSegments.some(
|
|
@@ -280,7 +288,6 @@ export function createPartialUpdater(
|
|
|
280
288
|
onUpdate(cachedUpdate);
|
|
281
289
|
}
|
|
282
290
|
|
|
283
|
-
handleNavigationEnd({ scroll: commitScroll });
|
|
284
291
|
debugLog("[Browser] Navigation complete (rendered from cache)");
|
|
285
292
|
return;
|
|
286
293
|
}
|
|
@@ -303,9 +310,9 @@ export function createPartialUpdater(
|
|
|
303
310
|
onUpdate({
|
|
304
311
|
root: newTree,
|
|
305
312
|
metadata: payload.metadata,
|
|
313
|
+
scroll: toScrollPayload(leaveScroll),
|
|
306
314
|
});
|
|
307
315
|
|
|
308
|
-
handleNavigationEnd({ scroll: leaveScroll });
|
|
309
316
|
debugLog("[Browser] Navigation complete (left intercept)");
|
|
310
317
|
return;
|
|
311
318
|
}
|
|
@@ -454,8 +461,10 @@ export function createPartialUpdater(
|
|
|
454
461
|
|
|
455
462
|
debugLog("[partial-update] updating document");
|
|
456
463
|
|
|
457
|
-
// Emit update to trigger React render
|
|
464
|
+
// Emit update to trigger React render.
|
|
465
|
+
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
458
466
|
const hasTransition = reconciled.mainSegments.some((s) => s.transition);
|
|
467
|
+
const scrollPayload = toScrollPayload(navScroll);
|
|
459
468
|
|
|
460
469
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
461
470
|
startTransition(() => {
|
|
@@ -465,6 +474,7 @@ export function createPartialUpdater(
|
|
|
465
474
|
onUpdate({
|
|
466
475
|
root: newTree,
|
|
467
476
|
metadata: payload.metadata!,
|
|
477
|
+
scroll: scrollPayload,
|
|
468
478
|
});
|
|
469
479
|
});
|
|
470
480
|
} else if (hasTransition) {
|
|
@@ -475,18 +485,17 @@ export function createPartialUpdater(
|
|
|
475
485
|
onUpdate({
|
|
476
486
|
root: newTree,
|
|
477
487
|
metadata: payload.metadata!,
|
|
488
|
+
scroll: scrollPayload,
|
|
478
489
|
});
|
|
479
490
|
});
|
|
480
491
|
} else {
|
|
481
492
|
onUpdate({
|
|
482
493
|
root: newTree,
|
|
483
494
|
metadata: payload.metadata!,
|
|
495
|
+
scroll: scrollPayload,
|
|
484
496
|
});
|
|
485
497
|
}
|
|
486
498
|
|
|
487
|
-
// Scroll after onUpdate so React has the new content before we scroll
|
|
488
|
-
handleNavigationEnd({ scroll: navScroll });
|
|
489
|
-
|
|
490
499
|
debugLog("[Browser] Navigation complete");
|
|
491
500
|
return;
|
|
492
501
|
} else {
|
|
@@ -519,6 +528,7 @@ export function createPartialUpdater(
|
|
|
519
528
|
const fullHasTransition = segments.some(
|
|
520
529
|
(s: ResolvedSegment) => s.transition,
|
|
521
530
|
);
|
|
531
|
+
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
522
532
|
|
|
523
533
|
if (mode.type === "stale-revalidation") {
|
|
524
534
|
await rawStreamComplete;
|
|
@@ -529,6 +539,7 @@ export function createPartialUpdater(
|
|
|
529
539
|
onUpdate({
|
|
530
540
|
root: newTree,
|
|
531
541
|
metadata: payload.metadata!,
|
|
542
|
+
scroll: fullScrollPayload,
|
|
532
543
|
});
|
|
533
544
|
});
|
|
534
545
|
} else if (mode.type === "action") {
|
|
@@ -539,6 +550,7 @@ export function createPartialUpdater(
|
|
|
539
550
|
onUpdate({
|
|
540
551
|
root: newTree,
|
|
541
552
|
metadata: payload.metadata!,
|
|
553
|
+
scroll: fullScrollPayload,
|
|
542
554
|
});
|
|
543
555
|
});
|
|
544
556
|
} else if (fullHasTransition) {
|
|
@@ -549,16 +561,17 @@ export function createPartialUpdater(
|
|
|
549
561
|
onUpdate({
|
|
550
562
|
root: newTree,
|
|
551
563
|
metadata: payload.metadata!,
|
|
564
|
+
scroll: fullScrollPayload,
|
|
552
565
|
});
|
|
553
566
|
});
|
|
554
567
|
} else {
|
|
555
568
|
onUpdate({
|
|
556
569
|
root: newTree,
|
|
557
570
|
metadata: payload.metadata!,
|
|
571
|
+
scroll: fullScrollPayload,
|
|
558
572
|
});
|
|
559
573
|
}
|
|
560
574
|
|
|
561
|
-
handleNavigationEnd({ scroll: fullScroll });
|
|
562
575
|
return;
|
|
563
576
|
}
|
|
564
577
|
}
|
|
@@ -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,
|
|
@@ -51,19 +55,20 @@ function buildPrefetchUrl(
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
/**
|
|
54
|
-
* Core prefetch fetch logic. Fetches the response,
|
|
55
|
-
*
|
|
56
|
-
*
|
|
58
|
+
* Core prefetch fetch logic. Fetches the response, tees the body, and stores
|
|
59
|
+
* one branch in the in-memory cache. The returned Promise resolves to the
|
|
60
|
+
* sibling navigation branch (or null on failure) so navigation can safely
|
|
61
|
+
* reuse an in-flight prefetch via consumeInflightPrefetch().
|
|
57
62
|
*/
|
|
58
63
|
function executePrefetchFetch(
|
|
59
64
|
key: string,
|
|
60
65
|
fetchUrl: string,
|
|
61
66
|
signal?: AbortSignal,
|
|
62
|
-
): Promise<
|
|
67
|
+
): Promise<Response | null> {
|
|
63
68
|
const gen = currentGeneration();
|
|
64
69
|
markPrefetchInflight(key);
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
const promise: Promise<Response | null> = fetch(fetchUrl, {
|
|
67
72
|
priority: "low" as RequestPriority,
|
|
68
73
|
signal,
|
|
69
74
|
headers: {
|
|
@@ -72,26 +77,27 @@ function executePrefetchFetch(
|
|
|
72
77
|
"X-Rango-Prefetch": "1",
|
|
73
78
|
},
|
|
74
79
|
})
|
|
75
|
-
.then(
|
|
76
|
-
if (!response.ok) return;
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const cachedResponse = new Response(buffer, {
|
|
80
|
+
.then((response) => {
|
|
81
|
+
if (!response.ok) return null;
|
|
82
|
+
// Don't buffer with arrayBuffer() — that blocks until the entire
|
|
83
|
+
// body downloads, defeating streaming for slow loaders.
|
|
84
|
+
// Tee the body: one branch for navigation, one for cache storage.
|
|
85
|
+
const [navStream, cacheStream] = response.body!.tee();
|
|
86
|
+
const responseInit = {
|
|
83
87
|
headers: response.headers,
|
|
84
88
|
status: response.status,
|
|
85
89
|
statusText: response.statusText,
|
|
86
|
-
}
|
|
87
|
-
storePrefetch(key,
|
|
88
|
-
|
|
89
|
-
.catch(() => {
|
|
90
|
-
// Silently ignore prefetch failures (including abort)
|
|
90
|
+
};
|
|
91
|
+
storePrefetch(key, new Response(cacheStream, responseInit), gen);
|
|
92
|
+
return new Response(navStream, responseInit);
|
|
91
93
|
})
|
|
94
|
+
.catch(() => null)
|
|
92
95
|
.finally(() => {
|
|
93
96
|
clearPrefetchInflight(key);
|
|
94
97
|
});
|
|
98
|
+
|
|
99
|
+
setInflightPromise(key, promise);
|
|
100
|
+
return promise;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
/**
|
|
@@ -128,8 +134,11 @@ export function prefetchQueued(
|
|
|
128
134
|
const key = buildPrefetchKey(window.location.href, targetUrl);
|
|
129
135
|
if (hasPrefetch(key)) return key;
|
|
130
136
|
const fetchUrlStr = targetUrl.toString();
|
|
131
|
-
enqueuePrefetch(key, (signal) =>
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
enqueuePrefetch(key, (signal) => {
|
|
138
|
+
// Re-check at execution time: a hover-triggered prefetchDirect may
|
|
139
|
+
// have started or completed this key while the item sat in the queue.
|
|
140
|
+
if (hasPrefetch(key)) return Promise.resolve();
|
|
141
|
+
return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
|
|
142
|
+
});
|
|
134
143
|
return key;
|
|
135
144
|
}
|