@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.
Files changed (105) hide show
  1. package/README.md +19 -1
  2. package/dist/types/browser/event-controller.d.ts +4 -0
  3. package/dist/types/browser/link-interceptor.d.ts +17 -7
  4. package/dist/types/browser/navigation-bridge.d.ts +13 -1
  5. package/dist/types/browser/notify-listeners.d.ts +2 -0
  6. package/dist/types/browser/prefetch/cache.d.ts +1 -1
  7. package/dist/types/browser/prefetch/default-strategy.d.ts +31 -0
  8. package/dist/types/browser/prefetch/invalidation.d.ts +6 -0
  9. package/dist/types/browser/prefetch/loader.d.ts +3 -1
  10. package/dist/types/browser/prefetch/observer.d.ts +3 -6
  11. package/dist/types/browser/prefetch/runtime.d.ts +0 -1
  12. package/dist/types/browser/rango-state.d.ts +4 -0
  13. package/dist/types/browser/react/Link.d.ts +11 -19
  14. package/dist/types/browser/react/context.d.ts +3 -0
  15. package/dist/types/browser/rsc-router.d.ts +7 -2
  16. package/dist/types/browser/types.d.ts +6 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +11 -3
  18. package/dist/types/cache/cache-scope.d.ts +82 -3
  19. package/dist/types/cache/cf/cf-cache-store.d.ts +2 -2
  20. package/dist/types/cache/index.d.ts +3 -1
  21. package/dist/types/cache/search-params-filter.d.ts +64 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +4 -4
  23. package/dist/types/cache/types.d.ts +36 -2
  24. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -2
  25. package/dist/types/index.d.ts +1 -0
  26. package/dist/types/index.rsc.d.ts +1 -0
  27. package/dist/types/router/match-middleware/cache-lookup.d.ts +13 -0
  28. package/dist/types/router/navigation-snapshot.d.ts +29 -0
  29. package/dist/types/router/prefetch-default.d.ts +28 -0
  30. package/dist/types/router/router-interfaces.d.ts +7 -0
  31. package/dist/types/router/router-options.d.ts +54 -0
  32. package/dist/types/rsc/capture-queue.d.ts +6 -0
  33. package/dist/types/rsc/shell-build-manifest.d.ts +7 -1
  34. package/dist/types/rsc/shell-capture.d.ts +15 -7
  35. package/dist/types/rsc/shell-serve.d.ts +11 -4
  36. package/dist/types/rsc/types.d.ts +19 -0
  37. package/dist/types/server/request-context.d.ts +57 -4
  38. package/dist/types/testing/e2e/index.d.ts +2 -2
  39. package/dist/types/testing/e2e/page-helpers.d.ts +24 -0
  40. package/dist/types/testing/render-route.d.ts +10 -1
  41. package/dist/types/testing/shell-status.d.ts +6 -3
  42. package/dist/types/vite/plugin-types.d.ts +1 -1
  43. package/dist/vite/index.js +9 -6
  44. package/package.json +23 -22
  45. package/skills/caching/SKILL.md +39 -0
  46. package/skills/comparison/references/framework-comparison.md +9 -2
  47. package/skills/links/SKILL.md +24 -0
  48. package/skills/ppr/SKILL.md +80 -16
  49. package/skills/router-setup/SKILL.md +9 -0
  50. package/skills/testing/client-components.md +15 -14
  51. package/skills/vercel/SKILL.md +1 -1
  52. package/src/browser/event-controller.ts +110 -11
  53. package/src/browser/link-interceptor.ts +469 -20
  54. package/src/browser/navigation-bridge.ts +71 -2
  55. package/src/browser/navigation-store.ts +24 -1
  56. package/src/browser/notify-listeners.ts +22 -0
  57. package/src/browser/prefetch/cache.ts +4 -2
  58. package/src/browser/prefetch/default-strategy.ts +74 -0
  59. package/src/browser/prefetch/invalidation.ts +30 -0
  60. package/src/browser/prefetch/loader.ts +18 -17
  61. package/src/browser/prefetch/observer.ts +50 -22
  62. package/src/browser/prefetch/runtime.ts +0 -1
  63. package/src/browser/rango-state.ts +21 -0
  64. package/src/browser/react/Link.tsx +111 -101
  65. package/src/browser/react/NavigationProvider.tsx +1 -0
  66. package/src/browser/react/context.ts +4 -0
  67. package/src/browser/rsc-router.tsx +30 -9
  68. package/src/browser/types.ts +6 -0
  69. package/src/cache/cache-key-utils.ts +18 -4
  70. package/src/cache/cache-runtime.ts +7 -2
  71. package/src/cache/cache-scope.ts +152 -23
  72. package/src/cache/cf/cf-cache-store.ts +55 -16
  73. package/src/cache/document-cache.ts +7 -1
  74. package/src/cache/index.ts +7 -0
  75. package/src/cache/search-params-filter.ts +118 -0
  76. package/src/cache/shell-snapshot.ts +8 -4
  77. package/src/cache/types.ts +39 -2
  78. package/src/cache/vercel/vercel-cache-store.ts +22 -3
  79. package/src/index.rsc.ts +4 -0
  80. package/src/index.ts +6 -0
  81. package/src/router/match-middleware/cache-lookup.ts +146 -33
  82. package/src/router/match-middleware/cache-store.ts +70 -0
  83. package/src/router/navigation-snapshot.ts +46 -6
  84. package/src/router/prefetch-default.ts +59 -0
  85. package/src/router/router-interfaces.ts +8 -0
  86. package/src/router/router-options.ts +59 -1
  87. package/src/router.ts +7 -0
  88. package/src/rsc/capture-queue.ts +24 -1
  89. package/src/rsc/full-payload.ts +1 -0
  90. package/src/rsc/handler.ts +10 -0
  91. package/src/rsc/response-cache-serve.ts +1 -1
  92. package/src/rsc/rsc-rendering.ts +367 -40
  93. package/src/rsc/shell-build-manifest.ts +8 -1
  94. package/src/rsc/shell-capture.ts +99 -50
  95. package/src/rsc/shell-serve.ts +14 -6
  96. package/src/rsc/types.ts +19 -0
  97. package/src/server/request-context.ts +62 -3
  98. package/src/testing/dispatch.ts +21 -3
  99. package/src/testing/e2e/index.ts +6 -0
  100. package/src/testing/e2e/page-helpers.ts +47 -0
  101. package/src/testing/e2e/parity.ts +13 -0
  102. package/src/testing/render-route.tsx +86 -17
  103. package/src/testing/shell-status.ts +20 -3
  104. package/src/vite/plugin-types.ts +1 -1
  105. 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
