From 97dbffa9cc137532e16932e144a73d563c0c9cf6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 9 May 2026 18:25:15 +1000 Subject: [PATCH 1/5] fix(link): align app-router prefetch scheduling with intent Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path. The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough. --- packages/vinext/src/shims/link.tsx | 71 ++++--- tests/link-navigation.test.ts | 302 +++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 22 deletions(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 280452b76..f38ff26a0 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -17,6 +17,7 @@ import React, { useState, type AnchorHTMLAttributes, type MouseEvent, + type TouchEvent, } from "react"; // Import shared RSC prefetch utilities from navigation shim (relative path // so this resolves both via the Vite plugin and in direct vitest imports) @@ -53,6 +54,8 @@ type NavigateEvent = { defaultPrevented: boolean; }; +type PrefetchPriority = "low" | "high"; + type LinkProps = { href: string | { pathname?: string; query?: UrlQuery }; /** URL displayed in the browser (when href is a route pattern like /user/[id]) */ @@ -118,16 +121,11 @@ function resolveHref(href: LinkProps["href"]): string { * Uses `requestIdleCallback` (or `setTimeout` fallback) to avoid blocking * the main thread during initial page load. */ -function prefetchUrl(href: string): void { +function prefetchUrl(href: string, priority: PrefetchPriority = "low"): void { if (typeof window === "undefined") return; - // Normalize same-origin absolute URLs to local paths before prefetching - let prefetchHref = href; - if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { - const localPath = toSameOriginAppPath(href, __basePath); - if (localPath == null) return; // truly external — don't prefetch - prefetchHref = localPath; - } + const prefetchHref = getPrefetchHref(href); + if (prefetchHref == null) return; const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath); @@ -154,7 +152,7 @@ function prefetchUrl(href: string): void { fetch(rscUrl, { headers, credentials: "include", - priority: "low" as const, + priority, // @ts-expect-error — purpose is a valid fetch option in some browsers purpose: "prefetch", }), @@ -178,6 +176,14 @@ function prefetchUrl(href: string): void { }); } +function getPrefetchHref(href: string): string | null { + if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { + return toSameOriginAppPath(href, __basePath); + } + + return href; +} + /** * Shared IntersectionObserver for viewport-based prefetching. * All Link elements use the same observer to minimize resource usage. @@ -282,6 +288,8 @@ const Link = forwardRef(function Link( scroll = true, children, onClick, + onMouseEnter, + onTouchStart, onNavigate, ...rest }, @@ -315,7 +323,8 @@ const Link = forwardRef(function Link( // Prefetching: observe the element when it enters the viewport. // prefetch={false} disables, prefetch={true} or undefined/null (default) enables. const internalRef = useRef(null); - const shouldPrefetch = prefetchProp !== false && !isDangerous; + const shouldPrefetch = + process.env.NODE_ENV === "production" && prefetchProp !== false && !isDangerous; const setRefs = useCallback( (node: HTMLAnchorElement | null) => { @@ -332,17 +341,8 @@ const Link = forwardRef(function Link( const node = internalRef.current; if (!node) return; - // Normalize same-origin absolute URLs; skip truly external ones - let hrefToPrefetch = localizedHref; - if ( - localizedHref.startsWith("http://") || - localizedHref.startsWith("https://") || - localizedHref.startsWith("//") - ) { - const localPath = toSameOriginAppPath(localizedHref, __basePath); - if (localPath == null) return; // truly external - hrefToPrefetch = localPath; - } + const hrefToPrefetch = getPrefetchHref(localizedHref); + if (hrefToPrefetch == null) return; const observer = getSharedObserver(); if (!observer) return; @@ -356,6 +356,27 @@ const Link = forwardRef(function Link( }; }, [shouldPrefetch, localizedHref]); + const prefetchOnIntent = useCallback(() => { + if (!shouldPrefetch) return; + prefetchUrl(localizedHref, "high"); + }, [shouldPrefetch, localizedHref]); + + const handleMouseEnter = useCallback( + (e: MouseEvent) => { + onMouseEnter?.(e); + prefetchOnIntent(); + }, + [onMouseEnter, prefetchOnIntent], + ); + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + onTouchStart?.(e); + prefetchOnIntent(); + }, + [onTouchStart, prefetchOnIntent], + ); + const handleClick = async (e: MouseEvent) => { if (onClick) onClick(e); if (e.defaultPrevented) return; @@ -471,7 +492,11 @@ const Link = forwardRef(function Link( if (process.env.NODE_ENV !== "production") { console.warn(` blocked dangerous href: ${resolvedHref}`); } - return {children}; + return ( + + {children} + + ); } return ( @@ -482,6 +507,8 @@ const Link = forwardRef(function Link( onClick={(event) => { void handleClick(event); }} + onMouseEnter={handleMouseEnter} + onTouchStart={handleTouchStart} {...anchorProps} > {children} diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index c9613c68d..76aafa788 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import ReactDOMServer from "react-dom/server"; import type { ElementType, ReactNode } from "react"; +type CapturedEffect = () => void | (() => void); + type CapturedClickEvent = { altKey?: boolean; button: number; @@ -15,6 +17,9 @@ type CapturedClickEvent = { type CapturedAnchorProps = { onClick?: (event: CapturedClickEvent) => void | Promise; + onMouseEnter?: (event: { currentTarget: HTMLAnchorElement }) => void; + onTouchStart?: (event: { currentTarget: HTMLAnchorElement }) => void; + ref?: (node: HTMLAnchorElement | null) => void; }; afterEach(() => { @@ -23,6 +28,7 @@ afterEach(() => { vi.doUnmock("react/jsx-dev-runtime"); vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.resetModules(); }); describe("Link App Router navigation scheduling", () => { @@ -147,3 +153,299 @@ describe("Link App Router navigation scheduling", () => { expect(transitionStates).toEqual([true]); }); }); + +async function renderIsolatedLink(options: { + href: string; + nodeEnv: string; + props?: Record; + requireRef?: boolean; + windowOverrides?: Record; +}) { + vi.resetModules(); + + const previousNodeEnv = process.env.NODE_ENV; + const restoreNodeEnv = () => { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + }; + process.env.NODE_ENV = options.nodeEnv; + + const effects: CapturedEffect[] = []; + let capturedAnchorProps: CapturedAnchorProps | undefined; + + const captureAnchor = (type: unknown, props: unknown) => { + if (type === "a" && props !== null && typeof props === "object") { + capturedAnchorProps = props; + } + }; + + vi.doMock("react", async () => { + const actual = await vi.importActual("react"); + const createElement = (( + type: ElementType, + props: Record | null, + ...children: ReactNode[] + ) => { + captureAnchor(type, props); + return actual.createElement(type, props, ...children); + }) as typeof actual.createElement; + + const useEffect = (effect: CapturedEffect) => { + effects.push(effect); + }; + + return { + ...actual, + createElement, + useEffect, + default: { ...actual, createElement, useEffect }, + }; + }); + + vi.doMock("react/jsx-runtime", async () => { + const actual = await vi.importActual("react/jsx-runtime"); + return { + ...actual, + jsx(type: ElementType, props: Record, key?: string) { + captureAnchor(type, props); + return actual.jsx(type, props, key); + }, + jsxs(type: ElementType, props: Record, key?: string) { + captureAnchor(type, props); + return actual.jsxs(type, props, key); + }, + }; + }); + + vi.doMock("react/jsx-dev-runtime", async () => { + const actual = + await vi.importActual("react/jsx-dev-runtime"); + return { + ...actual, + jsxDEV( + type: ElementType, + props: Record, + key?: string, + isStaticChildren?: boolean, + source?: Parameters[4], + self?: Parameters[5], + ) { + captureAnchor(type, props); + return actual.jsxDEV(type, props, key, isStaticChildren ?? false, source, self); + }, + }; + }); + + const fetch = vi.fn(() => Promise.resolve(new Response(""))); + const location = { + href: "https://example.com/current", + origin: "https://example.com", + }; + + vi.stubGlobal("fetch", fetch); + vi.stubGlobal("window", { + __VINEXT_RSC_NAVIGATE__: vi.fn(), + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + history: { + pushState: vi.fn(), + replaceState: vi.fn(), + }, + location, + requestIdleCallback: vi.fn((callback: () => void) => { + callback(); + return 1; + }), + scrollTo: vi.fn(), + ...options.windowOverrides, + }); + + const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js"); + const React = await vi.importActual("react"); + + try { + ReactDOMServer.renderToString( + React.createElement(IsolatedLink, { href: options.href, ...options.props }, "target"), + ); + + if (capturedAnchorProps === undefined) { + throw new Error("Expected rendered Link to expose anchor props"); + } + + if (options.requireRef !== false && capturedAnchorProps.ref === undefined) { + throw new Error("Expected rendered Link anchor to expose a ref"); + } + + const anchor = { href: options.href } as HTMLAnchorElement; + capturedAnchorProps.ref?.(anchor); + + for (const effect of effects) { + effect(); + } + + return { + anchor, + capturedAnchorProps, + fetch, + restoreNodeEnv, + }; + } catch (error) { + restoreNodeEnv(); + throw error; + } +} + +describe("Link App Router prefetch scheduling", () => { + it("does not prefetch visible links in development", async () => { + // Next.js disables App Router viewport prefetching in development: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/links.ts + const observe = vi.fn(); + const unobserve = vi.fn(); + class FakeIntersectionObserver { + observe = observe; + unobserve = unobserve; + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + + const result = await renderIsolatedLink({ + href: "/dev-prefetch-target", + nodeEnv: "development", + }); + + try { + expect(observe).not.toHaveBeenCalled(); + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + }); + + it("prefetches on mouse intent in production while preserving the user handler", async () => { + // Next.js triggers intent prefetch from Link onMouseEnter: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/app-dir/link.tsx + const userOnMouseEnter = vi.fn(); + const result = await renderIsolatedLink({ + href: "/intent-prefetch-target", + nodeEnv: "production", + props: { onMouseEnter: userOnMouseEnter }, + }); + + try { + expect(result.capturedAnchorProps.onMouseEnter).toBeTypeOf("function"); + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await Promise.resolve(); + await Promise.resolve(); + + expect(userOnMouseEnter).toHaveBeenCalledTimes(1); + expect(result.fetch).toHaveBeenCalledWith( + expect.stringContaining("/intent-prefetch-target.rsc"), + expect.objectContaining({ + credentials: "include", + priority: "high", + }), + ); + } finally { + result.restoreNodeEnv(); + } + }); + + it("prefetches on touch intent in production while preserving the user handler", async () => { + const userOnTouchStart = vi.fn(); + const result = await renderIsolatedLink({ + href: "/touch-prefetch-target", + nodeEnv: "production", + props: { onTouchStart: userOnTouchStart }, + }); + + try { + expect(result.capturedAnchorProps.onTouchStart).toBeTypeOf("function"); + result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor }); + await Promise.resolve(); + await Promise.resolve(); + + expect(userOnTouchStart).toHaveBeenCalledTimes(1); + expect(result.fetch).toHaveBeenCalledWith( + expect.stringContaining("/touch-prefetch-target.rsc"), + expect.objectContaining({ + credentials: "include", + priority: "high", + }), + ); + } finally { + result.restoreNodeEnv(); + } + }); + + it("does not prefetch on intent when prefetch is false", async () => { + const userOnMouseEnter = vi.fn(); + const result = await renderIsolatedLink({ + href: "/disabled-intent-prefetch-target", + nodeEnv: "production", + props: { onMouseEnter: userOnMouseEnter, prefetch: false }, + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await Promise.resolve(); + await Promise.resolve(); + + expect(userOnMouseEnter).toHaveBeenCalledTimes(1); + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + }); + + it("does not observe visible links when prefetch is false", async () => { + const observe = vi.fn(); + const unobserve = vi.fn(); + class FakeIntersectionObserver { + observe = observe; + unobserve = unobserve; + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + + const result = await renderIsolatedLink({ + href: "/disabled-viewport-prefetch-target", + nodeEnv: "production", + props: { prefetch: false }, + }); + + try { + expect(observe).not.toHaveBeenCalled(); + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + }); + + it("preserves user intent handlers on dangerous inert links", async () => { + const userOnMouseEnter = vi.fn(); + const userOnTouchStart = vi.fn(); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await renderIsolatedLink({ + href: "javascript:alert(1)", + nodeEnv: "development", + props: { + onMouseEnter: userOnMouseEnter, + onTouchStart: userOnTouchStart, + }, + requireRef: false, + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor }); + + expect(userOnMouseEnter).toHaveBeenCalledTimes(1); + expect(userOnTouchStart).toHaveBeenCalledTimes(1); + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + consoleWarn.mockRestore(); + result.restoreNodeEnv(); + } + }); +}); From d78d45a4b16b218d983a09562d56a8fd9884dd2e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 9 May 2026 18:36:08 +1000 Subject: [PATCH 2/5] fix(link): align app-router prefetch scheduling with intent Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path. The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough. --- tests/e2e/app-router/advanced.spec.ts | 5 +- .../app-router/nextjs-compat/prefetch.spec.ts | 87 +----- tests/link-navigation.test.ts | 276 +++++++++++------- 3 files changed, 170 insertions(+), 198 deletions(-) diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index d372eb997..ee355dcc8 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -287,9 +287,12 @@ test.describe("Intercepting Routes", () => { await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); }); - test("prefetches keep separate cache entries for feed and gallery interception contexts", async ({ + test.skip("prefetches keep separate cache entries for feed and gallery interception contexts", async ({ page, }) => { + // App-router E2E runs against the dev server, while Link viewport prefetching + // is production-only for Next.js parity. The deterministic production Link + // prefetch contract is covered in tests/link-navigation.test.ts. await page.goto(`${BASE}/feed`); await waitForAppRouterHydration(page); await expect diff --git a/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts b/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts index 4d1f3a403..5b2c25e8b 100644 --- a/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts @@ -57,49 +57,11 @@ test.describe("Next.js compat: prefetch (browser)", () => { expect(marker).toBe(true); }); - // Test that prefetch populates the in-memory RSC cache - test("visible Link prefetches RSC payload into in-memory cache", async ({ page }) => { + test("Link with prefetch={false} does not prefetch RSC payload in dev", async ({ page }) => { await page.goto(`${BASE}/nextjs-compat/prefetch-test`); await waitForAppRouterHydration(page); - // Wait for prefetch to complete (requestIdleCallback + fetch) - await expect(async () => { - const cacheSize = await page.evaluate(() => { - const cache = (window as any).__VINEXT_RSC_PREFETCH_CACHE__; - return cache ? cache.size : 0; - }); - // The prefetch-enabled link should have populated the cache - // (the prefetch={false} link should not) - expect(cacheSize).toBeGreaterThanOrEqual(1); - }).toPass({ timeout: 10_000 }); - - // Verify the cached URL is for the prefetch target - const cachedUrls = await page.evaluate(() => { - const cache = (window as any).__VINEXT_RSC_PREFETCH_CACHE__; - return cache ? Array.from(cache.keys()) : []; - }); - expect((cachedUrls as string[]).some((url) => url.includes("prefetch-test/target.rsc"))).toBe( - true, - ); - }); - - // Test that prefetch={false} does NOT populate the cache - test("Link with prefetch={false} does not prefetch RSC payload", async ({ page }) => { - await page.goto(`${BASE}/nextjs-compat/prefetch-test`); - await waitForAppRouterHydration(page); - - // Wait for the prefetch-enabled link to populate the cache, then check - // that the no-prefetch target is NOT in the cache. - await expect(async () => { - const cacheSize = await page.evaluate(() => { - const cache = (window as any).__VINEXT_RSC_PREFETCH_CACHE__; - return cache ? cache.size : 0; - }); - // The prefetch-enabled link should have populated the cache by now - expect(cacheSize).toBeGreaterThanOrEqual(1); - }).toPass({ timeout: 10_000 }); - - // Verify the no-prefetch target is NOT in the cache + await page.waitForTimeout(500); const hasNoPrefetchCached = await page.evaluate(() => { const cache = (window as any).__VINEXT_RSC_PREFETCH_CACHE__; if (!cache) return false; @@ -110,49 +72,4 @@ test.describe("Next.js compat: prefetch (browser)", () => { }); expect(hasNoPrefetchCached).toBe(false); }); - - // Test that navigating to a prefetched link uses the cache (no extra fetch) - test("navigation to prefetched link uses cached RSC payload", async ({ page }) => { - await page.goto(`${BASE}/nextjs-compat/prefetch-test`); - await waitForAppRouterHydration(page); - - // Wait for prefetch to populate the cache - await expect(async () => { - const cacheSize = await page.evaluate(() => { - const cache = (window as any).__VINEXT_RSC_PREFETCH_CACHE__; - return cache ? cache.size : 0; - }); - expect(cacheSize).toBeGreaterThanOrEqual(1); - }).toPass({ timeout: 10_000 }); - - // Start monitoring network requests for .rsc fetches - const rscRequests: string[] = []; - page.on("request", (req) => { - if (req.url().includes("target.rsc")) { - rscRequests.push(req.url()); - } - }); - - // Navigate via the prefetched link - await page.click("#prefetch-link"); - await expect(page.locator("#prefetch-target")).toHaveText("Prefetch Target Page", { - timeout: 10_000, - }); - - // The cache should have been consumed (no extra network request for the .rsc) - // The prefetch fetch already happened, but __VINEXT_RSC_NAVIGATE__ should - // NOT have made an additional fetch — it used the cached response. - expect(rscRequests.length).toBe(0); - - // The cache entry should have been consumed (deleted after use) - const cacheHasTarget = await page.evaluate(() => { - const cache = (window as any).__VINEXT_RSC_PREFETCH_CACHE__; - if (!cache) return false; - for (const key of cache.keys()) { - if (key.includes("target.rsc")) return true; - } - return false; - }); - expect(cacheHasTarget).toBe(false); - }); }); diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 76aafa788..adb23b784 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -22,6 +22,94 @@ type CapturedAnchorProps = { ref?: (node: HTMLAnchorElement | null) => void; }; +type MockReactAnchorCaptureOptions = { + captureAnchor(type: unknown, props: unknown): void; + captureEffect?: (effect: CapturedEffect) => void; + startTransition?: (callback: () => void) => void; +}; + +function mockReactAnchorCapture(options: MockReactAnchorCaptureOptions): void { + vi.doMock("react", async () => { + const actual = await vi.importActual("react"); + const createElement = (( + type: ElementType, + props: Record | null, + ...children: ReactNode[] + ) => { + options.captureAnchor(type, props); + return actual.createElement(type, props, ...children); + }) as typeof actual.createElement; + + const mockDefault = { ...actual, createElement }; + if (options.captureEffect !== undefined) { + const useEffect = (effect: CapturedEffect) => { + options.captureEffect?.(effect); + }; + return { + ...actual, + createElement, + useEffect, + default: { ...mockDefault, useEffect }, + }; + } + + if (options.startTransition !== undefined) { + return { + ...actual, + createElement, + startTransition: options.startTransition, + default: { ...mockDefault, startTransition: options.startTransition }, + }; + } + + return { + ...actual, + createElement, + default: mockDefault, + }; + }); + + vi.doMock("react/jsx-runtime", async () => { + const actual = await vi.importActual("react/jsx-runtime"); + return { + ...actual, + jsx(type: ElementType, props: Record, key?: string) { + options.captureAnchor(type, props); + return actual.jsx(type, props, key); + }, + jsxs(type: ElementType, props: Record, key?: string) { + options.captureAnchor(type, props); + return actual.jsxs(type, props, key); + }, + }; + }); + + vi.doMock("react/jsx-dev-runtime", async () => { + const actual = + await vi.importActual("react/jsx-dev-runtime"); + return { + ...actual, + jsxDEV( + type: ElementType, + props: Record, + key?: string, + isStaticChildren?: boolean, + source?: Parameters[4], + self?: Parameters[5], + ) { + options.captureAnchor(type, props); + return actual.jsxDEV(type, props, key, isStaticChildren ?? false, source, self); + }, + }; + }); +} + +async function flushPrefetchTasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + afterEach(() => { vi.doUnmock("react"); vi.doUnmock("react/jsx-runtime"); @@ -53,58 +141,7 @@ describe("Link App Router navigation scheduling", () => { } }; - vi.doMock("react", async () => { - const actual = await vi.importActual("react"); - const createElement = (( - type: ElementType, - props: Record | null, - ...children: ReactNode[] - ) => { - captureAnchor(type, props); - return actual.createElement(type, props, ...children); - }) as typeof actual.createElement; - - return { - ...actual, - createElement, - default: { ...actual, createElement, startTransition }, - startTransition, - }; - }); - - vi.doMock("react/jsx-runtime", async () => { - const actual = await vi.importActual("react/jsx-runtime"); - return { - ...actual, - jsx(type: ElementType, props: Record, key?: string) { - captureAnchor(type, props); - return actual.jsx(type, props, key); - }, - jsxs(type: ElementType, props: Record, key?: string) { - captureAnchor(type, props); - return actual.jsxs(type, props, key); - }, - }; - }); - - vi.doMock("react/jsx-dev-runtime", async () => { - const actual = - await vi.importActual("react/jsx-dev-runtime"); - return { - ...actual, - jsxDEV( - type: ElementType, - props: Record, - key?: string, - isStaticChildren?: boolean, - source?: Parameters[4], - self?: Parameters[5], - ) { - captureAnchor(type, props); - return actual.jsxDEV(type, props, key, isStaticChildren ?? false, source, self); - }, - }; - }); + mockReactAnchorCapture({ captureAnchor, startTransition }); const navigate = vi.fn(async () => { transitionStates.push(transitionActive); @@ -182,61 +219,11 @@ async function renderIsolatedLink(options: { } }; - vi.doMock("react", async () => { - const actual = await vi.importActual("react"); - const createElement = (( - type: ElementType, - props: Record | null, - ...children: ReactNode[] - ) => { - captureAnchor(type, props); - return actual.createElement(type, props, ...children); - }) as typeof actual.createElement; - - const useEffect = (effect: CapturedEffect) => { + mockReactAnchorCapture({ + captureAnchor, + captureEffect(effect) { effects.push(effect); - }; - - return { - ...actual, - createElement, - useEffect, - default: { ...actual, createElement, useEffect }, - }; - }); - - vi.doMock("react/jsx-runtime", async () => { - const actual = await vi.importActual("react/jsx-runtime"); - return { - ...actual, - jsx(type: ElementType, props: Record, key?: string) { - captureAnchor(type, props); - return actual.jsx(type, props, key); - }, - jsxs(type: ElementType, props: Record, key?: string) { - captureAnchor(type, props); - return actual.jsxs(type, props, key); - }, - }; - }); - - vi.doMock("react/jsx-dev-runtime", async () => { - const actual = - await vi.importActual("react/jsx-dev-runtime"); - return { - ...actual, - jsxDEV( - type: ElementType, - props: Record, - key?: string, - isStaticChildren?: boolean, - source?: Parameters[4], - self?: Parameters[5], - ) { - captureAnchor(type, props); - return actual.jsxDEV(type, props, key, isStaticChildren ?? false, source, self); - }, - }; + }, }); const fetch = vi.fn(() => Promise.resolve(new Response(""))); @@ -299,6 +286,74 @@ async function renderIsolatedLink(options: { } describe("Link App Router prefetch scheduling", () => { + it("prefetches visible links in production with low priority", async () => { + let intersectionCallback: IntersectionObserverCallback | undefined; + const observe = vi.fn(); + const unobserve = vi.fn(); + class FakeIntersectionObserver { + readonly root = null; + readonly rootMargin = "250px"; + readonly thresholds = [0]; + + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + + observe = observe; + unobserve = unobserve; + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + + const result = await renderIsolatedLink({ + href: "/viewport-prefetch-target", + nodeEnv: "production", + }); + + try { + expect(observe).toHaveBeenCalledWith(result.anchor); + expect(intersectionCallback).toBeTypeOf("function"); + const rect = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }; + intersectionCallback?.( + [ + { + boundingClientRect: rect, + intersectionRatio: 1, + intersectionRect: rect, + isIntersecting: true, + rootBounds: null, + target: result.anchor, + time: 0, + }, + ], + {} as IntersectionObserver, + ); + await flushPrefetchTasks(); + + expect(unobserve).toHaveBeenCalledWith(result.anchor); + expect(result.fetch).toHaveBeenCalledWith( + expect.stringContaining("/viewport-prefetch-target.rsc"), + expect.objectContaining({ + credentials: "include", + priority: "low", + }), + ); + } finally { + result.restoreNodeEnv(); + } + }); + it("does not prefetch visible links in development", async () => { // Next.js disables App Router viewport prefetching in development: // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/links.ts @@ -336,8 +391,7 @@ describe("Link App Router prefetch scheduling", () => { try { expect(result.capturedAnchorProps.onMouseEnter).toBeTypeOf("function"); result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); - await Promise.resolve(); - await Promise.resolve(); + await flushPrefetchTasks(); expect(userOnMouseEnter).toHaveBeenCalledTimes(1); expect(result.fetch).toHaveBeenCalledWith( @@ -363,8 +417,7 @@ describe("Link App Router prefetch scheduling", () => { try { expect(result.capturedAnchorProps.onTouchStart).toBeTypeOf("function"); result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor }); - await Promise.resolve(); - await Promise.resolve(); + await flushPrefetchTasks(); expect(userOnTouchStart).toHaveBeenCalledTimes(1); expect(result.fetch).toHaveBeenCalledWith( @@ -389,8 +442,7 @@ describe("Link App Router prefetch scheduling", () => { try { result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); - await Promise.resolve(); - await Promise.resolve(); + await flushPrefetchTasks(); expect(userOnMouseEnter).toHaveBeenCalledTimes(1); expect(result.fetch).not.toHaveBeenCalled(); From 29499756fa7257aee73befebc0a8917199b45533 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 9 May 2026 18:45:03 +1000 Subject: [PATCH 3/5] test: tighten link prefetch coverage --- tests/e2e/app-router/advanced.spec.ts | 36 --------------------------- tests/link-navigation.test.ts | 9 +++++-- 2 files changed, 7 insertions(+), 38 deletions(-) diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index ee355dcc8..276d3ca43 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -8,22 +8,6 @@ async function waitForAppRouterHydration(page: Page) { }); } -function normalizePrefetchCacheKey(key: string): string { - const [url = "", context] = key.split("\0"); - const normalizedUrl = url.replace(/[?&]_rsc(?:=[^&]*)?/, ""); - return context === undefined ? normalizedUrl : `${normalizedUrl}\0${context}`; -} - -async function readPhotoPrefetchCacheKeys(page: Page): Promise { - const keys = await page.evaluate(() => - Array.from(window.__VINEXT_RSC_PREFETCH_CACHE__?.keys() ?? []).filter((key) => - key.includes("/photos/42.rsc"), - ), - ); - - return keys.map(normalizePrefetchCacheKey).sort(); -} - test.describe("Parallel Routes", () => { test("dashboard renders all parallel slot content", async ({ page }) => { await page.goto(`${BASE}/dashboard`); @@ -286,26 +270,6 @@ test.describe("Intercepting Routes", () => { await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); }); - - test.skip("prefetches keep separate cache entries for feed and gallery interception contexts", async ({ - page, - }) => { - // App-router E2E runs against the dev server, while Link viewport prefetching - // is production-only for Next.js parity. The deterministic production Link - // prefetch contract is covered in tests/link-navigation.test.ts. - await page.goto(`${BASE}/feed`); - await waitForAppRouterHydration(page); - await expect - .poll(async () => readPhotoPrefetchCacheKeys(page)) - .toEqual(["/photos/42.rsc\u0000/feed"]); - - await page.click("#gallery-link"); - await page.waitForURL(`${BASE}/gallery`); - await waitForAppRouterHydration(page); - await expect - .poll(async () => readPhotoPrefetchCacheKeys(page)) - .toEqual(["/photos/42.rsc\u0000/feed", "/photos/42.rsc\u0000/gallery"]); - }); }); test.describe("Route Segment Config", () => { diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index adb23b784..4624a78fa 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -15,10 +15,12 @@ type CapturedClickEvent = { shiftKey?: boolean; }; +type CapturedIntentEvent = Pick; + type CapturedAnchorProps = { onClick?: (event: CapturedClickEvent) => void | Promise; - onMouseEnter?: (event: { currentTarget: HTMLAnchorElement }) => void; - onTouchStart?: (event: { currentTarget: HTMLAnchorElement }) => void; + onMouseEnter?: (event: CapturedIntentEvent) => void; + onTouchStart?: (event: CapturedIntentEvent) => void; ref?: (node: HTMLAnchorElement | null) => void; }; @@ -28,6 +30,9 @@ type MockReactAnchorCaptureOptions = { startTransition?: (callback: () => void) => void; }; +// This is a narrow Link shim test harness. It captures rendered anchor props +// because app-router production prefetch is not covered by dev E2E. Do not +// expand this pattern for general component testing. function mockReactAnchorCapture(options: MockReactAnchorCaptureOptions): void { vi.doMock("react", async () => { const actual = await vi.importActual("react"); From d7e142b000756a6262808a0b30365ec8c978bb6c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 11 May 2026 20:47:23 +1000 Subject: [PATCH 4/5] test(link): tighten prefetch decision coverage --- packages/vinext/src/shims/link-prefetch.ts | 61 +++++ packages/vinext/src/shims/link.tsx | 59 +++-- .../app-router/nextjs-compat/prefetch.spec.ts | 87 +++++- tests/link-navigation.test.ts | 248 +++++++++++++++++- 4 files changed, 419 insertions(+), 36 deletions(-) create mode 100644 packages/vinext/src/shims/link-prefetch.ts diff --git a/packages/vinext/src/shims/link-prefetch.ts b/packages/vinext/src/shims/link-prefetch.ts new file mode 100644 index 000000000..5260a8b66 --- /dev/null +++ b/packages/vinext/src/shims/link-prefetch.ts @@ -0,0 +1,61 @@ +import { hasBasePath, stripBasePath } from "../utils/base-path.js"; + +export type LinkPrefetchIntent = "viewport" | "intent"; +export type LinkPrefetchPriority = "low" | "high"; + +export type LinkPrefetchDecision = + | { + shouldPrefetch: false; + } + | { + shouldPrefetch: true; + priority: LinkPrefetchPriority; + }; + +export function getLinkPrefetchDecision(input: { + nodeEnv: string | undefined; + prefetch: boolean | null | undefined; + isDangerous: boolean; + intent: LinkPrefetchIntent; +}): LinkPrefetchDecision { + if (input.nodeEnv !== "production") return { shouldPrefetch: false }; + if (input.prefetch === false) return { shouldPrefetch: false }; + if (input.isDangerous) return { shouldPrefetch: false }; + + return { + shouldPrefetch: true, + priority: input.intent === "intent" ? "high" : "low", + }; +} + +export function getLinkPrefetchHref(input: { + href: string; + basePath: string; + currentOrigin: string | undefined; +}): string | null { + const { href, basePath, currentOrigin } = input; + if (!isAbsoluteOrProtocolRelative(href)) return href; + if (currentOrigin === undefined) return null; + + try { + const current = new URL(currentOrigin); + const parsed = href.startsWith("//") ? new URL(href, current.origin) : new URL(href); + if (parsed.origin !== current.origin) return null; + + if (!basePath) { + return parsed.pathname + parsed.search + parsed.hash; + } + + if (!hasBasePath(parsed.pathname, basePath)) { + return null; + } + + return stripBasePath(parsed.pathname, basePath) + parsed.search + parsed.hash; + } catch { + return null; + } +} + +function isAbsoluteOrProtocolRelative(href: string): boolean { + return href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//"); +} diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index f38ff26a0..e3dc7f45a 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -35,6 +35,7 @@ import { VINEXT_RSC_MOUNTED_SLOTS_HEADER, } from "../server/app-rsc-cache-busting.js"; import { isDangerousScheme } from "./url-safety.js"; +import { getLinkPrefetchDecision, getLinkPrefetchHref } from "./link-prefetch.js"; import { resolveRelativeHref, toBrowserNavigationHref, @@ -54,8 +55,6 @@ type NavigateEvent = { defaultPrevented: boolean; }; -type PrefetchPriority = "low" | "high"; - type LinkProps = { href: string | { pathname?: string; query?: UrlQuery }; /** URL displayed in the browser (when href is a route pattern like /user/[id]) */ @@ -121,10 +120,14 @@ function resolveHref(href: LinkProps["href"]): string { * Uses `requestIdleCallback` (or `setTimeout` fallback) to avoid blocking * the main thread during initial page load. */ -function prefetchUrl(href: string, priority: PrefetchPriority = "low"): void { +function prefetchUrl(href: string, priority: "low" | "high" = "low"): void { if (typeof window === "undefined") return; - const prefetchHref = getPrefetchHref(href); + const prefetchHref = getLinkPrefetchHref({ + href, + basePath: __basePath, + currentOrigin: window.location.origin, + }); if (prefetchHref == null) return; const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath); @@ -176,14 +179,6 @@ function prefetchUrl(href: string, priority: PrefetchPriority = "low"): void { }); } -function getPrefetchHref(href: string): string | null { - if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { - return toSameOriginAppPath(href, __basePath); - } - - return href; -} - /** * Shared IntersectionObserver for viewport-based prefetching. * All Link elements use the same observer to minimize resource usage. @@ -323,8 +318,26 @@ const Link = forwardRef(function Link( // Prefetching: observe the element when it enters the viewport. // prefetch={false} disables, prefetch={true} or undefined/null (default) enables. const internalRef = useRef(null); - const shouldPrefetch = - process.env.NODE_ENV === "production" && prefetchProp !== false && !isDangerous; + const viewportPrefetchDecision = getLinkPrefetchDecision({ + nodeEnv: process.env.NODE_ENV, + prefetch: prefetchProp, + isDangerous, + intent: "viewport", + }); + const intentPrefetchDecision = getLinkPrefetchDecision({ + nodeEnv: process.env.NODE_ENV, + prefetch: prefetchProp, + isDangerous, + intent: "intent", + }); + const shouldViewportPrefetch = viewportPrefetchDecision.shouldPrefetch; + const viewportPrefetchPriority = viewportPrefetchDecision.shouldPrefetch + ? viewportPrefetchDecision.priority + : "low"; + const shouldIntentPrefetch = intentPrefetchDecision.shouldPrefetch; + const intentPrefetchPriority = intentPrefetchDecision.shouldPrefetch + ? intentPrefetchDecision.priority + : "high"; const setRefs = useCallback( (node: HTMLAnchorElement | null) => { @@ -337,29 +350,33 @@ 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; - const hrefToPrefetch = getPrefetchHref(localizedHref); + const hrefToPrefetch = getLinkPrefetchHref({ + href: localizedHref, + basePath: __basePath, + currentOrigin: window.location.origin, + }); if (hrefToPrefetch == null) return; const observer = getSharedObserver(); if (!observer) return; - observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch)); + observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch, viewportPrefetchPriority)); observer.observe(node); return () => { observer.unobserve(node); observerCallbacks.delete(node); }; - }, [shouldPrefetch, localizedHref]); + }, [shouldViewportPrefetch, viewportPrefetchPriority, localizedHref]); const prefetchOnIntent = useCallback(() => { - if (!shouldPrefetch) return; - prefetchUrl(localizedHref, "high"); - }, [shouldPrefetch, localizedHref]); + if (!shouldIntentPrefetch) return; + prefetchUrl(localizedHref, intentPrefetchPriority); + }, [shouldIntentPrefetch, intentPrefetchPriority, localizedHref]); const handleMouseEnter = useCallback( (e: MouseEvent) => { diff --git a/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts b/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts index 5b2c25e8b..ad3d8ff03 100644 --- a/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts @@ -11,6 +11,13 @@ import { waitForAppRouterHydration } from "../../helpers"; const BASE = "http://localhost:4174"; +type PrefetchTestState = { + fetchUrls: string[]; + requestIdleCallbackCalls: number; +}; + +type PrefetchTestWindow = Window & Partial>; + test.describe("Next.js compat: prefetch (browser)", () => { // Next.js: 'should navigate when prefetch is false' test("should navigate when prefetch is false", async ({ page }) => { @@ -58,18 +65,80 @@ test.describe("Next.js compat: prefetch (browser)", () => { }); test("Link with prefetch={false} does not prefetch RSC payload in dev", async ({ page }) => { + await page.addInitScript(() => { + const testWindow: PrefetchTestWindow = window; + const originalFetch = window.fetch.bind(window); + const originalRequestIdleCallback = window.requestIdleCallback?.bind(window); + const state: PrefetchTestState = { + fetchUrls: [], + requestIdleCallbackCalls: 0, + }; + testWindow.__VINEXT_PREFETCH_TEST__ = state; + window.fetch = (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes(".rsc")) { + state.fetchUrls.push(url); + } + return originalFetch(input, init); + }; + window.requestIdleCallback = (callback, options) => { + state.requestIdleCallbackCalls += 1; + if (originalRequestIdleCallback) { + return originalRequestIdleCallback(callback, options); + } + return window.setTimeout(() => { + callback({ + didTimeout: false, + timeRemaining: () => 50, + }); + }, 1); + }; + }); + await page.goto(`${BASE}/nextjs-compat/prefetch-test`); await waitForAppRouterHydration(page); - await page.waitForTimeout(500); - const hasNoPrefetchCached = await page.evaluate(() => { - const cache = (window as any).__VINEXT_RSC_PREFETCH_CACHE__; - if (!cache) return false; - for (const key of cache.keys()) { - if (key.includes("no-prefetch.rsc")) return true; - } - return false; + await page.evaluate(async () => { + await window.fetch("/nextjs-compat/prefetch-test/target.rsc"); + }); + await expect + .poll(async () => + page.evaluate(() => { + const testWindow: PrefetchTestWindow = window; + const state = testWindow.__VINEXT_PREFETCH_TEST__; + if (state === undefined) throw new Error("Missing prefetch test instrumentation"); + return state.fetchUrls.some((url) => url.includes("target.rsc")); + }), + ) + .toBe(true); + + await page.evaluate(() => { + const testWindow: PrefetchTestWindow = window; + const state = testWindow.__VINEXT_PREFETCH_TEST__; + if (state === undefined) throw new Error("Missing prefetch test instrumentation"); + state.fetchUrls = []; + state.requestIdleCallbackCalls = 0; + }); + + await page.hover("#no-prefetch-link"); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + + const diagnostics = await page.evaluate(() => { + const testWindow: PrefetchTestWindow = window; + const state = testWindow.__VINEXT_PREFETCH_TEST__; + if (state === undefined) throw new Error("Missing prefetch test instrumentation"); + return { + fetchUrls: state.fetchUrls, + requestIdleCallbackCalls: state.requestIdleCallbackCalls, + }; }); - expect(hasNoPrefetchCached).toBe(false); + expect(diagnostics.fetchUrls.some((url) => url.includes("no-prefetch.rsc"))).toBe(false); + expect(diagnostics.requestIdleCallbackCalls).toBe(0); }); }); diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 4624a78fa..0482b4354 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import ReactDOMServer from "react-dom/server"; import type { ElementType, ReactNode } from "react"; +import { + getLinkPrefetchDecision, + getLinkPrefetchHref, + type LinkPrefetchIntent, + type LinkPrefetchDecision, +} from "../packages/vinext/src/shims/link-prefetch.js"; type CapturedEffect = () => void | (() => void); @@ -30,10 +36,12 @@ type MockReactAnchorCaptureOptions = { startTransition?: (callback: () => void) => void; }; -// This is a narrow Link shim test harness. It captures rendered anchor props -// because app-router production prefetch is not covered by dev E2E. Do not -// expand this pattern for general component testing. -function mockReactAnchorCapture(options: MockReactAnchorCaptureOptions): void { +// This is a tactical escape hatch for Link only. It intercepts React and JSX +// runtime output because the current E2E setup cannot honestly reach the +// production-only Link prefetch path. Do not reuse it as a component harness. +function mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE( + options: MockReactAnchorCaptureOptions, +): void { vi.doMock("react", async () => { const actual = await vi.importActual("react"); const createElement = (( @@ -115,6 +123,135 @@ async function flushPrefetchTasks(): Promise { await Promise.resolve(); } +describe("Link prefetch pure decisions", () => { + it("decides whether Link should prefetch and with which priority", () => { + const cases = [ + { + name: "dev + viewport", + input: { + nodeEnv: "development", + prefetch: undefined, + isDangerous: false, + intent: "viewport", + }, + expected: { shouldPrefetch: false }, + }, + { + name: "dev + intent", + input: { + nodeEnv: "development", + prefetch: undefined, + isDangerous: false, + intent: "intent", + }, + expected: { shouldPrefetch: false }, + }, + { + name: "prod + viewport", + input: { + nodeEnv: "production", + prefetch: undefined, + isDangerous: false, + intent: "viewport", + }, + expected: { shouldPrefetch: true, priority: "low" }, + }, + { + name: "prod + intent", + input: { nodeEnv: "production", prefetch: undefined, isDangerous: false, intent: "intent" }, + expected: { shouldPrefetch: true, priority: "high" }, + }, + { + name: "prod + prefetch=false", + input: { nodeEnv: "production", prefetch: false, isDangerous: false, intent: "intent" }, + expected: { shouldPrefetch: false }, + }, + { + name: "prod + dangerous", + input: { nodeEnv: "production", prefetch: undefined, isDangerous: true, intent: "intent" }, + expected: { shouldPrefetch: false }, + }, + ] satisfies Array<{ + name: string; + input: { + nodeEnv: string; + prefetch: boolean | undefined; + isDangerous: boolean; + intent: LinkPrefetchIntent; + }; + expected: LinkPrefetchDecision; + }>; + + for (const testCase of cases) { + expect(getLinkPrefetchDecision(testCase.input), testCase.name).toEqual(testCase.expected); + } + }); + + it("normalizes only local or same-origin prefetch hrefs", () => { + const cases = [ + { + name: "local path", + input: { href: "/local", basePath: "", currentOrigin: "https://example.com" }, + expected: "/local", + }, + { + name: "same-origin absolute URL", + input: { + href: "https://example.com/path", + basePath: "", + currentOrigin: "https://example.com", + }, + expected: "/path", + }, + { + name: "same-origin protocol-relative URL", + input: { href: "//example.com/path", basePath: "", currentOrigin: "https://example.com" }, + expected: "/path", + }, + { + name: "external absolute URL", + input: { + href: "https://external.com/path", + basePath: "", + currentOrigin: "https://example.com", + }, + expected: null, + }, + { + name: "external protocol-relative URL", + input: { href: "//external.com/path", basePath: "", currentOrigin: "https://example.com" }, + expected: null, + }, + { + name: "same-origin with basePath", + input: { + href: "https://example.com/docs/path?tab=1#section", + basePath: "/docs", + currentOrigin: "https://example.com", + }, + expected: "/path?tab=1#section", + }, + { + name: "same-origin without required basePath", + input: { + href: "https://example.com/path", + basePath: "/docs", + currentOrigin: "https://example.com", + }, + expected: null, + }, + ] satisfies Array<{ + name: string; + input: Parameters[0]; + expected: string | null; + }>; + + for (const testCase of cases) { + expect(getLinkPrefetchHref(testCase.input), testCase.name).toBe(testCase.expected); + } + }); +}); + afterEach(() => { vi.doUnmock("react"); vi.doUnmock("react/jsx-runtime"); @@ -146,7 +283,7 @@ describe("Link App Router navigation scheduling", () => { } }; - mockReactAnchorCapture({ captureAnchor, startTransition }); + mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor, startTransition }); const navigate = vi.fn(async () => { transitionStates.push(transitionActive); @@ -224,7 +361,7 @@ async function renderIsolatedLink(options: { } }; - mockReactAnchorCapture({ + mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor, captureEffect(effect) { effects.push(effect); @@ -383,6 +520,44 @@ describe("Link App Router prefetch scheduling", () => { } }); + it("does not prefetch on mouse intent in development while preserving the user handler", async () => { + const userOnMouseEnter = vi.fn(); + const result = await renderIsolatedLink({ + href: "/dev-mouse-intent-prefetch-target", + nodeEnv: "development", + props: { onMouseEnter: userOnMouseEnter }, + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(userOnMouseEnter).toHaveBeenCalledTimes(1); + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + }); + + it("does not prefetch on touch intent in development while preserving the user handler", async () => { + const userOnTouchStart = vi.fn(); + const result = await renderIsolatedLink({ + href: "/dev-touch-intent-prefetch-target", + nodeEnv: "development", + props: { onTouchStart: userOnTouchStart }, + }); + + try { + result.capturedAnchorProps.onTouchStart?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(userOnTouchStart).toHaveBeenCalledTimes(1); + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + }); + it("prefetches on mouse intent in production while preserving the user handler", async () => { // Next.js triggers intent prefetch from Link onMouseEnter: // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/app-dir/link.tsx @@ -437,6 +612,67 @@ describe("Link App Router prefetch scheduling", () => { } }); + it("does not prefetch external absolute URLs on production intent", async () => { + const userOnMouseEnter = vi.fn(); + const result = await renderIsolatedLink({ + href: "https://external.example/prefetch-target", + nodeEnv: "production", + props: { onMouseEnter: userOnMouseEnter }, + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(userOnMouseEnter).toHaveBeenCalledTimes(1); + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + }); + + it("normalizes same-origin absolute URLs before production intent prefetch", async () => { + const result = await renderIsolatedLink({ + href: "https://example.com/same-origin-intent-prefetch-target", + nodeEnv: "production", + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(result.fetch).toHaveBeenCalledWith( + expect.stringContaining("/same-origin-intent-prefetch-target.rsc"), + expect.objectContaining({ + credentials: "include", + priority: "high", + }), + ); + expect(result.fetch).not.toHaveBeenCalledWith( + expect.stringContaining("https://example.com/same-origin-intent-prefetch-target.rsc"), + expect.anything(), + ); + } finally { + result.restoreNodeEnv(); + } + }); + + it("does not prefetch external protocol-relative URLs on production intent", async () => { + const result = await renderIsolatedLink({ + href: "//external.example/protocol-relative-prefetch-target", + nodeEnv: "production", + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + }); + it("does not prefetch on intent when prefetch is false", async () => { const userOnMouseEnter = vi.fn(); const result = await renderIsolatedLink({ From f0b925ec2a0781b8c64e0c16a9ac20c5bb4fbf1e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 12 May 2026 13:24:08 +1000 Subject: [PATCH 5/5] test(link): address prefetch review notes --- packages/vinext/src/shims/link-prefetch.ts | 18 ++++++++-- packages/vinext/src/shims/link.tsx | 33 +++++-------------- .../app-router/nextjs-compat/prefetch.spec.ts | 2 ++ tests/link-navigation.test.ts | 8 ++++- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/packages/vinext/src/shims/link-prefetch.ts b/packages/vinext/src/shims/link-prefetch.ts index 5260a8b66..5132595b6 100644 --- a/packages/vinext/src/shims/link-prefetch.ts +++ b/packages/vinext/src/shims/link-prefetch.ts @@ -12,15 +12,21 @@ export type LinkPrefetchDecision = priority: LinkPrefetchPriority; }; +export function canLinkPrefetch(input: { + nodeEnv: string | undefined; + prefetch: boolean | null | undefined; + isDangerous: boolean; +}): boolean { + return input.nodeEnv === "production" && input.prefetch !== false && !input.isDangerous; +} + export function getLinkPrefetchDecision(input: { nodeEnv: string | undefined; prefetch: boolean | null | undefined; isDangerous: boolean; intent: LinkPrefetchIntent; }): LinkPrefetchDecision { - if (input.nodeEnv !== "production") return { shouldPrefetch: false }; - if (input.prefetch === false) return { shouldPrefetch: false }; - if (input.isDangerous) return { shouldPrefetch: false }; + if (!canLinkPrefetch(input)) return { shouldPrefetch: false }; return { shouldPrefetch: true, @@ -28,6 +34,12 @@ export function getLinkPrefetchDecision(input: { }; } +/** + * Normalize absolute and protocol-relative Link hrefs to app-relative paths + * that are eligible for prefetching. Non-absolute relative hrefs are returned + * unchanged; callers must resolve them against the current browser URL before + * constructing a concrete fetch target. + */ export function getLinkPrefetchHref(input: { href: string; basePath: string; diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index e3dc7f45a..b321b73f7 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -35,7 +35,7 @@ import { VINEXT_RSC_MOUNTED_SLOTS_HEADER, } from "../server/app-rsc-cache-busting.js"; import { isDangerousScheme } from "./url-safety.js"; -import { getLinkPrefetchDecision, getLinkPrefetchHref } from "./link-prefetch.js"; +import { canLinkPrefetch, getLinkPrefetchHref } from "./link-prefetch.js"; import { resolveRelativeHref, toBrowserNavigationHref, @@ -318,26 +318,11 @@ const Link = forwardRef(function Link( // Prefetching: observe the element when it enters the viewport. // prefetch={false} disables, prefetch={true} or undefined/null (default) enables. const internalRef = useRef(null); - const viewportPrefetchDecision = getLinkPrefetchDecision({ + const shouldPrefetch = canLinkPrefetch({ nodeEnv: process.env.NODE_ENV, prefetch: prefetchProp, isDangerous, - intent: "viewport", }); - const intentPrefetchDecision = getLinkPrefetchDecision({ - nodeEnv: process.env.NODE_ENV, - prefetch: prefetchProp, - isDangerous, - intent: "intent", - }); - const shouldViewportPrefetch = viewportPrefetchDecision.shouldPrefetch; - const viewportPrefetchPriority = viewportPrefetchDecision.shouldPrefetch - ? viewportPrefetchDecision.priority - : "low"; - const shouldIntentPrefetch = intentPrefetchDecision.shouldPrefetch; - const intentPrefetchPriority = intentPrefetchDecision.shouldPrefetch - ? intentPrefetchDecision.priority - : "high"; const setRefs = useCallback( (node: HTMLAnchorElement | null) => { @@ -350,7 +335,7 @@ const Link = forwardRef(function Link( ); useEffect(() => { - if (!shouldViewportPrefetch || typeof window === "undefined") return; + if (!shouldPrefetch || typeof window === "undefined") return; const node = internalRef.current; if (!node) return; @@ -364,19 +349,19 @@ const Link = forwardRef(function Link( const observer = getSharedObserver(); if (!observer) return; - observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch, viewportPrefetchPriority)); + observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch, "low")); observer.observe(node); return () => { observer.unobserve(node); observerCallbacks.delete(node); }; - }, [shouldViewportPrefetch, viewportPrefetchPriority, localizedHref]); + }, [shouldPrefetch, localizedHref]); const prefetchOnIntent = useCallback(() => { - if (!shouldIntentPrefetch) return; - prefetchUrl(localizedHref, intentPrefetchPriority); - }, [shouldIntentPrefetch, intentPrefetchPriority, localizedHref]); + if (!shouldPrefetch) return; + prefetchUrl(localizedHref, "high"); + }, [shouldPrefetch, localizedHref]); const handleMouseEnter = useCallback( (e: MouseEvent) => { @@ -510,7 +495,7 @@ const Link = forwardRef(function Link( console.warn(` blocked dangerous href: ${resolvedHref}`); } return ( - + {children} ); diff --git a/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts b/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts index ad3d8ff03..9ee653ad1 100644 --- a/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts @@ -99,6 +99,8 @@ test.describe("Next.js compat: prefetch (browser)", () => { await page.goto(`${BASE}/nextjs-compat/prefetch-test`); await waitForAppRouterHydration(page); + // Verify the fetch instrumentation sees .rsc URLs before relying on it + // to assert that Link prefetch does not issue a no-prefetch request. await page.evaluate(async () => { await window.fetch("/nextjs-compat/prefetch-test/target.rsc"); }); diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 0482b4354..69567b26b 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -38,7 +38,10 @@ type MockReactAnchorCaptureOptions = { // This is a tactical escape hatch for Link only. It intercepts React and JSX // runtime output because the current E2E setup cannot honestly reach the -// production-only Link prefetch path. Do not reuse it as a component harness. +// production-only Link prefetch path. It mocks useEffect synchronously and +// captures element creation before reconciliation, so it cannot test commit +// scheduling, cleanup, re-renders, or conditional effect execution. Do not +// reuse it as a component harness. function mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE( options: MockReactAnchorCaptureOptions, ): void { @@ -118,6 +121,9 @@ function mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE( } async function flushPrefetchTasks(): Promise { + // requestIdleCallback is mocked as sync, then prefetchUrl enters an async + // IIFE with one awaited createRscRequestUrl call. These ticks drain the + // current chain; update this helper if the async depth grows. await Promise.resolve(); await Promise.resolve(); await Promise.resolve();