@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bd1b239
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 +76 -18
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +853 -435
- package/package.json +17 -16
- 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/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +53 -43
- 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 +55 -0
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/typesafety/SKILL.md +10 -0
- package/src/__internal.ts +1 -1
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +40 -15
- 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 +76 -9
- package/src/browser/react/NavigationProvider.tsx +40 -4
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-navigation.ts +11 -10
- 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 +36 -9
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +36 -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.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 +3 -1
- package/src/index.ts +46 -6
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +25 -1
- package/src/route-definition/dsl-helpers.ts +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 +82 -23
- 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 +124 -189
- 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 +6 -8
- package/src/router/middleware.ts +4 -6
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +110 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-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/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 +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +109 -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 +185 -19
- 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 -33
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-entry.ts +8 -1
- package/src/types/segments.ts +2 -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
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
* real navigation so the server returns a proper diff. The Response is fully
|
|
7
7
|
* buffered and stored in an in-memory cache for instant consumption on
|
|
8
8
|
* subsequent navigation.
|
|
9
|
+
*
|
|
10
|
+
* In-flight promises are tracked in the cache so that navigation can reuse
|
|
11
|
+
* a prefetch that is still downloading instead of starting a duplicate request.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import {
|
|
12
15
|
buildPrefetchKey,
|
|
13
16
|
hasPrefetch,
|
|
14
17
|
markPrefetchInflight,
|
|
18
|
+
setInflightPromise,
|
|
15
19
|
storePrefetch,
|
|
16
20
|
clearPrefetchInflight,
|
|
17
21
|
currentGeneration,
|
|
@@ -19,6 +23,24 @@ import {
|
|
|
19
23
|
import { getRangoState } from "../rango-state.js";
|
|
20
24
|
import { enqueuePrefetch } from "./queue.js";
|
|
21
25
|
import { shouldPrefetch } from "./policy.js";
|
|
26
|
+
import { debugLog } from "../logging.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a URL resolves to the current page (same pathname + search).
|
|
30
|
+
* Used to prevent same-page prefetching with prefetchKey, which would
|
|
31
|
+
* produce a trivial diff that corrupts the wildcard cache.
|
|
32
|
+
*/
|
|
33
|
+
function isSamePage(url: string): boolean {
|
|
34
|
+
try {
|
|
35
|
+
const target = new URL(url, window.location.origin);
|
|
36
|
+
return (
|
|
37
|
+
target.pathname + target.search ===
|
|
38
|
+
window.location.pathname + window.location.search
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
22
44
|
|
|
23
45
|
/**
|
|
24
46
|
* Build an RSC partial URL for prefetching.
|
|
@@ -30,6 +52,7 @@ function buildPrefetchUrl(
|
|
|
30
52
|
url: string,
|
|
31
53
|
segmentIds: string[],
|
|
32
54
|
version?: string,
|
|
55
|
+
routerId?: string,
|
|
33
56
|
): URL | null {
|
|
34
57
|
let targetUrl: URL;
|
|
35
58
|
try {
|
|
@@ -47,23 +70,27 @@ function buildPrefetchUrl(
|
|
|
47
70
|
if (version) {
|
|
48
71
|
targetUrl.searchParams.set("_rsc_v", version);
|
|
49
72
|
}
|
|
73
|
+
if (routerId) {
|
|
74
|
+
targetUrl.searchParams.set("_rsc_rid", routerId);
|
|
75
|
+
}
|
|
50
76
|
return targetUrl;
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
/**
|
|
54
|
-
* Core prefetch fetch logic. Fetches the response,
|
|
55
|
-
*
|
|
56
|
-
*
|
|
80
|
+
* Core prefetch fetch logic. Fetches the response, tees the body, and stores
|
|
81
|
+
* one branch in the in-memory cache. The returned Promise resolves to the
|
|
82
|
+
* sibling navigation branch (or null on failure) so navigation can safely
|
|
83
|
+
* reuse an in-flight prefetch via consumeInflightPrefetch().
|
|
57
84
|
*/
|
|
58
85
|
function executePrefetchFetch(
|
|
59
86
|
key: string,
|
|
60
87
|
fetchUrl: string,
|
|
61
88
|
signal?: AbortSignal,
|
|
62
|
-
): Promise<
|
|
89
|
+
): Promise<Response | null> {
|
|
63
90
|
const gen = currentGeneration();
|
|
64
91
|
markPrefetchInflight(key);
|
|
65
92
|
|
|
66
|
-
|
|
93
|
+
const promise: Promise<Response | null> = fetch(fetchUrl, {
|
|
67
94
|
priority: "low" as RequestPriority,
|
|
68
95
|
signal,
|
|
69
96
|
headers: {
|
|
@@ -72,26 +99,27 @@ function executePrefetchFetch(
|
|
|
72
99
|
"X-Rango-Prefetch": "1",
|
|
73
100
|
},
|
|
74
101
|
})
|
|
75
|
-
.then(
|
|
76
|
-
if (!response.ok) return;
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const cachedResponse = new Response(buffer, {
|
|
102
|
+
.then((response) => {
|
|
103
|
+
if (!response.ok) return null;
|
|
104
|
+
// Don't buffer with arrayBuffer() — that blocks until the entire
|
|
105
|
+
// body downloads, defeating streaming for slow loaders.
|
|
106
|
+
// Tee the body: one branch for navigation, one for cache storage.
|
|
107
|
+
const [navStream, cacheStream] = response.body!.tee();
|
|
108
|
+
const responseInit = {
|
|
83
109
|
headers: response.headers,
|
|
84
110
|
status: response.status,
|
|
85
111
|
statusText: response.statusText,
|
|
86
|
-
}
|
|
87
|
-
storePrefetch(key,
|
|
88
|
-
|
|
89
|
-
.catch(() => {
|
|
90
|
-
// Silently ignore prefetch failures (including abort)
|
|
112
|
+
};
|
|
113
|
+
storePrefetch(key, new Response(cacheStream, responseInit), gen);
|
|
114
|
+
return new Response(navStream, responseInit);
|
|
91
115
|
})
|
|
116
|
+
.catch(() => null)
|
|
92
117
|
.finally(() => {
|
|
93
118
|
clearPrefetchInflight(key);
|
|
94
119
|
});
|
|
120
|
+
|
|
121
|
+
setInflightPromise(key, promise);
|
|
122
|
+
return promise;
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
/**
|
|
@@ -102,13 +130,33 @@ export function prefetchDirect(
|
|
|
102
130
|
url: string,
|
|
103
131
|
segmentIds: string[],
|
|
104
132
|
version?: string,
|
|
133
|
+
routerId?: string,
|
|
134
|
+
prefetchKey?: string | ((from: string) => string),
|
|
105
135
|
): void {
|
|
106
136
|
if (!shouldPrefetch()) return;
|
|
107
137
|
|
|
108
|
-
const targetUrl = buildPrefetchUrl(url, segmentIds, version);
|
|
138
|
+
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
109
139
|
if (!targetUrl) return;
|
|
110
|
-
|
|
111
|
-
|
|
140
|
+
// Skip same-page prefetch with prefetchKey — a same-page diff is trivial
|
|
141
|
+
// and would corrupt the wildcard cache entry for cross-page navigation.
|
|
142
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
|
|
146
|
+
if (hasPrefetch(key)) {
|
|
147
|
+
debugLog("[prefetch] direct dedup (key already exists)", {
|
|
148
|
+
url,
|
|
149
|
+
key,
|
|
150
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
debugLog("[prefetch] direct fetch", {
|
|
155
|
+
url,
|
|
156
|
+
key,
|
|
157
|
+
source: window.location.href,
|
|
158
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
159
|
+
});
|
|
112
160
|
executePrefetchFetch(key, targetUrl.toString());
|
|
113
161
|
}
|
|
114
162
|
|
|
@@ -121,15 +169,38 @@ export function prefetchQueued(
|
|
|
121
169
|
url: string,
|
|
122
170
|
segmentIds: string[],
|
|
123
171
|
version?: string,
|
|
172
|
+
routerId?: string,
|
|
173
|
+
prefetchKey?: string | ((from: string) => string),
|
|
124
174
|
): string {
|
|
125
175
|
if (!shouldPrefetch()) return "";
|
|
126
|
-
const targetUrl = buildPrefetchUrl(url, segmentIds, version);
|
|
176
|
+
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
127
177
|
if (!targetUrl) return "";
|
|
128
|
-
|
|
129
|
-
|
|
178
|
+
// Skip same-page prefetch with prefetchKey — a same-page diff is trivial
|
|
179
|
+
// and would corrupt the wildcard cache entry for cross-page navigation.
|
|
180
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
|
|
184
|
+
if (hasPrefetch(key)) {
|
|
185
|
+
debugLog("[prefetch] queued dedup (key already exists)", {
|
|
186
|
+
url,
|
|
187
|
+
key,
|
|
188
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
189
|
+
});
|
|
190
|
+
return key;
|
|
191
|
+
}
|
|
130
192
|
const fetchUrlStr = targetUrl.toString();
|
|
131
|
-
enqueuePrefetch(key, (signal) =>
|
|
132
|
-
|
|
133
|
-
|
|
193
|
+
enqueuePrefetch(key, (signal) => {
|
|
194
|
+
// Re-check at execution time: a hover-triggered prefetchDirect may
|
|
195
|
+
// have started or completed this key while the item sat in the queue.
|
|
196
|
+
if (hasPrefetch(key)) return Promise.resolve();
|
|
197
|
+
// By execution time, the user may have navigated to the target page.
|
|
198
|
+
// A same-page prefetch produces a trivial diff that would overwrite
|
|
199
|
+
// the useful cross-page entry in the wildcard cache.
|
|
200
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
201
|
+
return Promise.resolve();
|
|
202
|
+
}
|
|
203
|
+
return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
|
|
204
|
+
});
|
|
134
205
|
return key;
|
|
135
206
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Honors browser reduced-data preferences when available.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isPrefetchCacheDisabled } from "./cache.js";
|
|
9
|
+
|
|
8
10
|
type NavigatorWithConnection = Navigator & {
|
|
9
11
|
connection?: {
|
|
10
12
|
saveData?: boolean;
|
|
@@ -18,6 +20,10 @@ type NavigatorWithConnection = Navigator & {
|
|
|
18
20
|
export function shouldPrefetch(): boolean {
|
|
19
21
|
if (typeof window === "undefined") return false;
|
|
20
22
|
|
|
23
|
+
// When prefetchCacheTTL is false/0, prefetching is fully disabled —
|
|
24
|
+
// no point issuing requests whose responses will be discarded.
|
|
25
|
+
if (isPrefetchCacheDisabled()) return false;
|
|
26
|
+
|
|
21
27
|
const nav =
|
|
22
28
|
typeof navigator !== "undefined"
|
|
23
29
|
? (navigator as NavigatorWithConnection)
|
|
@@ -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,
|
|
@@ -95,6 +97,26 @@ export interface LinkProps extends Omit<
|
|
|
95
97
|
* @default "none"
|
|
96
98
|
*/
|
|
97
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);
|
|
98
120
|
/**
|
|
99
121
|
* State to pass to history.pushState/replaceState.
|
|
100
122
|
* Accessible via useLocationState() hook.
|
|
@@ -182,6 +204,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
182
204
|
reloadDocument = false,
|
|
183
205
|
revalidate,
|
|
184
206
|
prefetch = "none",
|
|
207
|
+
prefetchKey,
|
|
185
208
|
state,
|
|
186
209
|
children,
|
|
187
210
|
onClick,
|
|
@@ -192,6 +215,16 @@ export const Link: ForwardRefExoticComponent<
|
|
|
192
215
|
const ctx = useContext(NavigationStoreContext);
|
|
193
216
|
const isExternal = isExternalUrl(to);
|
|
194
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
|
+
|
|
195
228
|
// Resolve adaptive: viewport on touch devices, hover on pointer devices
|
|
196
229
|
const resolvedStrategy =
|
|
197
230
|
prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
|
|
@@ -273,17 +306,45 @@ export const Link: ForwardRefExoticComponent<
|
|
|
273
306
|
resolvedState = currentState;
|
|
274
307
|
}
|
|
275
308
|
|
|
276
|
-
ctx.navigate(
|
|
309
|
+
ctx.navigate(resolvedTo, {
|
|
310
|
+
replace,
|
|
311
|
+
scroll,
|
|
312
|
+
state: resolvedState,
|
|
313
|
+
revalidate,
|
|
314
|
+
});
|
|
277
315
|
},
|
|
278
|
-
[
|
|
316
|
+
[
|
|
317
|
+
resolvedTo,
|
|
318
|
+
isExternal,
|
|
319
|
+
reloadDocument,
|
|
320
|
+
replace,
|
|
321
|
+
scroll,
|
|
322
|
+
revalidate,
|
|
323
|
+
ctx,
|
|
324
|
+
onClick,
|
|
325
|
+
],
|
|
279
326
|
);
|
|
280
327
|
|
|
281
328
|
const handleMouseEnter = useCallback(() => {
|
|
282
|
-
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.
|
|
283
338
|
const segmentState = ctx.store.getSegmentState();
|
|
284
|
-
prefetchDirect(
|
|
339
|
+
prefetchDirect(
|
|
340
|
+
resolvedTo,
|
|
341
|
+
segmentState.currentSegmentIds,
|
|
342
|
+
getAppVersion(),
|
|
343
|
+
ctx.store.getRouterId?.(),
|
|
344
|
+
prefetchKey,
|
|
345
|
+
);
|
|
285
346
|
}
|
|
286
|
-
}, [resolvedStrategy,
|
|
347
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
287
348
|
|
|
288
349
|
// Viewport/render prefetch: waits for idle before starting,
|
|
289
350
|
// uses concurrency-limited queue to avoid flooding.
|
|
@@ -300,7 +361,13 @@ export const Link: ForwardRefExoticComponent<
|
|
|
300
361
|
const triggerPrefetch = () => {
|
|
301
362
|
if (cancelled) return;
|
|
302
363
|
const segmentState = ctx.store.getSegmentState();
|
|
303
|
-
prefetchQueued(
|
|
364
|
+
prefetchQueued(
|
|
365
|
+
resolvedTo,
|
|
366
|
+
segmentState.currentSegmentIds,
|
|
367
|
+
getAppVersion(),
|
|
368
|
+
ctx.store.getRouterId?.(),
|
|
369
|
+
prefetchKey,
|
|
370
|
+
);
|
|
304
371
|
};
|
|
305
372
|
|
|
306
373
|
// Schedule prefetch only when the app is idle (no navigation/streaming).
|
|
@@ -339,12 +406,12 @@ export const Link: ForwardRefExoticComponent<
|
|
|
339
406
|
unobserveForPrefetch(observedElement);
|
|
340
407
|
}
|
|
341
408
|
};
|
|
342
|
-
}, [resolvedStrategy,
|
|
409
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
343
410
|
|
|
344
411
|
return (
|
|
345
412
|
<a
|
|
346
413
|
ref={setRef}
|
|
347
|
-
href={
|
|
414
|
+
href={resolvedTo}
|
|
348
415
|
onClick={handleClick}
|
|
349
416
|
onMouseEnter={handleMouseEnter}
|
|
350
417
|
data-link-component
|
|
@@ -354,7 +421,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
354
421
|
data-revalidate={revalidate === false ? "false" : undefined}
|
|
355
422
|
{...props}
|
|
356
423
|
>
|
|
357
|
-
<LinkContext.Provider value={
|
|
424
|
+
<LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
|
|
358
425
|
</a>
|
|
359
426
|
);
|
|
360
427
|
});
|