@rangojs/router 0.0.0-experimental.69 → 0.0.0-experimental.6c70a2ab
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 +112 -17
- package/dist/vite/index.js +1456 -467
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +7 -5
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +54 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +45 -0
- package/skills/layout/SKILL.md +24 -0
- package/skills/links/SKILL.md +234 -16
- package/skills/loader/SKILL.md +70 -3
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/parallel/SKILL.md +68 -0
- package/skills/rango/SKILL.md +26 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +48 -0
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +9 -1
- package/skills/view-transitions/SKILL.md +212 -0
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +44 -4
- package/src/browser/navigation-bridge.ts +80 -5
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +58 -12
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +70 -18
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +17 -4
- package/src/browser/react/use-reverse.ts +99 -0
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +19 -0
- package/src/build/route-trie.ts +52 -25
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +87 -175
- package/src/href-client.ts +4 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +40 -9
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +62 -36
- package/src/route-definition/dsl-helpers.ts +175 -23
- package/src/route-definition/helpers-types.ts +63 -14
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +21 -38
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/cache-lookup.ts +2 -1
- package/src/router/match-result.ts +101 -4
- package/src/router/middleware-types.ts +14 -25
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/fresh.ts +13 -0
- package/src/router/segment-resolution/revalidation.ts +135 -101
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/trie-matching.ts +18 -13
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +16 -8
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +10 -0
- package/src/rsc/server-action.ts +4 -0
- package/src/rsc/types.ts +6 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +71 -70
- package/src/server/context.ts +26 -3
- package/src/server/request-context.ts +10 -42
- package/src/ssr/index.tsx +5 -1
- package/src/types/handler-context.ts +12 -39
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +18 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +30 -4
- package/src/urls/response-types.ts +2 -10
- package/src/use-loader.tsx +4 -1
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +31 -3
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +172 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- 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-action-id.ts +52 -28
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +540 -376
- package/src/vite/plugins/performance-tracks.ts +17 -9
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +558 -53
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +21 -6
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ResolvedSegment,
|
|
6
6
|
} from "./types.js";
|
|
7
7
|
import { setAppVersion } from "./app-version.js";
|
|
8
|
+
import { setRangoStateLocal } from "./rango-state.js";
|
|
9
|
+
import type { AppShell, AppShellRef } from "./app-shell.js";
|
|
8
10
|
import * as React from "react";
|
|
9
11
|
import { startTransition } from "react";
|
|
10
12
|
import {
|
|
@@ -48,8 +50,13 @@ export { createNavigationTransaction };
|
|
|
48
50
|
*/
|
|
49
51
|
export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
|
|
50
52
|
eventController: EventController;
|
|
51
|
-
/** RSC version from initial payload metadata */
|
|
53
|
+
/** RSC version from initial payload metadata (fallback when appShellRef is not provided) */
|
|
52
54
|
version?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Live app-shell ref. When supplied, the bridge reads version/basename
|
|
57
|
+
* from this ref so cross-app navigations propagate correctly.
|
|
58
|
+
*/
|
|
59
|
+
appShellRef?: AppShellRef;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/**
|
|
@@ -68,9 +75,46 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
|
|
|
68
75
|
export function createNavigationBridge(
|
|
69
76
|
config: NavigationBridgeConfigWithController,
|
|
70
77
|
): NavigationBridge {
|
|
71
|
-
const {
|
|
78
|
+
const {
|
|
79
|
+
store,
|
|
80
|
+
client,
|
|
81
|
+
eventController,
|
|
82
|
+
onUpdate,
|
|
83
|
+
renderSegments,
|
|
84
|
+
appShellRef,
|
|
85
|
+
} = config;
|
|
72
86
|
let version = config.version;
|
|
73
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Replace the active app-shell snapshot atomically. Called by the partial
|
|
90
|
+
* updater when a response's routerId indicates the navigation crossed
|
|
91
|
+
* into a different app. Runs the local-only side-effects tied to
|
|
92
|
+
* app-shell fields (app version, rango-state namespace) so the new app
|
|
93
|
+
* owns them after the swap. Theme, warmup, and prefetch TTL are
|
|
94
|
+
* document-lifetime and are NOT touched here.
|
|
95
|
+
*/
|
|
96
|
+
function applyAppShell(next: AppShell): void {
|
|
97
|
+
if (appShellRef) {
|
|
98
|
+
appShellRef.update(next);
|
|
99
|
+
}
|
|
100
|
+
if (next.version !== undefined) {
|
|
101
|
+
version = next.version;
|
|
102
|
+
setAppVersion(next.version);
|
|
103
|
+
// Use the local-only setter — initRangoState writes the shared
|
|
104
|
+
// localStorage key and fires a storage event in other tabs still in
|
|
105
|
+
// the old app. setRangoStateLocal only mutates this tab's in-memory
|
|
106
|
+
// cache and rebinds it to the target app's routerId-scoped key,
|
|
107
|
+
// preserving the "local-only, no broadcast/rotation" contract for
|
|
108
|
+
// smooth app-switch transitions.
|
|
109
|
+
setRangoStateLocal(next.version, next.routerId);
|
|
110
|
+
}
|
|
111
|
+
// Cross-app: prior cache entries belong to a different app's segments.
|
|
112
|
+
// Drop them locally only — do NOT broadcast invalidation or rotate the
|
|
113
|
+
// shared X-Rango-State token, since other tabs still in the old app are
|
|
114
|
+
// unaffected by this tab's transition.
|
|
115
|
+
store.clearHistoryCacheLocal();
|
|
116
|
+
}
|
|
117
|
+
|
|
74
118
|
// Create shared partial updater
|
|
75
119
|
const fetchPartialUpdate = createPartialUpdater({
|
|
76
120
|
store,
|
|
@@ -78,6 +122,7 @@ export function createNavigationBridge(
|
|
|
78
122
|
onUpdate,
|
|
79
123
|
renderSegments,
|
|
80
124
|
getVersion: () => version,
|
|
125
|
+
applyAppShell,
|
|
81
126
|
});
|
|
82
127
|
|
|
83
128
|
return {
|
|
@@ -261,18 +306,24 @@ export function createNavigationBridge(
|
|
|
261
306
|
// 2. routes that CAN be intercepted - we don't know if this navigation will intercept
|
|
262
307
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
263
308
|
// 4. redirect-with-state - force re-render so hooks read fresh state
|
|
309
|
+
// 5. stale cache - server action invalidated it, need fresh data with loading state
|
|
264
310
|
const hasUsableCache =
|
|
265
311
|
cachedSegments &&
|
|
266
312
|
cachedSegments.length > 0 &&
|
|
267
313
|
!isInterceptOnlyCache(cachedSegments) &&
|
|
268
314
|
!hasInterceptCache &&
|
|
269
315
|
!isLeavingIntercept &&
|
|
316
|
+
!cached?.stale &&
|
|
270
317
|
!options?._skipCache;
|
|
271
318
|
|
|
319
|
+
// Forward navigations always await fetchPartialUpdate before rendering,
|
|
320
|
+
// so useNavigation should always report "loading". skipLoadingState is
|
|
321
|
+
// only used for popstate background revalidation (line ~526) where
|
|
322
|
+
// cached content renders instantly without a network wait.
|
|
272
323
|
const tx = createNavigationTransaction(store, eventController, url, {
|
|
273
324
|
...options,
|
|
274
325
|
state: resolvedState,
|
|
275
|
-
skipLoadingState:
|
|
326
|
+
skipLoadingState: false,
|
|
276
327
|
});
|
|
277
328
|
|
|
278
329
|
// REVALIDATE: Fetch fresh data from server
|
|
@@ -412,6 +463,15 @@ export function createNavigationBridge(
|
|
|
412
463
|
eventController.abortAllActions();
|
|
413
464
|
}
|
|
414
465
|
|
|
466
|
+
// Popstate that exits an intercept to a non-intercept destination. The
|
|
467
|
+
// fallback fetch path below needs `leave-intercept` mode so it filters
|
|
468
|
+
// the cached @modal segment from the request and forces a re-render —
|
|
469
|
+
// otherwise a cache-miss popstate whose server response has an empty
|
|
470
|
+
// diff hits the "no changes" branch in partial-update and the modal
|
|
471
|
+
// stays on screen.
|
|
472
|
+
const isLeavingIntercept =
|
|
473
|
+
!isIntercept && currentInterceptSource !== null;
|
|
474
|
+
|
|
415
475
|
// Compute history key from URL (with intercept suffix if applicable)
|
|
416
476
|
const historyKey = generateHistoryKey(url, { intercept: isIntercept });
|
|
417
477
|
|
|
@@ -481,7 +541,14 @@ export function createNavigationBridge(
|
|
|
481
541
|
},
|
|
482
542
|
scroll: { restore: true, isStreaming },
|
|
483
543
|
};
|
|
484
|
-
|
|
544
|
+
// Intercept-driven popstate (entering OR leaving an intercept) only
|
|
545
|
+
// mutates the parallel slot; the main outlet shows the same content.
|
|
546
|
+
// Skip startViewTransition in those cases — same rationale as the
|
|
547
|
+
// intercept guard in partial-update.ts's hasTransition computation.
|
|
548
|
+
const hasTransition =
|
|
549
|
+
!isIntercept &&
|
|
550
|
+
!isLeavingIntercept &&
|
|
551
|
+
cachedSegments.some((s) => s.transition);
|
|
485
552
|
if (hasTransition) {
|
|
486
553
|
startTransition(() => {
|
|
487
554
|
if (addTransitionType) {
|
|
@@ -562,7 +629,11 @@ export function createNavigationBridge(
|
|
|
562
629
|
intercept: isIntercept,
|
|
563
630
|
interceptSourceUrl,
|
|
564
631
|
}),
|
|
565
|
-
isIntercept
|
|
632
|
+
isIntercept
|
|
633
|
+
? { type: "navigate", interceptSourceUrl }
|
|
634
|
+
: isLeavingIntercept
|
|
635
|
+
? { type: "leave-intercept" }
|
|
636
|
+
: undefined,
|
|
566
637
|
);
|
|
567
638
|
// Restore scroll position after fetch completes
|
|
568
639
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -645,6 +716,10 @@ export function createNavigationBridge(
|
|
|
645
716
|
setAppVersion(newVersion);
|
|
646
717
|
store.clearHistoryCache();
|
|
647
718
|
},
|
|
719
|
+
|
|
720
|
+
updateAppShell(next: AppShell): void {
|
|
721
|
+
applyAppShell(next);
|
|
722
|
+
},
|
|
648
723
|
};
|
|
649
724
|
}
|
|
650
725
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "./response-adapter.js";
|
|
20
20
|
import {
|
|
21
21
|
buildPrefetchKey,
|
|
22
|
+
buildSourceKey,
|
|
22
23
|
consumeInflightPrefetch,
|
|
23
24
|
consumePrefetch,
|
|
24
25
|
} from "./prefetch/cache.js";
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
* deserializing the response using the RSC runtime.
|
|
31
32
|
*
|
|
32
33
|
* Checks the in-memory prefetch cache before making a network request.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
|
35
38
|
*
|
|
36
39
|
* @param deps - RSC browser dependencies (createFromFetch)
|
|
37
40
|
* @returns NavigationClient instance
|
|
@@ -93,18 +96,42 @@ export function createNavigationClient(
|
|
|
93
96
|
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
// Check completed in-memory prefetch cache before making a network
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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.
|
|
99
106
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
100
107
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
101
|
-
//
|
|
102
108
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
}
|
|
108
135
|
// Track when the stream completes
|
|
109
136
|
let resolveStreamComplete: () => void;
|
|
110
137
|
const streamComplete = new Promise<void>((resolve) => {
|
|
@@ -197,7 +224,10 @@ export function createNavigationClient(
|
|
|
197
224
|
|
|
198
225
|
if (cachedResponse) {
|
|
199
226
|
if (tx) {
|
|
200
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
227
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
228
|
+
key: hitKey,
|
|
229
|
+
wildcard: hitKey === wildcardKey,
|
|
230
|
+
});
|
|
201
231
|
}
|
|
202
232
|
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
203
233
|
const validated = validateRscHeaders(response, "prefetch cache");
|
|
@@ -214,8 +244,12 @@ export function createNavigationClient(
|
|
|
214
244
|
});
|
|
215
245
|
} else if (inflightResponsePromise) {
|
|
216
246
|
if (tx) {
|
|
217
|
-
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
247
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
248
|
+
key: hitKey,
|
|
249
|
+
wildcard: hitKey === wildcardKey,
|
|
250
|
+
});
|
|
218
251
|
}
|
|
252
|
+
const adoptedViaWildcard = hitKey === wildcardKey;
|
|
219
253
|
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
220
254
|
if (!response) {
|
|
221
255
|
if (tx) {
|
|
@@ -224,6 +258,23 @@ export function createNavigationClient(
|
|
|
224
258
|
return doFreshFetch();
|
|
225
259
|
}
|
|
226
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
|
+
|
|
227
278
|
const validated = validateRscHeaders(response, "inflight prefetch");
|
|
228
279
|
if (validated instanceof Promise) return validated;
|
|
229
280
|
|
|
@@ -12,7 +12,10 @@ import type {
|
|
|
12
12
|
ActionStateListener,
|
|
13
13
|
HandleData,
|
|
14
14
|
} from "./types.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
clearPrefetchCache,
|
|
17
|
+
clearPrefetchCacheLocal,
|
|
18
|
+
} from "./prefetch/cache.js";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Default action state (idle with no payload)
|
|
@@ -335,6 +338,18 @@ export function createNavigationStore(
|
|
|
335
338
|
clearPrefetchCache();
|
|
336
339
|
}
|
|
337
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Drop this tab's navigation + prefetch caches without broadcasting or
|
|
343
|
+
* rotating shared state. Used when the local session changes in a way that
|
|
344
|
+
* doesn't affect other tabs — e.g. this tab crosses into a different app
|
|
345
|
+
* via a cross-router navigation. Other tabs in the old app keep their
|
|
346
|
+
* caches and their X-Rango-State token.
|
|
347
|
+
*/
|
|
348
|
+
function clearCacheInternalLocal(): void {
|
|
349
|
+
historyCache.length = 0;
|
|
350
|
+
clearPrefetchCacheLocal();
|
|
351
|
+
}
|
|
352
|
+
|
|
338
353
|
/**
|
|
339
354
|
* Mark all cache entries as stale (internal - does not broadcast)
|
|
340
355
|
*/
|
|
@@ -668,6 +683,15 @@ export function createNavigationStore(
|
|
|
668
683
|
clearCacheAndBroadcast();
|
|
669
684
|
},
|
|
670
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Drop this tab's navigation + prefetch caches locally without
|
|
688
|
+
* broadcasting or rotating shared state. Intended for cross-app
|
|
689
|
+
* transitions where the session state diverges for this tab only.
|
|
690
|
+
*/
|
|
691
|
+
clearHistoryCacheLocal(): void {
|
|
692
|
+
clearCacheInternalLocal();
|
|
693
|
+
},
|
|
694
|
+
|
|
671
695
|
/**
|
|
672
696
|
* Mark cache as stale and broadcast to other tabs
|
|
673
697
|
* Called after server actions - allows SWR pattern for popstate
|
|
@@ -14,7 +14,10 @@ const addTransitionType: ((type: string) => void) | undefined =
|
|
|
14
14
|
import type { RenderSegmentsOptions } from "../segment-system.js";
|
|
15
15
|
import { reconcileSegments } from "./segment-reconciler.js";
|
|
16
16
|
import type { ReconcileActor } from "./segment-reconciler.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
hasActiveIntercept as hasActiveInterceptSlots,
|
|
19
|
+
isInterceptSegment,
|
|
20
|
+
} from "./intercept-utils.js";
|
|
18
21
|
import type { BoundTransaction } from "./navigation-transaction.js";
|
|
19
22
|
import { ServerRedirect } from "../errors.js";
|
|
20
23
|
import { debugLog } from "./logging.js";
|
|
@@ -28,6 +31,23 @@ function toScrollPayload(
|
|
|
28
31
|
return { enabled: scroll !== false ? scroll : false };
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Whether to wrap an update in startViewTransition.
|
|
36
|
+
*
|
|
37
|
+
* Intercept-driven updates only mutate the parallel slot — the main outlet
|
|
38
|
+
* shows the same content — so transitions on the underlying main segments
|
|
39
|
+
* shouldn't fire (otherwise their elements get hoisted above the modal).
|
|
40
|
+
*/
|
|
41
|
+
function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
|
|
42
|
+
let hasIntercept = false;
|
|
43
|
+
let hasTransition = false;
|
|
44
|
+
for (const s of segments) {
|
|
45
|
+
if (isInterceptSegment(s)) hasIntercept = true;
|
|
46
|
+
else if (s.transition) hasTransition = true;
|
|
47
|
+
}
|
|
48
|
+
return !hasIntercept && hasTransition;
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Configuration for creating a partial updater
|
|
33
53
|
*/
|
|
@@ -41,6 +61,13 @@ export interface PartialUpdateConfig {
|
|
|
41
61
|
) => Promise<ReactNode> | ReactNode;
|
|
42
62
|
/** RSC version getter — returns the current version (may change after HMR) */
|
|
43
63
|
getVersion?: () => string | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Replace the active app-shell when a cross-app navigation is detected.
|
|
66
|
+
* Called before the full-update tree replacement renders, so the new
|
|
67
|
+
* payload's rootLayout, basename, and version are picked up. Theme,
|
|
68
|
+
* warmup, and prefetch TTL are not part of the shell — see AppShell.
|
|
69
|
+
*/
|
|
70
|
+
applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
|
|
44
71
|
}
|
|
45
72
|
|
|
46
73
|
/**
|
|
@@ -110,6 +137,7 @@ export function createPartialUpdater(
|
|
|
110
137
|
onUpdate,
|
|
111
138
|
renderSegments,
|
|
112
139
|
getVersion = () => undefined,
|
|
140
|
+
applyAppShell,
|
|
113
141
|
} = config;
|
|
114
142
|
|
|
115
143
|
/**
|
|
@@ -167,9 +195,16 @@ export function createPartialUpdater(
|
|
|
167
195
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
168
196
|
}
|
|
169
197
|
|
|
170
|
-
// For intercept revalidation, use the intercept source URL as previousUrl
|
|
198
|
+
// For intercept revalidation, use the intercept source URL as previousUrl.
|
|
199
|
+
// For leave-intercept, tx.currentUrl captures window.location.href at tx
|
|
200
|
+
// creation, which on popstate is already the destination URL and would
|
|
201
|
+
// tell the server "from == to". segmentState.currentUrl still points at
|
|
202
|
+
// the URL the cached segments render (the intercept URL), which is the
|
|
203
|
+
// correct "from" for the server's diff computation.
|
|
171
204
|
const previousUrl =
|
|
172
|
-
|
|
205
|
+
mode.type === "leave-intercept"
|
|
206
|
+
? segmentState.currentUrl || tx.currentUrl
|
|
207
|
+
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
173
208
|
|
|
174
209
|
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
175
210
|
debugLog(`[Browser] From: ${previousUrl}`);
|
|
@@ -188,6 +223,11 @@ export function createPartialUpdater(
|
|
|
188
223
|
targetCache && targetCache.length > 0
|
|
189
224
|
? targetCache
|
|
190
225
|
: getCurrentCachedSegments();
|
|
226
|
+
const cachedSegsSource =
|
|
227
|
+
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
228
|
+
debugLog(
|
|
229
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
230
|
+
);
|
|
191
231
|
|
|
192
232
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
193
233
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
@@ -216,7 +256,12 @@ export function createPartialUpdater(
|
|
|
216
256
|
// Detect app switch: if routerId changed, the navigation crossed into
|
|
217
257
|
// a different router (e.g., via host router path mount). Downgrade
|
|
218
258
|
// partial to full so the entire tree is replaced without reconciliation
|
|
219
|
-
// against stale segments from the previous app
|
|
259
|
+
// against stale segments from the previous app, and replace the app
|
|
260
|
+
// shell (rootLayout, basename, version) so the target app's document
|
|
261
|
+
// and router config take effect instead of remaining captured from the
|
|
262
|
+
// initial load. Theme, warmup, and prefetch TTL are intentionally
|
|
263
|
+
// document-lifetime (see AppShell doc); a new document navigation
|
|
264
|
+
// applies them.
|
|
220
265
|
if (payload.metadata?.routerId) {
|
|
221
266
|
const prevRouterId = store.getRouterId?.();
|
|
222
267
|
if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
|
|
@@ -224,6 +269,12 @@ export function createPartialUpdater(
|
|
|
224
269
|
`[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
|
|
225
270
|
);
|
|
226
271
|
payload.metadata.isPartial = false;
|
|
272
|
+
applyAppShell?.({
|
|
273
|
+
routerId: payload.metadata.routerId,
|
|
274
|
+
rootLayout: payload.metadata.rootLayout,
|
|
275
|
+
basename: payload.metadata.basename,
|
|
276
|
+
version: payload.metadata.version,
|
|
277
|
+
});
|
|
227
278
|
}
|
|
228
279
|
store.setRouterId?.(payload.metadata.routerId);
|
|
229
280
|
}
|
|
@@ -307,10 +358,7 @@ export function createPartialUpdater(
|
|
|
307
358
|
scroll: toScrollPayload(commitScroll),
|
|
308
359
|
};
|
|
309
360
|
|
|
310
|
-
|
|
311
|
-
(s) => s.transition,
|
|
312
|
-
);
|
|
313
|
-
if (cachedHasTransition) {
|
|
361
|
+
if (shouldStartViewTransition(existingSegments)) {
|
|
314
362
|
startTransition(() => {
|
|
315
363
|
if (addTransitionType) {
|
|
316
364
|
addTransitionType("navigation");
|
|
@@ -496,7 +544,7 @@ export function createPartialUpdater(
|
|
|
496
544
|
|
|
497
545
|
// Emit update to trigger React render.
|
|
498
546
|
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
499
|
-
const hasTransition = reconciled.
|
|
547
|
+
const hasTransition = shouldStartViewTransition(reconciled.segments);
|
|
500
548
|
const scrollPayload = toScrollPayload(navScroll);
|
|
501
549
|
|
|
502
550
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
@@ -558,9 +606,7 @@ export function createPartialUpdater(
|
|
|
558
606
|
})
|
|
559
607
|
: tx.commit(segmentIds, segments);
|
|
560
608
|
|
|
561
|
-
const fullHasTransition = segments
|
|
562
|
-
(s: ResolvedSegment) => s.transition,
|
|
563
|
-
);
|
|
609
|
+
const fullHasTransition = shouldStartViewTransition(segments);
|
|
564
610
|
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
565
611
|
|
|
566
612
|
if (mode.type === "stale-revalidation") {
|
|
@@ -2,13 +2,27 @@
|
|
|
2
2
|
* Prefetch Cache
|
|
3
3
|
*
|
|
4
4
|
* In-memory cache storing prefetched Response objects for instant cache hits
|
|
5
|
-
* on subsequent navigation.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* on subsequent navigation. Two key scopes are in play:
|
|
6
|
+
* - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
|
|
7
|
+
* shape `rangoState\0/target?...`. Shared across all source pages and
|
|
8
|
+
* invalidated automatically when Rango state bumps (deploy or
|
|
9
|
+
* server-action invalidation).
|
|
10
|
+
* - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
|
|
11
|
+
* — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
|
|
12
|
+
* (so rotation invalidates source-scoped entries too) plus the source
|
|
13
|
+
* href (so each originating page gets its own slot). Populated when the
|
|
14
|
+
* server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
|
|
15
|
+
* modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
|
|
16
|
+
* both cases so source-sensitive responses cannot bleed into navigations
|
|
17
|
+
* from other pages.
|
|
8
18
|
*
|
|
9
19
|
* Also tracks in-flight prefetch promises. Each promise resolves to the
|
|
10
20
|
* navigation branch of a tee'd Response, allowing navigation to adopt a
|
|
11
|
-
* still-downloading prefetch without reparsing or buffering the body.
|
|
21
|
+
* still-downloading prefetch without reparsing or buffering the body. A
|
|
22
|
+
* single promise can be registered under multiple alias keys (see
|
|
23
|
+
* `setInflightPromiseWithAliases`) so same-source navigations adopt via
|
|
24
|
+
* their source key while cross-source ones fall through to the wildcard
|
|
25
|
+
* alias — with consume/clear atomically removing every alias.
|
|
12
26
|
*
|
|
13
27
|
* Replaces the previous browser HTTP cache approach which was unreliable
|
|
14
28
|
* due to response draining race conditions and browser inconsistencies.
|
|
@@ -55,19 +69,71 @@ const inflight = new Set<string>();
|
|
|
55
69
|
*/
|
|
56
70
|
const inflightPromises = new Map<string, Promise<Response | null>>();
|
|
57
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Alias map for in-flight promises registered under multiple keys (see
|
|
74
|
+
* dual inflight in prefetch/fetch.ts). Records each key's sibling set so
|
|
75
|
+
* that consuming or clearing any one key atomically removes every alias —
|
|
76
|
+
* guaranteeing a single consumer for the shared Response stream.
|
|
77
|
+
*/
|
|
78
|
+
const inflightAliases = new Map<string, string[]>();
|
|
79
|
+
|
|
58
80
|
// Generation counter incremented on each clearPrefetchCache(). Fetches that
|
|
59
81
|
// started before a clear carry a stale generation and must not store their
|
|
60
82
|
// response (the data may be stale due to a server action invalidation).
|
|
61
83
|
let generation = 0;
|
|
62
84
|
|
|
63
85
|
/**
|
|
64
|
-
* Build a
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
86
|
+
* Build a cache key by combining a scope prefix with the target URL.
|
|
87
|
+
*
|
|
88
|
+
* Low-level primitive — callers that want a specific scope should use
|
|
89
|
+
* one of:
|
|
90
|
+
* - Wildcard (source-agnostic): prefix is the Rango state value from
|
|
91
|
+
* `getRangoState()`. Shared across all source pages. Invalidated
|
|
92
|
+
* automatically when Rango state bumps (deploy or server-action).
|
|
93
|
+
* Key shape: `rangoState\0/target?...`.
|
|
94
|
+
* - Source-scoped: use `buildSourceKey()`. Key shape:
|
|
95
|
+
* `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
|
|
96
|
+
* rotation invalidates source-scoped entries alongside wildcard ones,
|
|
97
|
+
* plus the source page href so the key is unique per originating page.
|
|
98
|
+
* Populated either when the server tags a response with
|
|
99
|
+
* `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
|
|
100
|
+
* Link opts in via `prefetchKey=":source"`.
|
|
101
|
+
*
|
|
102
|
+
* The `_rsc_segments` query param that travels in the target URL means
|
|
103
|
+
* clients with different mounted segment trees naturally get different
|
|
104
|
+
* keys — so segment-level diffs remain consistent across both scopes.
|
|
105
|
+
*/
|
|
106
|
+
export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
|
|
107
|
+
return prefix + "\0" + targetUrl.pathname + targetUrl.search;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build a source-scoped cache key. Key shape:
|
|
112
|
+
* `rangoState\0sourceHref\0/target?...`.
|
|
113
|
+
*
|
|
114
|
+
* - `rangoState` is included so state rotation invalidates source-scoped
|
|
115
|
+
* entries alongside wildcard ones.
|
|
116
|
+
* - `sourceHref` makes the key unique per originating page.
|
|
117
|
+
*/
|
|
118
|
+
export function buildSourceKey(
|
|
119
|
+
rangoState: string,
|
|
120
|
+
sourceHref: string,
|
|
121
|
+
targetUrl: URL,
|
|
122
|
+
): string {
|
|
123
|
+
return buildPrefetchKey(rangoState + "\0" + sourceHref, targetUrl);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Walk an inflight key plus any sibling aliases registered via
|
|
128
|
+
* `setInflightPromiseWithAliases`, invoking `fn` for each.
|
|
68
129
|
*/
|
|
69
|
-
|
|
70
|
-
|
|
130
|
+
function forEachAlias(key: string, fn: (k: string) => void): void {
|
|
131
|
+
const aliases = inflightAliases.get(key);
|
|
132
|
+
if (aliases) {
|
|
133
|
+
for (const k of aliases) fn(k);
|
|
134
|
+
} else {
|
|
135
|
+
fn(key);
|
|
136
|
+
}
|
|
71
137
|
}
|
|
72
138
|
|
|
73
139
|
/**
|
|
@@ -110,21 +176,27 @@ export function consumePrefetch(key: string): Response | null {
|
|
|
110
176
|
* in-flight for this key. The returned Promise resolves to the buffered
|
|
111
177
|
* Response (or null if the fetch failed/was aborted).
|
|
112
178
|
*
|
|
113
|
-
* One-time consumption: the promise entry is removed
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
179
|
+
* One-time consumption: the promise entry is removed (along with any
|
|
180
|
+
* sibling aliases registered via `setInflightPromiseWithAliases`) so a
|
|
181
|
+
* second call on any alias returns null — only one caller can adopt the
|
|
182
|
+
* shared Response stream. The `inflight` set entry is intentionally
|
|
183
|
+
* kept so that `hasPrefetch()` continues to return true while the
|
|
184
|
+
* underlying fetch is still downloading — this prevents
|
|
185
|
+
* `prefetchDirect()` or other callers from starting a duplicate request
|
|
186
|
+
* during the handoff window. The inflight flag is cleaned up naturally
|
|
187
|
+
* by `clearPrefetchInflight()` in the fetch's `.finally()`.
|
|
120
188
|
*/
|
|
121
189
|
export function consumeInflightPrefetch(
|
|
122
190
|
key: string,
|
|
123
191
|
): Promise<Response | null> | null {
|
|
124
192
|
const promise = inflightPromises.get(key);
|
|
125
193
|
if (!promise) return null;
|
|
126
|
-
// Remove the promise
|
|
127
|
-
|
|
194
|
+
// Remove the promise under every alias so a second consumer cannot
|
|
195
|
+
// adopt the same stream and race on the body. `inflightAliases` is
|
|
196
|
+
// intentionally preserved — `clearPrefetchInflight()` in the fetch's
|
|
197
|
+
// `.finally()` still needs it to clear every inflight flag; deleting
|
|
198
|
+
// here would strand the sibling's flag forever.
|
|
199
|
+
forEachAlias(key, (k) => inflightPromises.delete(k));
|
|
128
200
|
return promise;
|
|
129
201
|
}
|
|
130
202
|
|
|
@@ -183,9 +255,28 @@ export function setInflightPromise(
|
|
|
183
255
|
inflightPromises.set(key, promise);
|
|
184
256
|
}
|
|
185
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Store the same in-flight Promise under multiple keys, recording them
|
|
260
|
+
* as sibling aliases. Consuming or clearing any one alias atomically
|
|
261
|
+
* removes every entry, guaranteeing the shared Response stream has a
|
|
262
|
+
* single consumer even when navigation looks up either key.
|
|
263
|
+
*/
|
|
264
|
+
export function setInflightPromiseWithAliases(
|
|
265
|
+
keys: string[],
|
|
266
|
+
promise: Promise<Response | null>,
|
|
267
|
+
): void {
|
|
268
|
+
for (const k of keys) {
|
|
269
|
+
inflightPromises.set(k, promise);
|
|
270
|
+
inflightAliases.set(k, keys);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
186
274
|
export function clearPrefetchInflight(key: string): void {
|
|
187
|
-
|
|
188
|
-
|
|
275
|
+
forEachAlias(key, (k) => {
|
|
276
|
+
inflight.delete(k);
|
|
277
|
+
inflightPromises.delete(k);
|
|
278
|
+
inflightAliases.delete(k);
|
|
279
|
+
});
|
|
189
280
|
}
|
|
190
281
|
|
|
191
282
|
/**
|
|
@@ -200,7 +291,24 @@ export function clearPrefetchCache(): void {
|
|
|
200
291
|
generation++;
|
|
201
292
|
inflight.clear();
|
|
202
293
|
inflightPromises.clear();
|
|
294
|
+
inflightAliases.clear();
|
|
203
295
|
cache.clear();
|
|
204
296
|
abortAllPrefetches();
|
|
205
297
|
invalidateRangoState();
|
|
206
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Drop all in-memory prefetch state for this tab without rotating rango-state.
|
|
302
|
+
*
|
|
303
|
+
* Use for local-only invalidations (e.g. app switch in this tab) where
|
|
304
|
+
* other tabs should NOT observe a state rotation. Unlike clearPrefetchCache,
|
|
305
|
+
* does not call invalidateRangoState, so the shared X-Rango-State token
|
|
306
|
+
* stays intact and siblings in the old app keep their prefetches.
|
|
307
|
+
*/
|
|
308
|
+
export function clearPrefetchCacheLocal(): void {
|
|
309
|
+
generation++;
|
|
310
|
+
inflight.clear();
|
|
311
|
+
inflightPromises.clear();
|
|
312
|
+
cache.clear();
|
|
313
|
+
abortAllPrefetches();
|
|
314
|
+
}
|