Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/vinext/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ declare global {
*/
__VINEXT_RSC_PREFETCHED_URLS__: Set<string> | undefined;

/**
* Re-prefetches currently visible App Router links after cache invalidation
* or router-state changes. Installed by `next/link` when Link is loaded on
* the client; called opportunistically by navigation/cache owners without a
* direct import to avoid a circular dependency.
*/
__VINEXT_PING_VISIBLE_LINKS__: (() => void) | undefined;

// ── Next.js conventional globals ────────────────────────────────────────
//
// `__NEXT_DATA__` is already declared by `next/dist/client/index.d.ts` as
Expand Down
1 change: 1 addition & 0 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ function BrowserRoot({

useLayoutEffect(() => {
setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements));
window.__VINEXT_PING_VISIBLE_LINKS__?.();
Copy link
Copy Markdown
Contributor

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 setMountedSlotsHeader updates the slot context, visible links will re-prefetch with the correct mounted-slots header. The ping is conditional on treeState.elements changing (the effect dep), which is when slot bindings can change. Clean.

}, [treeState.elements]);

useLayoutEffect(() => {
Expand Down
21 changes: 20 additions & 1 deletion packages/vinext/src/shims/link-prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
| {
Expand All @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 prefetch !== false. The short-circuit reads naturally.

}

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,
Expand Down
108 changes: 89 additions & 19 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) */
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: registerVisibleLinkPing is called on every effect setup (every mount of every prefetchable Link). The function is cheap (one property write), but it unconditionally overwrites window.__VINEXT_PING_VISIBLE_LINKS__ on every call. Since it always assigns the same function reference, this is harmless, but you could guard it with a module-level boolean to make the intent clearer and avoid the repeated write:

Suggested change
window.__VINEXT_PING_VISIBLE_LINKS__ = pingVisibleLinkPrefetches;
function registerVisibleLinkPing(): void {
if (typeof window === "undefined" || window.__VINEXT_PING_VISIBLE_LINKS__) return;
window.__VINEXT_PING_VISIBLE_LINKS__ = pingVisibleLinkPrefetches;
}

This also documents that the hook is meant to be installed once.

}

function pingVisibleLinkPrefetches(): void {
for (const instance of visibleLinkPrefetches) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instance.isVisible check inside pingVisibleLinkPrefetches is always true by construction — instances are only in visibleLinkPrefetches when isVisible is true (added on setVisibleLinkPrefetch(_, true), removed on setVisibleLinkPrefetch(_, false)). The check is defensive and doesn't hurt, but worth a comment explaining it's a safety belt rather than a reachable branch, so future readers don't wonder when it could be false.

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;
Expand All @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entry.isIntersecting || entry.intersectionRatio > 0 disjunction matches what Next.js does in links.ts for browser compatibility. Good.

}
},
{
Expand Down Expand Up @@ -343,6 +387,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
onMouseEnter,
onTouchStart,
onNavigate,
unstable_dynamicOnHover = false,
...rest
},
forwardedRef,
Expand Down Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor observation: The unstable_dynamicOnHover instance upgrade mutates instance.mode to "full", but there's no path that reverts it. If the user navigates away and back (re-mount), a new instance is created so that's fine. But if the localizedHref or prefetchMode changes (effect deps), the cleanup removes the old instance and creates a new one with the original mode — also correct.

Just want to confirm: is there a scenario where the user hovers, the instance gets upgraded to "full", then the component re-renders with unstable_dynamicOnHover={false} without the effect re-running? Since unstable_dynamicOnHover is not in the viewport-prefetch effect's dependency array (only in prefetchOnIntent's useCallback deps), the instance created by the viewport effect would stay upgraded. This seems unlikely in practice (the prop is static), but worth noting.

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>) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,9 @@ export function invalidatePrefetchCache(): void {
deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true);
}
prefetched.clear();
if (!isServer) {
window.__VINEXT_PING_VISIBLE_LINKS__?.();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering here is important and correct: prefetched.clear() runs before pingVisibleLinkPrefetches(), so the dedup guard inside prefetchUrl won't block re-prefetch. Good.

}
}

/**
Expand Down Expand Up @@ -1182,6 +1185,7 @@ export function commitClientNavigationState(

if (shouldNotify) {
notifyNavigationListeners();
window.__VINEXT_PING_VISIBLE_LINKS__?.();
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/vinext/src/shims/next-shims.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ declare module "next/link" {
href: string | { pathname?: string; query?: UrlQuery };
as?: string;
replace?: boolean;
prefetch?: boolean;
prefetch?: boolean | "auto" | null;
unstable_dynamicOnHover?: boolean;
passHref?: boolean;
scroll?: boolean;
locale?: string | false;
Expand Down
Loading
Loading