From 27fc775420ce887c43dd2056612d12028ea21826 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 22:19:46 +1000 Subject: [PATCH] fix(link): preserve native download clicks Link currently treats anchors with the HTML download attribute like normal same-origin SPA navigations. That prevents the browser from performing its native download behavior and can also run onNavigate for a click that should never become client-side navigation. The click handler now bails out after user onClick and before preventDefault whenever the anchor has a download attribute, matching Next.js Link semantics. A focused regression test covers the default action, onNavigate, transition, and RSC navigate boundaries. --- packages/vinext/src/shims/link.tsx | 5 ++ tests/link-navigation.test.ts | 75 +++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 98e4a656d..0b8759632 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -442,6 +442,11 @@ const Link = forwardRef(function Link( if (onClick) onClick(e); if (e.defaultPrevented) return; + // Native download links must keep the browser's default behavior. + if (e.currentTarget.hasAttribute("download")) { + return; + } + // Only intercept left clicks without modifiers (standard link behavior) if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 4f3cf2532..42bde657c 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -15,7 +15,7 @@ type CapturedClickEvent = { altKey?: boolean; button: number; ctrlKey?: boolean; - currentTarget: { target: string }; + currentTarget: { hasAttribute(name: string): boolean; target: string }; defaultPrevented: boolean; metaKey?: boolean; preventDefault(): void; @@ -335,7 +335,7 @@ describe("Link App Router navigation scheduling", () => { const clickEvent = { button: 0, - currentTarget: { target: "" }, + currentTarget: { hasAttribute: () => false, target: "" }, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; @@ -353,6 +353,77 @@ describe("Link App Router navigation scheduling", () => { expect(navigate).toHaveBeenCalledWith("/target", 0, "navigate", "push", undefined, true); expect(transitionStates).toEqual([true]); }); + + it("lets the browser handle download links without app-router navigation", async () => { + vi.resetModules(); + + let capturedAnchorProps: CapturedAnchorProps | undefined; + const startTransition = vi.fn((callback: () => void) => { + callback(); + }); + + const captureAnchor = (type: unknown, props: unknown) => { + if (type === "a" && props !== null && typeof props === "object") { + capturedAnchorProps = props; + } + }; + + mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor, startTransition }); + + const navigate = vi.fn(async () => {}); + vi.stubGlobal("window", { + __VINEXT_RSC_NAVIGATE__: navigate, + addEventListener: vi.fn(), + history: { + pushState: vi.fn(), + replaceState: vi.fn(), + }, + location: { + href: "https://example.com/current", + origin: "https://example.com", + }, + scrollTo: vi.fn(), + }); + + const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js"); + const React = await vi.importActual("react"); + const onClick = vi.fn(); + const onNavigate = vi.fn(); + + // Ported from Next.js: test/e2e/link-on-navigate-prop/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/link-on-navigate-prop/index.test.ts + ReactDOMServer.renderToString( + React.createElement( + IsolatedLink, + { download: true, href: "/file.pdf", onClick, onNavigate, prefetch: false }, + "download", + ), + ); + + const clickEvent = { + button: 0, + currentTarget: { + hasAttribute: (name: string) => name === "download", + target: "", + }, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; + const linkOnClick = capturedAnchorProps?.onClick; + expect(linkOnClick).toBeTypeOf("function"); + if (linkOnClick === undefined) { + throw new Error("Expected rendered Link anchor to expose an onClick handler"); + } + await linkOnClick(clickEvent); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(clickEvent.defaultPrevented).toBe(false); + expect(onNavigate).not.toHaveBeenCalled(); + expect(startTransition).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); }); async function renderIsolatedLink(options: {