diff --git a/packages/vinext/src/shims/link-prefetch.ts b/packages/vinext/src/shims/link-prefetch.ts new file mode 100644 index 000000000..5132595b6 --- /dev/null +++ b/packages/vinext/src/shims/link-prefetch.ts @@ -0,0 +1,73 @@ +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 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 (!canLinkPrefetch(input)) return { shouldPrefetch: false }; + + return { + shouldPrefetch: true, + priority: input.intent === "intent" ? "high" : "low", + }; +} + +/** + * 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; + 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 280452b76..b321b73f7 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) @@ -34,6 +35,7 @@ import { VINEXT_RSC_MOUNTED_SLOTS_HEADER, } from "../server/app-rsc-cache-busting.js"; import { isDangerousScheme } from "./url-safety.js"; +import { canLinkPrefetch, getLinkPrefetchHref } from "./link-prefetch.js"; import { resolveRelativeHref, toBrowserNavigationHref, @@ -118,16 +120,15 @@ 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: "low" | "high" = "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 = getLinkPrefetchHref({ + href, + basePath: __basePath, + currentOrigin: window.location.origin, + }); + if (prefetchHref == null) return; const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath); @@ -154,7 +155,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", }), @@ -282,6 +283,8 @@ const Link = forwardRef(function Link( scroll = true, children, onClick, + onMouseEnter, + onTouchStart, onNavigate, ...rest }, @@ -315,7 +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 shouldPrefetch = prefetchProp !== false && !isDangerous; + const shouldPrefetch = canLinkPrefetch({ + nodeEnv: process.env.NODE_ENV, + prefetch: prefetchProp, + isDangerous, + }); const setRefs = useCallback( (node: HTMLAnchorElement | null) => { @@ -332,22 +339,17 @@ 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 = 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, "low")); observer.observe(node); return () => { @@ -356,6 +358,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 +494,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 +509,8 @@ const Link = forwardRef(function Link( onClick={(event) => { void handleClick(event); }} + onMouseEnter={handleMouseEnter} + onTouchStart={handleTouchStart} {...anchorProps} > {children} diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index d372eb997..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,23 +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("prefetches keep separate cache entries for feed and gallery interception contexts", async ({ - page, - }) => { - 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/e2e/app-router/nextjs-compat/prefetch.spec.ts b/tests/e2e/app-router/nextjs-compat/prefetch.spec.ts index 4d1f3a403..9ee653ad1 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 }) => { @@ -57,102 +64,83 @@ 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 }) => { - 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()) : []; + 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); + }; }); - 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 - 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; + // 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"); }); - expect(hasNoPrefetchCached).toBe(false); - }); + 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); - // 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()); - } + 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; }); - // Navigate via the prefetched link - await page.click("#prefetch-link"); - await expect(page.locator("#prefetch-target")).toHaveText("Prefetch Target Page", { - timeout: 10_000, - }); + await page.hover("#no-prefetch-link"); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); - // 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; + 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(cacheHasTarget).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 c9613c68d..69567b26b 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -1,6 +1,14 @@ 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); type CapturedClickEvent = { altKey?: boolean; @@ -13,16 +21,250 @@ type CapturedClickEvent = { shiftKey?: boolean; }; +type CapturedIntentEvent = Pick; + type CapturedAnchorProps = { onClick?: (event: CapturedClickEvent) => void | Promise; + onMouseEnter?: (event: CapturedIntentEvent) => void; + onTouchStart?: (event: CapturedIntentEvent) => void; + ref?: (node: HTMLAnchorElement | null) => void; +}; + +type MockReactAnchorCaptureOptions = { + captureAnchor(type: unknown, props: unknown): void; + captureEffect?: (effect: CapturedEffect) => void; + startTransition?: (callback: () => void) => 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. 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 { + 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 { + // 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(); +} + +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"); vi.doUnmock("react/jsx-dev-runtime"); vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.resetModules(); }); describe("Link App Router navigation scheduling", () => { @@ -47,58 +289,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); - }, - }; - }); + mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor, startTransition }); const navigate = vi.fn(async () => { transitionStates.push(transitionActive); @@ -147,3 +338,413 @@ 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; + } + }; + + mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ + captureAnchor, + captureEffect(effect) { + effects.push(effect); + }, + }); + + 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("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 + 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("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 + 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 flushPrefetchTasks(); + + 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 flushPrefetchTasks(); + + 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 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({ + href: "/disabled-intent-prefetch-target", + nodeEnv: "production", + props: { onMouseEnter: userOnMouseEnter, prefetch: false }, + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + 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(); + } + }); +});