From 926877ecbb686e362ffc8710525fb0e42e97f0cf Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 17:24:37 +1000 Subject: [PATCH 1/2] fix(link): reschedule visible prefetches Visible App Router links were prefetched as one-shot observer callbacks. That lost the mounted visibility state needed to re-prefetch after cache invalidation or router state changes, and automatic dynamic links could not be upgraded to a full prefetch on hover. The Link shim now keeps mounted prefetch instances, tracks the visible set, exposes an internal ping hook for cache and router commits, and persists the full-prefetch strategy after unstable_dynamicOnHover intent. Regression coverage verifies visible-link cache invalidation and dynamic-on-hover strategy upshift. --- packages/vinext/src/global.d.ts | 8 ++ .../vinext/src/server/app-browser-entry.ts | 1 + packages/vinext/src/shims/link.tsx | 72 +++++++++++++---- packages/vinext/src/shims/navigation.ts | 4 + packages/vinext/src/shims/next-shims.d.ts | 3 +- tests/link-navigation.test.ts | 77 ++++++++++++++++++- 6 files changed, 147 insertions(+), 18 deletions(-) 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.tsx b/packages/vinext/src/shims/link.tsx index 98e4a656d..e9fd1abd2 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -62,6 +62,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) */ @@ -241,7 +246,37 @@ 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; + mode: LinkPrefetchMode; + isVisible: boolean; +}; + +const observedLinkPrefetches = new WeakMap(); +const visibleLinkPrefetches = new Set(); + +function setVisibleLinkPrefetch(instance: LinkPrefetchInstance, isVisible: boolean): void { + instance.isVisible = isVisible; + if (isVisible) { + visibleLinkPrefetches.add(instance); + prefetchUrl(instance.href, instance.mode, "low"); + } 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) { + prefetchUrl(instance.href, instance.mode, "low"); + } + } +} function getSharedObserver(): IntersectionObserver | null { if (typeof window === "undefined" || typeof IntersectionObserver === "undefined") return null; @@ -250,15 +285,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 +372,7 @@ const Link = forwardRef(function Link( onMouseEnter, onTouchStart, onNavigate, + unstable_dynamicOnHover = false, ...rest }, forwardedRef, @@ -408,19 +438,33 @@ const Link = forwardRef(function Link( const observer = getSharedObserver(); if (!observer) return; - observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch, prefetchMode, "low")); + registerVisibleLinkPing(); + const instance: LinkPrefetchInstance = { + href: hrefToPrefetch, + mode: prefetchMode, + isVisible: false, + }; + observedLinkPrefetches.set(node, instance); observer.observe(node); return () => { observer.unobserve(node); - observerCallbacks.delete(node); + observedLinkPrefetches.delete(node); + visibleLinkPrefetches.delete(instance); }; }, [shouldPrefetch, prefetchMode, localizedHref]); const prefetchOnIntent = useCallback(() => { if (!shouldPrefetch) return; - prefetchUrl(localizedHref, prefetchMode, "high"); - }, [shouldPrefetch, prefetchMode, localizedHref]); + 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"); + }, [shouldPrefetch, 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..ebea8855b 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -512,7 +512,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 +525,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 +568,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 +589,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 +692,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({ From f20410a69962f34360d7b176c7c23ef2ba19ab7f Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 23:21:54 +1000 Subject: [PATCH 2/2] fix(link): preserve pages intent prefetch Pages Router Link prefetch=false should only disable viewport prefetch. The shared gate also blocked hover and touch intent prefetches, which matches App Router but diverges from the Pages Router contract. Split intent eligibility by router mode so App Router keeps the stricter gate while Pages Router intent still prefetches in production. Pages viewport prefetch remains one-shot per mounted link to avoid duplicate document prefetch nodes under the visible-link registry. Regression coverage verifies App Router prefetch=false intent suppression, Pages Router hover and touch intent prefetch, and Pages viewport re-entry dedupe. --- packages/vinext/src/shims/link-prefetch.ts | 21 ++- packages/vinext/src/shims/link.tsx | 44 ++++-- tests/link-navigation.test.ts | 147 ++++++++++++++++++++- 3 files changed, 196 insertions(+), 16 deletions(-) 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 e9fd1abd2..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, @@ -146,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; @@ -248,8 +259,10 @@ function prefetchUrl(href: string, mode: LinkPrefetchMode, priority: "low" | "hi let sharedObserver: IntersectionObserver | null = null; type LinkPrefetchInstance = { href: string; - mode: LinkPrefetchMode; isVisible: boolean; + mode: LinkPrefetchMode; + routerMode: LinkPrefetchRouterMode; + viewportPrefetched: boolean; }; const observedLinkPrefetches = new WeakMap(); @@ -259,7 +272,9 @@ function setVisibleLinkPrefetch(instance: LinkPrefetchInstance, isVisible: boole 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); } @@ -272,7 +287,7 @@ function registerVisibleLinkPing(): void { function pingVisibleLinkPrefetches(): void { for (const instance of visibleLinkPrefetches) { - if (instance.isVisible) { + if (instance.isVisible && instance.routerMode === "app") { prefetchUrl(instance.href, instance.mode, "low"); } } @@ -407,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, @@ -424,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; @@ -441,8 +456,10 @@ const Link = forwardRef(function Link( registerVisibleLinkPing(); const instance: LinkPrefetchInstance = { href: hrefToPrefetch, - mode: prefetchMode, isVisible: false, + mode: prefetchMode, + routerMode: getLinkPrefetchRouterMode(), + viewportPrefetched: false, }; observedLinkPrefetches.set(node, instance); observer.observe(node); @@ -452,10 +469,19 @@ const Link = forwardRef(function Link( observedLinkPrefetches.delete(node); visibleLinkPrefetches.delete(instance); }; - }, [shouldPrefetch, prefetchMode, localizedHref]); + }, [shouldViewportPrefetch, prefetchMode, localizedHref]); const prefetchOnIntent = useCallback(() => { - if (!shouldPrefetch) return; + 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); @@ -464,7 +490,7 @@ const Link = forwardRef(function Link( } } prefetchUrl(localizedHref, intentMode, "high"); - }, [shouldPrefetch, prefetchMode, localizedHref, unstable_dynamicOnHover]); + }, [prefetchProp, isDangerous, prefetchMode, localizedHref, unstable_dynamicOnHover]); const handleMouseEnter = useCallback( (e: MouseEvent) => { diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index ebea8855b..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, @@ -820,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", @@ -839,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();