@rangojs/router 0.2.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/dist/types/browser/event-controller.d.ts +4 -0
- package/dist/types/browser/link-interceptor.d.ts +17 -7
- package/dist/types/browser/navigation-bridge.d.ts +13 -1
- package/dist/types/browser/notify-listeners.d.ts +2 -0
- package/dist/types/browser/prefetch/cache.d.ts +1 -1
- package/dist/types/browser/prefetch/default-strategy.d.ts +31 -0
- package/dist/types/browser/prefetch/invalidation.d.ts +6 -0
- package/dist/types/browser/prefetch/loader.d.ts +3 -1
- package/dist/types/browser/prefetch/observer.d.ts +3 -6
- package/dist/types/browser/prefetch/runtime.d.ts +0 -1
- package/dist/types/browser/rango-state.d.ts +4 -0
- package/dist/types/browser/react/Link.d.ts +11 -19
- package/dist/types/browser/react/context.d.ts +3 -0
- package/dist/types/browser/rsc-router.d.ts +7 -2
- package/dist/types/browser/types.d.ts +6 -0
- package/dist/types/cache/cache-key-utils.d.ts +11 -3
- package/dist/types/cache/cache-scope.d.ts +82 -3
- package/dist/types/cache/cf/cf-cache-store.d.ts +2 -2
- package/dist/types/cache/index.d.ts +3 -1
- package/dist/types/cache/search-params-filter.d.ts +64 -0
- package/dist/types/cache/shell-snapshot.d.ts +4 -4
- package/dist/types/cache/types.d.ts +36 -2
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.rsc.d.ts +1 -0
- package/dist/types/router/match-middleware/cache-lookup.d.ts +13 -0
- package/dist/types/router/navigation-snapshot.d.ts +29 -0
- package/dist/types/router/prefetch-default.d.ts +28 -0
- package/dist/types/router/router-interfaces.d.ts +7 -0
- package/dist/types/router/router-options.d.ts +54 -0
- package/dist/types/rsc/capture-queue.d.ts +6 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +7 -1
- package/dist/types/rsc/shell-capture.d.ts +15 -7
- package/dist/types/rsc/shell-serve.d.ts +11 -4
- package/dist/types/rsc/types.d.ts +19 -0
- package/dist/types/server/request-context.d.ts +57 -4
- package/dist/types/testing/e2e/index.d.ts +2 -2
- package/dist/types/testing/e2e/page-helpers.d.ts +24 -0
- package/dist/types/testing/render-route.d.ts +10 -1
- package/dist/types/testing/shell-status.d.ts +6 -3
- package/dist/types/vite/plugin-types.d.ts +1 -1
- package/dist/vite/index.js +9 -6
- package/package.json +23 -22
- package/skills/caching/SKILL.md +39 -0
- package/skills/comparison/references/framework-comparison.md +9 -2
- package/skills/links/SKILL.md +24 -0
- package/skills/ppr/SKILL.md +80 -16
- package/skills/router-setup/SKILL.md +9 -0
- package/skills/testing/client-components.md +15 -14
- package/skills/vercel/SKILL.md +1 -1
- package/src/browser/event-controller.ts +110 -11
- package/src/browser/link-interceptor.ts +469 -20
- package/src/browser/navigation-bridge.ts +71 -2
- package/src/browser/navigation-store.ts +24 -1
- package/src/browser/notify-listeners.ts +22 -0
- package/src/browser/prefetch/cache.ts +4 -2
- package/src/browser/prefetch/default-strategy.ts +74 -0
- package/src/browser/prefetch/invalidation.ts +30 -0
- package/src/browser/prefetch/loader.ts +18 -17
- package/src/browser/prefetch/observer.ts +50 -22
- package/src/browser/prefetch/runtime.ts +0 -1
- package/src/browser/rango-state.ts +21 -0
- package/src/browser/react/Link.tsx +111 -101
- package/src/browser/react/NavigationProvider.tsx +1 -0
- package/src/browser/react/context.ts +4 -0
- package/src/browser/rsc-router.tsx +30 -9
- package/src/browser/types.ts +6 -0
- package/src/cache/cache-key-utils.ts +18 -4
- package/src/cache/cache-runtime.ts +7 -2
- package/src/cache/cache-scope.ts +152 -23
- package/src/cache/cf/cf-cache-store.ts +55 -16
- package/src/cache/document-cache.ts +7 -1
- package/src/cache/index.ts +7 -0
- package/src/cache/search-params-filter.ts +118 -0
- package/src/cache/shell-snapshot.ts +8 -4
- package/src/cache/types.ts +39 -2
- package/src/cache/vercel/vercel-cache-store.ts +22 -3
- package/src/index.rsc.ts +4 -0
- package/src/index.ts +6 -0
- package/src/router/match-middleware/cache-lookup.ts +146 -33
- package/src/router/match-middleware/cache-store.ts +70 -0
- package/src/router/navigation-snapshot.ts +46 -6
- package/src/router/prefetch-default.ts +59 -0
- package/src/router/router-interfaces.ts +8 -0
- package/src/router/router-options.ts +59 -1
- package/src/router.ts +7 -0
- package/src/rsc/capture-queue.ts +24 -1
- package/src/rsc/full-payload.ts +1 -0
- package/src/rsc/handler.ts +10 -0
- package/src/rsc/response-cache-serve.ts +1 -1
- package/src/rsc/rsc-rendering.ts +367 -40
- package/src/rsc/shell-build-manifest.ts +8 -1
- package/src/rsc/shell-capture.ts +99 -50
- package/src/rsc/shell-serve.ts +14 -6
- package/src/rsc/types.ts +19 -0
- package/src/server/request-context.ts +62 -3
- package/src/testing/dispatch.ts +21 -3
- package/src/testing/e2e/index.ts +6 -0
- package/src/testing/e2e/page-helpers.ts +47 -0
- package/src/testing/e2e/parity.ts +13 -0
- package/src/testing/render-route.tsx +86 -17
- package/src/testing/shell-status.ts +20 -3
- package/src/vite/plugin-types.ts +1 -1
- package/src/vite/plugins/vercel-output.ts +2 -2
|
@@ -1,18 +1,164 @@
|
|
|
1
1
|
import type { LinkInterceptorOptions, NavigateOptions } from "./types.js";
|
|
2
|
+
import {
|
|
3
|
+
subscribeToLocationChange,
|
|
4
|
+
type EventController,
|
|
5
|
+
} from "./event-controller.js";
|
|
6
|
+
import {
|
|
7
|
+
getDefaultPrefetchStrategy,
|
|
8
|
+
resolveAdaptiveStrategy,
|
|
9
|
+
subscribeToAdaptiveStrategyChange,
|
|
10
|
+
} from "./prefetch/default-strategy.js";
|
|
11
|
+
import { observeForPrefetch } from "./prefetch/observer.js";
|
|
12
|
+
import { subscribeToPrefetchCacheInvalidation } from "./prefetch/invalidation.js";
|
|
13
|
+
import { notifyListeners } from "./notify-listeners.js";
|
|
14
|
+
import type { PrefetchStrategy } from "../router/prefetch-default.js";
|
|
15
|
+
|
|
16
|
+
const ELIGIBILITY_ATTRIBUTES = {
|
|
17
|
+
href: "href",
|
|
18
|
+
target: "target",
|
|
19
|
+
download: "download",
|
|
20
|
+
noIntercept: "data-no-intercept",
|
|
21
|
+
linkComponent: "data-link-component",
|
|
22
|
+
external: "data-external",
|
|
23
|
+
prefetch: "data-prefetch",
|
|
24
|
+
prefetchScope: "data-prefetch-scope",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const ELIGIBILITY_ATTRIBUTE_FILTER = Object.values(ELIGIBILITY_ATTRIBUTES);
|
|
28
|
+
const ANCHOR_SELECTOR = "a";
|
|
29
|
+
const ELIGIBLE_ANCHOR_SELECTOR = `a[${ELIGIBILITY_ATTRIBUTES.href}]`;
|
|
30
|
+
const LINK_COMPONENT_SELECTOR = `a[${ELIGIBILITY_ATTRIBUTES.linkComponent}]`;
|
|
31
|
+
const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
32
|
+
const PREFETCH_OPT_OUT_VALUES = new Set(["false", "none"]);
|
|
33
|
+
const PREFETCH_SCOPE_OPT_OUT_SELECTOR =
|
|
34
|
+
`[${ELIGIBILITY_ATTRIBUTES.prefetchScope}="false"],` +
|
|
35
|
+
`[${ELIGIBILITY_ATTRIBUTES.prefetchScope}="none"]`;
|
|
36
|
+
const STATIC_RESOURCE_EXTENSION =
|
|
37
|
+
/\.(?:7z|avif|bmp|bz2|cjs|css|csv|docx?|eot|gif|gz|ico|jpe?g|js|json|m4a|map|mjs|mov|mp3|mp4|ogg|ogv|otf|pdf|png|pptx?|rar|svg|tar|tgz|tiff?|ttf|txt|wasm|wav|webm|webp|woff2?|xlsx?|xml|zip)$/i;
|
|
38
|
+
|
|
39
|
+
function isElementNode(value: unknown): value is Element {
|
|
40
|
+
return (
|
|
41
|
+
typeof value === "object" &&
|
|
42
|
+
value !== null &&
|
|
43
|
+
(value as Node).nodeType === 1 &&
|
|
44
|
+
typeof (value as Element).matches === "function"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isNode(value: unknown): value is Node {
|
|
49
|
+
return (
|
|
50
|
+
typeof value === "object" &&
|
|
51
|
+
value !== null &&
|
|
52
|
+
typeof (value as Node).nodeType === "number"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type PrefetchScopeChangeListener = () => void;
|
|
57
|
+
|
|
58
|
+
interface PrefetchScopeObserverState {
|
|
59
|
+
observer: MutationObserver;
|
|
60
|
+
subscriptions: Map<Element, Set<PrefetchScopeChangeListener>>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const prefetchScopeObservers = new WeakMap<
|
|
64
|
+
Document,
|
|
65
|
+
PrefetchScopeObserverState
|
|
66
|
+
>();
|
|
67
|
+
|
|
68
|
+
function createPrefetchScopeObserverState(
|
|
69
|
+
ownerDocument: Document,
|
|
70
|
+
): PrefetchScopeObserverState | undefined {
|
|
71
|
+
if (
|
|
72
|
+
typeof MutationObserver === "undefined" ||
|
|
73
|
+
!ownerDocument.documentElement
|
|
74
|
+
) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const subscriptions = new Map<Element, Set<PrefetchScopeChangeListener>>();
|
|
79
|
+
const observer = new MutationObserver((mutations) => {
|
|
80
|
+
const affected = new Set<PrefetchScopeChangeListener>();
|
|
81
|
+
const collect = (element: Element) => {
|
|
82
|
+
subscriptions.get(element)?.forEach((listener) => affected.add(listener));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
for (const mutation of mutations) {
|
|
86
|
+
if (!isElementNode(mutation.target)) continue;
|
|
87
|
+
collect(mutation.target);
|
|
88
|
+
mutation.target
|
|
89
|
+
.querySelectorAll(LINK_COMPONENT_SELECTOR)
|
|
90
|
+
.forEach(collect);
|
|
91
|
+
}
|
|
92
|
+
notifyListeners(affected, (listener) => listener());
|
|
93
|
+
});
|
|
94
|
+
observer.observe(ownerDocument.documentElement, {
|
|
95
|
+
subtree: true,
|
|
96
|
+
attributes: true,
|
|
97
|
+
attributeFilter: [ELIGIBILITY_ATTRIBUTES.prefetchScope],
|
|
98
|
+
});
|
|
99
|
+
return { observer, subscriptions };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function subscribeToPrefetchScopeChange(
|
|
103
|
+
element: HTMLAnchorElement,
|
|
104
|
+
listener: PrefetchScopeChangeListener,
|
|
105
|
+
): () => void {
|
|
106
|
+
const ownerDocument = element.ownerDocument;
|
|
107
|
+
let state = prefetchScopeObservers.get(ownerDocument);
|
|
108
|
+
if (!state) {
|
|
109
|
+
state = createPrefetchScopeObserverState(ownerDocument);
|
|
110
|
+
if (!state) return () => {};
|
|
111
|
+
prefetchScopeObservers.set(ownerDocument, state);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let listeners = state.subscriptions.get(element);
|
|
115
|
+
if (!listeners) {
|
|
116
|
+
listeners = new Set();
|
|
117
|
+
state.subscriptions.set(element, listeners);
|
|
118
|
+
}
|
|
119
|
+
listeners.add(listener);
|
|
120
|
+
|
|
121
|
+
let active = true;
|
|
122
|
+
return () => {
|
|
123
|
+
if (!active) return;
|
|
124
|
+
active = false;
|
|
125
|
+
listeners.delete(listener);
|
|
126
|
+
if (
|
|
127
|
+
listeners.size === 0 &&
|
|
128
|
+
state.subscriptions.get(element) === listeners
|
|
129
|
+
) {
|
|
130
|
+
state.subscriptions.delete(element);
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
state.subscriptions.size === 0 &&
|
|
134
|
+
prefetchScopeObservers.get(ownerDocument) === state
|
|
135
|
+
) {
|
|
136
|
+
state.observer.disconnect();
|
|
137
|
+
prefetchScopeObservers.delete(ownerDocument);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
2
141
|
|
|
3
142
|
/**
|
|
4
143
|
* Check if an anchor points to the same page with only a hash change.
|
|
5
144
|
* Used by both Link component and link-interceptor to let the browser
|
|
6
145
|
* handle anchor scrolling natively.
|
|
7
146
|
*/
|
|
8
|
-
|
|
147
|
+
function isHashOnlyNavigationToPath(
|
|
148
|
+
anchor: HTMLAnchorElement,
|
|
149
|
+
pathname: string,
|
|
150
|
+
): boolean {
|
|
9
151
|
return (
|
|
10
|
-
|
|
152
|
+
pathname === window.location.pathname &&
|
|
11
153
|
anchor.search === window.location.search &&
|
|
12
154
|
!!anchor.hash
|
|
13
155
|
);
|
|
14
156
|
}
|
|
15
157
|
|
|
158
|
+
export function isHashOnlyNavigation(anchor: HTMLAnchorElement): boolean {
|
|
159
|
+
return isHashOnlyNavigationToPath(anchor, anchor.pathname);
|
|
160
|
+
}
|
|
161
|
+
|
|
16
162
|
/**
|
|
17
163
|
* Default link interception predicate
|
|
18
164
|
*
|
|
@@ -27,50 +173,119 @@ export function isHashOnlyNavigation(anchor: HTMLAnchorElement): boolean {
|
|
|
27
173
|
* @returns true if the link should be intercepted
|
|
28
174
|
*/
|
|
29
175
|
export function defaultShouldIntercept(link: HTMLAnchorElement): boolean {
|
|
30
|
-
|
|
31
|
-
|
|
176
|
+
if (!isEligiblePlainAnchor(link)) return false;
|
|
177
|
+
|
|
178
|
+
// Don't intercept hash-only navigation (same path, only fragment changes).
|
|
179
|
+
// Let the browser handle anchor scrolling natively.
|
|
180
|
+
return !isHashOnlyNavigation(link);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isEligiblePlainAnchor(link: HTMLAnchorElement): boolean {
|
|
184
|
+
// Namespace identity survives adoptNode/importNode across realms; instanceof
|
|
185
|
+
// does not. Structural test doubles omit namespaceURI and retain the legacy
|
|
186
|
+
// property-based behavior.
|
|
187
|
+
if (link.namespaceURI && link.namespaceURI !== HTML_NAMESPACE) {
|
|
32
188
|
return false;
|
|
33
189
|
}
|
|
34
190
|
|
|
35
|
-
//
|
|
36
|
-
if (link.
|
|
191
|
+
// Only handle same-origin links
|
|
192
|
+
if (link.origin !== window.location.origin) {
|
|
37
193
|
return false;
|
|
38
194
|
}
|
|
39
195
|
|
|
40
|
-
//
|
|
41
|
-
if (link.
|
|
196
|
+
// Skip links with a download attribute
|
|
197
|
+
if (link.hasAttribute(ELIGIBILITY_ATTRIBUTES.download)) {
|
|
42
198
|
return false;
|
|
43
199
|
}
|
|
44
200
|
|
|
45
|
-
//
|
|
46
|
-
|
|
201
|
+
// Skip links targeting another browsing context
|
|
202
|
+
const target = link.getAttribute(ELIGIBILITY_ATTRIBUTES.target);
|
|
203
|
+
if (target && target !== "_self") {
|
|
47
204
|
return false;
|
|
48
205
|
}
|
|
49
206
|
|
|
50
|
-
//
|
|
51
|
-
if (link.
|
|
207
|
+
// Skip links explicitly excluded from delegated handling
|
|
208
|
+
if (link.getAttribute(ELIGIBILITY_ATTRIBUTES.noIntercept) === "true") {
|
|
52
209
|
return false;
|
|
53
210
|
}
|
|
54
211
|
|
|
55
|
-
//
|
|
56
|
-
if (link.hasAttribute(
|
|
212
|
+
// Link components handle their own navigation and prefetch
|
|
213
|
+
if (link.hasAttribute(ELIGIBILITY_ATTRIBUTES.linkComponent)) {
|
|
57
214
|
return false;
|
|
58
215
|
}
|
|
59
216
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
if (isHashOnlyNavigation(link)) {
|
|
217
|
+
// Skip links explicitly marked external
|
|
218
|
+
if (link.hasAttribute(ELIGIBILITY_ATTRIBUTES.external)) {
|
|
63
219
|
return false;
|
|
64
220
|
}
|
|
65
221
|
|
|
66
222
|
return true;
|
|
67
223
|
}
|
|
68
224
|
|
|
225
|
+
function normalizePathname(pathname: string): string {
|
|
226
|
+
return pathname
|
|
227
|
+
.replace(/%[0-9a-f]{2}/gi, (escape) => escape.toUpperCase())
|
|
228
|
+
.replace(/\/+$/, "");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function canonicalizeBasenamePathname(basename?: string): string | undefined {
|
|
232
|
+
if (!basename) return undefined;
|
|
233
|
+
return normalizePathname(new URL(basename, window.location.origin).pathname);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function isPrefetchScopeDisabled(element: Element): boolean {
|
|
237
|
+
return (
|
|
238
|
+
typeof element.closest === "function" &&
|
|
239
|
+
element.closest(PREFETCH_SCOPE_OPT_OUT_SELECTOR) !== null
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
type PrefetchEligibility = "eligible" | "location-dependent" | "ineligible";
|
|
244
|
+
|
|
245
|
+
function defaultPrefetchEligibilityInBasename(
|
|
246
|
+
link: HTMLAnchorElement,
|
|
247
|
+
basename?: string,
|
|
248
|
+
): PrefetchEligibility {
|
|
249
|
+
if (!isEligiblePlainAnchor(link)) return "ineligible";
|
|
250
|
+
if (isPrefetchScopeDisabled(link)) return "ineligible";
|
|
251
|
+
|
|
252
|
+
const prefetch = link.getAttribute(ELIGIBILITY_ATTRIBUTES.prefetch);
|
|
253
|
+
if (prefetch && PREFETCH_OPT_OUT_VALUES.has(prefetch)) return "ineligible";
|
|
254
|
+
|
|
255
|
+
const rawPathname = link.pathname;
|
|
256
|
+
if (isHashOnlyNavigationToPath(link, rawPathname)) {
|
|
257
|
+
return "location-dependent";
|
|
258
|
+
}
|
|
259
|
+
const pathname = normalizePathname(rawPathname);
|
|
260
|
+
if (
|
|
261
|
+
basename !== undefined &&
|
|
262
|
+
pathname !== basename &&
|
|
263
|
+
!pathname.startsWith(`${basename}/`)
|
|
264
|
+
) {
|
|
265
|
+
return "ineligible";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (prefetch === "true") return "eligible";
|
|
269
|
+
|
|
270
|
+
// Scope is decided on canonical encoded path segments. Decode only for
|
|
271
|
+
// resource classification so malformed escapes cannot expand router scope.
|
|
272
|
+
let resourcePathname: string;
|
|
273
|
+
try {
|
|
274
|
+
resourcePathname = decodeURIComponent(pathname);
|
|
275
|
+
} catch {
|
|
276
|
+
return "ineligible";
|
|
277
|
+
}
|
|
278
|
+
if (STATIC_RESOURCE_EXTENSION.test(resourcePathname)) {
|
|
279
|
+
return "ineligible";
|
|
280
|
+
}
|
|
281
|
+
return "eligible";
|
|
282
|
+
}
|
|
283
|
+
|
|
69
284
|
/**
|
|
70
285
|
* Set up link interception for SPA navigation
|
|
71
286
|
*
|
|
72
|
-
* Attaches a global click handler to intercept clicks on anchor elements
|
|
73
|
-
*
|
|
287
|
+
* Attaches a global click handler to intercept clicks on anchor elements and
|
|
288
|
+
* call the onNavigate callback instead of performing a full page load.
|
|
74
289
|
*
|
|
75
290
|
* @param onNavigate - Callback when a link should navigate via SPA
|
|
76
291
|
* @param options - Configuration options
|
|
@@ -99,7 +314,8 @@ export function setupLinkInterception(
|
|
|
99
314
|
return;
|
|
100
315
|
}
|
|
101
316
|
|
|
102
|
-
|
|
317
|
+
if (!isElementNode(event.target)) return;
|
|
318
|
+
const target = event.target;
|
|
103
319
|
const link = target.closest("a");
|
|
104
320
|
|
|
105
321
|
if (!link || !shouldIntercept(link)) {
|
|
@@ -139,3 +355,236 @@ export function setupLinkInterception(
|
|
|
139
355
|
document.removeEventListener("click", handleClick);
|
|
140
356
|
};
|
|
141
357
|
}
|
|
358
|
+
|
|
359
|
+
export type DelegatedPrefetchCallback = (
|
|
360
|
+
url: string,
|
|
361
|
+
priority: "direct" | "queued",
|
|
362
|
+
) => void | (() => void);
|
|
363
|
+
|
|
364
|
+
export interface DelegatedPrefetchOptions {
|
|
365
|
+
eventController: Pick<EventController, "getState" | "subscribe">;
|
|
366
|
+
shouldPrefetch?: (link: HTMLAnchorElement) => boolean;
|
|
367
|
+
defaultPrefetch?: PrefetchStrategy;
|
|
368
|
+
root?: HTMLElement;
|
|
369
|
+
basename?: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
interface DelegatedPrefetchState {
|
|
373
|
+
stopObserving?: () => void;
|
|
374
|
+
cancelPending?: () => void;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function setupDelegatedLinkPrefetch(
|
|
378
|
+
onPrefetch: DelegatedPrefetchCallback,
|
|
379
|
+
options: DelegatedPrefetchOptions,
|
|
380
|
+
): () => void {
|
|
381
|
+
const defaultStrategy =
|
|
382
|
+
options.defaultPrefetch ?? getDefaultPrefetchStrategy();
|
|
383
|
+
if (defaultStrategy === "none") return () => {};
|
|
384
|
+
|
|
385
|
+
const basename = canonicalizeBasenamePathname(options.basename);
|
|
386
|
+
const customShouldPrefetch = options.shouldPrefetch;
|
|
387
|
+
const getEligibility = customShouldPrefetch
|
|
388
|
+
? (link: HTMLAnchorElement): PrefetchEligibility => {
|
|
389
|
+
if (isPrefetchScopeDisabled(link)) return "ineligible";
|
|
390
|
+
return customShouldPrefetch(link) ? "eligible" : "ineligible";
|
|
391
|
+
}
|
|
392
|
+
: (link: HTMLAnchorElement) =>
|
|
393
|
+
defaultPrefetchEligibilityInBasename(link, basename);
|
|
394
|
+
const states = new Map<HTMLAnchorElement, DelegatedPrefetchState>();
|
|
395
|
+
const parked = new Set<HTMLAnchorElement>();
|
|
396
|
+
let strategy = resolveAdaptiveStrategy(defaultStrategy);
|
|
397
|
+
|
|
398
|
+
const disarm = (link: HTMLAnchorElement) => {
|
|
399
|
+
const state = states.get(link);
|
|
400
|
+
state?.stopObserving?.();
|
|
401
|
+
state?.cancelPending?.();
|
|
402
|
+
states.delete(link);
|
|
403
|
+
if (strategy === "hover") syncMutationObserver();
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const evaluateEligibility = (link: HTMLAnchorElement): PrefetchEligibility =>
|
|
407
|
+
link.hasAttribute(ELIGIBILITY_ATTRIBUTES.href)
|
|
408
|
+
? getEligibility(link)
|
|
409
|
+
: "ineligible";
|
|
410
|
+
|
|
411
|
+
const updateParked = (
|
|
412
|
+
link: HTMLAnchorElement,
|
|
413
|
+
eligibility: PrefetchEligibility,
|
|
414
|
+
) => {
|
|
415
|
+
if (eligibility === "location-dependent" && strategy !== "hover") {
|
|
416
|
+
parked.add(link);
|
|
417
|
+
} else parked.delete(link);
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const forget = (link: HTMLAnchorElement) => {
|
|
421
|
+
disarm(link);
|
|
422
|
+
parked.delete(link);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const trigger = (link: HTMLAnchorElement, priority: "direct" | "queued") => {
|
|
426
|
+
const eligibility = evaluateEligibility(link);
|
|
427
|
+
updateParked(link, eligibility);
|
|
428
|
+
if (eligibility !== "eligible") {
|
|
429
|
+
disarm(link);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let state = states.get(link);
|
|
434
|
+
if (!state) {
|
|
435
|
+
state = {};
|
|
436
|
+
states.set(link, state);
|
|
437
|
+
}
|
|
438
|
+
state.cancelPending?.();
|
|
439
|
+
state.cancelPending = undefined;
|
|
440
|
+
try {
|
|
441
|
+
state.cancelPending = onPrefetch(link.href, priority) ?? undefined;
|
|
442
|
+
} finally {
|
|
443
|
+
if (strategy === "hover" && !state.cancelPending) states.delete(link);
|
|
444
|
+
syncMutationObserver();
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const register = (link: HTMLAnchorElement) => {
|
|
449
|
+
disarm(link);
|
|
450
|
+
const eligibility = evaluateEligibility(link);
|
|
451
|
+
updateParked(link, eligibility);
|
|
452
|
+
if (eligibility !== "eligible") return;
|
|
453
|
+
if (strategy === "hover") return;
|
|
454
|
+
|
|
455
|
+
const state: DelegatedPrefetchState = {};
|
|
456
|
+
states.set(link, state);
|
|
457
|
+
|
|
458
|
+
if (strategy === "render") {
|
|
459
|
+
trigger(link, "queued");
|
|
460
|
+
} else if (strategy === "viewport") {
|
|
461
|
+
state.stopObserving = observeForPrefetch(link, () => {
|
|
462
|
+
state.stopObserving = undefined;
|
|
463
|
+
trigger(link, "queued");
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const visitAnchors = (
|
|
469
|
+
node: Node,
|
|
470
|
+
visit: (link: HTMLAnchorElement) => void,
|
|
471
|
+
) => {
|
|
472
|
+
if (!isElementNode(node)) return;
|
|
473
|
+
if (node.matches(ANCHOR_SELECTOR)) {
|
|
474
|
+
visit(node as HTMLAnchorElement);
|
|
475
|
+
}
|
|
476
|
+
node
|
|
477
|
+
.querySelectorAll<HTMLAnchorElement>(ANCHOR_SELECTOR)
|
|
478
|
+
.forEach((link) => visit(link));
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const root = options.root ?? document.documentElement;
|
|
482
|
+
let mutationObserver: MutationObserver | undefined;
|
|
483
|
+
function syncMutationObserver(): void {
|
|
484
|
+
const shouldObserve =
|
|
485
|
+
strategy === "viewport" || strategy === "render" || states.size > 0;
|
|
486
|
+
if (!shouldObserve) {
|
|
487
|
+
mutationObserver?.disconnect();
|
|
488
|
+
mutationObserver = undefined;
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (mutationObserver || typeof MutationObserver === "undefined" || !root) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
mutationObserver = new MutationObserver((mutations) => {
|
|
496
|
+
const affected = new Set<HTMLAnchorElement>();
|
|
497
|
+
const markAffected = (link: HTMLAnchorElement) => affected.add(link);
|
|
498
|
+
for (const mutation of mutations) {
|
|
499
|
+
if (mutation.type === "attributes") {
|
|
500
|
+
if (mutation.attributeName === ELIGIBILITY_ATTRIBUTES.prefetchScope) {
|
|
501
|
+
visitAnchors(mutation.target, markAffected);
|
|
502
|
+
} else if (
|
|
503
|
+
isElementNode(mutation.target) &&
|
|
504
|
+
mutation.target.matches(ANCHOR_SELECTOR)
|
|
505
|
+
) {
|
|
506
|
+
affected.add(mutation.target as HTMLAnchorElement);
|
|
507
|
+
}
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
mutation.removedNodes.forEach((node) =>
|
|
511
|
+
visitAnchors(node, markAffected),
|
|
512
|
+
);
|
|
513
|
+
mutation.addedNodes.forEach((node) => visitAnchors(node, markAffected));
|
|
514
|
+
}
|
|
515
|
+
for (const link of affected) {
|
|
516
|
+
if (link.isConnected && root.contains(link)) register(link);
|
|
517
|
+
else forget(link);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
mutationObserver.observe(root, {
|
|
521
|
+
subtree: true,
|
|
522
|
+
childList: true,
|
|
523
|
+
attributes: true,
|
|
524
|
+
attributeFilter: ELIGIBILITY_ATTRIBUTE_FILTER,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const rescan = () => {
|
|
529
|
+
for (const link of [...states.keys()]) disarm(link);
|
|
530
|
+
parked.clear();
|
|
531
|
+
strategy = resolveAdaptiveStrategy(defaultStrategy);
|
|
532
|
+
syncMutationObserver();
|
|
533
|
+
if ((strategy === "viewport" || strategy === "render") && root) {
|
|
534
|
+
visitAnchors(root, register);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
rescan();
|
|
539
|
+
|
|
540
|
+
const handleMouseOver = (event: MouseEvent) => {
|
|
541
|
+
if (!isElementNode(event.target)) return;
|
|
542
|
+
|
|
543
|
+
const link = event.target.closest<HTMLAnchorElement>(
|
|
544
|
+
ELIGIBLE_ANCHOR_SELECTOR,
|
|
545
|
+
);
|
|
546
|
+
if (!link || !root.contains(link)) return;
|
|
547
|
+
if (isNode(event.relatedTarget) && link.contains(event.relatedTarget)) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
trigger(link, "direct");
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
if (strategy === "hover" || strategy === "viewport") {
|
|
554
|
+
root.addEventListener("mouseover", handleMouseOver);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const unsubscribeLocation =
|
|
558
|
+
defaultStrategy === "viewport" ||
|
|
559
|
+
defaultStrategy === "render" ||
|
|
560
|
+
defaultStrategy === "adaptive"
|
|
561
|
+
? subscribeToLocationChange(options.eventController, () => {
|
|
562
|
+
if (strategy !== "viewport" && strategy !== "render") return;
|
|
563
|
+
for (const link of [...parked]) {
|
|
564
|
+
if (link.isConnected && root.contains(link)) register(link);
|
|
565
|
+
else forget(link);
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
: undefined;
|
|
569
|
+
const unsubscribeAdaptive =
|
|
570
|
+
defaultStrategy === "adaptive"
|
|
571
|
+
? subscribeToAdaptiveStrategyChange(rescan)
|
|
572
|
+
: undefined;
|
|
573
|
+
const unsubscribeInvalidation = subscribeToPrefetchCacheInvalidation(() => {
|
|
574
|
+
if (strategy !== "viewport" && strategy !== "render") return;
|
|
575
|
+
for (const link of [...states.keys()]) {
|
|
576
|
+
if (link.isConnected && root.contains(link)) register(link);
|
|
577
|
+
else forget(link);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
return () => {
|
|
582
|
+
mutationObserver?.disconnect();
|
|
583
|
+
root.removeEventListener("mouseover", handleMouseOver);
|
|
584
|
+
unsubscribeLocation?.();
|
|
585
|
+
unsubscribeAdaptive?.();
|
|
586
|
+
unsubscribeInvalidation();
|
|
587
|
+
for (const link of [...states.keys()]) disarm(link);
|
|
588
|
+
parked.clear();
|
|
589
|
+
};
|
|
590
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
NavigationBridge,
|
|
3
3
|
NavigationBridgeConfig,
|
|
4
|
+
NavigationStore,
|
|
4
5
|
NavigateOptionsInternal,
|
|
5
6
|
ResolvedSegment,
|
|
6
7
|
} from "./types.js";
|
|
@@ -24,7 +25,10 @@ import {
|
|
|
24
25
|
const addTransitionType: ((type: string) => void) | undefined =
|
|
25
26
|
"addTransitionType" in React ? (React as any).addTransitionType : undefined;
|
|
26
27
|
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
setupDelegatedLinkPrefetch,
|
|
30
|
+
setupLinkInterception,
|
|
31
|
+
} from "./link-interceptor.js";
|
|
28
32
|
import { createPartialUpdater } from "./partial-update.js";
|
|
29
33
|
import { generateHistoryKey } from "./navigation-store.js";
|
|
30
34
|
import type { EventController } from "./event-controller.js";
|
|
@@ -38,6 +42,12 @@ import {
|
|
|
38
42
|
import { debugLog } from "./logging.js";
|
|
39
43
|
import { ServerRedirect } from "../errors.js";
|
|
40
44
|
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
45
|
+
import {
|
|
46
|
+
prefetchDirect,
|
|
47
|
+
prefetchQueued,
|
|
48
|
+
schedulePrefetchWhenRouterIdle,
|
|
49
|
+
} from "./prefetch/loader.js";
|
|
50
|
+
import type { PrefetchStrategy } from "../router/prefetch-default.js";
|
|
41
51
|
|
|
42
52
|
// Polyfill Symbol.dispose for Safari and older browsers
|
|
43
53
|
if (typeof Symbol.dispose === "undefined") {
|
|
@@ -53,6 +63,48 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
|
|
|
53
63
|
eventController: EventController;
|
|
54
64
|
/** RSC version from initial payload metadata. */
|
|
55
65
|
version?: string;
|
|
66
|
+
/** Server-resolved default used by both Links and delegated anchors. */
|
|
67
|
+
defaultPrefetch?: PrefetchStrategy;
|
|
68
|
+
/** Canonical router basename used to scope delegated anchor prefetch. */
|
|
69
|
+
basename?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface NavigationBridgeDelegatedPrefetchOptions {
|
|
73
|
+
defaultPrefetch?: PrefetchStrategy;
|
|
74
|
+
root?: HTMLElement;
|
|
75
|
+
basename?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Register delegated anchor prefetch with the production bridge wiring. */
|
|
79
|
+
export function setupNavigationBridgeDelegatedPrefetch(
|
|
80
|
+
store: NavigationStore,
|
|
81
|
+
eventController: EventController,
|
|
82
|
+
getVersion: () => string | undefined,
|
|
83
|
+
options: NavigationBridgeDelegatedPrefetchOptions = {},
|
|
84
|
+
): () => void {
|
|
85
|
+
return setupDelegatedLinkPrefetch(
|
|
86
|
+
(url, priority) => {
|
|
87
|
+
const trigger = () => {
|
|
88
|
+
const segmentState = store.getSegmentState();
|
|
89
|
+
const prefetch =
|
|
90
|
+
priority === "direct" ? prefetchDirect : prefetchQueued;
|
|
91
|
+
prefetch(
|
|
92
|
+
url,
|
|
93
|
+
segmentState.currentSegmentIds,
|
|
94
|
+
getVersion(),
|
|
95
|
+
store.getRouterId?.(),
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (priority === "direct") {
|
|
100
|
+
trigger();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return schedulePrefetchWhenRouterIdle(eventController, trigger);
|
|
105
|
+
},
|
|
106
|
+
{ eventController, ...options },
|
|
107
|
+
);
|
|
56
108
|
}
|
|
57
109
|
|
|
58
110
|
/**
|
|
@@ -71,7 +123,15 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
|
|
|
71
123
|
export function createNavigationBridge(
|
|
72
124
|
config: NavigationBridgeConfigWithController,
|
|
73
125
|
): NavigationBridge {
|
|
74
|
-
const {
|
|
126
|
+
const {
|
|
127
|
+
store,
|
|
128
|
+
client,
|
|
129
|
+
eventController,
|
|
130
|
+
onUpdate,
|
|
131
|
+
renderSegments,
|
|
132
|
+
defaultPrefetch,
|
|
133
|
+
basename,
|
|
134
|
+
} = config;
|
|
75
135
|
let version = config.version;
|
|
76
136
|
|
|
77
137
|
// Create shared partial updater
|
|
@@ -724,6 +784,15 @@ export function createNavigationBridge(
|
|
|
724
784
|
};
|
|
725
785
|
},
|
|
726
786
|
|
|
787
|
+
registerDelegatedPrefetch(): () => void {
|
|
788
|
+
return setupNavigationBridgeDelegatedPrefetch(
|
|
789
|
+
store,
|
|
790
|
+
eventController,
|
|
791
|
+
() => version,
|
|
792
|
+
{ defaultPrefetch, basename },
|
|
793
|
+
);
|
|
794
|
+
},
|
|
795
|
+
|
|
727
796
|
getVersion(): string | undefined {
|
|
728
797
|
return version;
|
|
729
798
|
},
|
|
@@ -8,6 +8,11 @@ import type {
|
|
|
8
8
|
HandleData,
|
|
9
9
|
} from "./types.js";
|
|
10
10
|
import { clearPrefetchCache } from "./prefetch/cache.js";
|
|
11
|
+
import {
|
|
12
|
+
adoptRangoState,
|
|
13
|
+
getRangoState,
|
|
14
|
+
getRangoStateCookieName,
|
|
15
|
+
} from "./rango-state.js";
|
|
11
16
|
|
|
12
17
|
// Maximum number of history entries to cache (URLs visited)
|
|
13
18
|
const HISTORY_CACHE_SIZE = 20;
|
|
@@ -316,6 +321,8 @@ export function createNavigationStore(
|
|
|
316
321
|
type: "invalidate",
|
|
317
322
|
path: currentPath,
|
|
318
323
|
segmentIds: currentSegmentIds,
|
|
324
|
+
rangoState: getRangoState(),
|
|
325
|
+
stateCookieName: getRangoStateCookieName(),
|
|
319
326
|
});
|
|
320
327
|
}
|
|
321
328
|
}
|
|
@@ -341,7 +348,23 @@ export function createNavigationStore(
|
|
|
341
348
|
return;
|
|
342
349
|
}
|
|
343
350
|
|
|
344
|
-
|
|
351
|
+
const rangoState = event.data.rangoState;
|
|
352
|
+
const stateCookieName = event.data.stateCookieName;
|
|
353
|
+
if (
|
|
354
|
+
typeof rangoState === "string" &&
|
|
355
|
+
typeof stateCookieName === "string"
|
|
356
|
+
) {
|
|
357
|
+
if (stateCookieName !== getRangoStateCookieName()) return;
|
|
358
|
+
// The sender already rotated the same-origin cookie. Adopt that
|
|
359
|
+
// value before clearing locally so tabs cannot ping-pong rotations
|
|
360
|
+
// and obsolete each other's post-invalidation prefetches.
|
|
361
|
+
const adopted = adoptRangoState(rangoState);
|
|
362
|
+
markHistoryStale();
|
|
363
|
+
clearPrefetchCache(!adopted);
|
|
364
|
+
} else {
|
|
365
|
+
// Compatibility with an already-open tab running an older sender.
|
|
366
|
+
markCacheAsStaleInternal();
|
|
367
|
+
}
|
|
345
368
|
|
|
346
369
|
// Auto-refresh if enabled and callback is registered
|
|
347
370
|
if (crossTabAutoRefresh && crossTabRefreshCallback) {
|