@rangojs/router 0.0.0-experimental.29 → 0.0.0-experimental.2a0dea97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +78 -19
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +853 -435
- package/package.json +17 -16
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +45 -4
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +22 -4
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +71 -21
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +185 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +56 -2
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/typesafety/SKILL.md +33 -21
- package/src/__internal.ts +92 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +125 -16
- package/src/browser/navigation-client.ts +142 -57
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +94 -17
- package/src/browser/prefetch/cache.ts +82 -12
- package/src/browser/prefetch/fetch.ts +98 -27
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +88 -9
- package/src/browser/react/NavigationProvider.tsx +40 -4
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +134 -59
- package/src/browser/scroll-restoration.ts +41 -42
- package/src/browser/segment-reconciler.ts +72 -10
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +55 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- package/src/build/route-trie.ts +50 -24
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +223 -74
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +48 -7
- package/src/cache/cf/cf-cache-store.ts +453 -11
- package/src/cache/cf/index.ts +5 -1
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/taint.ts +55 -0
- package/src/client.rsc.tsx +2 -0
- package/src/client.tsx +6 -66
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/index.rsc.ts +6 -36
- package/src/index.ts +50 -43
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +25 -1
- package/src/route-definition/dsl-helpers.ts +224 -37
- package/src/route-definition/helpers-types.ts +67 -19
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +11 -3
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-map-builder.ts +7 -1
- package/src/route-types.ts +11 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +111 -25
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +4 -1
- package/src/router/loader-resolution.ts +156 -21
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +9 -3
- package/src/router/match-api.ts +125 -190
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +94 -17
- package/src/router/match-middleware/cache-store.ts +53 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +61 -5
- package/src/router/match-result.ts +104 -10
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +16 -22
- package/src/router/middleware.ts +24 -30
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +114 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +6 -1
- package/src/router/router-interfaces.ts +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 +30 -25
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +438 -300
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/types.ts +1 -0
- package/src/router.ts +59 -6
- package/src/rsc/handler.ts +472 -372
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +14 -2
- package/src/rsc/rsc-rendering.ts +12 -1
- package/src/rsc/server-action.ts +8 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +9 -1
- package/src/segment-content-promise.ts +33 -0
- package/src/segment-system.tsx +164 -23
- package/src/server/context.ts +140 -14
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +204 -28
- package/src/ssr/index.tsx +4 -0
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +149 -49
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-entry.ts +8 -1
- package/src/types/segments.ts +6 -0
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +48 -13
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/discover-routers.ts +5 -1
- package/src/vite/discovery/prerender-collection.ts +128 -74
- package/src/vite/discovery/state.ts +13 -6
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +51 -79
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +163 -211
- package/src/vite/router-discovery.ts +178 -45
- package/src/vite/utils/banner.ts +3 -3
- package/src/vite/utils/prerender-utils.ts +37 -5
- package/src/vite/utils/shared-utils.ts +3 -2
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
* Hover prefetches bypass this queue — they fire directly for immediate response
|
|
6
6
|
* to user intent.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Draining waits for an idle main-thread moment and for viewport images to
|
|
9
|
+
* finish loading, so prefetch fetch() calls never compete with critical
|
|
10
|
+
* resources for the browser's connection pool.
|
|
11
|
+
*
|
|
12
|
+
* When a navigation starts, queued prefetches are cancelled but executing ones
|
|
13
|
+
* are left running. Navigation can reuse their in-flight responses via the
|
|
14
|
+
* prefetch cache's inflight promise map, avoiding duplicate requests.
|
|
10
15
|
*/
|
|
11
16
|
|
|
17
|
+
import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
|
|
18
|
+
|
|
12
19
|
const MAX_CONCURRENT = 2;
|
|
20
|
+
const IMAGE_WAIT_TIMEOUT = 2000;
|
|
13
21
|
|
|
14
22
|
let active = 0;
|
|
15
23
|
const queue: Array<{
|
|
@@ -18,7 +26,9 @@ const queue: Array<{
|
|
|
18
26
|
}> = [];
|
|
19
27
|
const queued = new Set<string>();
|
|
20
28
|
const executing = new Set<string>();
|
|
21
|
-
|
|
29
|
+
const abortControllers = new Map<string, AbortController>();
|
|
30
|
+
let drainScheduled = false;
|
|
31
|
+
let drainGeneration = 0;
|
|
22
32
|
|
|
23
33
|
function startExecution(
|
|
24
34
|
key: string,
|
|
@@ -26,18 +36,49 @@ function startExecution(
|
|
|
26
36
|
): void {
|
|
27
37
|
active++;
|
|
28
38
|
executing.add(key);
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
const ac = new AbortController();
|
|
40
|
+
abortControllers.set(key, ac);
|
|
41
|
+
execute(ac.signal).finally(() => {
|
|
42
|
+
abortControllers.delete(key);
|
|
31
43
|
// Only decrement if this key wasn't already cleared by cancelAllPrefetches.
|
|
32
44
|
// Without this guard, cancelled tasks' .finally() would underflow active
|
|
33
45
|
// below zero, breaking the MAX_CONCURRENT guarantee.
|
|
34
46
|
if (executing.delete(key)) {
|
|
35
47
|
active--;
|
|
36
48
|
}
|
|
37
|
-
|
|
49
|
+
scheduleDrain();
|
|
38
50
|
});
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Schedule a drain after the browser is idle and viewport images are loaded.
|
|
55
|
+
* Coalesces multiple drain requests into a single deferred callback so
|
|
56
|
+
* batch completion doesn't schedule redundant waits.
|
|
57
|
+
*
|
|
58
|
+
* The two-step wait ensures prefetch fetch() calls don't compete with
|
|
59
|
+
* images for the browser's connection pool:
|
|
60
|
+
* 1. waitForIdle — yield until the main thread has a quiet moment
|
|
61
|
+
* 2. waitForViewportImages OR 2s timeout — yield until visible images
|
|
62
|
+
* finish loading, but don't let slow/broken images block indefinitely
|
|
63
|
+
*/
|
|
64
|
+
function scheduleDrain(): void {
|
|
65
|
+
if (drainScheduled) return;
|
|
66
|
+
if (active >= MAX_CONCURRENT || queue.length === 0) return;
|
|
67
|
+
drainScheduled = true;
|
|
68
|
+
const gen = drainGeneration;
|
|
69
|
+
waitForIdle()
|
|
70
|
+
.then(() =>
|
|
71
|
+
Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
|
|
72
|
+
)
|
|
73
|
+
.then(() => {
|
|
74
|
+
drainScheduled = false;
|
|
75
|
+
// Stale drain: a cancel/abort happened while we were waiting.
|
|
76
|
+
// A fresh scheduleDrain will be called by whatever enqueues next.
|
|
77
|
+
if (gen !== drainGeneration) return;
|
|
78
|
+
if (queue.length > 0) drain();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
41
82
|
function drain(): void {
|
|
42
83
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
|
43
84
|
const item = queue.shift()!;
|
|
@@ -48,9 +89,10 @@ function drain(): void {
|
|
|
48
89
|
|
|
49
90
|
/**
|
|
50
91
|
* Enqueue a prefetch for concurrency-limited execution.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* Deduplicates by key — items already queued or executing
|
|
92
|
+
* Execution is deferred until the browser is idle and viewport images
|
|
93
|
+
* have finished loading, so prefetches never compete with critical
|
|
94
|
+
* resources. Deduplicates by key — items already queued or executing
|
|
95
|
+
* are skipped.
|
|
54
96
|
*
|
|
55
97
|
* The executor receives an AbortSignal that is aborted when
|
|
56
98
|
* cancelAllPrefetches() is called (e.g. on navigation start).
|
|
@@ -61,22 +103,50 @@ export function enqueuePrefetch(
|
|
|
61
103
|
): void {
|
|
62
104
|
if (queued.has(key) || executing.has(key)) return;
|
|
63
105
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
106
|
+
queued.add(key);
|
|
107
|
+
queue.push({ key, execute });
|
|
108
|
+
scheduleDrain();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Cancel queued prefetches and abort in-flight ones that don't match
|
|
113
|
+
* the current navigation target. If `keepUrl` is provided, the
|
|
114
|
+
* executing prefetch whose key contains that URL is kept alive so
|
|
115
|
+
* navigation can reuse its response via consumeInflightPrefetch.
|
|
116
|
+
*
|
|
117
|
+
* Called when a navigation starts via the NavigationProvider's
|
|
118
|
+
* event controller subscription.
|
|
119
|
+
*/
|
|
120
|
+
export function cancelAllPrefetches(keepUrl?: string | null): void {
|
|
121
|
+
queue.length = 0;
|
|
122
|
+
queued.clear();
|
|
123
|
+
drainScheduled = false;
|
|
124
|
+
drainGeneration++;
|
|
125
|
+
|
|
126
|
+
// Abort in-flight prefetches that aren't for the navigation target.
|
|
127
|
+
// Keys use format "sourceHref\0targetPathname+search" — match the
|
|
128
|
+
// target portion (after \0) against keepUrl.
|
|
129
|
+
for (const [key, ac] of abortControllers) {
|
|
130
|
+
const target = key.split("\0")[1];
|
|
131
|
+
if (keepUrl && target && keepUrl.startsWith(target)) continue;
|
|
132
|
+
ac.abort();
|
|
133
|
+
abortControllers.delete(key);
|
|
134
|
+
if (executing.delete(key)) {
|
|
135
|
+
active--;
|
|
136
|
+
}
|
|
69
137
|
}
|
|
70
138
|
}
|
|
71
139
|
|
|
72
140
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
141
|
+
* Hard-cancel everything including in-flight prefetches.
|
|
142
|
+
* Used by clearPrefetchCache (server action invalidation) where
|
|
143
|
+
* in-flight responses would be stale.
|
|
76
144
|
*/
|
|
77
|
-
export function
|
|
78
|
-
|
|
79
|
-
|
|
145
|
+
export function abortAllPrefetches(): void {
|
|
146
|
+
for (const ac of abortControllers.values()) {
|
|
147
|
+
ac.abort();
|
|
148
|
+
}
|
|
149
|
+
abortControllers.clear();
|
|
80
150
|
|
|
81
151
|
queue.length = 0;
|
|
82
152
|
queued.clear();
|
|
@@ -85,4 +155,6 @@ export function cancelAllPrefetches(): void {
|
|
|
85
155
|
// so active settles at 0 without underflow.
|
|
86
156
|
executing.clear();
|
|
87
157
|
active = 0;
|
|
158
|
+
drainScheduled = false;
|
|
159
|
+
drainGeneration++;
|
|
88
160
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource Readiness
|
|
3
|
+
*
|
|
4
|
+
* Utilities to defer speculative prefetches until critical resources
|
|
5
|
+
* (viewport images) have finished loading. Prevents prefetch fetch()
|
|
6
|
+
* calls from competing with images for the browser's connection pool.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve when all in-viewport images have finished loading.
|
|
11
|
+
* Returns immediately if no images are pending.
|
|
12
|
+
*
|
|
13
|
+
* Only checks images that exist at call time — does not observe
|
|
14
|
+
* dynamically added images. For SPA navigations where new images
|
|
15
|
+
* appear after render, call this after the navigation settles.
|
|
16
|
+
*/
|
|
17
|
+
export function waitForViewportImages(): Promise<void> {
|
|
18
|
+
if (typeof document === "undefined") return Promise.resolve();
|
|
19
|
+
|
|
20
|
+
const pending = Array.from(document.querySelectorAll("img")).filter((img) => {
|
|
21
|
+
if (img.complete) return false;
|
|
22
|
+
const rect = img.getBoundingClientRect();
|
|
23
|
+
return (
|
|
24
|
+
rect.bottom > 0 &&
|
|
25
|
+
rect.right > 0 &&
|
|
26
|
+
rect.top < window.innerHeight &&
|
|
27
|
+
rect.left < window.innerWidth
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (pending.length === 0) return Promise.resolve();
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const settled = new Set<HTMLImageElement>();
|
|
35
|
+
|
|
36
|
+
const settle = (img: HTMLImageElement) => {
|
|
37
|
+
if (settled.has(img)) return;
|
|
38
|
+
settled.add(img);
|
|
39
|
+
if (settled.size >= pending.length) resolve();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const img of pending) {
|
|
43
|
+
img.addEventListener("load", () => settle(img), { once: true });
|
|
44
|
+
img.addEventListener("error", () => settle(img), { once: true });
|
|
45
|
+
// Re-check: image may have completed between the initial filter
|
|
46
|
+
// and listener attachment. settle() is idempotent per image, so
|
|
47
|
+
// a queued load event firing afterward is harmless.
|
|
48
|
+
if (img.complete) settle(img);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve after the given number of milliseconds.
|
|
55
|
+
*/
|
|
56
|
+
export function wait(ms: number): Promise<void> {
|
|
57
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve when the browser has an idle main-thread moment.
|
|
62
|
+
* Uses requestIdleCallback where available, falls back to setTimeout.
|
|
63
|
+
*
|
|
64
|
+
* This is a scheduling hint, not an asset-loaded detector — combine
|
|
65
|
+
* with waitForViewportImages() for full resource readiness.
|
|
66
|
+
*/
|
|
67
|
+
export function waitForIdle(timeout = 200): Promise<void> {
|
|
68
|
+
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
window.requestIdleCallback(() => resolve(), { timeout });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
setTimeout(resolve, 0);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -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,
|
|
@@ -80,11 +82,41 @@ export interface LinkProps extends Omit<
|
|
|
80
82
|
* Force full document navigation instead of SPA
|
|
81
83
|
*/
|
|
82
84
|
reloadDocument?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Whether to revalidate server data on navigation.
|
|
87
|
+
* Set to `false` to skip the RSC server fetch and only update the URL.
|
|
88
|
+
*
|
|
89
|
+
* Only takes effect when the pathname stays the same (search param / hash changes).
|
|
90
|
+
* If the pathname changes, this option is ignored and a full navigation occurs.
|
|
91
|
+
*
|
|
92
|
+
* @default true
|
|
93
|
+
*/
|
|
94
|
+
revalidate?: boolean;
|
|
83
95
|
/**
|
|
84
96
|
* Prefetch strategy for the link destination
|
|
85
97
|
* @default "none"
|
|
86
98
|
*/
|
|
87
99
|
prefetch?: PrefetchStrategy;
|
|
100
|
+
/**
|
|
101
|
+
* Custom prefetch cache key for source-agnostic cache reuse.
|
|
102
|
+
* When set, prefetch responses are cached independently of the current
|
|
103
|
+
* page URL, so navigating to the same target from different source pages
|
|
104
|
+
* reuses the cached prefetch.
|
|
105
|
+
*
|
|
106
|
+
* - String: static group name (e.g., `"pages"`)
|
|
107
|
+
* - Function: receives current URL (`window.location.href`), returns a
|
|
108
|
+
* normalized source key
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```tsx
|
|
112
|
+
* // Static group — all "pages" links share one cache entry per target
|
|
113
|
+
* <Link to="/page/3" prefetch="hover" prefetchKey="pages" />
|
|
114
|
+
*
|
|
115
|
+
* // Normalize — strip trailing page number from source URL
|
|
116
|
+
* <Link to="/page/3" prefetch="hover" prefetchKey={(from) => from.replace(/\/\d+$/, '')} />
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
prefetchKey?: string | ((from: string) => string);
|
|
88
120
|
/**
|
|
89
121
|
* State to pass to history.pushState/replaceState.
|
|
90
122
|
* Accessible via useLocationState() hook.
|
|
@@ -170,7 +202,9 @@ export const Link: ForwardRefExoticComponent<
|
|
|
170
202
|
replace = false,
|
|
171
203
|
scroll = true,
|
|
172
204
|
reloadDocument = false,
|
|
205
|
+
revalidate,
|
|
173
206
|
prefetch = "none",
|
|
207
|
+
prefetchKey,
|
|
174
208
|
state,
|
|
175
209
|
children,
|
|
176
210
|
onClick,
|
|
@@ -181,6 +215,16 @@ export const Link: ForwardRefExoticComponent<
|
|
|
181
215
|
const ctx = useContext(NavigationStoreContext);
|
|
182
216
|
const isExternal = isExternalUrl(to);
|
|
183
217
|
|
|
218
|
+
// Auto-prefix with basename for app-local paths.
|
|
219
|
+
// Skip if external, already prefixed, or not a root-relative path.
|
|
220
|
+
const resolvedTo = useMemo(() => {
|
|
221
|
+
if (isExternal) return to;
|
|
222
|
+
const bn = ctx?.basename;
|
|
223
|
+
if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
|
|
224
|
+
return to;
|
|
225
|
+
return to === "/" ? bn : bn + to;
|
|
226
|
+
}, [to, isExternal, ctx?.basename]);
|
|
227
|
+
|
|
184
228
|
// Resolve adaptive: viewport on touch devices, hover on pointer devices
|
|
185
229
|
const resolvedStrategy =
|
|
186
230
|
prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
|
|
@@ -262,17 +306,45 @@ export const Link: ForwardRefExoticComponent<
|
|
|
262
306
|
resolvedState = currentState;
|
|
263
307
|
}
|
|
264
308
|
|
|
265
|
-
ctx.navigate(
|
|
309
|
+
ctx.navigate(resolvedTo, {
|
|
310
|
+
replace,
|
|
311
|
+
scroll,
|
|
312
|
+
state: resolvedState,
|
|
313
|
+
revalidate,
|
|
314
|
+
});
|
|
266
315
|
},
|
|
267
|
-
[
|
|
316
|
+
[
|
|
317
|
+
resolvedTo,
|
|
318
|
+
isExternal,
|
|
319
|
+
reloadDocument,
|
|
320
|
+
replace,
|
|
321
|
+
scroll,
|
|
322
|
+
revalidate,
|
|
323
|
+
ctx,
|
|
324
|
+
onClick,
|
|
325
|
+
],
|
|
268
326
|
);
|
|
269
327
|
|
|
270
328
|
const handleMouseEnter = useCallback(() => {
|
|
271
|
-
if (
|
|
329
|
+
if (
|
|
330
|
+
(resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
|
|
331
|
+
!isExternal &&
|
|
332
|
+
ctx?.store
|
|
333
|
+
) {
|
|
334
|
+
// For "hover", this is the primary prefetch trigger.
|
|
335
|
+
// For "viewport", this upgrades/prioritizes a potentially queued
|
|
336
|
+
// prefetch — prefetchDirect bypasses the queue, and hasPrefetch
|
|
337
|
+
// deduplicates if the viewport prefetch already completed.
|
|
272
338
|
const segmentState = ctx.store.getSegmentState();
|
|
273
|
-
prefetchDirect(
|
|
339
|
+
prefetchDirect(
|
|
340
|
+
resolvedTo,
|
|
341
|
+
segmentState.currentSegmentIds,
|
|
342
|
+
getAppVersion(),
|
|
343
|
+
ctx.store.getRouterId?.(),
|
|
344
|
+
prefetchKey,
|
|
345
|
+
);
|
|
274
346
|
}
|
|
275
|
-
}, [resolvedStrategy,
|
|
347
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
276
348
|
|
|
277
349
|
// Viewport/render prefetch: waits for idle before starting,
|
|
278
350
|
// uses concurrency-limited queue to avoid flooding.
|
|
@@ -289,7 +361,13 @@ export const Link: ForwardRefExoticComponent<
|
|
|
289
361
|
const triggerPrefetch = () => {
|
|
290
362
|
if (cancelled) return;
|
|
291
363
|
const segmentState = ctx.store.getSegmentState();
|
|
292
|
-
prefetchQueued(
|
|
364
|
+
prefetchQueued(
|
|
365
|
+
resolvedTo,
|
|
366
|
+
segmentState.currentSegmentIds,
|
|
367
|
+
getAppVersion(),
|
|
368
|
+
ctx.store.getRouterId?.(),
|
|
369
|
+
prefetchKey,
|
|
370
|
+
);
|
|
293
371
|
};
|
|
294
372
|
|
|
295
373
|
// Schedule prefetch only when the app is idle (no navigation/streaming).
|
|
@@ -328,21 +406,22 @@ export const Link: ForwardRefExoticComponent<
|
|
|
328
406
|
unobserveForPrefetch(observedElement);
|
|
329
407
|
}
|
|
330
408
|
};
|
|
331
|
-
}, [resolvedStrategy,
|
|
409
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
332
410
|
|
|
333
411
|
return (
|
|
334
412
|
<a
|
|
335
413
|
ref={setRef}
|
|
336
|
-
href={
|
|
414
|
+
href={resolvedTo}
|
|
337
415
|
onClick={handleClick}
|
|
338
416
|
onMouseEnter={handleMouseEnter}
|
|
339
417
|
data-link-component
|
|
340
418
|
data-external={isExternal ? "" : undefined}
|
|
341
419
|
data-scroll={scroll === false ? "false" : undefined}
|
|
342
420
|
data-replace={replace ? "true" : undefined}
|
|
421
|
+
data-revalidate={revalidate === false ? "false" : undefined}
|
|
343
422
|
{...props}
|
|
344
423
|
>
|
|
345
|
-
<LinkContext.Provider value={
|
|
424
|
+
<LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
|
|
346
425
|
</a>
|
|
347
426
|
);
|
|
348
427
|
});
|
|
@@ -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,7 @@ 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";
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Process handles from an async generator, updating the event controller
|
|
@@ -131,9 +134,14 @@ export interface NavigationProviderProps {
|
|
|
131
134
|
|
|
132
135
|
/**
|
|
133
136
|
* App version from server payload (stable, immutable).
|
|
134
|
-
* Forwarded to
|
|
137
|
+
* Forwarded to context for cache key building.
|
|
135
138
|
*/
|
|
136
139
|
version?: string;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* URL prefix for all routes (from createRouter({ basename })).
|
|
143
|
+
*/
|
|
144
|
+
basename?: string;
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
/**
|
|
@@ -166,6 +174,7 @@ export function NavigationProvider({
|
|
|
166
174
|
initialTheme,
|
|
167
175
|
warmupEnabled,
|
|
168
176
|
version,
|
|
177
|
+
basename,
|
|
169
178
|
}: NavigationProviderProps): ReactNode {
|
|
170
179
|
// Track current payload for rendering (this triggers re-renders)
|
|
171
180
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -195,6 +204,7 @@ export function NavigationProvider({
|
|
|
195
204
|
navigate,
|
|
196
205
|
refresh,
|
|
197
206
|
version,
|
|
207
|
+
basename,
|
|
198
208
|
}),
|
|
199
209
|
[],
|
|
200
210
|
);
|
|
@@ -286,24 +296,50 @@ export function NavigationProvider({
|
|
|
286
296
|
};
|
|
287
297
|
}, [warmupEnabled]);
|
|
288
298
|
|
|
289
|
-
// Cancel
|
|
290
|
-
//
|
|
299
|
+
// Cancel non-matching prefetches when navigation starts.
|
|
300
|
+
// Frees connections so the navigation fetch isn't competing with
|
|
301
|
+
// speculative prefetches. The prefetch matching the navigation target
|
|
302
|
+
// is kept alive so it can be reused via consumeInflightPrefetch.
|
|
291
303
|
useEffect(() => {
|
|
292
304
|
let wasIdle = true;
|
|
293
305
|
const unsub = eventController.subscribe(() => {
|
|
294
306
|
const state = eventController.getState();
|
|
295
307
|
const isIdle = state.state === "idle" && !state.isStreaming;
|
|
296
308
|
if (wasIdle && !isIdle) {
|
|
297
|
-
cancelAllPrefetches();
|
|
309
|
+
cancelAllPrefetches(state.pendingUrl);
|
|
298
310
|
}
|
|
299
311
|
wasIdle = isIdle;
|
|
300
312
|
});
|
|
301
313
|
return unsub;
|
|
302
314
|
}, [eventController]);
|
|
303
315
|
|
|
316
|
+
// Pending scroll action to apply after React commits
|
|
317
|
+
const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
|
|
318
|
+
|
|
319
|
+
// Apply scroll after React commits the new content to the DOM
|
|
320
|
+
useLayoutEffect(() => {
|
|
321
|
+
const scrollAction = pendingScrollRef.current;
|
|
322
|
+
if (!scrollAction) return;
|
|
323
|
+
pendingScrollRef.current = undefined;
|
|
324
|
+
|
|
325
|
+
if (scrollAction.enabled === false) return;
|
|
326
|
+
|
|
327
|
+
handleNavigationEnd({
|
|
328
|
+
restore: scrollAction.restore,
|
|
329
|
+
scroll: scrollAction.enabled,
|
|
330
|
+
isStreaming: scrollAction.isStreaming,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
304
334
|
// Subscribe to UI updates (for re-rendering the tree)
|
|
305
335
|
useEffect(() => {
|
|
306
336
|
const unsubscribe = store.onUpdate((update) => {
|
|
337
|
+
// Capture scroll intent — it will be applied in useLayoutEffect
|
|
338
|
+
// after React commits this state update to the DOM.
|
|
339
|
+
// Always assign (even undefined) to clear stale scroll from prior navigations,
|
|
340
|
+
// so server actions or error updates don't accidentally replay old scroll.
|
|
341
|
+
pendingScrollRef.current = update.scroll;
|
|
342
|
+
|
|
307
343
|
setPayload({
|
|
308
344
|
root: update.root,
|
|
309
345
|
metadata: update.metadata,
|
|
@@ -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;
|