@rangojs/router 0.0.0-experimental.8123bb7e → 0.0.0-experimental.82
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/README.md +76 -18
- package/dist/bin/rango.js +130 -47
- package/dist/vite/index.js +829 -380
- package/dist/vite/index.js.bak +5448 -0
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +4 -4
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +24 -18
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +24 -0
- package/skills/router-setup/SKILL.md +35 -0
- package/src/__internal.ts +1 -1
- package/src/browser/app-version.ts +14 -0
- package/src/browser/navigation-bridge.ts +37 -5
- package/src/browser/navigation-client.ts +128 -77
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/partial-update.ts +41 -7
- package/src/browser/prefetch/cache.ts +113 -21
- package/src/browser/prefetch/fetch.ts +156 -18
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/react/Link.tsx +72 -8
- package/src/browser/react/NavigationProvider.tsx +14 -3
- 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-params.ts +11 -1
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +26 -3
- package/src/browser/scroll-restoration.ts +10 -8
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/server-action-bridge.ts +8 -18
- package/src/browser/types.ts +20 -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 +211 -72
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/client.tsx +84 -230
- package/src/deps/browser.ts +0 -1
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +3 -1
- package/src/index.ts +46 -6
- 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 +194 -32
- package/src/route-definition/helpers-types.ts +61 -14
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +9 -1
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +51 -15
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/lazy-includes.ts +5 -5
- package/src/router/loader-resolution.ts +150 -21
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +124 -189
- package/src/router/match-middleware/cache-lookup.ts +28 -8
- package/src/router/match-middleware/segment-resolution.ts +53 -0
- package/src/router/match-result.ts +82 -4
- package/src/router/middleware-types.ts +0 -6
- package/src/router/middleware.ts +0 -3
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +110 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +70 -5
- package/src/router/segment-resolution/revalidation.ts +87 -9
- package/src/router.ts +53 -5
- package/src/rsc/handler.ts +472 -397
- package/src/rsc/loader-fetch.ts +18 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +14 -3
- package/src/rsc/rsc-rendering.ts +15 -2
- package/src/rsc/server-action.ts +10 -2
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +6 -4
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/context.ts +65 -5
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +132 -13
- package/src/ssr/index.tsx +3 -0
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +17 -11
- package/src/types/loader-types.ts +32 -5
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +1 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +47 -12
- 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 -4
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +60 -5
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- 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 +64 -211
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/rango.ts +17 -11
- package/src/vite/router-discovery.ts +237 -37
- package/src/vite/utils/prerender-utils.ts +37 -5
- package/src/vite/utils/shared-utils.ts +3 -2
- package/src/browser/debug-channel.ts +0 -93
|
@@ -4,6 +4,7 @@ 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 {
|
|
@@ -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 {
|
|
@@ -260,18 +261,24 @@ export function createNavigationBridge(
|
|
|
260
261
|
// 2. routes that CAN be intercepted - we don't know if this navigation will intercept
|
|
261
262
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
262
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
|
|
263
265
|
const hasUsableCache =
|
|
264
266
|
cachedSegments &&
|
|
265
267
|
cachedSegments.length > 0 &&
|
|
266
268
|
!isInterceptOnlyCache(cachedSegments) &&
|
|
267
269
|
!hasInterceptCache &&
|
|
268
270
|
!isLeavingIntercept &&
|
|
271
|
+
!cached?.stale &&
|
|
269
272
|
!options?._skipCache;
|
|
270
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.
|
|
271
278
|
const tx = createNavigationTransaction(store, eventController, url, {
|
|
272
279
|
...options,
|
|
273
280
|
state: resolvedState,
|
|
274
|
-
skipLoadingState:
|
|
281
|
+
skipLoadingState: false,
|
|
275
282
|
});
|
|
276
283
|
|
|
277
284
|
// REVALIDATE: Fetch fresh data from server
|
|
@@ -411,6 +418,15 @@ export function createNavigationBridge(
|
|
|
411
418
|
eventController.abortAllActions();
|
|
412
419
|
}
|
|
413
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
|
+
|
|
414
430
|
// Compute history key from URL (with intercept suffix if applicable)
|
|
415
431
|
const historyKey = generateHistoryKey(url, { intercept: isIntercept });
|
|
416
432
|
|
|
@@ -447,6 +463,12 @@ export function createNavigationBridge(
|
|
|
447
463
|
store.setCurrentUrl(url);
|
|
448
464
|
store.setPath(new URL(url).pathname);
|
|
449
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
|
+
|
|
450
472
|
// Render from cache - force await to skip loading fallbacks
|
|
451
473
|
try {
|
|
452
474
|
const root = await renderSegments(cachedSegments, {
|
|
@@ -555,7 +577,11 @@ export function createNavigationBridge(
|
|
|
555
577
|
intercept: isIntercept,
|
|
556
578
|
interceptSourceUrl,
|
|
557
579
|
}),
|
|
558
|
-
isIntercept
|
|
580
|
+
isIntercept
|
|
581
|
+
? { type: "navigate", interceptSourceUrl }
|
|
582
|
+
: isLeavingIntercept
|
|
583
|
+
? { type: "leave-intercept" }
|
|
584
|
+
: undefined,
|
|
559
585
|
);
|
|
560
586
|
// Restore scroll position after fetch completes
|
|
561
587
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -632,6 +658,12 @@ export function createNavigationBridge(
|
|
|
632
658
|
window.removeEventListener("pageshow", handlePageShow);
|
|
633
659
|
};
|
|
634
660
|
},
|
|
661
|
+
|
|
662
|
+
updateVersion(newVersion: string): void {
|
|
663
|
+
version = newVersion;
|
|
664
|
+
setAppVersion(newVersion);
|
|
665
|
+
store.clearHistoryCache();
|
|
666
|
+
},
|
|
635
667
|
};
|
|
636
668
|
}
|
|
637
669
|
|
|
@@ -12,8 +12,6 @@ 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";
|
|
17
15
|
import {
|
|
18
16
|
extractRscHeaderUrl,
|
|
19
17
|
emptyResponse,
|
|
@@ -21,6 +19,7 @@ import {
|
|
|
21
19
|
} from "./response-adapter.js";
|
|
22
20
|
import {
|
|
23
21
|
buildPrefetchKey,
|
|
22
|
+
buildSourceKey,
|
|
24
23
|
consumeInflightPrefetch,
|
|
25
24
|
consumePrefetch,
|
|
26
25
|
} from "./prefetch/cache.js";
|
|
@@ -32,8 +31,10 @@ import {
|
|
|
32
31
|
* deserializing the response using the RSC runtime.
|
|
33
32
|
*
|
|
34
33
|
* Checks the in-memory prefetch cache before making a network request.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* Tries the source-scoped key first (populated when the server tagged
|
|
35
|
+
* the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
|
|
36
|
+
* and falls back to the Rango-state-keyed wildcard slot used for the
|
|
37
|
+
* common source-agnostic case.
|
|
37
38
|
*
|
|
38
39
|
* @param deps - RSC browser dependencies (createFromFetch)
|
|
39
40
|
* @returns NavigationClient instance
|
|
@@ -63,6 +64,7 @@ export function createNavigationClient(
|
|
|
63
64
|
staleRevalidation,
|
|
64
65
|
interceptSourceUrl,
|
|
65
66
|
version,
|
|
67
|
+
routerId,
|
|
66
68
|
hmr,
|
|
67
69
|
} = options;
|
|
68
70
|
|
|
@@ -90,40 +92,99 @@ export function createNavigationClient(
|
|
|
90
92
|
if (version) {
|
|
91
93
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
92
94
|
}
|
|
95
|
+
if (routerId) {
|
|
96
|
+
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
97
|
+
}
|
|
93
98
|
|
|
94
|
-
// Check completed in-memory prefetch cache before making a network
|
|
95
|
-
//
|
|
96
|
-
//
|
|
99
|
+
// Check completed in-memory prefetch cache before making a network
|
|
100
|
+
// request. Try the source-scoped key first (populated when the server
|
|
101
|
+
// tagged the prefetch response as source-sensitive, e.g. intercepts,
|
|
102
|
+
// or when a Link opted in with `prefetchKey=":source"`), then fall
|
|
103
|
+
// back to the wildcard slot shared across source pages.
|
|
104
|
+
// Both keys embed the Rango state, so state rotation (deploy or
|
|
105
|
+
// server-action invalidation) auto-invalidates both scopes.
|
|
97
106
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
98
107
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
99
|
-
//
|
|
100
108
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
const rangoState = getRangoState();
|
|
110
|
+
const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
|
|
111
|
+
const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
|
|
112
|
+
|
|
113
|
+
let cachedResponse: Response | null = null;
|
|
114
|
+
let hitKey: string | null = null;
|
|
115
|
+
if (canUsePrefetch) {
|
|
116
|
+
cachedResponse = consumePrefetch(cacheKey);
|
|
117
|
+
if (cachedResponse) {
|
|
118
|
+
hitKey = cacheKey;
|
|
119
|
+
} else {
|
|
120
|
+
cachedResponse = consumePrefetch(wildcardKey);
|
|
121
|
+
if (cachedResponse) hitKey = wildcardKey;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let inflightResponsePromise: Promise<Response | null> | null = null;
|
|
126
|
+
if (canUsePrefetch && !cachedResponse) {
|
|
127
|
+
inflightResponsePromise = consumeInflightPrefetch(cacheKey);
|
|
128
|
+
if (inflightResponsePromise) {
|
|
129
|
+
hitKey = cacheKey;
|
|
130
|
+
} else {
|
|
131
|
+
inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
|
|
132
|
+
if (inflightResponsePromise) hitKey = wildcardKey;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
106
135
|
// Track when the stream completes
|
|
107
136
|
let resolveStreamComplete: () => void;
|
|
108
137
|
const streamComplete = new Promise<void>((resolve) => {
|
|
109
138
|
resolveStreamComplete = resolve;
|
|
110
139
|
});
|
|
111
140
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Validate RSC control headers on any response (fresh, cached, or
|
|
143
|
+
* in-flight). Handles version-mismatch reloads and server redirects.
|
|
144
|
+
* Returns the response unchanged when no control header is present.
|
|
145
|
+
*/
|
|
146
|
+
const validateRscHeaders = (
|
|
147
|
+
response: Response,
|
|
148
|
+
source: string,
|
|
149
|
+
): Response | Promise<Response> => {
|
|
150
|
+
// Version mismatch — server wants a full page reload
|
|
151
|
+
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
152
|
+
if (reload === "blocked") {
|
|
153
|
+
resolveStreamComplete();
|
|
154
|
+
return emptyResponse();
|
|
155
|
+
}
|
|
156
|
+
if (reload) {
|
|
157
|
+
if (tx) {
|
|
158
|
+
browserDebugLog(tx, `version mismatch, reloading (${source})`, {
|
|
159
|
+
reloadUrl: reload.url,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
window.location.href = reload.url;
|
|
163
|
+
// Block further processing — page is reloading
|
|
164
|
+
return new Promise<Response>(() => {});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Server-side redirect without state: the server returned 204 with
|
|
168
|
+
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
169
|
+
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
170
|
+
// navigation bridge catches it and re-navigates with _skipCache.
|
|
171
|
+
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
172
|
+
if (redirect === "blocked") {
|
|
173
|
+
resolveStreamComplete();
|
|
174
|
+
return emptyResponse();
|
|
175
|
+
}
|
|
176
|
+
if (redirect) {
|
|
177
|
+
if (tx) {
|
|
178
|
+
browserDebugLog(tx, `server redirect (${source})`, {
|
|
179
|
+
redirectUrl: redirect.url,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
resolveStreamComplete();
|
|
183
|
+
throw new ServerRedirect(redirect.url, undefined);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return response;
|
|
187
|
+
};
|
|
127
188
|
|
|
128
189
|
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
129
190
|
const doFreshFetch = (): Promise<Response> => {
|
|
@@ -142,47 +203,14 @@ export function createNavigationClient(
|
|
|
142
203
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
143
204
|
}),
|
|
144
205
|
...(hmr && { "X-RSC-HMR": "1" }),
|
|
145
|
-
...(debugId && { [DEBUG_ID_HEADER]: debugId }),
|
|
146
206
|
},
|
|
147
207
|
signal,
|
|
148
208
|
}).then((response) => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (reload === "blocked") {
|
|
152
|
-
resolveStreamComplete();
|
|
153
|
-
return emptyResponse();
|
|
154
|
-
}
|
|
155
|
-
if (reload) {
|
|
156
|
-
if (tx) {
|
|
157
|
-
browserDebugLog(tx, "version mismatch, reloading", {
|
|
158
|
-
reloadUrl: reload.url,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
window.location.href = reload.url;
|
|
162
|
-
return new Promise<Response>(() => {});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Server-side redirect without state: the server returned 204 with
|
|
166
|
-
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
167
|
-
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
168
|
-
// navigation bridge catches it and re-navigates with _skipCache.
|
|
169
|
-
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
170
|
-
if (redirect === "blocked") {
|
|
171
|
-
resolveStreamComplete();
|
|
172
|
-
return emptyResponse();
|
|
173
|
-
}
|
|
174
|
-
if (redirect) {
|
|
175
|
-
if (tx) {
|
|
176
|
-
browserDebugLog(tx, "server redirect", {
|
|
177
|
-
redirectUrl: redirect.url,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
resolveStreamComplete();
|
|
181
|
-
throw new ServerRedirect(redirect.url, undefined);
|
|
182
|
-
}
|
|
209
|
+
const validated = validateRscHeaders(response, "fetch");
|
|
210
|
+
if (validated instanceof Promise) return validated;
|
|
183
211
|
|
|
184
212
|
return teeWithCompletion(
|
|
185
|
-
|
|
213
|
+
validated,
|
|
186
214
|
() => {
|
|
187
215
|
if (tx) browserDebugLog(tx, "stream complete");
|
|
188
216
|
resolveStreamComplete();
|
|
@@ -196,13 +224,17 @@ export function createNavigationClient(
|
|
|
196
224
|
|
|
197
225
|
if (cachedResponse) {
|
|
198
226
|
if (tx) {
|
|
199
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
227
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
228
|
+
key: hitKey,
|
|
229
|
+
wildcard: hitKey === wildcardKey,
|
|
230
|
+
});
|
|
200
231
|
}
|
|
201
|
-
// Cached response body is already fully buffered (arrayBuffer),
|
|
202
|
-
// so stream completion is immediate.
|
|
203
232
|
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
233
|
+
const validated = validateRscHeaders(response, "prefetch cache");
|
|
234
|
+
if (validated instanceof Promise) return validated;
|
|
235
|
+
|
|
204
236
|
return teeWithCompletion(
|
|
205
|
-
|
|
237
|
+
validated,
|
|
206
238
|
() => {
|
|
207
239
|
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
208
240
|
resolveStreamComplete();
|
|
@@ -212,8 +244,12 @@ export function createNavigationClient(
|
|
|
212
244
|
});
|
|
213
245
|
} else if (inflightResponsePromise) {
|
|
214
246
|
if (tx) {
|
|
215
|
-
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
247
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
248
|
+
key: hitKey,
|
|
249
|
+
wildcard: hitKey === wildcardKey,
|
|
250
|
+
});
|
|
216
251
|
}
|
|
252
|
+
const adoptedViaWildcard = hitKey === wildcardKey;
|
|
217
253
|
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
218
254
|
if (!response) {
|
|
219
255
|
if (tx) {
|
|
@@ -222,8 +258,28 @@ export function createNavigationClient(
|
|
|
222
258
|
return doFreshFetch();
|
|
223
259
|
}
|
|
224
260
|
|
|
261
|
+
// Cross-source safety: an inflight promise adopted via the
|
|
262
|
+
// wildcard key may turn out to be source-scoped (server emitted
|
|
263
|
+
// `X-RSC-Prefetch-Scope: source`), which means it was built for
|
|
264
|
+
// a different source page. Discard and refetch.
|
|
265
|
+
if (
|
|
266
|
+
adoptedViaWildcard &&
|
|
267
|
+
response.headers.get("x-rsc-prefetch-scope") === "source"
|
|
268
|
+
) {
|
|
269
|
+
if (tx) {
|
|
270
|
+
browserDebugLog(
|
|
271
|
+
tx,
|
|
272
|
+
"wildcard inflight turned out source-scoped, refetching",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return doFreshFetch();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const validated = validateRscHeaders(response, "inflight prefetch");
|
|
279
|
+
if (validated instanceof Promise) return validated;
|
|
280
|
+
|
|
225
281
|
return teeWithCompletion(
|
|
226
|
-
|
|
282
|
+
validated,
|
|
227
283
|
() => {
|
|
228
284
|
if (tx) {
|
|
229
285
|
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
@@ -238,13 +294,8 @@ export function createNavigationClient(
|
|
|
238
294
|
}
|
|
239
295
|
|
|
240
296
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
responsePromise,
|
|
244
|
-
{
|
|
245
|
-
...(debugChannel && { debugChannel, findSourceMapURL }),
|
|
246
|
-
},
|
|
247
|
-
);
|
|
297
|
+
const payload = await deps.createFromFetch<RscPayload>(responsePromise);
|
|
298
|
+
|
|
248
299
|
if (tx) {
|
|
249
300
|
browserDebugLog(tx, "response received", {
|
|
250
301
|
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
|
// ========================================================================
|
|
@@ -39,8 +39,8 @@ export interface PartialUpdateConfig {
|
|
|
39
39
|
segments: ResolvedSegment[],
|
|
40
40
|
options?: RenderSegmentsOptions,
|
|
41
41
|
) => Promise<ReactNode> | ReactNode;
|
|
42
|
-
/** RSC version
|
|
43
|
-
|
|
42
|
+
/** RSC version getter — returns the current version (may change after HMR) */
|
|
43
|
+
getVersion?: () => string | undefined;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -104,7 +104,13 @@ export type PartialUpdater = (
|
|
|
104
104
|
export function createPartialUpdater(
|
|
105
105
|
config: PartialUpdateConfig,
|
|
106
106
|
): PartialUpdater {
|
|
107
|
-
const {
|
|
107
|
+
const {
|
|
108
|
+
store,
|
|
109
|
+
client,
|
|
110
|
+
onUpdate,
|
|
111
|
+
renderSegments,
|
|
112
|
+
getVersion = () => undefined,
|
|
113
|
+
} = config;
|
|
108
114
|
|
|
109
115
|
/**
|
|
110
116
|
* Get current page's cached segments as an array
|
|
@@ -161,9 +167,16 @@ export function createPartialUpdater(
|
|
|
161
167
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
// 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.
|
|
165
176
|
const previousUrl =
|
|
166
|
-
|
|
177
|
+
mode.type === "leave-intercept"
|
|
178
|
+
? segmentState.currentUrl || tx.currentUrl
|
|
179
|
+
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
167
180
|
|
|
168
181
|
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
169
182
|
debugLog(`[Browser] From: ${previousUrl}`);
|
|
@@ -182,6 +195,11 @@ export function createPartialUpdater(
|
|
|
182
195
|
targetCache && targetCache.length > 0
|
|
183
196
|
? targetCache
|
|
184
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
|
+
);
|
|
185
203
|
|
|
186
204
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
187
205
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
@@ -193,7 +211,8 @@ export function createPartialUpdater(
|
|
|
193
211
|
// (action redirect sends empty segments for a fresh render).
|
|
194
212
|
staleRevalidation:
|
|
195
213
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
196
|
-
version,
|
|
214
|
+
version: getVersion(),
|
|
215
|
+
routerId: store.getRouterId?.(),
|
|
197
216
|
});
|
|
198
217
|
// Mark navigation as streaming (response received, now parsing RSC).
|
|
199
218
|
// Called after fetchPartial so pendingUrl stays set during the network wait,
|
|
@@ -206,6 +225,21 @@ export function createPartialUpdater(
|
|
|
206
225
|
streamingToken.end();
|
|
207
226
|
});
|
|
208
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
|
+
|
|
209
243
|
// Handle server-side redirect with state
|
|
210
244
|
if (payload.metadata?.redirect) {
|
|
211
245
|
if (signal?.aborted) {
|
|
@@ -259,7 +293,7 @@ export function createPartialUpdater(
|
|
|
259
293
|
existingSegments,
|
|
260
294
|
);
|
|
261
295
|
|
|
262
|
-
//
|
|
296
|
+
// tx.commit() cached the source page's handleData because
|
|
263
297
|
// eventController hasn't been updated yet. Overwrite with the
|
|
264
298
|
// correct cached handleData to prevent cache corruption on
|
|
265
299
|
// subsequent navigations to this same URL.
|