@rangojs/router 0.0.0-experimental.28 → 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/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
|
@@ -4,12 +4,19 @@ import type {
|
|
|
4
4
|
NavigateOptionsInternal,
|
|
5
5
|
ResolvedSegment,
|
|
6
6
|
} from "./types.js";
|
|
7
|
+
import { setAppVersion } from "./app-version.js";
|
|
7
8
|
import * as React from "react";
|
|
8
9
|
import { startTransition } from "react";
|
|
9
10
|
import {
|
|
10
11
|
createNavigationTransaction,
|
|
11
12
|
resolveNavigationState,
|
|
12
13
|
} from "./navigation-transaction.js";
|
|
14
|
+
import { buildHistoryState } from "./history-state.js";
|
|
15
|
+
import {
|
|
16
|
+
handleNavigationStart,
|
|
17
|
+
handleNavigationEnd,
|
|
18
|
+
ensureHistoryKey,
|
|
19
|
+
} from "./scroll-restoration.js";
|
|
13
20
|
|
|
14
21
|
// addTransitionType is only available in React experimental
|
|
15
22
|
const addTransitionType: ((type: string) => void) | undefined =
|
|
@@ -18,7 +25,6 @@ const addTransitionType: ((type: string) => void) | undefined =
|
|
|
18
25
|
import { setupLinkInterception } from "./link-interceptor.js";
|
|
19
26
|
import { createPartialUpdater } from "./partial-update.js";
|
|
20
27
|
import { generateHistoryKey } from "./navigation-store.js";
|
|
21
|
-
import { handleNavigationEnd } from "./scroll-restoration.js";
|
|
22
28
|
import type { EventController } from "./event-controller.js";
|
|
23
29
|
import { isInterceptOnlyCache } from "./intercept-utils.js";
|
|
24
30
|
import {
|
|
@@ -35,11 +41,6 @@ if (typeof Symbol.dispose === "undefined") {
|
|
|
35
41
|
(Symbol as any).dispose = Symbol("Symbol.dispose");
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
/** Get IDs of non-loader segments (layouts, routes, parallels). */
|
|
39
|
-
function getNonLoaderSegmentIds(segments: ResolvedSegment[]): string[] {
|
|
40
|
-
return segments.filter((s) => s.type !== "loader").map((s) => s.id);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
44
|
export { createNavigationTransaction };
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -67,8 +68,8 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
|
|
|
67
68
|
export function createNavigationBridge(
|
|
68
69
|
config: NavigationBridgeConfigWithController,
|
|
69
70
|
): NavigationBridge {
|
|
70
|
-
const { store, client, eventController, onUpdate, renderSegments
|
|
71
|
-
|
|
71
|
+
const { store, client, eventController, onUpdate, renderSegments } = config;
|
|
72
|
+
let version = config.version;
|
|
72
73
|
|
|
73
74
|
// Create shared partial updater
|
|
74
75
|
const fetchPartialUpdate = createPartialUpdater({
|
|
@@ -76,7 +77,7 @@ export function createNavigationBridge(
|
|
|
76
77
|
client,
|
|
77
78
|
onUpdate,
|
|
78
79
|
renderSegments,
|
|
79
|
-
version,
|
|
80
|
+
getVersion: () => version,
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
return {
|
|
@@ -114,6 +115,85 @@ export function createNavigationBridge(
|
|
|
114
115
|
return;
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// Shallow navigation: skip RSC fetch when revalidate is false
|
|
119
|
+
// and the pathname hasn't changed (search param / hash only change).
|
|
120
|
+
if (
|
|
121
|
+
options?.revalidate === false &&
|
|
122
|
+
targetUrl.pathname === new URL(window.location.href).pathname
|
|
123
|
+
) {
|
|
124
|
+
// Preserve intercept context from the current history entry so that
|
|
125
|
+
// popstate uses the correct cache key (:intercept suffix) and restores
|
|
126
|
+
// the right full-page vs modal semantics.
|
|
127
|
+
const currentHistoryState = window.history.state;
|
|
128
|
+
const isIntercept = currentHistoryState?.intercept === true;
|
|
129
|
+
const interceptSourceUrl = isIntercept
|
|
130
|
+
? currentHistoryState?.sourceUrl
|
|
131
|
+
: undefined;
|
|
132
|
+
|
|
133
|
+
const historyKey = generateHistoryKey(url, { intercept: isIntercept });
|
|
134
|
+
|
|
135
|
+
// Copy current segments to the new history key so back/forward restores instantly
|
|
136
|
+
const currentKey = store.getHistoryKey();
|
|
137
|
+
const currentCache = store.getCachedSegments(currentKey);
|
|
138
|
+
if (currentCache?.segments) {
|
|
139
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
140
|
+
store.cacheSegmentsForHistory(
|
|
141
|
+
historyKey,
|
|
142
|
+
currentCache.segments,
|
|
143
|
+
currentHandleData,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Save current scroll position before changing URL
|
|
148
|
+
handleNavigationStart();
|
|
149
|
+
|
|
150
|
+
// Snapshot old state before pushState/replaceState overwrites it
|
|
151
|
+
const oldState = window.history.state;
|
|
152
|
+
|
|
153
|
+
// Update browser URL (carry intercept context into history state)
|
|
154
|
+
const historyState = buildHistoryState(
|
|
155
|
+
resolvedState,
|
|
156
|
+
{
|
|
157
|
+
intercept: isIntercept || undefined,
|
|
158
|
+
sourceUrl: interceptSourceUrl,
|
|
159
|
+
},
|
|
160
|
+
{},
|
|
161
|
+
);
|
|
162
|
+
if (options.replace) {
|
|
163
|
+
window.history.replaceState(historyState, "", url);
|
|
164
|
+
} else {
|
|
165
|
+
window.history.pushState(historyState, "", url);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Ensure new history entry has a scroll restoration key
|
|
169
|
+
ensureHistoryKey();
|
|
170
|
+
|
|
171
|
+
// Notify useLocationState() hooks when state changes
|
|
172
|
+
const hasOldState =
|
|
173
|
+
oldState &&
|
|
174
|
+
typeof oldState === "object" &&
|
|
175
|
+
("state" in oldState ||
|
|
176
|
+
Object.keys(oldState).some((k) => k.startsWith("__rsc_ls_")));
|
|
177
|
+
const hasNewState =
|
|
178
|
+
historyState &&
|
|
179
|
+
("state" in historyState ||
|
|
180
|
+
Object.keys(historyState).some((k) => k.startsWith("__rsc_ls_")));
|
|
181
|
+
if (hasOldState || hasNewState) {
|
|
182
|
+
window.dispatchEvent(new Event("__rsc_locationstate"));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Update store history key so future navigations reference the right cache
|
|
186
|
+
store.setHistoryKey(historyKey);
|
|
187
|
+
store.setCurrentUrl(url);
|
|
188
|
+
|
|
189
|
+
// Notify hooks — location updates, state stays idle
|
|
190
|
+
eventController.setLocation(targetUrl);
|
|
191
|
+
|
|
192
|
+
// Handle post-navigation scroll
|
|
193
|
+
handleNavigationEnd({ scroll: options.scroll });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
117
197
|
// Only abort pending requests when navigating to a different route
|
|
118
198
|
// Same-route navigation (e.g., /todos -> /todos) should not cancel in-flight actions
|
|
119
199
|
const currentPath = new URL(window.location.href).pathname;
|
|
@@ -181,18 +261,24 @@ export function createNavigationBridge(
|
|
|
181
261
|
// 2. routes that CAN be intercepted - we don't know if this navigation will intercept
|
|
182
262
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
183
263
|
// 4. redirect-with-state - force re-render so hooks read fresh state
|
|
264
|
+
// 5. stale cache - server action invalidated it, need fresh data with loading state
|
|
184
265
|
const hasUsableCache =
|
|
185
266
|
cachedSegments &&
|
|
186
267
|
cachedSegments.length > 0 &&
|
|
187
268
|
!isInterceptOnlyCache(cachedSegments) &&
|
|
188
269
|
!hasInterceptCache &&
|
|
189
270
|
!isLeavingIntercept &&
|
|
271
|
+
!cached?.stale &&
|
|
190
272
|
!options?._skipCache;
|
|
191
273
|
|
|
274
|
+
// Forward navigations always await fetchPartialUpdate before rendering,
|
|
275
|
+
// so useNavigation should always report "loading". skipLoadingState is
|
|
276
|
+
// only used for popstate background revalidation (line ~526) where
|
|
277
|
+
// cached content renders instantly without a network wait.
|
|
192
278
|
const tx = createNavigationTransaction(store, eventController, url, {
|
|
193
279
|
...options,
|
|
194
280
|
state: resolvedState,
|
|
195
|
-
skipLoadingState:
|
|
281
|
+
skipLoadingState: false,
|
|
196
282
|
});
|
|
197
283
|
|
|
198
284
|
// REVALIDATE: Fetch fresh data from server
|
|
@@ -200,7 +286,7 @@ export function createNavigationBridge(
|
|
|
200
286
|
await fetchPartialUpdate(
|
|
201
287
|
url,
|
|
202
288
|
hasUsableCache
|
|
203
|
-
?
|
|
289
|
+
? cachedSegments!.map((s) => s.id)
|
|
204
290
|
: options?._skipCache
|
|
205
291
|
? [] // Action redirect: send no segments so server renders everything fresh
|
|
206
292
|
: undefined,
|
|
@@ -332,6 +418,15 @@ export function createNavigationBridge(
|
|
|
332
418
|
eventController.abortAllActions();
|
|
333
419
|
}
|
|
334
420
|
|
|
421
|
+
// Popstate that exits an intercept to a non-intercept destination. The
|
|
422
|
+
// fallback fetch path below needs `leave-intercept` mode so it filters
|
|
423
|
+
// the cached @modal segment from the request and forces a re-render —
|
|
424
|
+
// otherwise a cache-miss popstate whose server response has an empty
|
|
425
|
+
// diff hits the "no changes" branch in partial-update and the modal
|
|
426
|
+
// stays on screen.
|
|
427
|
+
const isLeavingIntercept =
|
|
428
|
+
!isIntercept && currentInterceptSource !== null;
|
|
429
|
+
|
|
335
430
|
// Compute history key from URL (with intercept suffix if applicable)
|
|
336
431
|
const historyKey = generateHistoryKey(url, { intercept: isIntercept });
|
|
337
432
|
|
|
@@ -368,6 +463,12 @@ export function createNavigationBridge(
|
|
|
368
463
|
store.setCurrentUrl(url);
|
|
369
464
|
store.setPath(new URL(url).pathname);
|
|
370
465
|
|
|
466
|
+
// Restore router identity from cache so subsequent navigations
|
|
467
|
+
// don't falsely detect an app switch.
|
|
468
|
+
if (cached?.routerId) {
|
|
469
|
+
store.setRouterId?.(cached.routerId);
|
|
470
|
+
}
|
|
471
|
+
|
|
371
472
|
// Render from cache - force await to skip loading fallbacks
|
|
372
473
|
try {
|
|
373
474
|
const root = await renderSegments(cachedSegments, {
|
|
@@ -393,6 +494,7 @@ export function createNavigationBridge(
|
|
|
393
494
|
cachedHandleData,
|
|
394
495
|
params: cachedParams,
|
|
395
496
|
},
|
|
497
|
+
scroll: { restore: true, isStreaming },
|
|
396
498
|
};
|
|
397
499
|
const hasTransition = cachedSegments.some((s) => s.transition);
|
|
398
500
|
if (hasTransition) {
|
|
@@ -406,14 +508,11 @@ export function createNavigationBridge(
|
|
|
406
508
|
onUpdate(popstateUpdate);
|
|
407
509
|
}
|
|
408
510
|
|
|
409
|
-
// Restore scroll position for back/forward navigation
|
|
410
|
-
handleNavigationEnd({ restore: true, isStreaming });
|
|
411
|
-
|
|
412
511
|
// SWR: If stale, trigger background revalidation
|
|
413
512
|
if (isStale) {
|
|
414
513
|
debugLog("[Browser] Cache is stale, background revalidating...");
|
|
415
514
|
// Background revalidation - don't await, just fire and forget
|
|
416
|
-
const segmentIds =
|
|
515
|
+
const segmentIds = cachedSegments.map((s) => s.id);
|
|
417
516
|
|
|
418
517
|
const tx = createNavigationTransaction(
|
|
419
518
|
store,
|
|
@@ -478,7 +577,11 @@ export function createNavigationBridge(
|
|
|
478
577
|
intercept: isIntercept,
|
|
479
578
|
interceptSourceUrl,
|
|
480
579
|
}),
|
|
481
|
-
isIntercept
|
|
580
|
+
isIntercept
|
|
581
|
+
? { type: "navigate", interceptSourceUrl }
|
|
582
|
+
: isLeavingIntercept
|
|
583
|
+
? { type: "leave-intercept" }
|
|
584
|
+
: undefined,
|
|
482
585
|
);
|
|
483
586
|
// Restore scroll position after fetch completes
|
|
484
587
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -555,6 +658,12 @@ export function createNavigationBridge(
|
|
|
555
658
|
window.removeEventListener("pageshow", handlePageShow);
|
|
556
659
|
};
|
|
557
660
|
},
|
|
661
|
+
|
|
662
|
+
updateVersion(newVersion: string): void {
|
|
663
|
+
version = newVersion;
|
|
664
|
+
setAppVersion(newVersion);
|
|
665
|
+
store.clearHistoryCache();
|
|
666
|
+
},
|
|
558
667
|
};
|
|
559
668
|
}
|
|
560
669
|
|
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
emptyResponse,
|
|
18
18
|
teeWithCompletion,
|
|
19
19
|
} from "./response-adapter.js";
|
|
20
|
+
import {
|
|
21
|
+
buildPrefetchKey,
|
|
22
|
+
consumeInflightPrefetch,
|
|
23
|
+
consumePrefetch,
|
|
24
|
+
} from "./prefetch/cache.js";
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -24,21 +29,12 @@ import {
|
|
|
24
29
|
* The client handles building URLs with RSC parameters and
|
|
25
30
|
* deserializing the response using the RSC runtime.
|
|
26
31
|
*
|
|
32
|
+
* Checks the in-memory prefetch cache before making a network request.
|
|
33
|
+
* The cache key is source-dependent (includes the previous URL) so
|
|
34
|
+
* prefetch responses match the exact diff the server would produce.
|
|
35
|
+
*
|
|
27
36
|
* @param deps - RSC browser dependencies (createFromFetch)
|
|
28
37
|
* @returns NavigationClient instance
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```typescript
|
|
32
|
-
* import { createFromFetch } from "@vitejs/plugin-rsc/browser";
|
|
33
|
-
*
|
|
34
|
-
* const client = createNavigationClient({ createFromFetch });
|
|
35
|
-
*
|
|
36
|
-
* const payload = await client.fetchPartial({
|
|
37
|
-
* targetUrl: "/shop/products",
|
|
38
|
-
* segmentIds: ["root", "shop"],
|
|
39
|
-
* previousUrl: "/",
|
|
40
|
-
* });
|
|
41
|
-
* ```
|
|
42
38
|
*/
|
|
43
39
|
export function createNavigationClient(
|
|
44
40
|
deps: Pick<RscBrowserDependencies, "createFromFetch">,
|
|
@@ -47,8 +43,9 @@ export function createNavigationClient(
|
|
|
47
43
|
/**
|
|
48
44
|
* Fetch a partial RSC payload for navigation
|
|
49
45
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
46
|
+
* First checks the in-memory prefetch cache for a matching entry.
|
|
47
|
+
* If found, uses the cached response instantly. Otherwise sends
|
|
48
|
+
* current segment IDs to the server for diff-based rendering.
|
|
52
49
|
*
|
|
53
50
|
* @param options - Fetch options
|
|
54
51
|
* @returns RSC payload with segments and metadata, plus stream completion promise
|
|
@@ -64,6 +61,7 @@ export function createNavigationClient(
|
|
|
64
61
|
staleRevalidation,
|
|
65
62
|
interceptSourceUrl,
|
|
66
63
|
version,
|
|
64
|
+
routerId,
|
|
67
65
|
hmr,
|
|
68
66
|
} = options;
|
|
69
67
|
|
|
@@ -80,7 +78,8 @@ export function createNavigationClient(
|
|
|
80
78
|
});
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
// Build fetch URL with partial rendering params
|
|
81
|
+
// Build fetch URL with partial rendering params (used for both
|
|
82
|
+
// cache key lookup and actual fetch if cache misses)
|
|
84
83
|
const fetchUrl = new URL(targetUrl, window.location.origin);
|
|
85
84
|
fetchUrl.searchParams.set("_rsc_partial", "true");
|
|
86
85
|
fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
|
|
@@ -90,32 +89,60 @@ export function createNavigationClient(
|
|
|
90
89
|
if (version) {
|
|
91
90
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
92
91
|
}
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
if (routerId) {
|
|
93
|
+
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check completed in-memory prefetch cache before making a network request.
|
|
97
|
+
// The cache key includes the source URL (previousUrl) because the
|
|
98
|
+
// server's diff response depends on the source page context.
|
|
99
|
+
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
100
|
+
// fresh modules), and intercept contexts (source-dependent responses).
|
|
101
|
+
//
|
|
102
|
+
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
103
|
+
const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
|
|
104
|
+
// Wildcard key matches prefetch entries stored with a custom prefetchKey
|
|
105
|
+
// (Link's prefetchKey prop stores under "*" instead of the source URL).
|
|
106
|
+
const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
|
|
107
|
+
|
|
108
|
+
let cachedResponse: Response | null = null;
|
|
109
|
+
let hitKey: string | null = null;
|
|
110
|
+
if (canUsePrefetch) {
|
|
111
|
+
cachedResponse = consumePrefetch(cacheKey);
|
|
112
|
+
if (cachedResponse) {
|
|
113
|
+
hitKey = cacheKey;
|
|
114
|
+
} else {
|
|
115
|
+
cachedResponse = consumePrefetch(wildcardKey);
|
|
116
|
+
if (cachedResponse) hitKey = wildcardKey;
|
|
117
|
+
}
|
|
97
118
|
}
|
|
98
119
|
|
|
120
|
+
let inflightResponsePromise: Promise<Response | null> | null = null;
|
|
121
|
+
if (canUsePrefetch && !cachedResponse) {
|
|
122
|
+
inflightResponsePromise = consumeInflightPrefetch(cacheKey);
|
|
123
|
+
if (inflightResponsePromise) {
|
|
124
|
+
hitKey = cacheKey;
|
|
125
|
+
} else {
|
|
126
|
+
inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
|
|
127
|
+
if (inflightResponsePromise) hitKey = wildcardKey;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
99
130
|
// Track when the stream completes
|
|
100
131
|
let resolveStreamComplete: () => void;
|
|
101
132
|
const streamComplete = new Promise<void>((resolve) => {
|
|
102
133
|
resolveStreamComplete = resolve;
|
|
103
134
|
});
|
|
104
135
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
},
|
|
116
|
-
signal,
|
|
117
|
-
}).then((response) => {
|
|
118
|
-
// Check for version mismatch - server wants us to reload
|
|
136
|
+
/**
|
|
137
|
+
* Validate RSC control headers on any response (fresh, cached, or
|
|
138
|
+
* in-flight). Handles version-mismatch reloads and server redirects.
|
|
139
|
+
* Returns the response unchanged when no control header is present.
|
|
140
|
+
*/
|
|
141
|
+
const validateRscHeaders = (
|
|
142
|
+
response: Response,
|
|
143
|
+
source: string,
|
|
144
|
+
): Response | Promise<Response> => {
|
|
145
|
+
// Version mismatch — server wants a full page reload
|
|
119
146
|
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
120
147
|
if (reload === "blocked") {
|
|
121
148
|
resolveStreamComplete();
|
|
@@ -123,11 +150,12 @@ export function createNavigationClient(
|
|
|
123
150
|
}
|
|
124
151
|
if (reload) {
|
|
125
152
|
if (tx) {
|
|
126
|
-
browserDebugLog(tx,
|
|
153
|
+
browserDebugLog(tx, `version mismatch, reloading (${source})`, {
|
|
127
154
|
reloadUrl: reload.url,
|
|
128
155
|
});
|
|
129
156
|
}
|
|
130
157
|
window.location.href = reload.url;
|
|
158
|
+
// Block further processing — page is reloading
|
|
131
159
|
return new Promise<Response>(() => {});
|
|
132
160
|
}
|
|
133
161
|
|
|
@@ -142,7 +170,7 @@ export function createNavigationClient(
|
|
|
142
170
|
}
|
|
143
171
|
if (redirect) {
|
|
144
172
|
if (tx) {
|
|
145
|
-
browserDebugLog(tx,
|
|
173
|
+
browserDebugLog(tx, `server redirect (${source})`, {
|
|
146
174
|
redirectUrl: redirect.url,
|
|
147
175
|
});
|
|
148
176
|
}
|
|
@@ -150,19 +178,101 @@ export function createNavigationClient(
|
|
|
150
178
|
throw new ServerRedirect(redirect.url, undefined);
|
|
151
179
|
}
|
|
152
180
|
|
|
153
|
-
return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
181
|
+
return response;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
185
|
+
const doFreshFetch = (): Promise<Response> => {
|
|
186
|
+
if (tx) {
|
|
187
|
+
browserDebugLog(tx, "fetching", {
|
|
188
|
+
path: `${fetchUrl.pathname}${fetchUrl.search}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return fetch(fetchUrl, {
|
|
193
|
+
headers: {
|
|
194
|
+
"X-RSC-Router-Client-Path": previousUrl,
|
|
195
|
+
"X-Rango-State": getRangoState(),
|
|
196
|
+
...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
|
|
197
|
+
...(interceptSourceUrl && {
|
|
198
|
+
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
199
|
+
}),
|
|
200
|
+
...(hmr && { "X-RSC-HMR": "1" }),
|
|
158
201
|
},
|
|
159
202
|
signal,
|
|
160
|
-
)
|
|
161
|
-
|
|
203
|
+
}).then((response) => {
|
|
204
|
+
const validated = validateRscHeaders(response, "fetch");
|
|
205
|
+
if (validated instanceof Promise) return validated;
|
|
206
|
+
|
|
207
|
+
return teeWithCompletion(
|
|
208
|
+
validated,
|
|
209
|
+
() => {
|
|
210
|
+
if (tx) browserDebugLog(tx, "stream complete");
|
|
211
|
+
resolveStreamComplete();
|
|
212
|
+
},
|
|
213
|
+
signal,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let responsePromise: Promise<Response>;
|
|
219
|
+
|
|
220
|
+
if (cachedResponse) {
|
|
221
|
+
if (tx) {
|
|
222
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
223
|
+
key: hitKey,
|
|
224
|
+
wildcard: hitKey === wildcardKey,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
228
|
+
const validated = validateRscHeaders(response, "prefetch cache");
|
|
229
|
+
if (validated instanceof Promise) return validated;
|
|
230
|
+
|
|
231
|
+
return teeWithCompletion(
|
|
232
|
+
validated,
|
|
233
|
+
() => {
|
|
234
|
+
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
235
|
+
resolveStreamComplete();
|
|
236
|
+
},
|
|
237
|
+
signal,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
} else if (inflightResponsePromise) {
|
|
241
|
+
if (tx) {
|
|
242
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
243
|
+
key: hitKey,
|
|
244
|
+
wildcard: hitKey === wildcardKey,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
248
|
+
if (!response) {
|
|
249
|
+
if (tx) {
|
|
250
|
+
browserDebugLog(tx, "inflight prefetch unavailable, refetching");
|
|
251
|
+
}
|
|
252
|
+
return doFreshFetch();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const validated = validateRscHeaders(response, "inflight prefetch");
|
|
256
|
+
if (validated instanceof Promise) return validated;
|
|
257
|
+
|
|
258
|
+
return teeWithCompletion(
|
|
259
|
+
validated,
|
|
260
|
+
() => {
|
|
261
|
+
if (tx) {
|
|
262
|
+
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
263
|
+
}
|
|
264
|
+
resolveStreamComplete();
|
|
265
|
+
},
|
|
266
|
+
signal,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
responsePromise = doFreshFetch();
|
|
271
|
+
}
|
|
162
272
|
|
|
163
273
|
try {
|
|
164
|
-
// Deserialize RSC payload
|
|
165
274
|
const payload = await deps.createFromFetch<RscPayload>(responsePromise);
|
|
275
|
+
|
|
166
276
|
if (tx) {
|
|
167
277
|
browserDebugLog(tx, "response received", {
|
|
168
278
|
isPartial: payload.metadata?.isPartial,
|
|
@@ -28,9 +28,15 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
|
|
|
28
28
|
// Maximum number of history entries to cache (URLs visited)
|
|
29
29
|
const HISTORY_CACHE_SIZE = 20;
|
|
30
30
|
|
|
31
|
-
// Cache entry: [url-key, segments, stale, handleData?]
|
|
31
|
+
// Cache entry: [url-key, segments, stale, handleData?, routerId?]
|
|
32
32
|
// stale=true means the data may be outdated and should be revalidated on access
|
|
33
|
-
type HistoryCacheEntry = [
|
|
33
|
+
type HistoryCacheEntry = [
|
|
34
|
+
string,
|
|
35
|
+
ResolvedSegment[],
|
|
36
|
+
boolean,
|
|
37
|
+
HandleData?,
|
|
38
|
+
string?,
|
|
39
|
+
];
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
42
|
* Shallow clone handleData to avoid reference sharing between cache entries.
|
|
@@ -258,6 +264,11 @@ export function createNavigationStore(
|
|
|
258
264
|
// Used to maintain intercept context during action revalidation
|
|
259
265
|
let interceptSourceUrl: string | null = null;
|
|
260
266
|
|
|
267
|
+
// Router identity - tracks which router is currently active.
|
|
268
|
+
// When this changes on a partial response, the client forces a full
|
|
269
|
+
// tree replacement instead of reconciling with stale segments.
|
|
270
|
+
let currentRouterId: string | undefined;
|
|
271
|
+
|
|
261
272
|
// Action state tracking (for useAction hook)
|
|
262
273
|
// Maps action function ID to its tracked state
|
|
263
274
|
const actionStates = new Map<string, TrackedActionState>();
|
|
@@ -571,10 +582,17 @@ export function createNavigationStore(
|
|
|
571
582
|
segments,
|
|
572
583
|
false,
|
|
573
584
|
clonedHandleData,
|
|
585
|
+
currentRouterId,
|
|
574
586
|
];
|
|
575
587
|
} else {
|
|
576
588
|
// Add new entry at the end (not stale)
|
|
577
|
-
historyCache.push([
|
|
589
|
+
historyCache.push([
|
|
590
|
+
historyKey,
|
|
591
|
+
segments,
|
|
592
|
+
false,
|
|
593
|
+
clonedHandleData,
|
|
594
|
+
currentRouterId,
|
|
595
|
+
]);
|
|
578
596
|
// Remove oldest entries if over limit
|
|
579
597
|
while (historyCache.length > cacheSize) {
|
|
580
598
|
historyCache.shift();
|
|
@@ -586,14 +604,22 @@ export function createNavigationStore(
|
|
|
586
604
|
* Get cached segments for a history entry
|
|
587
605
|
* Returns { segments, stale, handleData } or undefined if not cached
|
|
588
606
|
*/
|
|
589
|
-
getCachedSegments(
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
607
|
+
getCachedSegments(historyKey: string):
|
|
608
|
+
| {
|
|
609
|
+
segments: ResolvedSegment[];
|
|
610
|
+
stale: boolean;
|
|
611
|
+
handleData?: HandleData;
|
|
612
|
+
routerId?: string;
|
|
613
|
+
}
|
|
593
614
|
| undefined {
|
|
594
615
|
const entry = historyCache.find(([key]) => key === historyKey);
|
|
595
616
|
if (!entry) return undefined;
|
|
596
|
-
return {
|
|
617
|
+
return {
|
|
618
|
+
segments: entry[1],
|
|
619
|
+
stale: entry[2],
|
|
620
|
+
handleData: entry[3],
|
|
621
|
+
routerId: entry[4],
|
|
622
|
+
};
|
|
597
623
|
},
|
|
598
624
|
|
|
599
625
|
/**
|
|
@@ -621,6 +647,7 @@ export function createNavigationStore(
|
|
|
621
647
|
entry[1],
|
|
622
648
|
entry[2],
|
|
623
649
|
clonedHandleData,
|
|
650
|
+
entry[4], // preserve routerId
|
|
624
651
|
];
|
|
625
652
|
}
|
|
626
653
|
},
|
|
@@ -687,6 +714,14 @@ export function createNavigationStore(
|
|
|
687
714
|
interceptSourceUrl = url;
|
|
688
715
|
},
|
|
689
716
|
|
|
717
|
+
getRouterId(): string | undefined {
|
|
718
|
+
return currentRouterId;
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
setRouterId(id: string): void {
|
|
722
|
+
currentRouterId = id;
|
|
723
|
+
},
|
|
724
|
+
|
|
690
725
|
// ========================================================================
|
|
691
726
|
// UI Update Notifications
|
|
692
727
|
// ========================================================================
|