Skip to content

Commit 25ac4db

Browse files
fix(link): preserve native download clicks (#1262)
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.
1 parent 7790a0a commit 25ac4db

2 files changed

Lines changed: 78 additions & 2 deletions

File tree

packages/vinext/src/shims/link.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,11 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
512512
if (onClick) onClick(e);
513513
if (e.defaultPrevented) return;
514514

515+
// Native download links must keep the browser's default behavior.
516+
if (e.currentTarget.hasAttribute("download")) {
517+
return;
518+
}
519+
515520
// Only intercept left clicks without modifiers (standard link behavior)
516521
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
517522
return;

tests/link-navigation.test.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type CapturedClickEvent = {
1616
altKey?: boolean;
1717
button: number;
1818
ctrlKey?: boolean;
19-
currentTarget: { target: string };
19+
currentTarget: { hasAttribute(name: string): boolean; target: string };
2020
defaultPrevented: boolean;
2121
metaKey?: boolean;
2222
preventDefault(): void;
@@ -354,7 +354,7 @@ describe("Link App Router navigation scheduling", () => {
354354

355355
const clickEvent = {
356356
button: 0,
357-
currentTarget: { target: "" },
357+
currentTarget: { hasAttribute: () => false, target: "" },
358358
defaultPrevented: false,
359359
preventDefault() {
360360
this.defaultPrevented = true;
@@ -372,6 +372,77 @@ describe("Link App Router navigation scheduling", () => {
372372
expect(navigate).toHaveBeenCalledWith("/target", 0, "navigate", "push", undefined, true);
373373
expect(transitionStates).toEqual([true]);
374374
});
375+
376+
it("lets the browser handle download links without app-router navigation", async () => {
377+
vi.resetModules();
378+
379+
let capturedAnchorProps: CapturedAnchorProps | undefined;
380+
const startTransition = vi.fn((callback: () => void) => {
381+
callback();
382+
});
383+
384+
const captureAnchor = (type: unknown, props: unknown) => {
385+
if (type === "a" && props !== null && typeof props === "object") {
386+
capturedAnchorProps = props;
387+
}
388+
};
389+
390+
mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE({ captureAnchor, startTransition });
391+
392+
const navigate = vi.fn(async () => {});
393+
vi.stubGlobal("window", {
394+
__VINEXT_RSC_NAVIGATE__: navigate,
395+
addEventListener: vi.fn(),
396+
history: {
397+
pushState: vi.fn(),
398+
replaceState: vi.fn(),
399+
},
400+
location: {
401+
href: "https://example.com/current",
402+
origin: "https://example.com",
403+
},
404+
scrollTo: vi.fn(),
405+
});
406+
407+
const { default: IsolatedLink } = await import("../packages/vinext/src/shims/link.js");
408+
const React = await vi.importActual<typeof import("react")>("react");
409+
const onClick = vi.fn();
410+
const onNavigate = vi.fn();
411+
412+
// Ported from Next.js: test/e2e/link-on-navigate-prop/index.test.ts
413+
// https://github.com/vercel/next.js/blob/canary/test/e2e/link-on-navigate-prop/index.test.ts
414+
ReactDOMServer.renderToString(
415+
React.createElement(
416+
IsolatedLink,
417+
{ download: true, href: "/file.pdf", onClick, onNavigate, prefetch: false },
418+
"download",
419+
),
420+
);
421+
422+
const clickEvent = {
423+
button: 0,
424+
currentTarget: {
425+
hasAttribute: (name: string) => name === "download",
426+
target: "",
427+
},
428+
defaultPrevented: false,
429+
preventDefault() {
430+
this.defaultPrevented = true;
431+
},
432+
};
433+
const linkOnClick = capturedAnchorProps?.onClick;
434+
expect(linkOnClick).toBeTypeOf("function");
435+
if (linkOnClick === undefined) {
436+
throw new Error("Expected rendered Link anchor to expose an onClick handler");
437+
}
438+
await linkOnClick(clickEvent);
439+
440+
expect(onClick).toHaveBeenCalledTimes(1);
441+
expect(clickEvent.defaultPrevented).toBe(false);
442+
expect(onNavigate).not.toHaveBeenCalled();
443+
expect(startTransition).not.toHaveBeenCalled();
444+
expect(navigate).not.toHaveBeenCalled();
445+
});
375446
});
376447

377448
async function renderIsolatedLink(options: {

0 commit comments

Comments
 (0)