-
Notifications
You must be signed in to change notification settings - Fork 326
fix(link): align visible and intent prefetching #1258
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import { hasBasePath, stripBasePath } from "../utils/base-path.js"; | |
|
|
||
| export type LinkPrefetchIntent = "viewport" | "intent"; | ||
| export type LinkPrefetchPriority = "low" | "high"; | ||
| export type LinkPrefetchRouterMode = "app" | "pages"; | ||
|
|
||
| export type LinkPrefetchDecision = | ||
| | { | ||
|
|
@@ -20,13 +21,31 @@ export function canLinkPrefetch(input: { | |
| return input.nodeEnv === "production" && input.prefetch !== false && !input.isDangerous; | ||
| } | ||
|
|
||
| export function canLinkIntentPrefetch(input: { | ||
| nodeEnv: string | undefined; | ||
| prefetch: boolean | "auto" | null | undefined; | ||
| isDangerous: boolean; | ||
| routerMode: LinkPrefetchRouterMode; | ||
| }): boolean { | ||
| if (input.nodeEnv !== "production" || input.isDangerous) return false; | ||
| return input.routerMode === "pages" || input.prefetch !== false; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clear and correct: Pages Router intent is always allowed (dangerous/dev already filtered), App Router intent respects |
||
| } | ||
|
|
||
| export function getLinkPrefetchDecision(input: { | ||
| nodeEnv: string | undefined; | ||
| prefetch: boolean | "auto" | null | undefined; | ||
| isDangerous: boolean; | ||
| intent: LinkPrefetchIntent; | ||
| routerMode?: LinkPrefetchRouterMode; | ||
| }): LinkPrefetchDecision { | ||
| if (!canLinkPrefetch(input)) return { shouldPrefetch: false }; | ||
| const shouldPrefetch = | ||
| input.intent === "intent" | ||
| ? canLinkIntentPrefetch({ | ||
| ...input, | ||
| routerMode: input.routerMode ?? "app", | ||
| }) | ||
| : canLinkPrefetch(input); | ||
| if (!shouldPrefetch) return { shouldPrefetch: false }; | ||
|
|
||
| return { | ||
| shouldPrefetch: true, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -32,7 +32,12 @@ import { AppElementsWire } from "../server/app-elements.js"; | |||||||||||
| import { createRscRequestHeaders, createRscRequestUrl } from "../server/app-rsc-cache-busting.js"; | ||||||||||||
| import { VINEXT_MOUNTED_SLOTS_HEADER } from "../server/headers.js"; | ||||||||||||
| import { isDangerousScheme } from "./url-safety.js"; | ||||||||||||
| import { canLinkPrefetch, getLinkPrefetchHref } from "./link-prefetch.js"; | ||||||||||||
| import { | ||||||||||||
| canLinkIntentPrefetch, | ||||||||||||
| canLinkPrefetch, | ||||||||||||
| getLinkPrefetchHref, | ||||||||||||
| type LinkPrefetchRouterMode, | ||||||||||||
| } from "./link-prefetch.js"; | ||||||||||||
| import { | ||||||||||||
| resolveRelativeHref, | ||||||||||||
| toBrowserNavigationHref, | ||||||||||||
|
|
@@ -62,6 +67,11 @@ type LinkProps = { | |||||||||||
| replace?: boolean; | ||||||||||||
| /** Prefetch the page in the background (App Router default: auto, Pages Router default: true) */ | ||||||||||||
| prefetch?: boolean | "auto" | null; | ||||||||||||
| /** | ||||||||||||
| * Unstable App Router option matching Next.js canary: an automatic prefetch | ||||||||||||
| * is upgraded to a full prefetch when the user shows navigation intent. | ||||||||||||
| */ | ||||||||||||
| unstable_dynamicOnHover?: boolean; | ||||||||||||
| /** Whether to pass the href to the child element */ | ||||||||||||
| passHref?: boolean; | ||||||||||||
| /** Scroll to top on navigation (default: true) */ | ||||||||||||
|
|
@@ -141,6 +151,12 @@ function toSameOriginRouteHref(href: string): string | null { | |||||||||||
| return `${stripBasePath(url.pathname, __basePath)}${url.search}`; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function getLinkPrefetchRouterMode(): LinkPrefetchRouterMode { | ||||||||||||
| return typeof window !== "undefined" && typeof window.__VINEXT_RSC_NAVIGATE__ === "function" | ||||||||||||
| ? "app" | ||||||||||||
| : "pages"; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function canAutoPrefetchFullAppRoute(href: string): boolean { | ||||||||||||
| if (typeof window === "undefined") return false; | ||||||||||||
|
|
||||||||||||
|
|
@@ -241,7 +257,41 @@ function prefetchUrl(href: string, mode: LinkPrefetchMode, priority: "low" | "hi | |||||||||||
| * All Link elements use the same observer to minimize resource usage. | ||||||||||||
| */ | ||||||||||||
| let sharedObserver: IntersectionObserver | null = null; | ||||||||||||
| const observerCallbacks = new WeakMap<Element, () => void>(); | ||||||||||||
| type LinkPrefetchInstance = { | ||||||||||||
| href: string; | ||||||||||||
| isVisible: boolean; | ||||||||||||
| mode: LinkPrefetchMode; | ||||||||||||
| routerMode: LinkPrefetchRouterMode; | ||||||||||||
| viewportPrefetched: boolean; | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| const observedLinkPrefetches = new WeakMap<Element, LinkPrefetchInstance>(); | ||||||||||||
| const visibleLinkPrefetches = new Set<LinkPrefetchInstance>(); | ||||||||||||
|
|
||||||||||||
| function setVisibleLinkPrefetch(instance: LinkPrefetchInstance, isVisible: boolean): void { | ||||||||||||
| instance.isVisible = isVisible; | ||||||||||||
| if (isVisible) { | ||||||||||||
| visibleLinkPrefetches.add(instance); | ||||||||||||
| if (instance.routerMode === "pages" && instance.viewportPrefetched) return; | ||||||||||||
| prefetchUrl(instance.href, instance.mode, "low"); | ||||||||||||
| instance.viewportPrefetched = true; | ||||||||||||
| } else { | ||||||||||||
| visibleLinkPrefetches.delete(instance); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function registerVisibleLinkPing(): void { | ||||||||||||
| if (typeof window === "undefined") return; | ||||||||||||
| window.__VINEXT_PING_VISIBLE_LINKS__ = pingVisibleLinkPrefetches; | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Suggested change
This also documents that the hook is meant to be installed once. |
||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function pingVisibleLinkPrefetches(): void { | ||||||||||||
| for (const instance of visibleLinkPrefetches) { | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||
| if (instance.isVisible && instance.routerMode === "app") { | ||||||||||||
| prefetchUrl(instance.href, instance.mode, "low"); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function getSharedObserver(): IntersectionObserver | null { | ||||||||||||
| if (typeof window === "undefined" || typeof IntersectionObserver === "undefined") return null; | ||||||||||||
|
|
@@ -250,15 +300,9 @@ function getSharedObserver(): IntersectionObserver | null { | |||||||||||
| sharedObserver = new IntersectionObserver( | ||||||||||||
| (entries) => { | ||||||||||||
| for (const entry of entries) { | ||||||||||||
| if (entry.isIntersecting) { | ||||||||||||
| const callback = observerCallbacks.get(entry.target); | ||||||||||||
| if (callback) { | ||||||||||||
| callback(); | ||||||||||||
| // Unobserve after prefetching — only prefetch once | ||||||||||||
| sharedObserver?.unobserve(entry.target); | ||||||||||||
| observerCallbacks.delete(entry.target); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| const instance = observedLinkPrefetches.get(entry.target); | ||||||||||||
| if (!instance) continue; | ||||||||||||
| setVisibleLinkPrefetch(instance, entry.isIntersecting || entry.intersectionRatio > 0); | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||
| } | ||||||||||||
| }, | ||||||||||||
| { | ||||||||||||
|
|
@@ -343,6 +387,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |||||||||||
| onMouseEnter, | ||||||||||||
| onTouchStart, | ||||||||||||
| onNavigate, | ||||||||||||
| unstable_dynamicOnHover = false, | ||||||||||||
| ...rest | ||||||||||||
| }, | ||||||||||||
| forwardedRef, | ||||||||||||
|
|
@@ -377,7 +422,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |||||||||||
| // into a full RSC prefetch, matching Next.js's public prefetch contract. | ||||||||||||
| const internalRef = useRef<HTMLAnchorElement | null>(null); | ||||||||||||
| const prefetchMode = resolveLinkPrefetchMode(prefetchProp, isDangerous); | ||||||||||||
| const shouldPrefetch = canLinkPrefetch({ | ||||||||||||
| const shouldViewportPrefetch = canLinkPrefetch({ | ||||||||||||
| nodeEnv: process.env.NODE_ENV, | ||||||||||||
| prefetch: prefetchProp, | ||||||||||||
| isDangerous, | ||||||||||||
|
|
@@ -394,7 +439,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |||||||||||
| ); | ||||||||||||
|
|
||||||||||||
| useEffect(() => { | ||||||||||||
| if (!shouldPrefetch || typeof window === "undefined") return; | ||||||||||||
| if (!shouldViewportPrefetch || typeof window === "undefined") return; | ||||||||||||
| const node = internalRef.current; | ||||||||||||
| if (!node) return; | ||||||||||||
|
|
||||||||||||
|
|
@@ -408,19 +453,44 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |||||||||||
| const observer = getSharedObserver(); | ||||||||||||
| if (!observer) return; | ||||||||||||
|
|
||||||||||||
| observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch, prefetchMode, "low")); | ||||||||||||
| registerVisibleLinkPing(); | ||||||||||||
| const instance: LinkPrefetchInstance = { | ||||||||||||
| href: hrefToPrefetch, | ||||||||||||
| isVisible: false, | ||||||||||||
| mode: prefetchMode, | ||||||||||||
| routerMode: getLinkPrefetchRouterMode(), | ||||||||||||
| viewportPrefetched: false, | ||||||||||||
| }; | ||||||||||||
| observedLinkPrefetches.set(node, instance); | ||||||||||||
| observer.observe(node); | ||||||||||||
|
|
||||||||||||
| return () => { | ||||||||||||
| observer.unobserve(node); | ||||||||||||
| observerCallbacks.delete(node); | ||||||||||||
| observedLinkPrefetches.delete(node); | ||||||||||||
| visibleLinkPrefetches.delete(instance); | ||||||||||||
| }; | ||||||||||||
| }, [shouldPrefetch, prefetchMode, localizedHref]); | ||||||||||||
| }, [shouldViewportPrefetch, prefetchMode, localizedHref]); | ||||||||||||
|
|
||||||||||||
| const prefetchOnIntent = useCallback(() => { | ||||||||||||
| if (!shouldPrefetch) return; | ||||||||||||
| prefetchUrl(localizedHref, prefetchMode, "high"); | ||||||||||||
| }, [shouldPrefetch, prefetchMode, localizedHref]); | ||||||||||||
| if ( | ||||||||||||
| !canLinkIntentPrefetch({ | ||||||||||||
| nodeEnv: process.env.NODE_ENV, | ||||||||||||
| prefetch: prefetchProp, | ||||||||||||
| isDangerous, | ||||||||||||
| routerMode: getLinkPrefetchRouterMode(), | ||||||||||||
| }) | ||||||||||||
| ) { | ||||||||||||
| return; | ||||||||||||
| } | ||||||||||||
| const intentMode = unstable_dynamicOnHover ? "full" : prefetchMode; | ||||||||||||
| if (unstable_dynamicOnHover && internalRef.current) { | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor observation: The Just want to confirm: is there a scenario where the user hovers, the instance gets upgraded to |
||||||||||||
| const instance = observedLinkPrefetches.get(internalRef.current); | ||||||||||||
| if (instance) { | ||||||||||||
| instance.mode = "full"; | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| prefetchUrl(localizedHref, intentMode, "high"); | ||||||||||||
| }, [prefetchProp, isDangerous, prefetchMode, localizedHref, unstable_dynamicOnHover]); | ||||||||||||
|
|
||||||||||||
| const handleMouseEnter = useCallback( | ||||||||||||
| (e: MouseEvent<HTMLAnchorElement>) => { | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -473,6 +473,9 @@ export function invalidatePrefetchCache(): void { | |
| deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true); | ||
| } | ||
| prefetched.clear(); | ||
| if (!isServer) { | ||
| window.__VINEXT_PING_VISIBLE_LINKS__?.(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The ordering here is important and correct: |
||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -1182,6 +1185,7 @@ export function commitClientNavigationState( | |
|
|
||
| if (shouldNotify) { | ||
| notifyNavigationListeners(); | ||
| window.__VINEXT_PING_VISIBLE_LINKS__?.(); | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the right place to ping — after
setMountedSlotsHeaderupdates the slot context, visible links will re-prefetch with the correct mounted-slots header. The ping is conditional ontreeState.elementschanging (the effect dep), which is when slot bindings can change. Clean.