@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bcfea43
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 +126 -38
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +1171 -461
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +19 -16
- package/skills/breadcrumbs/SKILL.md +3 -1
- 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 +28 -20
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +91 -17
- package/skills/loader/SKILL.md +88 -45
- 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 +185 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +55 -0
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +13 -1
- package/src/__internal.ts +1 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +90 -16
- package/src/browser/navigation-client.ts +167 -59
- package/src/browser/navigation-store.ts +68 -9
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +113 -17
- package/src/browser/prefetch/cache.ts +184 -16
- package/src/browser/prefetch/fetch.ts +180 -33
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +123 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +81 -9
- package/src/browser/react/NavigationProvider.tsx +89 -14
- 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 +29 -9
- package/src/browser/rsc-router.tsx +168 -65
- 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 +49 -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 +455 -15
- 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.tsx +84 -230
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +6 -1
- package/src/index.ts +49 -6
- package/src/outlet-context.ts +1 -1
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +27 -2
- package/src/route-definition/dsl-helpers.ts +240 -40
- 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 +155 -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 +101 -25
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +10 -7
- package/src/router/loader-resolution.ts +159 -21
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +31 -16
- package/src/router/match-api.ts +127 -192
- 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 +8 -30
- package/src/router/middleware.ts +36 -10
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +60 -9
- 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-context.ts +6 -1
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +198 -20
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +438 -300
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/trie-matching.ts +10 -4
- package/src/router/types.ts +1 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +60 -8
- package/src/rsc/handler.ts +478 -374
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +16 -2
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +19 -1
- package/src/rsc/server-action.ts +10 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +9 -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 +194 -60
- 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 +137 -65
- package/src/types/loader-types.ts +41 -15
- package/src/types/request-scope.ts +126 -0
- 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 +18 -16
- package/src/use-loader.tsx +77 -5
- package/src/vite/debug.ts +55 -0
- 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/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 +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 +86 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +204 -217
- package/src/vite/router-discovery.ts +335 -64
- package/src/vite/utils/banner.ts +4 -4
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +37 -5
- package/src/vite/utils/shared-utils.ts +3 -2
|
@@ -6,21 +6,37 @@
|
|
|
6
6
|
* navigation requests. The server responds with `Vary: X-Rango-State`,
|
|
7
7
|
* so the browser HTTP cache keys responses by (URL, X-Rango-State value).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Value format: `{buildVersion}:{invalidationTimestamp}`
|
|
10
10
|
* - Build version changes on deploy, busting all cached prefetches.
|
|
11
11
|
* - Timestamp changes on server action invalidation.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Storage key is namespaced per routerId (`rango-state:{routerId}`) so
|
|
14
|
+
* tabs in different apps on the same origin do not collide. Two tabs in
|
|
15
|
+
* the same app share a key → one tab's invalidation is picked up by the
|
|
16
|
+
* other via the `storage` event. A smooth cross-app transition in this
|
|
17
|
+
* tab rebinds to the target app's key; other tabs still in the old app
|
|
18
|
+
* keep their own key intact.
|
|
19
|
+
*
|
|
20
|
+
* If no routerId is supplied, falls back to a single legacy key for
|
|
21
|
+
* backward compatibility (single-app deployments unaffected).
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
const
|
|
24
|
+
const LEGACY_STORAGE_KEY = "rango-state";
|
|
25
|
+
|
|
26
|
+
function buildStorageKey(routerId: string | undefined): string {
|
|
27
|
+
return routerId ? `${LEGACY_STORAGE_KEY}:${routerId}` : LEGACY_STORAGE_KEY;
|
|
28
|
+
}
|
|
19
29
|
|
|
20
30
|
// Module-level cache avoids hitting localStorage on every getRangoState() call.
|
|
21
31
|
// Initialized from localStorage on first access or by initRangoState().
|
|
22
32
|
let cachedState: string | null = null;
|
|
23
33
|
|
|
34
|
+
// The localStorage key this tab is currently bound to. Rebinds on
|
|
35
|
+
// initRangoState (document boot) and setRangoStateLocal (smooth app
|
|
36
|
+
// switch). The storage listener filters cross-tab events by this key so
|
|
37
|
+
// events from tabs in a different app are ignored.
|
|
38
|
+
let currentStorageKey: string = LEGACY_STORAGE_KEY;
|
|
39
|
+
|
|
24
40
|
// Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
|
|
25
41
|
// to localStorage, keeping cachedState fresh without polling.
|
|
26
42
|
let storageListenerAttached = false;
|
|
@@ -28,7 +44,10 @@ let storageListenerAttached = false;
|
|
|
28
44
|
function attachStorageListener(): void {
|
|
29
45
|
if (storageListenerAttached || typeof window === "undefined") return;
|
|
30
46
|
window.addEventListener("storage", (e) => {
|
|
31
|
-
|
|
47
|
+
// Only react to events for this tab's current app namespace. Events
|
|
48
|
+
// under other routerId-scoped keys belong to other apps and must not
|
|
49
|
+
// clobber this tab's state.
|
|
50
|
+
if (e.key !== currentStorageKey) return;
|
|
32
51
|
cachedState = e.newValue;
|
|
33
52
|
});
|
|
34
53
|
storageListenerAttached = true;
|
|
@@ -37,16 +56,22 @@ function attachStorageListener(): void {
|
|
|
37
56
|
/**
|
|
38
57
|
* Initialize the Rango state key in localStorage.
|
|
39
58
|
* Called once at app startup with the build version from the server.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
59
|
+
* The routerId scopes the storage key to this app; in multi-app setups
|
|
60
|
+
* each app owns its own `rango-state:{routerId}` key and cannot observe
|
|
61
|
+
* invalidations from sibling apps on the same origin.
|
|
62
|
+
*
|
|
63
|
+
* If localStorage already has a matching-version entry under the key,
|
|
64
|
+
* keeps it (preserves invalidation state across refresh). Otherwise
|
|
65
|
+
* writes a new value.
|
|
42
66
|
*/
|
|
43
|
-
export function initRangoState(version: string): void {
|
|
67
|
+
export function initRangoState(version: string, routerId?: string): void {
|
|
68
|
+
currentStorageKey = buildStorageKey(routerId);
|
|
44
69
|
if (typeof window === "undefined") return;
|
|
45
70
|
|
|
46
71
|
attachStorageListener();
|
|
47
72
|
|
|
48
73
|
try {
|
|
49
|
-
const existing = localStorage.getItem(
|
|
74
|
+
const existing = localStorage.getItem(currentStorageKey);
|
|
50
75
|
if (existing) {
|
|
51
76
|
const colonIdx = existing.indexOf(":");
|
|
52
77
|
if (colonIdx > 0) {
|
|
@@ -59,7 +84,7 @@ export function initRangoState(version: string): void {
|
|
|
59
84
|
}
|
|
60
85
|
// New version or first load
|
|
61
86
|
const newState = `${version}:${Date.now()}`;
|
|
62
|
-
localStorage.setItem(
|
|
87
|
+
localStorage.setItem(currentStorageKey, newState);
|
|
63
88
|
cachedState = newState;
|
|
64
89
|
} catch {
|
|
65
90
|
// localStorage may be unavailable (private browsing in some browsers)
|
|
@@ -77,7 +102,7 @@ export function getRangoState(): string {
|
|
|
77
102
|
if (typeof window === "undefined") return "0:0";
|
|
78
103
|
|
|
79
104
|
try {
|
|
80
|
-
const stored = localStorage.getItem(
|
|
105
|
+
const stored = localStorage.getItem(currentStorageKey);
|
|
81
106
|
if (stored) {
|
|
82
107
|
cachedState = stored;
|
|
83
108
|
return stored;
|
|
@@ -89,6 +114,21 @@ export function getRangoState(): string {
|
|
|
89
114
|
return "0:0";
|
|
90
115
|
}
|
|
91
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Update the in-memory rango-state to a new version WITHOUT writing
|
|
119
|
+
* localStorage. Intended for smooth cross-app transitions in this tab only:
|
|
120
|
+
* subsequent requests from this tab send the new token, but other tabs
|
|
121
|
+
* still in the previous app do not observe a storage event. Rebinds this
|
|
122
|
+
* tab's storage key to the target app's namespace (`rango-state:{routerId}`)
|
|
123
|
+
* so subsequent storage events only reflect the new app. On the next hard
|
|
124
|
+
* reload, initRangoState reconciles localStorage from the server's
|
|
125
|
+
* authoritative version.
|
|
126
|
+
*/
|
|
127
|
+
export function setRangoStateLocal(version: string, routerId?: string): void {
|
|
128
|
+
currentStorageKey = buildStorageKey(routerId);
|
|
129
|
+
cachedState = `${version}:${Date.now()}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
92
132
|
/**
|
|
93
133
|
* Invalidate the Rango state key. Called when server actions mutate data.
|
|
94
134
|
* Updates the timestamp portion while keeping the version prefix.
|
|
@@ -105,7 +145,7 @@ export function invalidateRangoState(): void {
|
|
|
105
145
|
if (typeof window === "undefined") return;
|
|
106
146
|
|
|
107
147
|
try {
|
|
108
|
-
localStorage.setItem(
|
|
148
|
+
localStorage.setItem(currentStorageKey, newState);
|
|
109
149
|
} catch {
|
|
110
150
|
// Silently handle localStorage errors
|
|
111
151
|
}
|
|
@@ -5,6 +5,7 @@ import React, {
|
|
|
5
5
|
useCallback,
|
|
6
6
|
useContext,
|
|
7
7
|
useEffect,
|
|
8
|
+
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
type ForwardRefExoticComponent,
|
|
10
11
|
type RefAttributes,
|
|
@@ -32,6 +33,7 @@ export type LinkState =
|
|
|
32
33
|
| StateOrGetter<Record<string, unknown>>;
|
|
33
34
|
|
|
34
35
|
import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
|
|
36
|
+
import { getAppVersion } from "../app-version.js";
|
|
35
37
|
import {
|
|
36
38
|
observeForPrefetch,
|
|
37
39
|
unobserveForPrefetch,
|
|
@@ -95,6 +97,31 @@ export interface LinkProps extends Omit<
|
|
|
95
97
|
* @default "none"
|
|
96
98
|
*/
|
|
97
99
|
prefetch?: PrefetchStrategy;
|
|
100
|
+
/**
|
|
101
|
+
* Opt-in override for the prefetch cache scope.
|
|
102
|
+
*
|
|
103
|
+
* The default cache is source-agnostic: one shared entry per target,
|
|
104
|
+
* keyed on Rango state + target URL. This is correct for routes whose
|
|
105
|
+
* response shape doesn't depend on where the user navigates from.
|
|
106
|
+
*
|
|
107
|
+
* Set `":source"` when this Link's response would legitimately differ
|
|
108
|
+
* based on the source page — typically when the target route (or one
|
|
109
|
+
* of its layouts) uses a custom `revalidate()` handler that reads
|
|
110
|
+
* `currentUrl` / `currentParams`, and the wildcard entry would
|
|
111
|
+
* therefore serve the wrong diff to a navigation from a different
|
|
112
|
+
* source.
|
|
113
|
+
*
|
|
114
|
+
* Intercept responses are auto-scoped to the source via a server-side
|
|
115
|
+
* tag, so `":source"` is only needed for custom revalidation logic.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```tsx
|
|
119
|
+
* // Route uses a `revalidate()` that branches on currentUrl — opt in
|
|
120
|
+
* // so prefetches don't bleed across source pages.
|
|
121
|
+
* <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
prefetchKey?: ":source";
|
|
98
125
|
/**
|
|
99
126
|
* State to pass to history.pushState/replaceState.
|
|
100
127
|
* Accessible via useLocationState() hook.
|
|
@@ -182,6 +209,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
182
209
|
reloadDocument = false,
|
|
183
210
|
revalidate,
|
|
184
211
|
prefetch = "none",
|
|
212
|
+
prefetchKey,
|
|
185
213
|
state,
|
|
186
214
|
children,
|
|
187
215
|
onClick,
|
|
@@ -192,6 +220,16 @@ export const Link: ForwardRefExoticComponent<
|
|
|
192
220
|
const ctx = useContext(NavigationStoreContext);
|
|
193
221
|
const isExternal = isExternalUrl(to);
|
|
194
222
|
|
|
223
|
+
// Auto-prefix with basename for app-local paths.
|
|
224
|
+
// Skip if external, already prefixed, or not a root-relative path.
|
|
225
|
+
const resolvedTo = useMemo(() => {
|
|
226
|
+
if (isExternal) return to;
|
|
227
|
+
const bn = ctx?.basename;
|
|
228
|
+
if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
|
|
229
|
+
return to;
|
|
230
|
+
return to === "/" ? bn : bn + to;
|
|
231
|
+
}, [to, isExternal, ctx?.basename]);
|
|
232
|
+
|
|
195
233
|
// Resolve adaptive: viewport on touch devices, hover on pointer devices
|
|
196
234
|
const resolvedStrategy =
|
|
197
235
|
prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
|
|
@@ -273,17 +311,45 @@ export const Link: ForwardRefExoticComponent<
|
|
|
273
311
|
resolvedState = currentState;
|
|
274
312
|
}
|
|
275
313
|
|
|
276
|
-
ctx.navigate(
|
|
314
|
+
ctx.navigate(resolvedTo, {
|
|
315
|
+
replace,
|
|
316
|
+
scroll,
|
|
317
|
+
state: resolvedState,
|
|
318
|
+
revalidate,
|
|
319
|
+
});
|
|
277
320
|
},
|
|
278
|
-
[
|
|
321
|
+
[
|
|
322
|
+
resolvedTo,
|
|
323
|
+
isExternal,
|
|
324
|
+
reloadDocument,
|
|
325
|
+
replace,
|
|
326
|
+
scroll,
|
|
327
|
+
revalidate,
|
|
328
|
+
ctx,
|
|
329
|
+
onClick,
|
|
330
|
+
],
|
|
279
331
|
);
|
|
280
332
|
|
|
281
333
|
const handleMouseEnter = useCallback(() => {
|
|
282
|
-
if (
|
|
334
|
+
if (
|
|
335
|
+
(resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
|
|
336
|
+
!isExternal &&
|
|
337
|
+
ctx?.store
|
|
338
|
+
) {
|
|
339
|
+
// For "hover", this is the primary prefetch trigger.
|
|
340
|
+
// For "viewport", this upgrades/prioritizes a potentially queued
|
|
341
|
+
// prefetch — prefetchDirect bypasses the queue, and hasPrefetch
|
|
342
|
+
// deduplicates if the viewport prefetch already completed.
|
|
283
343
|
const segmentState = ctx.store.getSegmentState();
|
|
284
|
-
prefetchDirect(
|
|
344
|
+
prefetchDirect(
|
|
345
|
+
resolvedTo,
|
|
346
|
+
segmentState.currentSegmentIds,
|
|
347
|
+
getAppVersion(),
|
|
348
|
+
ctx.store.getRouterId?.(),
|
|
349
|
+
prefetchKey,
|
|
350
|
+
);
|
|
285
351
|
}
|
|
286
|
-
}, [resolvedStrategy,
|
|
352
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
287
353
|
|
|
288
354
|
// Viewport/render prefetch: waits for idle before starting,
|
|
289
355
|
// uses concurrency-limited queue to avoid flooding.
|
|
@@ -300,7 +366,13 @@ export const Link: ForwardRefExoticComponent<
|
|
|
300
366
|
const triggerPrefetch = () => {
|
|
301
367
|
if (cancelled) return;
|
|
302
368
|
const segmentState = ctx.store.getSegmentState();
|
|
303
|
-
prefetchQueued(
|
|
369
|
+
prefetchQueued(
|
|
370
|
+
resolvedTo,
|
|
371
|
+
segmentState.currentSegmentIds,
|
|
372
|
+
getAppVersion(),
|
|
373
|
+
ctx.store.getRouterId?.(),
|
|
374
|
+
prefetchKey,
|
|
375
|
+
);
|
|
304
376
|
};
|
|
305
377
|
|
|
306
378
|
// Schedule prefetch only when the app is idle (no navigation/streaming).
|
|
@@ -339,12 +411,12 @@ export const Link: ForwardRefExoticComponent<
|
|
|
339
411
|
unobserveForPrefetch(observedElement);
|
|
340
412
|
}
|
|
341
413
|
};
|
|
342
|
-
}, [resolvedStrategy,
|
|
414
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
343
415
|
|
|
344
416
|
return (
|
|
345
417
|
<a
|
|
346
418
|
ref={setRef}
|
|
347
|
-
href={
|
|
419
|
+
href={resolvedTo}
|
|
348
420
|
onClick={handleClick}
|
|
349
421
|
onMouseEnter={handleMouseEnter}
|
|
350
422
|
data-link-component
|
|
@@ -354,7 +426,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
354
426
|
data-revalidate={revalidate === false ? "false" : undefined}
|
|
355
427
|
{...props}
|
|
356
428
|
>
|
|
357
|
-
<LinkContext.Provider value={
|
|
429
|
+
<LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
|
|
358
430
|
</a>
|
|
359
431
|
);
|
|
360
432
|
});
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
import React, {
|
|
4
4
|
useState,
|
|
5
5
|
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
6
7
|
useCallback,
|
|
7
8
|
useMemo,
|
|
9
|
+
useRef,
|
|
8
10
|
use,
|
|
9
11
|
type ReactNode,
|
|
10
12
|
} from "react";
|
|
@@ -25,6 +27,8 @@ import { ThemeProvider } from "../../theme/ThemeProvider.js";
|
|
|
25
27
|
import { NonceContext } from "./nonce-context.js";
|
|
26
28
|
import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
27
29
|
import { cancelAllPrefetches } from "../prefetch/queue.js";
|
|
30
|
+
import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
31
|
+
import type { AppShellRef } from "../app-shell.js";
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* Process handles from an async generator, updating the event controller
|
|
@@ -130,10 +134,23 @@ export interface NavigationProviderProps {
|
|
|
130
134
|
warmupEnabled?: boolean;
|
|
131
135
|
|
|
132
136
|
/**
|
|
133
|
-
* App version from server payload
|
|
134
|
-
*
|
|
137
|
+
* App version from server payload.
|
|
138
|
+
* Used only as a fallback when `appShellRef` is not supplied.
|
|
135
139
|
*/
|
|
136
140
|
version?: string;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* URL prefix for all routes (from createRouter({ basename })).
|
|
144
|
+
* Used only as a fallback when `appShellRef` is not supplied.
|
|
145
|
+
*/
|
|
146
|
+
basename?: string;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Live app-shell ref. When provided, the context's `basename` and `version`
|
|
150
|
+
* properties become live getters that track app-switch updates without
|
|
151
|
+
* invalidating the memoized context value.
|
|
152
|
+
*/
|
|
153
|
+
appShellRef?: AppShellRef;
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
/**
|
|
@@ -166,6 +183,8 @@ export function NavigationProvider({
|
|
|
166
183
|
initialTheme,
|
|
167
184
|
warmupEnabled,
|
|
168
185
|
version,
|
|
186
|
+
basename,
|
|
187
|
+
appShellRef,
|
|
169
188
|
}: NavigationProviderProps): ReactNode {
|
|
170
189
|
// Track current payload for rendering (this triggers re-renders)
|
|
171
190
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -187,17 +206,39 @@ export function NavigationProvider({
|
|
|
187
206
|
await bridge.refresh();
|
|
188
207
|
}, []);
|
|
189
208
|
|
|
190
|
-
// Context value is stable (store, eventController, navigate, refresh never
|
|
191
|
-
|
|
192
|
-
|
|
209
|
+
// Context value is stable (store, eventController, navigate, refresh never
|
|
210
|
+
// change). When an appShellRef is supplied, `basename` and `version` are
|
|
211
|
+
// installed as live getters so app-switch transitions (which update the ref)
|
|
212
|
+
// propagate to consumers without forcing a tree-wide rerender.
|
|
213
|
+
const contextValue = useMemo<NavigationStoreContextValue>(() => {
|
|
214
|
+
if (appShellRef) {
|
|
215
|
+
const value = {
|
|
216
|
+
store,
|
|
217
|
+
eventController,
|
|
218
|
+
navigate,
|
|
219
|
+
refresh,
|
|
220
|
+
} as NavigationStoreContextValue;
|
|
221
|
+
Object.defineProperty(value, "basename", {
|
|
222
|
+
configurable: true,
|
|
223
|
+
enumerable: true,
|
|
224
|
+
get: () => appShellRef.get().basename,
|
|
225
|
+
});
|
|
226
|
+
Object.defineProperty(value, "version", {
|
|
227
|
+
configurable: true,
|
|
228
|
+
enumerable: true,
|
|
229
|
+
get: () => appShellRef.get().version,
|
|
230
|
+
});
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
193
234
|
store,
|
|
194
235
|
eventController,
|
|
195
236
|
navigate,
|
|
196
237
|
refresh,
|
|
197
238
|
version,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
);
|
|
239
|
+
basename,
|
|
240
|
+
};
|
|
241
|
+
}, []);
|
|
201
242
|
|
|
202
243
|
// Connection warmup: keep TLS alive after idle periods.
|
|
203
244
|
// After 60s of no user interaction, marks connection as "cold".
|
|
@@ -286,31 +327,61 @@ export function NavigationProvider({
|
|
|
286
327
|
};
|
|
287
328
|
}, [warmupEnabled]);
|
|
288
329
|
|
|
289
|
-
// Cancel
|
|
290
|
-
//
|
|
330
|
+
// Cancel non-matching prefetches when navigation starts.
|
|
331
|
+
// Frees connections so the navigation fetch isn't competing with
|
|
332
|
+
// speculative prefetches. The prefetch matching the navigation target
|
|
333
|
+
// is kept alive so it can be reused via consumeInflightPrefetch.
|
|
291
334
|
useEffect(() => {
|
|
292
335
|
let wasIdle = true;
|
|
293
336
|
const unsub = eventController.subscribe(() => {
|
|
294
337
|
const state = eventController.getState();
|
|
295
338
|
const isIdle = state.state === "idle" && !state.isStreaming;
|
|
296
339
|
if (wasIdle && !isIdle) {
|
|
297
|
-
cancelAllPrefetches();
|
|
340
|
+
cancelAllPrefetches(state.pendingUrl);
|
|
298
341
|
}
|
|
299
342
|
wasIdle = isIdle;
|
|
300
343
|
});
|
|
301
344
|
return unsub;
|
|
302
345
|
}, [eventController]);
|
|
303
346
|
|
|
347
|
+
// Pending scroll action to apply after React commits
|
|
348
|
+
const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
|
|
349
|
+
|
|
350
|
+
// Apply scroll after React commits the new content to the DOM
|
|
351
|
+
useLayoutEffect(() => {
|
|
352
|
+
const scrollAction = pendingScrollRef.current;
|
|
353
|
+
if (!scrollAction) return;
|
|
354
|
+
pendingScrollRef.current = undefined;
|
|
355
|
+
|
|
356
|
+
if (scrollAction.enabled === false) return;
|
|
357
|
+
|
|
358
|
+
handleNavigationEnd({
|
|
359
|
+
restore: scrollAction.restore,
|
|
360
|
+
scroll: scrollAction.enabled,
|
|
361
|
+
isStreaming: scrollAction.isStreaming,
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
304
365
|
// Subscribe to UI updates (for re-rendering the tree)
|
|
305
366
|
useEffect(() => {
|
|
306
367
|
const unsubscribe = store.onUpdate((update) => {
|
|
368
|
+
// Capture scroll intent — it will be applied in useLayoutEffect
|
|
369
|
+
// after React commits this state update to the DOM.
|
|
370
|
+
// Always assign (even undefined) to clear stale scroll from prior navigations,
|
|
371
|
+
// so server actions or error updates don't accidentally replay old scroll.
|
|
372
|
+
pendingScrollRef.current = update.scroll;
|
|
373
|
+
|
|
307
374
|
setPayload({
|
|
308
375
|
root: update.root,
|
|
309
376
|
metadata: update.metadata,
|
|
310
377
|
});
|
|
311
378
|
|
|
312
|
-
// Update route params
|
|
313
|
-
|
|
379
|
+
// Update route params. Only reset when the server actually sends a params
|
|
380
|
+
// map — an absent `params` field means "no change" (e.g., legacy action
|
|
381
|
+
// responses that omitted params). Explicit `{}` still clears correctly.
|
|
382
|
+
if (update.metadata.params !== undefined) {
|
|
383
|
+
eventController.setParams(update.metadata.params);
|
|
384
|
+
}
|
|
314
385
|
|
|
315
386
|
// Update handle data progressively as it streams in
|
|
316
387
|
if (update.metadata.handles) {
|
|
@@ -362,7 +433,11 @@ export function NavigationProvider({
|
|
|
362
433
|
// Build the content tree
|
|
363
434
|
let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
|
|
364
435
|
|
|
365
|
-
// Wrap with ThemeProvider when theme is enabled
|
|
436
|
+
// Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
|
|
437
|
+
// document-lifetime: its config comes from the initial load and does NOT
|
|
438
|
+
// swap on cross-app transitions, because the ThemeProvider sits above the
|
|
439
|
+
// segment tree and a smooth (no-reload) app switch cannot safely remount
|
|
440
|
+
// it. A new theme config only takes effect on a full document load.
|
|
366
441
|
if (themeConfig) {
|
|
367
442
|
content = (
|
|
368
443
|
<ThemeProvider config={themeConfig} initialTheme={initialTheme}>
|
|
@@ -43,10 +43,15 @@ export interface NavigationStoreContextValue {
|
|
|
43
43
|
refresh: () => Promise<void>;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* App version from server payload
|
|
47
|
-
* Used in prefetch requests for version mismatch detection.
|
|
46
|
+
* App version from the initial server payload.
|
|
48
47
|
*/
|
|
49
48
|
version: string | undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* URL prefix for all routes (from createRouter({ basename })).
|
|
52
|
+
* Used by Link and useRouter() to auto-prefix app-local paths.
|
|
53
|
+
*/
|
|
54
|
+
basename: string | undefined;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
/**
|
|
@@ -9,64 +9,11 @@ import {
|
|
|
9
9
|
startTransition,
|
|
10
10
|
} from "react";
|
|
11
11
|
import type { Handle } from "../../handle.js";
|
|
12
|
-
import {
|
|
12
|
+
import { collectHandleData } from "../../handle.js";
|
|
13
13
|
import type { HandleData } from "../types.js";
|
|
14
14
|
import { NavigationStoreContext } from "./context.js";
|
|
15
15
|
import { shallowEqual } from "./shallow-equal.js";
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
* Resolve the collect function for a handle.
|
|
19
|
-
* Handle objects are plain { __brand, $$id } - collect is stored in the registry
|
|
20
|
-
* (populated when createHandle runs on the client).
|
|
21
|
-
*/
|
|
22
|
-
function resolveCollect<T, A>(handle: Handle<T, A>): (segments: T[][]) => A {
|
|
23
|
-
// Look up collect from the registry (populated when the handle module is imported).
|
|
24
|
-
const registered = getCollectFn(handle.$$id);
|
|
25
|
-
if (registered) {
|
|
26
|
-
return registered as (segments: T[][]) => A;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Fall back to default flat collect with a dev warning.
|
|
30
|
-
if (process.env.NODE_ENV !== "production") {
|
|
31
|
-
console.warn(
|
|
32
|
-
`[rsc-router] Handle "${handle.$$id}" was passed as a prop but its collect ` +
|
|
33
|
-
`function could not be resolved. Falling back to flat array. ` +
|
|
34
|
-
`Import the handle module in a client component to register its collect function.`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
return ((segments: unknown[][]) => segments.flat()) as unknown as (
|
|
38
|
-
segments: T[][],
|
|
39
|
-
) => A;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Collect handle data from segments and transform to final value.
|
|
44
|
-
*/
|
|
45
|
-
function collectHandle<T, A>(
|
|
46
|
-
handle: Handle<T, A>,
|
|
47
|
-
data: HandleData,
|
|
48
|
-
segmentOrder: string[],
|
|
49
|
-
): A {
|
|
50
|
-
const collect = resolveCollect(handle);
|
|
51
|
-
const segmentData = data[handle.$$id];
|
|
52
|
-
|
|
53
|
-
if (!segmentData) {
|
|
54
|
-
return collect([]);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Build array of segment arrays in parent -> child order
|
|
58
|
-
const segmentArrays: T[][] = [];
|
|
59
|
-
for (const segmentId of segmentOrder) {
|
|
60
|
-
const entries = segmentData[segmentId];
|
|
61
|
-
if (entries && entries.length > 0) {
|
|
62
|
-
segmentArrays.push(entries as T[]);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Call collect once with all segment data
|
|
67
|
-
return collect(segmentArrays);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
17
|
/**
|
|
71
18
|
* Hook to access collected handle data.
|
|
72
19
|
*
|
|
@@ -99,13 +46,13 @@ export function useHandle<T, A, S>(
|
|
|
99
46
|
// Initial state from context event controller, or empty fallback without provider.
|
|
100
47
|
const [value, setValue] = useState<A | S>(() => {
|
|
101
48
|
if (!ctx) {
|
|
102
|
-
const collected =
|
|
49
|
+
const collected = collectHandleData(handle, {}, []);
|
|
103
50
|
return selector ? selector(collected) : collected;
|
|
104
51
|
}
|
|
105
52
|
|
|
106
53
|
// On client, use event controller state
|
|
107
54
|
const state = ctx.eventController.getHandleState();
|
|
108
|
-
const collected =
|
|
55
|
+
const collected = collectHandleData(handle, state.data, state.segmentOrder);
|
|
109
56
|
return selector ? selector(collected) : collected;
|
|
110
57
|
});
|
|
111
58
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
@@ -125,7 +72,7 @@ export function useHandle<T, A, S>(
|
|
|
125
72
|
// Sync current state for the (possibly new) handle so that switching
|
|
126
73
|
// handles on an idle page doesn't leave stale data from the old handle.
|
|
127
74
|
const currentHandleState = ctx.eventController.getHandleState();
|
|
128
|
-
const currentCollected =
|
|
75
|
+
const currentCollected = collectHandleData(
|
|
129
76
|
handle,
|
|
130
77
|
currentHandleState.data,
|
|
131
78
|
currentHandleState.segmentOrder,
|
|
@@ -142,7 +89,11 @@ export function useHandle<T, A, S>(
|
|
|
142
89
|
const state = ctx.eventController.getHandleState();
|
|
143
90
|
const isAction =
|
|
144
91
|
ctx.eventController.getState().inflightActions.length > 0;
|
|
145
|
-
const collected =
|
|
92
|
+
const collected = collectHandleData(
|
|
93
|
+
handle,
|
|
94
|
+
state.data,
|
|
95
|
+
state.segmentOrder,
|
|
96
|
+
);
|
|
146
97
|
const nextValue = selectorRef.current
|
|
147
98
|
? selectorRef.current(collected)
|
|
148
99
|
: collected;
|
|
@@ -53,6 +53,12 @@ export function useNavigation<T>(
|
|
|
53
53
|
});
|
|
54
54
|
const prevState = useRef(baseValue);
|
|
55
55
|
|
|
56
|
+
// Tracks whether the most recent setOptimisticValue call pinned the value
|
|
57
|
+
// to a non-idle state. Used to decide whether to emit a release update when
|
|
58
|
+
// returning to idle, so the optimistic store doesn't stay pinned if a
|
|
59
|
+
// parent transition (e.g. <Link> click) is still pending.
|
|
60
|
+
const optimisticPinnedRef = useRef(false);
|
|
61
|
+
|
|
56
62
|
// useOptimistic allows immediate updates during transitions/actions
|
|
57
63
|
const [value, setOptimisticValue] = useOptimistic(baseValue);
|
|
58
64
|
|
|
@@ -82,11 +88,25 @@ export function useNavigation<T>(
|
|
|
82
88
|
const hasInflightActions =
|
|
83
89
|
ctx.eventController.getInflightActions().size > 0;
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
const shouldPin = hasInflightActions || publicState.state !== "idle";
|
|
92
|
+
|
|
93
|
+
if (shouldPin) {
|
|
94
|
+
// Pin the optimistic store so the loading value shows immediately
|
|
95
|
+
// even if a parent transition (e.g. <Link> click) defers the
|
|
96
|
+
// urgent setBaseValue commit.
|
|
97
|
+
startTransition(() => {
|
|
98
|
+
setOptimisticValue(nextSelected);
|
|
99
|
+
});
|
|
100
|
+
optimisticPinnedRef.current = true;
|
|
101
|
+
} else if (optimisticPinnedRef.current) {
|
|
102
|
+
// Release a previously-pinned optimistic value. Without this,
|
|
103
|
+
// useOptimistic keeps returning the stale loading value while
|
|
104
|
+
// any parent transition is still pending, even after baseValue
|
|
105
|
+
// flipped to idle.
|
|
87
106
|
startTransition(() => {
|
|
88
107
|
setOptimisticValue(nextSelected);
|
|
89
108
|
});
|
|
109
|
+
optimisticPinnedRef.current = false;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
// Always update base state so UI reflects current state
|
|
@@ -16,11 +16,21 @@ import { shallowEqual } from "./shallow-equal.js";
|
|
|
16
16
|
* const params = useParams();
|
|
17
17
|
* // { productId: "123" }
|
|
18
18
|
*
|
|
19
|
+
* // Annotate the expected shape via a generic
|
|
20
|
+
* const { productId } = useParams<{ productId: string }>();
|
|
21
|
+
*
|
|
19
22
|
* // With selector
|
|
20
23
|
* const productId = useParams(p => p.productId);
|
|
21
24
|
* ```
|
|
22
25
|
*/
|
|
23
|
-
|
|
26
|
+
// `T extends object` (not `Record<string, string | undefined>`) so that
|
|
27
|
+
// interface shapes pass the constraint — interfaces lack an implicit
|
|
28
|
+
// index signature and would otherwise be rejected. The generic is a
|
|
29
|
+
// shape annotation, not a runtime check; the body always returns the
|
|
30
|
+
// underlying params map unchanged.
|
|
31
|
+
export function useParams<
|
|
32
|
+
T extends object = Record<string, string>,
|
|
33
|
+
>(): Readonly<T>;
|
|
24
34
|
export function useParams<T>(
|
|
25
35
|
selector: (params: Record<string, string>) => T,
|
|
26
36
|
): T;
|