diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 44e2fdd80..83de42720 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -137,6 +137,14 @@ declare global { */ __VINEXT_RSC_PREFETCHED_URLS__: Set | 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 diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1c40bcd21..92a0f9efb 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -546,6 +546,7 @@ function BrowserRoot({ useLayoutEffect(() => { setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements)); + window.__VINEXT_PING_VISIBLE_LINKS__?.(); }, [treeState.elements]); useLayoutEffect(() => { diff --git a/packages/vinext/src/shims/link-prefetch.ts b/packages/vinext/src/shims/link-prefetch.ts index 29d5da0e3..179bf4d11 100644 --- a/packages/vinext/src/shims/link-prefetch.ts +++ b/packages/vinext/src/shims/link-prefetch.ts @@ -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; +} + 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, diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 98e4a656d..c47c9c56a 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -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 void>(); +type LinkPrefetchInstance = { + href: string; + isVisible: boolean; + mode: LinkPrefetchMode; + routerMode: LinkPrefetchRouterMode; + viewportPrefetched: boolean; +}; + +const observedLinkPrefetches = new WeakMap(); +const visibleLinkPrefetches = new Set(); + +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; +} + +function pingVisibleLinkPrefetches(): void { + for (const instance of visibleLinkPrefetches) { + 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); } }, { @@ -343,6 +387,7 @@ const Link = forwardRef(function Link( onMouseEnter, onTouchStart, onNavigate, + unstable_dynamicOnHover = false, ...rest }, forwardedRef, @@ -377,7 +422,7 @@ const Link = forwardRef(function Link( // into a full RSC prefetch, matching Next.js's public prefetch contract. const internalRef = useRef(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(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(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) { + 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) => { diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 0a593255e..5ab41b6ec 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -473,6 +473,9 @@ export function invalidatePrefetchCache(): void { deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true); } prefetched.clear(); + if (!isServer) { + window.__VINEXT_PING_VISIBLE_LINKS__?.(); + } } /** @@ -1182,6 +1185,7 @@ export function commitClientNavigationState( if (shouldNotify) { notifyNavigationListeners(); + window.__VINEXT_PING_VISIBLE_LINKS__?.(); } } diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index 80baac7ac..b9d9fd842 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -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; diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 4f3cf2532..a8d3964d7 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -6,6 +6,7 @@ import { getLinkPrefetchHref, type LinkPrefetchIntent, type LinkPrefetchDecision, + type LinkPrefetchRouterMode, } from "../packages/vinext/src/shims/link-prefetch.js"; import type { VinextLinkPrefetchRoute } from "../packages/vinext/src/client/vinext-next-data.js"; @@ -31,6 +32,12 @@ type CapturedAnchorProps = { ref?: (node: HTMLAnchorElement | null) => void; }; +type CapturedPrefetchLinkElement = { + as?: string; + href?: string; + rel?: string; +}; + const linkPrefetchRoutes = [ { patternParts: ["viewport-prefetch-target"], isDynamic: false }, { patternParts: ["intent-prefetch-target"], isDynamic: false }, @@ -177,10 +184,21 @@ describe("Link prefetch pure decisions", () => { expected: { shouldPrefetch: true, priority: "high" }, }, { - name: "prod + prefetch=false", + name: "prod + app intent + prefetch=false", input: { nodeEnv: "production", prefetch: false, isDangerous: false, intent: "intent" }, expected: { shouldPrefetch: false }, }, + { + name: "prod + pages intent + prefetch=false", + input: { + nodeEnv: "production", + prefetch: false, + isDangerous: false, + intent: "intent", + routerMode: "pages", + }, + expected: { shouldPrefetch: true, priority: "high" }, + }, { name: "prod + dangerous", input: { nodeEnv: "production", prefetch: undefined, isDangerous: true, intent: "intent" }, @@ -193,6 +211,7 @@ describe("Link prefetch pure decisions", () => { prefetch: boolean | undefined; isDangerous: boolean; intent: LinkPrefetchIntent; + routerMode?: LinkPrefetchRouterMode; }; expected: LinkPrefetchDecision; }>; @@ -386,12 +405,21 @@ async function renderIsolatedLink(options: { }); const fetch = vi.fn(() => Promise.resolve(new Response(""))); + const pagePrefetchLinks: CapturedPrefetchLinkElement[] = []; const location = { href: "https://example.com/current", origin: "https://example.com", }; vi.stubGlobal("fetch", fetch); + vi.stubGlobal("document", { + createElement: vi.fn(() => ({})), + head: { + appendChild: vi.fn((node: CapturedPrefetchLinkElement) => { + pagePrefetchLinks.push(node); + }), + }, + }); vi.stubGlobal("window", { __VINEXT_RSC_NAVIGATE__: vi.fn(), addEventListener: vi.fn(), @@ -437,6 +465,7 @@ async function renderIsolatedLink(options: { anchor, capturedAnchorProps, fetch, + pagePrefetchLinks, restoreNodeEnv, }; } catch (error) { @@ -445,7 +474,7 @@ async function renderIsolatedLink(options: { } } -describe("Link App Router prefetch scheduling", () => { +describe("Link prefetch scheduling", () => { function stubIntersectionObserver() { let intersectionCallback: IntersectionObserverCallback | undefined; const observe = vi.fn(); @@ -469,7 +498,7 @@ describe("Link App Router prefetch scheduling", () => { return { observe, unobserve, - dispatchIntersectingEntry(anchor: HTMLAnchorElement) { + dispatchIntersectingEntry(anchor: HTMLAnchorElement, isIntersecting = true) { const rect = { bottom: 0, height: 0, @@ -485,9 +514,9 @@ describe("Link App Router prefetch scheduling", () => { [ { boundingClientRect: rect, - intersectionRatio: 1, + intersectionRatio: isIntersecting ? 1 : 0, intersectionRect: rect, - isIntersecting: true, + isIntersecting, rootBounds: null, target: anchor, time: 0, @@ -512,7 +541,7 @@ describe("Link App Router prefetch scheduling", () => { observer.dispatchIntersectingEntry(result.anchor); await flushPrefetchTasks(); - expect(observer.unobserve).toHaveBeenCalledWith(result.anchor); + expect(observer.unobserve).not.toHaveBeenCalledWith(result.anchor); expect(result.fetch).toHaveBeenCalledWith( expect.stringContaining("/viewport-prefetch-target.rsc"), expect.objectContaining({ @@ -525,6 +554,36 @@ describe("Link App Router prefetch scheduling", () => { } }); + it("re-prefetches visible links after the prefetch cache is invalidated", async () => { + const observer = stubIntersectionObserver(); + + const result = await renderIsolatedLink({ + href: "/viewport-prefetch-target", + nodeEnv: "production", + }); + const { invalidatePrefetchCache } = await import("../packages/vinext/src/shims/navigation.js"); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await flushPrefetchTasks(); + expect(result.fetch).toHaveBeenCalledTimes(1); + + invalidatePrefetchCache(); + await flushPrefetchTasks(); + + expect(result.fetch).toHaveBeenCalledTimes(2); + expect(result.fetch).toHaveBeenLastCalledWith( + expect.stringContaining("/viewport-prefetch-target.rsc"), + expect.objectContaining({ + credentials: "include", + priority: "low", + }), + ); + } finally { + result.restoreNodeEnv(); + } + }); + it("does not full-prefetch visible dynamic links in automatic production mode", async () => { const observer = stubIntersectionObserver(); @@ -538,7 +597,7 @@ describe("Link App Router prefetch scheduling", () => { observer.dispatchIntersectingEntry(result.anchor); await flushPrefetchTasks(); - expect(observer.unobserve).toHaveBeenCalledWith(result.anchor); + expect(observer.unobserve).not.toHaveBeenCalledWith(result.anchor); expect(result.fetch).not.toHaveBeenCalled(); } finally { result.restoreNodeEnv(); @@ -559,7 +618,7 @@ describe("Link App Router prefetch scheduling", () => { observer.dispatchIntersectingEntry(result.anchor); await flushPrefetchTasks(); - expect(observer.unobserve).toHaveBeenCalledWith(result.anchor); + expect(observer.unobserve).not.toHaveBeenCalledWith(result.anchor); expect(result.fetch).toHaveBeenCalledWith( expect.stringContaining("/blog/hello.rsc"), expect.objectContaining({ @@ -662,6 +721,47 @@ describe("Link App Router prefetch scheduling", () => { } }); + it("upgrades automatic dynamic links to full prefetch on unstable_dynamicOnHover intent", async () => { + const observer = stubIntersectionObserver(); + const result = await renderIsolatedLink({ + href: "/blog/hello", + nodeEnv: "production", + props: { unstable_dynamicOnHover: true }, + }); + const { invalidatePrefetchCache } = await import("../packages/vinext/src/shims/navigation.js"); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await flushPrefetchTasks(); + expect(result.fetch).not.toHaveBeenCalled(); + + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(result.fetch).toHaveBeenCalledWith( + expect.stringContaining("/blog/hello.rsc"), + expect.objectContaining({ + credentials: "include", + priority: "high", + }), + ); + + invalidatePrefetchCache(); + await flushPrefetchTasks(); + + expect(result.fetch).toHaveBeenCalledTimes(2); + expect(result.fetch).toHaveBeenLastCalledWith( + expect.stringContaining("/blog/hello.rsc"), + expect.objectContaining({ + credentials: "include", + priority: "low", + }), + ); + } finally { + result.restoreNodeEnv(); + } + }); + it("prefetches on touch intent in production while preserving the user handler", async () => { const userOnTouchStart = vi.fn(); const result = await renderIsolatedLink({ @@ -749,7 +849,7 @@ describe("Link App Router prefetch scheduling", () => { } }); - it("does not prefetch on intent when prefetch is false", async () => { + it("does not App Router prefetch on intent when prefetch is false", async () => { const userOnMouseEnter = vi.fn(); const result = await renderIsolatedLink({ href: "/disabled-intent-prefetch-target", @@ -768,6 +868,112 @@ describe("Link App Router prefetch scheduling", () => { } }); + it("prefetches Pages Router links on mouse intent when prefetch is false", async () => { + const userOnMouseEnter = vi.fn(); + const result = await renderIsolatedLink({ + href: "/pages-disabled-mouse-intent-prefetch-target", + nodeEnv: "production", + props: { onMouseEnter: userOnMouseEnter, prefetch: false }, + windowOverrides: { + __VINEXT_RSC_NAVIGATE__: undefined, + __NEXT_DATA__: { + __vinext: { + pageModuleUrl: "/_next/static/chunks/pages/current.js", + }, + }, + }, + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(userOnMouseEnter).toHaveBeenCalledTimes(1); + expect(result.fetch).not.toHaveBeenCalled(); + expect(result.pagePrefetchLinks).toEqual([ + { + as: "document", + href: "/pages-disabled-mouse-intent-prefetch-target", + rel: "prefetch", + }, + ]); + } finally { + result.restoreNodeEnv(); + } + }); + + it("prefetches Pages Router links on touch intent when prefetch is false", async () => { + const userOnTouchStart = vi.fn(); + const result = await renderIsolatedLink({ + href: "/pages-disabled-touch-intent-prefetch-target", + nodeEnv: "production", + props: { onTouchStart: userOnTouchStart, prefetch: false }, + windowOverrides: { + __VINEXT_RSC_NAVIGATE__: undefined, + __NEXT_DATA__: { + __vinext: { + pageModuleUrl: "/_next/static/chunks/pages/current.js", + }, + }, + }, + }); + + try { + result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(userOnTouchStart).toHaveBeenCalledTimes(1); + expect(result.fetch).not.toHaveBeenCalled(); + expect(result.pagePrefetchLinks).toEqual([ + { + as: "document", + href: "/pages-disabled-touch-intent-prefetch-target", + rel: "prefetch", + }, + ]); + } finally { + result.restoreNodeEnv(); + } + }); + + it("does not duplicate Pages Router viewport prefetch after visibility changes", async () => { + const observer = stubIntersectionObserver(); + const result = await renderIsolatedLink({ + href: "/pages-viewport-prefetch-target", + nodeEnv: "production", + windowOverrides: { + __VINEXT_RSC_NAVIGATE__: undefined, + __NEXT_DATA__: { + __vinext: { + pageModuleUrl: "/_next/static/chunks/pages/current.js", + }, + }, + }, + }); + + try { + observer.dispatchIntersectingEntry(result.anchor, true); + await flushPrefetchTasks(); + observer.dispatchIntersectingEntry(result.anchor, false); + await flushPrefetchTasks(); + observer.dispatchIntersectingEntry(result.anchor, true); + await flushPrefetchTasks(); + window.__VINEXT_PING_VISIBLE_LINKS__?.(); + await flushPrefetchTasks(); + + expect(result.fetch).not.toHaveBeenCalled(); + expect(result.pagePrefetchLinks).toEqual([ + { + as: "document", + href: "/pages-viewport-prefetch-target", + rel: "prefetch", + }, + ]); + } finally { + result.restoreNodeEnv(); + } + }); + it("does not observe visible links when prefetch is false", async () => { const observe = vi.fn(); const unobserve = vi.fn();