- export function isHashOnlyNavigation(anchor: HTMLAnchorElement): boolean {
147
+ function isHashOnlyNavigationToPath(
148
+ anchor: HTMLAnchorElement,
149
+ pathname: string,
150
+ ): boolean {
9
151
  return (
10
- anchor.pathname === window.location.pathname &&
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
- // Only intercept same-origin links
31
- if (link.origin !== window.location.origin) {
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
- // Don't intercept if it has download attribute
36
- if (link.hasAttribute("download")) {
191
+ // Only handle same-origin links
192
+ if (link.origin !== window.location.origin) {
37
193
  return false;
38
194
  }
39
195
 
40
- // Don't intercept if target is set to something other than _self
41
- if (link.target && link.target !== "_self") {
196
+ // Skip links with a download attribute
197
+ if (link.hasAttribute(ELIGIBILITY_ATTRIBUTES.download)) {
42
198
  return false;
43
199
  }
44
200
 
45
- // Don't intercept if explicitly disabled
46
- if (link.getAttribute("data-no-intercept") === "true") {
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
- // Don't intercept Link component anchors - they handle their own navigation
51
- if (link.hasAttribute("data-link-component")) {
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
- // Don't intercept external links
56
- if (link.hasAttribute("data-external")) {
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
- // Don't intercept hash-only navigation (same path, only fragment changes).
61
- // Let the browser handle anchor scrolling natively.
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
- * and call the onNavigate callback instead of performing a full page load.
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
- const target = event.target as HTMLElement;
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 { setupLinkInterception } from "./link-interceptor.js";
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 { store, client, eventController, onUpdate, renderSegments } = config;
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
- markCacheAsStaleInternal();
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) {