@rangojs/router 0.0.0-experimental.27 → 0.0.0-experimental.289231ba
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 +78 -19
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +853 -435
- package/package.json +17 -16
- package/skills/breadcrumbs/SKILL.md +250 -0
- 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/hooks/SKILL.md +22 -4
- 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 +71 -21
- 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 +56 -2
- package/skills/router-setup/SKILL.md +92 -2
- package/skills/typesafety/SKILL.md +33 -21
- package/src/__internal.ts +92 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +125 -16
- package/src/browser/navigation-client.ts +154 -44
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +94 -17
- package/src/browser/prefetch/cache.ts +176 -27
- package/src/browser/prefetch/fetch.ts +110 -41
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +88 -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-navigation.ts +22 -2
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +143 -60
- package/src/browser/scroll-restoration.ts +41 -42
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +60 -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.rsc.tsx +2 -0
- package/src/client.tsx +85 -230
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/index.rsc.ts +6 -36
- package/src/index.ts +50 -43
- 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-map-builder.ts +7 -1
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +111 -25
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +9 -6
- package/src/router/loader-resolution.ts +156 -21
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +31 -16
- package/src/router/match-api.ts +125 -190
- 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 +61 -5
- package/src/router/match-result.ts +104 -10
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +40 -12
- package/src/router/middleware.ts +43 -79
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +114 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/revalidation.ts +21 -10
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +6 -1
- package/src/router/router-interfaces.ts +44 -5
- package/src/router/router-options.ts +49 -18
- package/src/router/segment-resolution/fresh.ts +198 -20
- package/src/router/segment-resolution/helpers.ts +30 -25
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +438 -300
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/types.ts +1 -0
- package/src/router.ts +73 -13
- 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 +13 -1
- package/src/rsc/server-action.ts +8 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +11 -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 +204 -28
- 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 +149 -49
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-entry.ts +19 -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
|
@@ -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
|
|
@@ -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) {
|
|
@@ -246,7 +288,21 @@ export function createPartialUpdater(
|
|
|
246
288
|
forceAwait: true,
|
|
247
289
|
});
|
|
248
290
|
|
|
249
|
-
tx.commit(
|
|
291
|
+
const { scroll: commitScroll } = tx.commit(
|
|
292
|
+
matchedIds,
|
|
293
|
+
existingSegments,
|
|
294
|
+
);
|
|
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
|
+
}
|
|
250
306
|
|
|
251
307
|
// Include cachedHandleData in metadata so NavigationProvider can restore
|
|
252
308
|
// breadcrumbs and other handle data from cache.
|
|
@@ -260,6 +316,7 @@ export function createPartialUpdater(
|
|
|
260
316
|
...metadataWithoutHandles,
|
|
261
317
|
cachedHandleData: mode.targetCacheHandleData,
|
|
262
318
|
},
|
|
319
|
+
scroll: toScrollPayload(commitScroll),
|
|
263
320
|
};
|
|
264
321
|
|
|
265
322
|
const cachedHasTransition = existingSegments.some(
|
|
@@ -290,11 +347,15 @@ export function createPartialUpdater(
|
|
|
290
347
|
forceAwait: true,
|
|
291
348
|
});
|
|
292
349
|
|
|
293
|
-
tx.commit(
|
|
350
|
+
const { scroll: leaveScroll } = tx.commit(
|
|
351
|
+
matchedIds,
|
|
352
|
+
existingSegments,
|
|
353
|
+
);
|
|
294
354
|
|
|
295
355
|
onUpdate({
|
|
296
356
|
root: newTree,
|
|
297
357
|
metadata: payload.metadata,
|
|
358
|
+
scroll: toScrollPayload(leaveScroll),
|
|
298
359
|
});
|
|
299
360
|
|
|
300
361
|
debugLog("[Browser] Navigation complete (left intercept)");
|
|
@@ -411,8 +472,10 @@ export function createPartialUpdater(
|
|
|
411
472
|
}
|
|
412
473
|
}
|
|
413
474
|
|
|
414
|
-
// Commit navigation -
|
|
415
|
-
|
|
475
|
+
// Commit navigation - use server's matched as the authoritative segment ID list.
|
|
476
|
+
// reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
|
|
477
|
+
// but the server's matched always includes all expected segment IDs.
|
|
478
|
+
const allSegmentIds = matchedIds;
|
|
416
479
|
const serverLocationState = payload.metadata?.locationState;
|
|
417
480
|
const overrides: CommitOverrides | undefined = isInterceptResponse
|
|
418
481
|
? {
|
|
@@ -424,7 +487,11 @@ export function createPartialUpdater(
|
|
|
424
487
|
: serverLocationState
|
|
425
488
|
? { serverState: serverLocationState }
|
|
426
489
|
: undefined;
|
|
427
|
-
tx.commit(
|
|
490
|
+
const { scroll: navScroll } = tx.commit(
|
|
491
|
+
allSegmentIds,
|
|
492
|
+
reconciled.segments,
|
|
493
|
+
overrides,
|
|
494
|
+
);
|
|
428
495
|
|
|
429
496
|
// For stale revalidation: verify history key hasn't changed before updating UI
|
|
430
497
|
if (mode.type === "stale-revalidation") {
|
|
@@ -439,8 +506,10 @@ export function createPartialUpdater(
|
|
|
439
506
|
|
|
440
507
|
debugLog("[partial-update] updating document");
|
|
441
508
|
|
|
442
|
-
// Emit update to trigger React render
|
|
509
|
+
// Emit update to trigger React render.
|
|
510
|
+
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
443
511
|
const hasTransition = reconciled.mainSegments.some((s) => s.transition);
|
|
512
|
+
const scrollPayload = toScrollPayload(navScroll);
|
|
444
513
|
|
|
445
514
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
446
515
|
startTransition(() => {
|
|
@@ -450,6 +519,7 @@ export function createPartialUpdater(
|
|
|
450
519
|
onUpdate({
|
|
451
520
|
root: newTree,
|
|
452
521
|
metadata: payload.metadata!,
|
|
522
|
+
scroll: scrollPayload,
|
|
453
523
|
});
|
|
454
524
|
});
|
|
455
525
|
} else if (hasTransition) {
|
|
@@ -460,12 +530,14 @@ export function createPartialUpdater(
|
|
|
460
530
|
onUpdate({
|
|
461
531
|
root: newTree,
|
|
462
532
|
metadata: payload.metadata!,
|
|
533
|
+
scroll: scrollPayload,
|
|
463
534
|
});
|
|
464
535
|
});
|
|
465
536
|
} else {
|
|
466
537
|
onUpdate({
|
|
467
538
|
root: newTree,
|
|
468
539
|
metadata: payload.metadata!,
|
|
540
|
+
scroll: scrollPayload,
|
|
469
541
|
});
|
|
470
542
|
}
|
|
471
543
|
|
|
@@ -492,15 +564,16 @@ export function createPartialUpdater(
|
|
|
492
564
|
}
|
|
493
565
|
|
|
494
566
|
const fullUpdateServerState = payload.metadata?.locationState;
|
|
495
|
-
|
|
496
|
-
tx.commit(segmentIds, segments, {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
567
|
+
const { scroll: fullScroll } = fullUpdateServerState
|
|
568
|
+
? tx.commit(segmentIds, segments, {
|
|
569
|
+
serverState: fullUpdateServerState,
|
|
570
|
+
})
|
|
571
|
+
: tx.commit(segmentIds, segments);
|
|
500
572
|
|
|
501
573
|
const fullHasTransition = segments.some(
|
|
502
574
|
(s: ResolvedSegment) => s.transition,
|
|
503
575
|
);
|
|
576
|
+
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
504
577
|
|
|
505
578
|
if (mode.type === "stale-revalidation") {
|
|
506
579
|
await rawStreamComplete;
|
|
@@ -511,6 +584,7 @@ export function createPartialUpdater(
|
|
|
511
584
|
onUpdate({
|
|
512
585
|
root: newTree,
|
|
513
586
|
metadata: payload.metadata!,
|
|
587
|
+
scroll: fullScrollPayload,
|
|
514
588
|
});
|
|
515
589
|
});
|
|
516
590
|
} else if (mode.type === "action") {
|
|
@@ -521,6 +595,7 @@ export function createPartialUpdater(
|
|
|
521
595
|
onUpdate({
|
|
522
596
|
root: newTree,
|
|
523
597
|
metadata: payload.metadata!,
|
|
598
|
+
scroll: fullScrollPayload,
|
|
524
599
|
});
|
|
525
600
|
});
|
|
526
601
|
} else if (fullHasTransition) {
|
|
@@ -531,12 +606,14 @@ export function createPartialUpdater(
|
|
|
531
606
|
onUpdate({
|
|
532
607
|
root: newTree,
|
|
533
608
|
metadata: payload.metadata!,
|
|
609
|
+
scroll: fullScrollPayload,
|
|
534
610
|
});
|
|
535
611
|
});
|
|
536
612
|
} else {
|
|
537
613
|
onUpdate({
|
|
538
614
|
root: newTree,
|
|
539
615
|
metadata: payload.metadata!,
|
|
616
|
+
scroll: fullScrollPayload,
|
|
540
617
|
});
|
|
541
618
|
}
|
|
542
619
|
|
|
@@ -1,67 +1,216 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prefetch
|
|
2
|
+
* Prefetch Cache
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* In-memory cache storing prefetched Response objects for instant cache hits
|
|
5
|
+
* on subsequent navigation. Cache key is source-dependent (includes the
|
|
6
|
+
* current page URL) because the server's diff-based response depends on
|
|
7
|
+
* where the user navigates from.
|
|
8
|
+
*
|
|
9
|
+
* 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
|
+
*
|
|
13
|
+
* Replaces the previous browser HTTP cache approach which was unreliable
|
|
14
|
+
* due to response draining race conditions and browser inconsistencies.
|
|
7
15
|
*/
|
|
8
16
|
|
|
9
|
-
import {
|
|
17
|
+
import { abortAllPrefetches } from "./queue.js";
|
|
10
18
|
import { invalidateRangoState } from "../rango-state.js";
|
|
11
19
|
|
|
20
|
+
// Default TTL: 5 minutes. Overridden by initPrefetchCache() with
|
|
21
|
+
// the server-configured prefetchCacheTTL from router options.
|
|
22
|
+
// 0 disables the in-memory cache entirely.
|
|
23
|
+
let cacheTTL = 300_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Initialize the prefetch cache with the configured TTL.
|
|
27
|
+
* Called once at app startup with the value from server metadata.
|
|
28
|
+
* A TTL of 0 disables the in-memory cache and all prefetching.
|
|
29
|
+
*/
|
|
30
|
+
export function initPrefetchCache(ttlMs: number): void {
|
|
31
|
+
cacheTTL = ttlMs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if the prefetch cache is disabled (TTL <= 0).
|
|
36
|
+
* When disabled, no prefetch requests should be issued.
|
|
37
|
+
*/
|
|
38
|
+
export function isPrefetchCacheDisabled(): boolean {
|
|
39
|
+
return cacheTTL <= 0;
|
|
40
|
+
}
|
|
41
|
+
const MAX_PREFETCH_CACHE_SIZE = 50;
|
|
42
|
+
|
|
43
|
+
interface PrefetchCacheEntry {
|
|
44
|
+
response: Response;
|
|
45
|
+
timestamp: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cache = new Map<string, PrefetchCacheEntry>();
|
|
12
49
|
const inflight = new Set<string>();
|
|
13
|
-
|
|
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>>();
|
|
14
57
|
|
|
15
58
|
// Generation counter incremented on each clearPrefetchCache(). Fetches that
|
|
16
|
-
// started before a clear carry a stale generation and must not
|
|
17
|
-
//
|
|
18
|
-
// due to Rango-State rotation).
|
|
59
|
+
// started before a clear carry a stale generation and must not store their
|
|
60
|
+
// response (the data may be stale due to a server action invalidation).
|
|
19
61
|
let generation = 0;
|
|
20
62
|
|
|
21
63
|
/**
|
|
22
|
-
*
|
|
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.
|
|
73
|
+
*/
|
|
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;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a prefetch is already cached, in-flight, or queued for the given key.
|
|
23
85
|
*/
|
|
24
86
|
export function hasPrefetch(key: string): boolean {
|
|
25
|
-
|
|
87
|
+
if (inflight.has(key)) return true;
|
|
88
|
+
if (cacheTTL <= 0) return false;
|
|
89
|
+
const entry = cache.get(key);
|
|
90
|
+
if (!entry) return false;
|
|
91
|
+
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
92
|
+
cache.delete(key);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
26
96
|
}
|
|
27
97
|
|
|
28
98
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
99
|
+
* Consume a cached prefetch response. Returns null if not found or expired.
|
|
100
|
+
* One-time consumption: the entry is deleted after retrieval.
|
|
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).
|
|
31
105
|
*/
|
|
32
|
-
export function
|
|
33
|
-
return
|
|
106
|
+
export function consumePrefetch(key: string): Response | null {
|
|
107
|
+
if (cacheTTL <= 0) return null;
|
|
108
|
+
const entry = cache.get(key);
|
|
109
|
+
if (!entry) return null;
|
|
110
|
+
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
111
|
+
cache.delete(key);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
cache.delete(key);
|
|
115
|
+
return entry.response;
|
|
34
116
|
}
|
|
35
117
|
|
|
36
118
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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().
|
|
40
130
|
*/
|
|
41
|
-
export function
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Store a prefetch response in the in-memory cache.
|
|
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.
|
|
145
|
+
*
|
|
146
|
+
* Skips storage if the generation has changed since the fetch started
|
|
147
|
+
* (a server action invalidated the cache mid-flight).
|
|
148
|
+
*/
|
|
149
|
+
export function storePrefetch(
|
|
150
|
+
key: string,
|
|
151
|
+
response: Response,
|
|
152
|
+
fetchGeneration: number,
|
|
153
|
+
): void {
|
|
154
|
+
if (cacheTTL <= 0) return;
|
|
155
|
+
if (fetchGeneration !== generation) return;
|
|
156
|
+
|
|
157
|
+
// Evict expired entries
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
for (const [k, entry] of cache) {
|
|
160
|
+
if (now - entry.timestamp > cacheTTL) {
|
|
161
|
+
cache.delete(k);
|
|
162
|
+
}
|
|
44
163
|
}
|
|
164
|
+
|
|
165
|
+
// FIFO eviction if at capacity
|
|
166
|
+
if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
|
|
167
|
+
const oldest = cache.keys().next().value;
|
|
168
|
+
if (oldest) cache.delete(oldest);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
cache.set(key, { response, timestamp: now });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Capture the current generation. The returned value is passed to
|
|
176
|
+
* storePrefetch so it can detect stale completions.
|
|
177
|
+
*/
|
|
178
|
+
export function currentGeneration(): number {
|
|
179
|
+
return generation;
|
|
45
180
|
}
|
|
46
181
|
|
|
47
182
|
export function markPrefetchInflight(key: string): void {
|
|
48
183
|
inflight.add(key);
|
|
49
184
|
}
|
|
50
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
|
+
|
|
51
196
|
export function clearPrefetchInflight(key: string): void {
|
|
52
197
|
inflight.delete(key);
|
|
198
|
+
inflightPromises.delete(key);
|
|
53
199
|
}
|
|
54
200
|
|
|
55
201
|
/**
|
|
56
|
-
* Invalidate prefetch state. Called when server actions mutate data.
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
202
|
+
* Invalidate all prefetch state. Called when server actions mutate data.
|
|
203
|
+
* Clears the in-memory cache, cancels in-flight prefetches, and rotates
|
|
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.
|
|
60
208
|
*/
|
|
61
209
|
export function clearPrefetchCache(): void {
|
|
62
210
|
generation++;
|
|
63
211
|
inflight.clear();
|
|
64
|
-
|
|
65
|
-
|
|
212
|
+
inflightPromises.clear();
|
|
213
|
+
cache.clear();
|
|
214
|
+
abortAllPrefetches();
|
|
66
215
|
invalidateRangoState();
|
|
67
216
|
}
|