From 1697f07d6696a630f5a585b2bfbd5396d5729327 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 22:22:46 +1000 Subject: [PATCH] fix(link): preserve unsafe href click handlers Vinext strips dangerous Link href values before rendering the anchor. That keeps javascript: URLs inert, but the unsafe render branch also dropped the developer-provided onClick handler, so links used as click targets stopped working after hydration. The dangerous-href branch now preserves the anchor ref, link status provider, and user click handler while continuing to omit href. The regression fixture verifies that the click handler runs and the browser URL stays unchanged. --- packages/vinext/src/shims/link.tsx | 16 +++++++++---- .../nextjs-compat/javascript-urls.spec.ts | 17 +++++++++++++ .../javascript-urls/link-onclick/page.tsx | 24 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/javascript-urls/link-onclick/page.tsx diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 98e4a656d..d0bbf5b35 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -547,16 +547,24 @@ const Link = forwardRef(function Link( // Block dangerous URI schemes (javascript:, data:, vbscript:). // Render an inert without href to prevent XSS while preserving - // styling and attributes like className, id, aria-*. + // styling, refs, and developer event handlers like onClick. // This check is placed after all hooks to satisfy the Rules of Hooks. if (isDangerous) { if (process.env.NODE_ENV !== "production") { console.warn(` blocked dangerous href: ${resolvedHref}`); } return ( - - {children} - + + + {children} + + ); } diff --git a/tests/e2e/app-router/nextjs-compat/javascript-urls.spec.ts b/tests/e2e/app-router/nextjs-compat/javascript-urls.spec.ts index e85c59e83..3ea25e531 100644 --- a/tests/e2e/app-router/nextjs-compat/javascript-urls.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/javascript-urls.spec.ts @@ -95,4 +95,21 @@ test.describe("javascript-urls", () => { await page.locator('a[href="/nextjs-compat/javascript-urls/safe"]').click(); await expect(page).toHaveURL(`${BASE}/nextjs-compat/javascript-urls/safe`); }); + + // Next.js keeps user-provided Link onClick handlers on anchors whose href is + // later blocked by React/Next.js javascript: URL protections. + // Reference implementation: https://github.com/vercel/next.js/blob/canary/packages/next/src/client/link.tsx + test("preserves custom Link onClick handlers when blocking unsafe hrefs", async ({ page }) => { + await page.goto(`${BASE}/nextjs-compat/javascript-urls/link-onclick`); + await waitForAppRouterHydration(page); + const initialUrl = page.url(); + + const unsafeLink = page.locator("#unsafe-link"); + await expect(unsafeLink).not.toHaveAttribute("href", /.+/); + + await unsafeLink.click(); + + await expect(page.locator("#click-count")).toHaveText("clicks: 1"); + expect(page.url()).toBe(initialUrl); + }); }); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/javascript-urls/link-onclick/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/javascript-urls/link-onclick/page.tsx new file mode 100644 index 000000000..17f4c6794 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/javascript-urls/link-onclick/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { DANGEROUS_JAVASCRIPT_URL } from "../bad-url"; + +export default function Page() { + const [clicks, setClicks] = useState(0); + + return ( + <> +

clicks: {clicks}

+ { + setClicks((value) => value + 1); + }} + > + unsafe link with custom click handler + + + ); +}