@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
@@ -0,0 +1,22 @@
1
+ /** Notify every active listener and surface every callback failure. */
2
+ export function notifyListeners<T>(
3
+ entries: Iterable<T>,
4
+ notify: (entry: T) => void,
5
+ isActive?: (entry: T) => boolean,
6
+ shouldContinue?: () => boolean,
7
+ ): void {
8
+ const errors: unknown[] = [];
9
+ for (const entry of entries) {
10
+ if (shouldContinue && !shouldContinue()) break;
11
+ if (isActive && !isActive(entry)) continue;
12
+ try {
13
+ notify(entry);
14
+ } catch (error) {
15
+ errors.push(error);
16
+ }
17
+ }
18
+ if (errors.length === 1) throw errors[0];
19
+ if (errors.length > 1) {
20
+ throw new AggregateError(errors, "Multiple listener callbacks failed");
21
+ }
22
+ }
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { abortAllPrefetches } from "./loader.js";
43
+ import { notifyPrefetchCacheInvalidated } from "./invalidation.js";
43
44
  import { invalidateRangoState } from "../rango-state.js";
44
45
  import type { RscPayload } from "../types.js";
45
46
 
@@ -368,7 +369,7 @@ export function clearPrefetchInflight(key: string): void {
368
369
  });
369
370
  }
370
371
 
