@rangojs/router 0.0.0-experimental.39 → 0.0.0-experimental.3b1deca8
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 +292 -204
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +45 -4
- package/skills/loader/SKILL.md +53 -43
- package/skills/parallel/SKILL.md +126 -0
- package/skills/route/SKILL.md +31 -0
- package/skills/router-setup/SKILL.md +52 -2
- package/skills/typesafety/SKILL.md +10 -0
- package/src/browser/debug-channel.ts +93 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +1 -5
- package/src/browser/navigation-client.ts +84 -27
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +50 -9
- package/src/browser/prefetch/cache.ts +57 -5
- package/src/browser/prefetch/fetch.ts +30 -21
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +9 -1
- package/src/browser/react/NavigationProvider.tsx +32 -3
- package/src/browser/rsc-router.tsx +109 -57
- package/src/browser/scroll-restoration.ts +31 -34
- package/src/browser/segment-reconciler.ts +6 -1
- package/src/browser/server-action-bridge.ts +12 -0
- package/src/browser/types.ts +17 -1
- package/src/build/route-types/router-processing.ts +12 -2
- 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/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/deps/browser.ts +1 -0
- package/src/route-definition/dsl-helpers.ts +32 -7
- package/src/route-definition/helpers-types.ts +6 -5
- package/src/route-definition/redirect.ts +2 -2
- package/src/route-map-builder.ts +7 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +31 -8
- package/src/router/intercept-resolution.ts +2 -0
- package/src/router/lazy-includes.ts +4 -1
- package/src/router/loader-resolution.ts +7 -1
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +9 -3
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +66 -9
- 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 +8 -5
- package/src/router/match-result.ts +22 -6
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +6 -2
- package/src/router/middleware.ts +4 -3
- package/src/router/router-context.ts +6 -1
- package/src/router/segment-resolution/fresh.ts +130 -17
- 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 +352 -290
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/types.ts +1 -0
- package/src/router.ts +6 -1
- package/src/rsc/handler.ts +28 -2
- package/src/rsc/loader-fetch.ts +7 -2
- package/src/rsc/progressive-enhancement.ts +4 -1
- package/src/rsc/rsc-rendering.ts +4 -1
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/types.ts +7 -1
- package/src/segment-system.tsx +140 -4
- package/src/server/context.ts +102 -13
- package/src/server/request-context.ts +59 -12
- package/src/ssr/index.tsx +1 -0
- package/src/types/handler-context.ts +120 -22
- package/src/types/loader-types.ts +4 -4
- package/src/types/route-entry.ts +7 -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/performance-tracks.ts +235 -0
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +148 -209
- package/src/vite/router-discovery.ts +0 -8
- package/src/vite/utils/banner.ts +3 -3
|
@@ -12,12 +12,18 @@ import {
|
|
|
12
12
|
startBrowserTransaction,
|
|
13
13
|
} from "./logging.js";
|
|
14
14
|
import { getRangoState } from "./rango-state.js";
|
|
15
|
+
import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
|
|
16
|
+
import { findSourceMapURL } from "../deps/browser.js";
|
|
15
17
|
import {
|
|
16
18
|
extractRscHeaderUrl,
|
|
17
19
|
emptyResponse,
|
|
18
20
|
teeWithCompletion,
|
|
19
21
|
} from "./response-adapter.js";
|
|
20
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
buildPrefetchKey,
|
|
24
|
+
consumeInflightPrefetch,
|
|
25
|
+
consumePrefetch,
|
|
26
|
+
} from "./prefetch/cache.js";
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
29
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -85,49 +91,49 @@ export function createNavigationClient(
|
|
|
85
91
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
// Check in-memory prefetch cache before making a network request.
|
|
94
|
+
// Check completed in-memory prefetch cache before making a network request.
|
|
89
95
|
// The cache key includes the source URL (previousUrl) because the
|
|
90
96
|
// server's diff response depends on the source page context.
|
|
91
97
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
92
98
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
99
|
+
//
|
|
100
|
+
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
93
101
|
const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
|
|
94
|
-
const cachedResponse =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
|
|
103
|
+
const inflightResponsePromise = canUsePrefetch
|
|
104
|
+
? consumeInflightPrefetch(cacheKey)
|
|
105
|
+
: null;
|
|
99
106
|
// Track when the stream completes
|
|
100
107
|
let resolveStreamComplete: () => void;
|
|
101
108
|
const streamComplete = new Promise<void>((resolve) => {
|
|
102
109
|
resolveStreamComplete = resolve;
|
|
103
110
|
});
|
|
104
111
|
|
|
105
|
-
|
|
112
|
+
// Dev-only: create debug channel for React Performance Tracks
|
|
113
|
+
const debugId = (import.meta as any).hot
|
|
114
|
+
? crypto.randomUUID()
|
|
115
|
+
: undefined;
|
|
116
|
+
const debugChannel = debugId
|
|
117
|
+
? createClientDebugChannel(debugId)
|
|
118
|
+
: undefined;
|
|
119
|
+
if (debugId) {
|
|
120
|
+
console.log(
|
|
121
|
+
"[perf-tracks] client: debugId =",
|
|
122
|
+
debugId,
|
|
123
|
+
"channel =",
|
|
124
|
+
debugChannel ? "created" : "null (no HMR)",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
106
127
|
|
|
107
|
-
|
|
108
|
-
|
|
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 {
|
|
128
|
+
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
129
|
+
const doFreshFetch = (): Promise<Response> => {
|
|
124
130
|
if (tx) {
|
|
125
131
|
browserDebugLog(tx, "fetching", {
|
|
126
132
|
path: `${fetchUrl.pathname}${fetchUrl.search}`,
|
|
127
133
|
});
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
|
|
136
|
+
return fetch(fetchUrl, {
|
|
131
137
|
headers: {
|
|
132
138
|
"X-RSC-Router-Client-Path": previousUrl,
|
|
133
139
|
"X-Rango-State": getRangoState(),
|
|
@@ -136,6 +142,7 @@ export function createNavigationClient(
|
|
|
136
142
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
137
143
|
}),
|
|
138
144
|
...(hmr && { "X-RSC-HMR": "1" }),
|
|
145
|
+
...(debugId && { [DEBUG_ID_HEADER]: debugId }),
|
|
139
146
|
},
|
|
140
147
|
signal,
|
|
141
148
|
}).then((response) => {
|
|
@@ -183,11 +190,61 @@ export function createNavigationClient(
|
|
|
183
190
|
signal,
|
|
184
191
|
);
|
|
185
192
|
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
let responsePromise: Promise<Response>;
|
|
196
|
+
|
|
197
|
+
if (cachedResponse) {
|
|
198
|
+
if (tx) {
|
|
199
|
+
browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
|
|
200
|
+
}
|
|
201
|
+
// Cached response body is already fully buffered (arrayBuffer),
|
|
202
|
+
// so stream completion is immediate.
|
|
203
|
+
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
204
|
+
return teeWithCompletion(
|
|
205
|
+
response,
|
|
206
|
+
() => {
|
|
207
|
+
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
208
|
+
resolveStreamComplete();
|
|
209
|
+
},
|
|
210
|
+
signal,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
} else if (inflightResponsePromise) {
|
|
214
|
+
if (tx) {
|
|
215
|
+
browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
|
|
216
|
+
}
|
|
217
|
+
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
218
|
+
if (!response) {
|
|
219
|
+
if (tx) {
|
|
220
|
+
browserDebugLog(tx, "inflight prefetch unavailable, refetching");
|
|
221
|
+
}
|
|
222
|
+
return doFreshFetch();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return teeWithCompletion(
|
|
226
|
+
response,
|
|
227
|
+
() => {
|
|
228
|
+
if (tx) {
|
|
229
|
+
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
230
|
+
}
|
|
231
|
+
resolveStreamComplete();
|
|
232
|
+
},
|
|
233
|
+
signal,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
responsePromise = doFreshFetch();
|
|
186
238
|
}
|
|
187
239
|
|
|
188
240
|
try {
|
|
189
241
|
// Deserialize RSC payload
|
|
190
|
-
const payload = await deps.createFromFetch<RscPayload>(
|
|
242
|
+
const payload = await deps.createFromFetch<RscPayload>(
|
|
243
|
+
responsePromise,
|
|
244
|
+
{
|
|
245
|
+
...(debugChannel && { debugChannel, findSourceMapURL }),
|
|
246
|
+
},
|
|
247
|
+
);
|
|
191
248
|
if (tx) {
|
|
192
249
|
browserDebugLog(tx, "response received", {
|
|
193
250
|
isPartial: payload.metadata?.isPartial,
|
|
@@ -7,7 +7,6 @@ import type {
|
|
|
7
7
|
import { generateHistoryKey } from "./navigation-store.js";
|
|
8
8
|
import {
|
|
9
9
|
handleNavigationStart,
|
|
10
|
-
handleNavigationEnd,
|
|
11
10
|
ensureHistoryKey,
|
|
12
11
|
} from "./scroll-restoration.js";
|
|
13
12
|
import type { EventController, NavigationHandle } from "./event-controller.js";
|
|
@@ -81,11 +80,12 @@ export interface BoundTransaction {
|
|
|
81
80
|
readonly currentUrl: string;
|
|
82
81
|
/** Start streaming and get a token to end it when the stream completes */
|
|
83
82
|
startStreaming(): StreamingToken;
|
|
83
|
+
/** Commit the navigation. Returns the effective scroll option for the caller to handle. */
|
|
84
84
|
commit(
|
|
85
85
|
segmentIds: string[],
|
|
86
86
|
segments: ResolvedSegment[],
|
|
87
87
|
overrides?: BoundCommitOverrides,
|
|
88
|
-
):
|
|
88
|
+
): { scroll?: boolean };
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
@@ -93,7 +93,7 @@ export interface BoundTransaction {
|
|
|
93
93
|
* Uses the event controller handle for lifecycle management
|
|
94
94
|
*/
|
|
95
95
|
interface NavigationTransaction extends Disposable {
|
|
96
|
-
commit(options: CommitOptions):
|
|
96
|
+
commit(options: CommitOptions): { scroll?: boolean };
|
|
97
97
|
with(
|
|
98
98
|
options: Omit<CommitOptions, "segmentIds" | "segments">,
|
|
99
99
|
): BoundTransaction;
|
|
@@ -120,7 +120,7 @@ export function createNavigationTransaction(
|
|
|
120
120
|
/**
|
|
121
121
|
* Commit the navigation - updates store and URL atomically
|
|
122
122
|
*/
|
|
123
|
-
function commit(opts: CommitOptions):
|
|
123
|
+
function commit(opts: CommitOptions): { scroll?: boolean } {
|
|
124
124
|
committed = true;
|
|
125
125
|
|
|
126
126
|
const {
|
|
@@ -150,7 +150,7 @@ export function createNavigationTransaction(
|
|
|
150
150
|
// Without this, the entry lingers and weakens state-machine invariants.
|
|
151
151
|
handle.complete(parsedUrl);
|
|
152
152
|
debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
|
|
153
|
-
return;
|
|
153
|
+
return { scroll: false };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
// Save current scroll position before navigating
|
|
@@ -172,7 +172,7 @@ export function createNavigationTransaction(
|
|
|
172
172
|
debugLog("[Browser] Store updated (action)");
|
|
173
173
|
// Complete navigation to clear loading state
|
|
174
174
|
handle.complete(parsedUrl);
|
|
175
|
-
return;
|
|
175
|
+
return { scroll: false };
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// Build history state - include user state, intercept info, and server-set state
|
|
@@ -205,14 +205,16 @@ export function createNavigationTransaction(
|
|
|
205
205
|
// Complete the navigation in event controller (sets idle state, updates location)
|
|
206
206
|
handle.complete(parsedUrl);
|
|
207
207
|
|
|
208
|
-
//
|
|
209
|
-
|
|
208
|
+
// NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
|
|
209
|
+
// scroll AFTER onUpdate() so React has the new content before we scroll.
|
|
210
210
|
|
|
211
211
|
debugLog(
|
|
212
212
|
"[Browser] Navigation committed, historyKey:",
|
|
213
213
|
historyKey,
|
|
214
214
|
intercept ? "(intercept)" : "",
|
|
215
215
|
);
|
|
216
|
+
|
|
217
|
+
return { scroll };
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
return {
|
|
@@ -263,7 +265,7 @@ export function createNavigationTransaction(
|
|
|
263
265
|
overrides?.state !== undefined ? overrides.state : opts.state;
|
|
264
266
|
// Server-set location state: only from overrides (set by partial-update)
|
|
265
267
|
const serverState = overrides?.serverState;
|
|
266
|
-
commit({
|
|
268
|
+
return commit({
|
|
267
269
|
...opts,
|
|
268
270
|
segmentIds,
|
|
269
271
|
segments,
|
|
@@ -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
|
|
@@ -246,7 +254,21 @@ export function createPartialUpdater(
|
|
|
246
254
|
forceAwait: true,
|
|
247
255
|
});
|
|
248
256
|
|
|
249
|
-
tx.commit(
|
|
257
|
+
const { scroll: commitScroll } = tx.commit(
|
|
258
|
+
matchedIds,
|
|
259
|
+
existingSegments,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Fix: tx.commit() cached the source page's handleData because
|
|
263
|
+
// eventController hasn't been updated yet. Overwrite with the
|
|
264
|
+
// correct cached handleData to prevent cache corruption on
|
|
265
|
+
// subsequent navigations to this same URL.
|
|
266
|
+
if (mode.targetCacheHandleData) {
|
|
267
|
+
store.updateCacheHandleData(
|
|
268
|
+
store.getHistoryKey(),
|
|
269
|
+
mode.targetCacheHandleData,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
250
272
|
|
|
251
273
|
// Include cachedHandleData in metadata so NavigationProvider can restore
|
|
252
274
|
// breadcrumbs and other handle data from cache.
|
|
@@ -260,6 +282,7 @@ export function createPartialUpdater(
|
|
|
260
282
|
...metadataWithoutHandles,
|
|
261
283
|
cachedHandleData: mode.targetCacheHandleData,
|
|
262
284
|
},
|
|
285
|
+
scroll: toScrollPayload(commitScroll),
|
|
263
286
|
};
|
|
264
287
|
|
|
265
288
|
const cachedHasTransition = existingSegments.some(
|
|
@@ -290,11 +313,15 @@ export function createPartialUpdater(
|
|
|
290
313
|
forceAwait: true,
|
|
291
314
|
});
|
|
292
315
|
|
|
293
|
-
tx.commit(
|
|
316
|
+
const { scroll: leaveScroll } = tx.commit(
|
|
317
|
+
matchedIds,
|
|
318
|
+
existingSegments,
|
|
319
|
+
);
|
|
294
320
|
|
|
295
321
|
onUpdate({
|
|
296
322
|
root: newTree,
|
|
297
323
|
metadata: payload.metadata,
|
|
324
|
+
scroll: toScrollPayload(leaveScroll),
|
|
298
325
|
});
|
|
299
326
|
|
|
300
327
|
debugLog("[Browser] Navigation complete (left intercept)");
|
|
@@ -426,7 +453,11 @@ export function createPartialUpdater(
|
|
|
426
453
|
: serverLocationState
|
|
427
454
|
? { serverState: serverLocationState }
|
|
428
455
|
: undefined;
|
|
429
|
-
tx.commit(
|
|
456
|
+
const { scroll: navScroll } = tx.commit(
|
|
457
|
+
allSegmentIds,
|
|
458
|
+
reconciled.segments,
|
|
459
|
+
overrides,
|
|
460
|
+
);
|
|
430
461
|
|
|
431
462
|
// For stale revalidation: verify history key hasn't changed before updating UI
|
|
432
463
|
if (mode.type === "stale-revalidation") {
|
|
@@ -441,8 +472,10 @@ export function createPartialUpdater(
|
|
|
441
472
|
|
|
442
473
|
debugLog("[partial-update] updating document");
|
|
443
474
|
|
|
444
|
-
// Emit update to trigger React render
|
|
475
|
+
// Emit update to trigger React render.
|
|
476
|
+
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
445
477
|
const hasTransition = reconciled.mainSegments.some((s) => s.transition);
|
|
478
|
+
const scrollPayload = toScrollPayload(navScroll);
|
|
446
479
|
|
|
447
480
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
448
481
|
startTransition(() => {
|
|
@@ -452,6 +485,7 @@ export function createPartialUpdater(
|
|
|
452
485
|
onUpdate({
|
|
453
486
|
root: newTree,
|
|
454
487
|
metadata: payload.metadata!,
|
|
488
|
+
scroll: scrollPayload,
|
|
455
489
|
});
|
|
456
490
|
});
|
|
457
491
|
} else if (hasTransition) {
|
|
@@ -462,12 +496,14 @@ export function createPartialUpdater(
|
|
|
462
496
|
onUpdate({
|
|
463
497
|
root: newTree,
|
|
464
498
|
metadata: payload.metadata!,
|
|
499
|
+
scroll: scrollPayload,
|
|
465
500
|
});
|
|
466
501
|
});
|
|
467
502
|
} else {
|
|
468
503
|
onUpdate({
|
|
469
504
|
root: newTree,
|
|
470
505
|
metadata: payload.metadata!,
|
|
506
|
+
scroll: scrollPayload,
|
|
471
507
|
});
|
|
472
508
|
}
|
|
473
509
|
|
|
@@ -494,15 +530,16 @@ export function createPartialUpdater(
|
|
|
494
530
|
}
|
|
495
531
|
|
|
496
532
|
const fullUpdateServerState = payload.metadata?.locationState;
|
|
497
|
-
|
|
498
|
-
tx.commit(segmentIds, segments, {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
533
|
+
const { scroll: fullScroll } = fullUpdateServerState
|
|
534
|
+
? tx.commit(segmentIds, segments, {
|
|
535
|
+
serverState: fullUpdateServerState,
|
|
536
|
+
})
|
|
537
|
+
: tx.commit(segmentIds, segments);
|
|
502
538
|
|
|
503
539
|
const fullHasTransition = segments.some(
|
|
504
540
|
(s: ResolvedSegment) => s.transition,
|
|
505
541
|
);
|
|
542
|
+
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
506
543
|
|
|
507
544
|
if (mode.type === "stale-revalidation") {
|
|
508
545
|
await rawStreamComplete;
|
|
@@ -513,6 +550,7 @@ export function createPartialUpdater(
|
|
|
513
550
|
onUpdate({
|
|
514
551
|
root: newTree,
|
|
515
552
|
metadata: payload.metadata!,
|
|
553
|
+
scroll: fullScrollPayload,
|
|
516
554
|
});
|
|
517
555
|
});
|
|
518
556
|
} else if (mode.type === "action") {
|
|
@@ -523,6 +561,7 @@ export function createPartialUpdater(
|
|
|
523
561
|
onUpdate({
|
|
524
562
|
root: newTree,
|
|
525
563
|
metadata: payload.metadata!,
|
|
564
|
+
scroll: fullScrollPayload,
|
|
526
565
|
});
|
|
527
566
|
});
|
|
528
567
|
} else if (fullHasTransition) {
|
|
@@ -533,12 +572,14 @@ export function createPartialUpdater(
|
|
|
533
572
|
onUpdate({
|
|
534
573
|
root: newTree,
|
|
535
574
|
metadata: payload.metadata!,
|
|
575
|
+
scroll: fullScrollPayload,
|
|
536
576
|
});
|
|
537
577
|
});
|
|
538
578
|
} else {
|
|
539
579
|
onUpdate({
|
|
540
580
|
root: newTree,
|
|
541
581
|
metadata: payload.metadata!,
|
|
582
|
+
scroll: fullScrollPayload,
|
|
542
583
|
});
|
|
543
584
|
}
|
|
544
585
|
|
|
@@ -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
|
}
|