371
- export function clearPrefetchCache(): void {
372
+ export function clearPrefetchCache(rotateRangoState = true): void {
372
373
  generation++;
373
374
  inflight.clear();
374
375
  inflightPromises.clear();
@@ -377,5 +378,6 @@ export function clearPrefetchCache(): void {
377
378
  cache.clear();
378
379
  earliestTimestamp = Infinity;
379
380
  abortAllPrefetches();
380
- invalidateRangoState();
381
+ if (rotateRangoState) invalidateRangoState();
382
+ notifyPrefetchCacheInvalidated();
381
383
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Router-wide default prefetch strategy (client seat).
3
+ *
4
+ * The server resolves `createRouter({ defaultPrefetch })` once at router init
5
+ * (router/prefetch-default.ts) and ships it in initial payload metadata; the
6
+ * browser entry applies it here before hydration — same lifecycle as
7
+ * `initPrefetchCache` / `setPrefetchConcurrency`. Every `<Link>` without an
8
+ * explicit `prefetch` prop and every eligible intercepted plain anchor that has
9
+ * not opted out with `data-prefetch="false"` or `data-prefetch="none"` uses it.
10
+ * Containers use the same `false`/`none` vocabulary via
11
+ * `data-prefetch-scope`.
12
+ *
13
+ * The module initial value must equal the server resolver's environment default:
14
+ * `"none"` in development and `"viewport"` in production. During SSR this
15
+ * module is never initialized from metadata, so keeping both seats aligned also
16
+ * gives metadata-less payloads the documented behavior.
17
+ */
18
+
19
+ import type { PrefetchStrategy } from "../../router/prefetch-default.js";
20
+
21
+ let hoverNoneQuery: MediaQueryList | null = null;
22
+
23
+ // Mirrors DEFAULT_PREFETCH_STRATEGY without pulling router-layer code into the
24
+ // client bundle. NODE_ENV is folded by the app build.
25
+ let defaultStrategy: PrefetchStrategy =
26
+ process.env.NODE_ENV === "production" ? "viewport" : "none";
27
+
28
+ function getHoverNoneQuery(): MediaQueryList | null {
29
+ if (typeof window === "undefined") return null;
30
+ if (!hoverNoneQuery) {
31
+ hoverNoneQuery = window.matchMedia("(hover: none)");
32
+ }
33
+ return hoverNoneQuery;
34
+ }
35
+
36
+ /** Reset module state between tests that replace browser media globals. */
37
+ export function resetAdaptiveStrategyForTesting(): void {
38
+ hoverNoneQuery = null;
39
+ }
40
+
41
+ /**
42
+ * Apply the server-resolved default strategy. Called once at browser app init
43
+ * from payload metadata; also used by tests to reset state.
44
+ */
45
+ export function setDefaultPrefetchStrategy(strategy: PrefetchStrategy): void {
46
+ defaultStrategy = strategy;
47
+ }
48
+
49
+ /** Current default strategy for Links and delegated plain anchors. */
50
+ export function getDefaultPrefetchStrategy(): PrefetchStrategy {
51
+ return defaultStrategy;
52
+ }
53
+
54
+ /** Resolve adaptive to the strategy for the current input capability. */
55
+ export function resolveAdaptiveStrategy(
56
+ strategy: PrefetchStrategy,
57
+ ): PrefetchStrategy {
58
+ if (strategy !== "adaptive") return strategy;
59
+ return getHoverNoneQuery()?.matches ? "viewport" : "hover";
60
+ }
61
+
62
+ /** Subscribe to changes in the input capability used by adaptive prefetch. */
63
+ export function subscribeToAdaptiveStrategyChange(
64
+ listener: () => void,
65
+ ): () => void {
66
+ const query = getHoverNoneQuery();
67
+ if (!query) return () => {};
68
+ if (typeof query.addEventListener === "function") {
69
+ query.addEventListener("change", listener);
70
+ return () => query.removeEventListener("change", listener);
71
+ }
72
+ query.addListener(listener);
73
+ return () => query.removeListener(listener);
74
+ }
@@ -0,0 +1,30 @@
1
+ import { notifyListeners } from "../notify-listeners.js";
2
+
3
+ type PrefetchCacheInvalidationListener = () => void;
4
+
5
+ const listeners = new Map<symbol, PrefetchCacheInvalidationListener>();
6
+
7
+ /** Subscribe to completed local prefetch-cache invalidations. */
8
+ export function subscribeToPrefetchCacheInvalidation(
9
+ listener: PrefetchCacheInvalidationListener,
10
+ ): () => void {
11
+ const subscription = Symbol();
12
+ listeners.set(subscription, listener);
13
+
14
+ return () => listeners.delete(subscription);
15
+ }
16
+
17
+ /** Notify the registrations that existed when the cache was invalidated. */
18
+ export function notifyPrefetchCacheInvalidated(): void {
19
+ const registrations = [...listeners];
20
+ // Finish the current broadcast/action finalizer before re-arming work. A
21
+ // synchronous callback could throw before cross-tab invalidation is sent or
22
+ // prefetch while the action fence is still raised.
23
+ queueMicrotask(() => {
24
+ notifyListeners(
25
+ registrations,
26
+ ([, listener]) => listener(),
27
+ ([subscription, listener]) => listeners.get(subscription) === listener,
28
+ );
29
+ });
30
+ }
@@ -1,4 +1,5 @@
1
1
  import type { RscPayload } from "../types.js";
2
+ import type { EventController } from "../event-controller.js";
2
3
 
3
4
  type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
4
5
 
@@ -78,25 +79,25 @@ export function prefetchQueued(
78
79
  );
79
80
  }
80
81
 
81
- export function observeForPrefetch(
82
- element: Element,
82
+ /** Run speculative work only when no navigation or stream is active. */
83
+ export function schedulePrefetchWhenRouterIdle(
84
+ eventController: Pick<EventController, "getState" | "subscribe">,
83
85
  callback: () => void,
84
- ): () => void {
85
- if (typeof IntersectionObserver === "undefined") return () => {};
86
- let active = true;
87
- if (runtime) {
88
- runtime.observeForPrefetch(element, callback);
89
- } else {
90
- void loadRuntime()
91
- .then((loaded) => {
92
- if (active) loaded.observeForPrefetch(element, callback);
93
- })
94
- .catch(() => {});
86
+ ): (() => void) | undefined {
87
+ const state = eventController.getState();
88
+ if (state.state === "idle" && !state.isStreaming) {
89
+ callback();
90
+ return;
95
91
  }
96
- return () => {
97
- active = false;
98
- runtime?.unobserveForPrefetch(element);
99
- };
92
+
93
+ const unsubscribe = eventController.subscribe(() => {
94
+ const nextState = eventController.getState();
95
+ if (nextState.state === "idle" && !nextState.isStreaming) {
96
+ unsubscribe();
97
+ callback();
98
+ }
99
+ });
100
+ return unsubscribe;
100
101
  }
101
102
 
102
103
  export function cancelAllPrefetches(keepUrl?: string | null): void {
@@ -12,32 +12,53 @@
12
12
  * scrolls in and out repeatedly.
13
13
  */
14
14
 
15
+ import { notifyListeners } from "../notify-listeners.js";
16
+
15
17
  type PrefetchCallback = () => void;
16
18
 
17
- const callbacks = new Map<Element, PrefetchCallback>();
19
+ const callbacks = new Map<Element, Map<symbol, PrefetchCallback>>();
18
20
  let observer: IntersectionObserver | null = null;
19
21
 
20
22
  function getObserver(): IntersectionObserver {
21
23
  if (!observer) {
22
24
  observer = new IntersectionObserver(
23
- (entries) => {
24
- for (const entry of entries) {
25
- if (entry.isIntersecting) {
26
- const callback = callbacks.get(entry.target);
27
- if (callback) {
28
- observer!.unobserve(entry.target);
29
- callbacks.delete(entry.target);
30
- callback();
25
+ (entries, currentObserver) => {
26
+ function* intersectingCallbacks(): Generator<
27
+ [Map<symbol, PrefetchCallback>, symbol, PrefetchCallback]
28
+ > {
29
+ for (const entry of entries) {
30
+ if (entry.isIntersecting) {
31
+ const subscriptions = callbacks.get(entry.target);
32
+ if (subscriptions) {
33
+ currentObserver.unobserve(entry.target);
34
+ callbacks.delete(entry.target);
35
+ for (const [subscription, callback] of subscriptions) {
36
+ yield [subscriptions, subscription, callback];
37
+ }
38
+ }
31
39
  }
32
40
  }
33
41
  }
42
+ notifyListeners(
43
+ intersectingCallbacks(),
44
+ ([, , callback]) => callback(),
45
+ ([subscriptions, subscription]) => subscriptions.has(subscription),
46
+ );
34
47
  },
35
48
  { rootMargin: "200px" },
36
49
  );
50
+ for (const element of callbacks.keys()) observer.observe(element);
37
51
  }
38
52
  return observer;
39
53
  }
40
54
 
55
+ /** Reset module state between tests that replace browser observer globals. */
56
+ export function resetPrefetchObserverForTesting(): void {
57
+ observer?.disconnect();
58
+ observer = null;
59
+ callbacks.clear();
60
+ }
61
+
41
62
  /**
42
63
  * Observe an element for viewport intersection.
43
64
  * When the element becomes visible (within 200px margin), the callback fires
@@ -47,19 +68,26 @@ function getObserver(): IntersectionObserver {
47
68
  export function observeForPrefetch(
48
69
  element: Element,
49
70
  onVisible: PrefetchCallback,
50
- ): void {
51
- if (typeof IntersectionObserver === "undefined") return;
52
- callbacks.set(element, onVisible);
53
- getObserver().observe(element);
54
- }
71
+ ): () => void {
72
+ if (typeof IntersectionObserver === "undefined") return () => {};
73
+ const currentObserver = getObserver();
55
74
 
56
- /**
57
- * Stop observing an element. Used for cleanup when a Link unmounts
58
- * before entering the viewport.
59
- */
60
- export function unobserveForPrefetch(element: Element): void {
61
- callbacks.delete(element);
62
- if (observer) {
63
- observer.unobserve(element);
75
+ let subscriptions = callbacks.get(element);
76
+ if (!subscriptions) {
77
+ subscriptions = new Map();
78
+ callbacks.set(element, subscriptions);
64
79
  }
80
+
81
+ const subscription = Symbol();
82
+ subscriptions.set(subscription, onVisible);
83
+ if (subscriptions.size === 1) currentObserver.observe(element);
84
+
85
+ return () => {
86
+ const current = callbacks.get(element);
87
+ subscriptions.delete(subscription);
88
+ if (current !== subscriptions) return;
89
+ if (current.size > 0) return;
90
+ callbacks.delete(element);
91
+ observer?.unobserve(element);
92
+ };
65
93
  }
@@ -4,4 +4,3 @@ export {
4
4
  cancelAllPrefetches,
5
5
  setPrefetchConcurrency,
6
6
  } from "./queue.js";
7
- export { observeForPrefetch, unobserveForPrefetch } from "./observer.js";
@@ -52,6 +52,11 @@ export function setRangoStateObserver(
52
52
  externalRotationObserver = observer;
53
53
  }
54
54
 
55
+ /** Return the resolved cookie name used to namespace cross-tab messages. */
56
+ export function getRangoStateCookieName(): string {
57
+ return cookieName;
58
+ }
59
+
55
60
  function notifyExternalRotation(value: string): void {
56
61
  externalRotationObserver?.(value);
57
62
  }
@@ -179,6 +184,22 @@ export function invalidateRangoState(): void {
179
184
  writeCookie(cookieName, mirror);
180
185
  }
181
186
 
187
+ /** Adopt the state carried by a same-origin cache-invalidation broadcast. */
188
+ export function adoptRangoState(value: string): boolean {
189
+ const incoming = decodeStateValue(value);
190
+ if (!incoming || incoming.version !== currentVersion) return false;
191
+ const current = mirror ? decodeStateValue(mirror) : null;
192
+ if (
193
+ current?.version === incoming.version &&
194
+ current.timestamp >= incoming.timestamp
195
+ ) {
196
+ return false;
197
+ }
198
+ mirror = value;
199
+ cookieBacked = false;
200
+ return true;
201
+ }
202
+
182
203
  function cleanupLegacyStorage(): void {
183
204
  if (typeof localStorage === "undefined") return;
184
205
  try {
@@ -7,13 +7,19 @@ import React, {
7
7
  useEffect,
8
8
  useMemo,
9
9
  useRef,
10
+ useSyncExternalStore,
10
11
  type ForwardRefExoticComponent,
11
12
  type RefAttributes,
12
13
  } from "react";
13
14
  import { NavigationStoreContext } from "./context.js";
14
15
  import { LinkContext } from "./use-link-status.js";
15
16
  import type { NavigateOptions } from "../types.js";
16
- import { isHashOnlyNavigation } from "../link-interceptor.js";
17
+ import {
18
+ isHashOnlyNavigation,
19
+ isPrefetchScopeDisabled,
20
+ subscribeToPrefetchScopeChange,
21
+ } from "../link-interceptor.js";
22
+ import { subscribeToLocationChange } from "../event-controller.js";
17
23
  import {
18
24
  isLocationStateEntry,
19
25
  type LocationStateEntry,
@@ -33,63 +39,26 @@ export type LinkState =
33
39
  | StateOrGetter<Record<string, unknown>>;
34
40
 
35
41
  import {
36
- observeForPrefetch,
37
42
  prefetchDirect,
38
43
  prefetchQueued,
44
+ schedulePrefetchWhenRouterIdle,
39
45
  } from "../prefetch/loader.js";
46
+ import { observeForPrefetch } from "../prefetch/observer.js";
47
+ import {
48
+ getDefaultPrefetchStrategy,
49
+ resolveAdaptiveStrategy,
50
+ subscribeToAdaptiveStrategyChange,
51
+ } from "../prefetch/default-strategy.js";
40
52
  import { getAppVersion } from "../app-version.js";
53
+ import type { PrefetchStrategy } from "../../router/prefetch-default.js";
41
54
 
42
- // The (hover: none) MediaQueryList, created lazily on first client read and
43
- // reused across every Link render. matchMedia allocates and registers a live
44
- // query object; a fresh one per render (Link renders can be very frequent) is
45
- // wasteful when the same object's `.matches` is already live. Left null on the
46
- // server (no window).
47
- let hoverNoneQuery: MediaQueryList | null = null;
48
-
49
- /**
50
- * Read current touch/no-hover capability from the cached MediaQueryList. The
51
- * `.matches` read is live, so `prefetch="adaptive"` still reacts to
52
- * input-capability changes on hybrid devices (touch laptops, tablets gaining or
53
- * losing a pointer) and after SSR -> hydrate. The SSR guard returns a stable
54
- * `false` (pointer/hover default) so the resolved strategy doesn't drift on the
55
- * server vs the first client render.
56
- */
57
- function isTouchDevice(): boolean {
58
- if (typeof window === "undefined") return false;
59
- if (!hoverNoneQuery) {
60
- hoverNoneQuery = window.matchMedia("(hover: none)");
61
- }
62
- return hoverNoneQuery.matches;
63
- }
55
+ // The PrefetchStrategy union is defined in router/prefetch-default.ts (both
56
+ // the server-side option resolver and this client seat consume it); re-export
57
+ // so the public `PrefetchStrategy` import path via client.tsx is unchanged.
58
+ export type { PrefetchStrategy } from "../../router/prefetch-default.js";
59
+ export { resolveAdaptiveStrategy } from "../prefetch/default-strategy.js";
64
60
 
65
- /**
66
- * Prefetch strategy for the Link component
67
- * - "hover": Prefetch on mouse enter (direct, no queue)
68
- * - "viewport": Prefetch when link enters viewport (queued, waits for idle)
69
- * - "render": Prefetch on component mount regardless of visibility (queued, waits for idle)
70
- * - "adaptive": Hover on pointer devices, viewport on touch devices
71
- * - "none": No prefetching (default)
72
- */
73
- export type PrefetchStrategy =
74
- | "hover"
75
- | "viewport"
76
- | "render"
77
- | "adaptive"
78
- | "none";
79
-
80
- /**
81
- * Resolve a prefetch strategy, expanding "adaptive" to the concrete strategy
82
- * for the CURRENT input capability: "viewport" on touch (no-hover) devices,
83
- * "hover" on pointer devices. Non-adaptive strategies pass through unchanged.
84
- * Reads touch capability live (not a module-load snapshot) so the result
85
- * tracks input-capability changes.
86
- */
87
- export function resolveAdaptiveStrategy(
88
- prefetch: PrefetchStrategy,
89
- ): PrefetchStrategy {
90
- if (prefetch !== "adaptive") return prefetch;
91
- return isTouchDevice() ? "viewport" : "hover";
92
- }
61
+ const IGNORE_STRATEGY_CHANGES = (_listener: () => void) => () => {};
93
62
 
94
63
  /**
95
64
  * Link component props
@@ -125,8 +94,14 @@ export interface LinkProps extends Omit<
125
94
  */
126
95
  revalidate?: boolean;
127
96
  /**
128
- * Prefetch strategy for the link destination
129
- * @default "none"
97
+ * Prefetch strategy for the link destination. When omitted, falls back to
98
+ * the router-wide default (`createRouter({ defaultPrefetch })`: `"none"` in
99
+ * development, `"viewport"` in production). An explicit value always wins
100
+ * over the router default, including `"none"` to opt a single Link out. An
101
+ * ancestor with `data-prefetch-scope="false"` or `"none"` remains a hard
102
+ * subtree opt-out.
103
+ *
104
+ * @default the router's environment-aware `defaultPrefetch`
130
105
  */
131
106
  prefetch?: PrefetchStrategy;
132
107
  /**
@@ -240,7 +215,7 @@ export const Link: ForwardRefExoticComponent<
240
215
  scroll = true,
241
216
  reloadDocument = false,
242
217
  revalidate,
243
- prefetch = "none",
218
+ prefetch,
244
219
  prefetchKey,
245
220
  state,
246
221
  children,
@@ -262,10 +237,19 @@ export const Link: ForwardRefExoticComponent<
262
237
  return to === "/" ? bn : bn + to;
263
238
  }, [to, isExternal, ctx?.basename]);
264
239
 
265
- // Resolve adaptive: viewport on touch devices, hover on pointer devices.
266
- // isTouchDevice() is read here (per render), not from a module-load snapshot,
267
- // so a device whose input capability changes resolves to the current value.
268
- const resolvedStrategy = resolveAdaptiveStrategy(prefetch);
240
+ // No explicit `prefetch` prop: fall back to the router-wide default
241
+ // (server-resolved, applied at browser init before hydration, so this
242
+ // render-time read never races the metadata). Adaptive reads the current
243
+ // input capability rather than a module-load snapshot.
244
+ const configuredStrategy =
245
+ prefetch ?? ctx?.defaultPrefetch ?? getDefaultPrefetchStrategy();
246
+ const resolvedStrategy = useSyncExternalStore(
247
+ configuredStrategy === "adaptive"
248
+ ? subscribeToAdaptiveStrategyChange
249
+ : IGNORE_STRATEGY_CHANGES,
250
+ () => resolveAdaptiveStrategy(configuredStrategy),
251
+ () => (configuredStrategy === "adaptive" ? "hover" : configuredStrategy),
252
+ );
269
253
 
270
254
  // Internal ref for viewport observation; merge with forwarded ref
271
255
  const internalRef = useRef<HTMLAnchorElement | null>(null);
@@ -364,10 +348,13 @@ export const Link: ForwardRefExoticComponent<
364
348
  );
365
349
 
366
350
  const handleMouseEnter = useCallback(() => {
351
+ const element = internalRef.current;
367
352
  if (
368
353
  (resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
369
354
  !isExternal &&
370
- ctx?.store
355
+ ctx?.store &&
356
+ (!element ||
357
+ (!isHashOnlyNavigation(element) && !isPrefetchScopeDisabled(element)))
371
358
  ) {
372
359
  // For "hover", this is the primary prefetch trigger.
373
360
  // For "viewport", this upgrades/prioritizes a potentially queued
@@ -392,54 +379,77 @@ export const Link: ForwardRefExoticComponent<
392
379
  const isRender = resolvedStrategy === "render";
393
380
  if (!isViewport && !isRender) return;
394
381
 
395
- let cancelled = false;
396
- let unsubIdle: (() => void) | undefined;
397
- let stopObserving: (() => void) | undefined;
382
+ const armPrefetch = (): (() => void) => {
383
+ const element = internalRef.current;
384
+ if (
385
+ element &&
386
+ (isHashOnlyNavigation(element) || isPrefetchScopeDisabled(element))
387
+ ) {
388
+ return () => {};
389
+ }
398
390
 
399
- const triggerPrefetch = () => {
400
- if (cancelled) return;
401
- const segmentState = ctx.store.getSegmentState();
402
- prefetchQueued(
403
- resolvedTo,
404
- segmentState.currentSegmentIds,
405
- getAppVersion(),
406
- ctx.store.getRouterId?.(),
407
- prefetchKey,
408
- );
409
- };
391
+ let cancelled = false;
392
+ let unsubIdle: (() => void) | undefined;
393
+ let stopObserving: (() => void) | undefined;
394
+
395
+ const triggerPrefetch = () => {
396
+ if (cancelled) return;
397
+ const currentElement = internalRef.current;
398
+ if (currentElement && isPrefetchScopeDisabled(currentElement)) return;
399
+ const segmentState = ctx.store.getSegmentState();
400
+ prefetchQueued(
401
+ resolvedTo,
402
+ segmentState.currentSegmentIds,
403
+ getAppVersion(),
404
+ ctx.store.getRouterId?.(),
405
+ prefetchKey,
406
+ );
407
+ };
410
408
 
411
- // Schedule prefetch only when the app is idle (no navigation/streaming).
412
- // This avoids competing with hydration and active navigation fetches.
413
- const scheduleWhenIdle = (callback: () => void) => {
414
- const state = ctx.eventController.getState();
415
- if (state.state === "idle" && !state.isStreaming) {
416
- callback();
417
- return;
418
- }
419
- const unsub = ctx.eventController.subscribe(() => {
420
- const s = ctx.eventController.getState();
421
- if (s.state === "idle" && !s.isStreaming) {
422
- unsub();
423
- callback();
409
+ if (isRender) {
410
+ unsubIdle = schedulePrefetchWhenRouterIdle(
411
+ ctx.eventController,
412
+ triggerPrefetch,
413
+ );
414
+ } else {
415
+ const viewportElement = internalRef.current;
416
+ if (viewportElement) {
417
+ stopObserving = observeForPrefetch(viewportElement, () => {
418
+ unsubIdle = schedulePrefetchWhenRouterIdle(
419
+ ctx.eventController,
420
+ triggerPrefetch,
421
+ );
422
+ });
424
423
  }
425
- });
426
- unsubIdle = unsub;
424
+ }
425
+
426
+ return () => {
427
+ cancelled = true;
428
+ unsubIdle?.();
429
+ stopObserving?.();
430
+ };
427
431
  };
428
432
 
429
- if (isRender) {
430
- scheduleWhenIdle(triggerPrefetch);
431
- } else if (isViewport) {
432
- const element = internalRef.current;
433
- if (!element) return;
434
- stopObserving = observeForPrefetch(element, () => {
435
- scheduleWhenIdle(triggerPrefetch);
436
- });
437
- }
433
+ let disarmPrefetch = armPrefetch();
434
+ const element = internalRef.current;
435
+ const unsubscribeScope = element
436
+ ? subscribeToPrefetchScopeChange(element, () => {
437
+ disarmPrefetch();
438
+ disarmPrefetch = armPrefetch();
439
+ })
440
+ : undefined;
441
+ const unsubscribeLocation = subscribeToLocationChange(
442
+ ctx.eventController,
443
+ () => {
444
+ disarmPrefetch();
445
+ disarmPrefetch = armPrefetch();
446
+ },
447
+ );
438
448
 
439
449
  return () => {
440
- cancelled = true;
441
- unsubIdle?.();
442
- stopObserving?.();
450
+ unsubscribeScope?.();
451
+ unsubscribeLocation();
452
+ disarmPrefetch();
443
453
  };
444
454
  }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
445
455
 
@@ -349,6 +349,7 @@ export function NavigationProvider({
349
349
  eventController,
350
350
  navigate,
351
351
  refresh,
352
+ defaultPrefetch: initialPayload.metadata.defaultPrefetch,
352
353
  } as NavigationStoreContextValue;
353
354
  Object.defineProperty(value, "basename", {
354
355
  configurable: true,
@@ -3,6 +3,7 @@
3
3
  import { createContext, type Context } from "react";
4
4
  import type { NavigationStore, NavigateOptions } from "../types.js";
5
5
  import type { EventController } from "../event-controller.js";
6
+ import type { PrefetchStrategy } from "../../router/prefetch-default.js";
6
7
 
7
8
  /**
8
9
  * Navigation context value provided by NavigationProvider
@@ -52,6 +53,9 @@ export interface NavigationStoreContextValue {
52
53
  * Used by Link and useRouter() to auto-prefix app-local paths.
53
54
  */
54
55
  basename: string | undefined;
56
+
57
+ /** Router default from this instance's initial payload. */
58
+ defaultPrefetch?: PrefetchStrategy;
55
59
  }
56
60
 
57
61
  /**