From e5263b7c336e3299ca163ae9150e70fc7b011184 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 23:01:06 +1000 Subject: [PATCH 1/3] fix(link): preserve native URI scheme navigation Link treated only http, https, and protocol-relative hrefs as browser-owned. Native schemes such as mailto:, tel:, and sms: were therefore locale-prefixed, eligible for prefetch normalization, and intercepted by client navigation. The URL boundary now mirrors Next.js absolute URL scheme classification while preserving vinext's protocol-relative handling. Link rendering, click interception, relative resolution, and prefetch normalization all share that classification so non-local schemes stay with the browser while same-origin absolute URLs remain routable. Adds focused regression coverage for native scheme rendering, click handling, and prefetch decisions. --- packages/vinext/src/shims/link-prefetch.ts | 7 +- packages/vinext/src/shims/link.tsx | 9 +-- packages/vinext/src/shims/url-utils.ts | 31 +++++---- tests/link-navigation.test.ts | 77 +++++++++++++++++++++- tests/link.test.ts | 38 +++++++++++ 5 files changed, 137 insertions(+), 25 deletions(-) diff --git a/packages/vinext/src/shims/link-prefetch.ts b/packages/vinext/src/shims/link-prefetch.ts index 29d5da0e3..e39f9786d 100644 --- a/packages/vinext/src/shims/link-prefetch.ts +++ b/packages/vinext/src/shims/link-prefetch.ts @@ -1,4 +1,5 @@ import { hasBasePath, stripBasePath } from "../utils/base-path.js"; +import { isAbsoluteOrProtocolRelativeUrl } from "./url-utils.js"; export type LinkPrefetchIntent = "viewport" | "intent"; export type LinkPrefetchPriority = "low" | "high"; @@ -46,7 +47,7 @@ export function getLinkPrefetchHref(input: { currentOrigin: string | undefined; }): string | null { const { href, basePath, currentOrigin } = input; - if (!isAbsoluteOrProtocolRelative(href)) return href; + if (!isAbsoluteOrProtocolRelativeUrl(href)) return href; if (currentOrigin === undefined) return null; try { @@ -67,7 +68,3 @@ export function getLinkPrefetchHref(input: { 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 98e4a656d..1bec7a49e 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -34,6 +34,7 @@ import { VINEXT_MOUNTED_SLOTS_HEADER } from "../server/headers.js"; import { isDangerousScheme } from "./url-safety.js"; import { canLinkPrefetch, getLinkPrefetchHref } from "./link-prefetch.js"; import { + isAbsoluteOrProtocolRelativeUrl, resolveRelativeHref, toBrowserNavigationHref, toSameOriginAppPath, @@ -319,7 +320,7 @@ function applyLocaleToHref(href: string, locale: string | false | undefined): st // Absolute and protocol-relative URLs must not be prefixed — locale // only applies to local paths. - if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { + if (isAbsoluteOrProtocolRelativeUrl(href)) { return href; } @@ -456,11 +457,7 @@ const Link = forwardRef(function Link( // Same-origin absolute URLs (e.g. http://localhost:3000/about) are // normalized to local paths so they get client-side navigation. let navigateHref = localizedHref; - if ( - resolvedHref.startsWith("http://") || - resolvedHref.startsWith("https://") || - resolvedHref.startsWith("//") - ) { + if (isAbsoluteOrProtocolRelativeUrl(resolvedHref)) { const localPath = toSameOriginAppPath(resolvedHref, __basePath); if (localPath == null) return; // truly external navigateHref = localPath; diff --git a/packages/vinext/src/shims/url-utils.ts b/packages/vinext/src/shims/url-utils.ts index 25b2c3861..c0fd4fac1 100644 --- a/packages/vinext/src/shims/url-utils.ts +++ b/packages/vinext/src/shims/url-utils.ts @@ -6,6 +6,22 @@ */ import { hasBasePath, stripBasePath } from "../utils/base-path.js"; +// Mirrors Next.js's absolute URL classification: +// packages/next/src/shared/lib/utils.ts +const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/; + +export function isAbsoluteUrl(url: string): boolean { + const firstChar = url.charCodeAt(0); + const startsWithLetter = + (firstChar >= 65 && firstChar <= 90) || (firstChar >= 97 && firstChar <= 122); + + return startsWithLetter && ABSOLUTE_URL_REGEX.test(url); +} + +export function isAbsoluteOrProtocolRelativeUrl(url: string): boolean { + return isAbsoluteUrl(url) || url.startsWith("//"); +} + /** * If `url` is an absolute same-origin URL, return the local path * (pathname + search + hash). Returns null for truly external URLs @@ -49,13 +65,7 @@ export function toSameOriginAppPath(url: string, basePath: string): string | nul * Prepend basePath to a local path for browser URLs / fetches. */ export function withBasePath(path: string, basePath: string): string { - if ( - !basePath || - !path.startsWith("/") || - path.startsWith("http://") || - path.startsWith("https://") || - path.startsWith("//") - ) { + if (!basePath || !path.startsWith("/") || isAbsoluteOrProtocolRelativeUrl(path)) { return path; } @@ -71,12 +81,7 @@ export function resolveRelativeHref(href: string, currentUrl?: string, basePath if (!base) return href; - if ( - href.startsWith("/") || - href.startsWith("http://") || - href.startsWith("https://") || - href.startsWith("//") - ) { + if (href.startsWith("/") || isAbsoluteOrProtocolRelativeUrl(href)) { return href; } diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 4f3cf2532..92d824937 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -237,6 +237,25 @@ describe("Link prefetch pure decisions", () => { input: { href: "//external.com/path", basePath: "", currentOrigin: "https://example.com" }, expected: null, }, + { + name: "mailto URL", + input: { + href: "mailto:hello@example.com", + basePath: "", + currentOrigin: "https://example.com", + }, + expected: null, + }, + { + name: "tel URL", + input: { href: "tel:+123456789", basePath: "", currentOrigin: "https://example.com" }, + expected: null, + }, + { + name: "sms URL", + input: { href: "sms:+123456789", basePath: "", currentOrigin: "https://example.com" }, + expected: null, + }, { name: "same-origin with basePath", input: { @@ -353,6 +372,44 @@ describe("Link App Router navigation scheduling", () => { expect(navigate).toHaveBeenCalledWith("/target", 0, "navigate", "push", undefined, true); expect(transitionStates).toEqual([true]); }); + + it("lets the browser handle native URI schemes without app-router navigation", async () => { + const userOnClick = vi.fn(); + const hrefs = ["mailto:hello@example.com", "tel:+123456789", "sms:+123456789"]; + + for (const href of hrefs) { + const result = await renderIsolatedLink({ + href, + nodeEnv: "production", + props: { onClick: userOnClick, prefetch: false }, + requireRef: false, + }); + + try { + const clickEvent = { + button: 0, + currentTarget: { target: "" }, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; + const onClick = result.capturedAnchorProps.onClick; + expect(onClick).toBeTypeOf("function"); + if (onClick === undefined) { + throw new Error("Expected rendered Link anchor to expose an onClick handler"); + } + + await onClick(clickEvent); + + expect(userOnClick).toHaveBeenCalledWith(clickEvent); + expect(clickEvent.defaultPrevented).toBe(false); + expect(result.navigate).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + } + }); }); async function renderIsolatedLink(options: { @@ -386,6 +443,7 @@ async function renderIsolatedLink(options: { }); const fetch = vi.fn(() => Promise.resolve(new Response(""))); + const navigate = vi.fn(); const location = { href: "https://example.com/current", origin: "https://example.com", @@ -393,7 +451,7 @@ async function renderIsolatedLink(options: { vi.stubGlobal("fetch", fetch); vi.stubGlobal("window", { - __VINEXT_RSC_NAVIGATE__: vi.fn(), + __VINEXT_RSC_NAVIGATE__: navigate, addEventListener: vi.fn(), dispatchEvent: vi.fn(), history: { @@ -437,6 +495,7 @@ async function renderIsolatedLink(options: { anchor, capturedAnchorProps, fetch, + navigate, restoreNodeEnv, }; } catch (error) { @@ -707,6 +766,22 @@ describe("Link App Router prefetch scheduling", () => { } }); + it("does not prefetch native URI schemes on production intent", async () => { + const result = await renderIsolatedLink({ + href: "mailto:hello@example.com", + nodeEnv: "production", + }); + + try { + result.capturedAnchorProps.onMouseEnter?.({ currentTarget: result.anchor }); + await flushPrefetchTasks(); + + 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", diff --git a/tests/link.test.ts b/tests/link.test.ts index 34912ea81..675d87cca 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -30,6 +30,8 @@ import { runWithI18nState } from "../packages/vinext/src/shims/i18n-state.js"; import { setI18nContext } from "../packages/vinext/src/shims/i18n-context.js"; import { + isAbsoluteOrProtocolRelativeUrl, + isAbsoluteUrl, resolveRelativeHref, toBrowserNavigationHref, toSameOriginAppPath, @@ -379,6 +381,17 @@ describe("Link locale handling", () => { expect(html).toContain('href="http://example.com/path"'); }); + it("locale does not mangle native URI schemes", () => { + const cases = ["mailto:hello@example.com", "tel:+123456789", "sms:+123456789"]; + + for (const href of cases) { + const html = ReactDOMServer.renderToString( + React.createElement(Link, { href, locale: "fr" } as any, "x"), + ); + expect(html).toContain(`href="${href}"`); + } + }); + it("locale string uses configured locale domains for cross-domain links", () => { (globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en", @@ -623,6 +636,25 @@ describe("toSameOriginPath", () => { }); }); +describe("absolute URL classification", () => { + it("matches Next.js scheme classification for native URI schemes", () => { + expect(isAbsoluteUrl("mailto:hello@example.com")).toBe(true); + expect(isAbsoluteUrl("tel:+123456789")).toBe(true); + expect(isAbsoluteUrl("sms:+123456789")).toBe(true); + expect(isAbsoluteUrl("ftp://example.com/file")).toBe(true); + expect(isAbsoluteUrl("/local")).toBe(false); + expect(isAbsoluteUrl("?page=2")).toBe(false); + expect(isAbsoluteUrl("#section")).toBe(false); + expect(isAbsoluteUrl("//example.com/path")).toBe(false); + }); + + it("treats protocol-relative URLs as browser-owned absolute-like hrefs", () => { + expect(isAbsoluteOrProtocolRelativeUrl("//example.com/path")).toBe(true); + expect(isAbsoluteOrProtocolRelativeUrl("mailto:hello@example.com")).toBe(true); + expect(isAbsoluteOrProtocolRelativeUrl("/local")).toBe(false); + }); +}); + describe("resolveRelativeHref", () => { it("resolves relative search params against the current page", () => { expect(resolveRelativeHref("?page=2", "http://localhost:3000/posts/1")).toBe("/posts/1?page=2"); @@ -644,6 +676,12 @@ describe("resolveRelativeHref", () => { expect(resolveRelativeHref("/about", "http://localhost:3000/posts/1")).toBe("/about"); }); + it("leaves native URI schemes unchanged", () => { + expect(resolveRelativeHref("mailto:hello@example.com", "http://localhost:3000/posts/1")).toBe( + "mailto:hello@example.com", + ); + }); + it("strips the current basePath before returning the app-relative href", () => { expect(resolveRelativeHref("?page=2", "http://localhost:3000/base/fr/posts/1", "/base")).toBe( "/fr/posts/1?page=2", From 8e0c59c526eea8ae03fec664f5b312e0b7d61ec7 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 17 May 2026 00:08:47 +1000 Subject: [PATCH 2/3] refactor(router): reuse shared URL scheme detection Router and navigation shims already had local external URL predicates with the same browser-owned URL semantics now used by Link. Reuse the shared url-utils predicate for Pages Router locale handling, Pages Router external detection, App Router external detection, and App Router prefetch normalization. Add focused shim coverage for native URI schemes through the Pages Router helpers. --- packages/vinext/src/shims/navigation.ts | 5 +++-- packages/vinext/src/shims/router.ts | 5 +++-- tests/shims.test.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 0a593255e..146fcae4b 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -21,6 +21,7 @@ import { } from "../server/app-rsc-cache-busting.js"; import { VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_PARAMS_HEADER } from "../server/headers.js"; import { + isAbsoluteOrProtocolRelativeUrl, isHashOnlyBrowserUrlChange, toBrowserNavigationHref, toSameOriginAppPath, @@ -1089,7 +1090,7 @@ export function useParams< * Check if a href is an external URL (any URL scheme per RFC 3986, or protocol-relative). */ function isExternalUrl(href: string): boolean { - return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//"); + return isAbsoluteOrProtocolRelativeUrl(href); } /** @@ -1428,7 +1429,7 @@ const _appRouter = { // origins so we don't pollute the prefetch cache with a same-path .rsc on // the current origin. Mirrors Link's prefetchUrl and navigateClientSide. let prefetchHref = href; - if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { + if (isAbsoluteOrProtocolRelativeUrl(href)) { const localPath = toSameOriginAppPath(href, __basePath); if (localPath == null) return; prefetchHref = localPath; diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 6f8d77c65..5ae1460ae 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -19,6 +19,7 @@ import type { VinextNextData } from "../client/vinext-next-data.js"; import { isValidModulePath } from "../client/validate-module-path.js"; import { installWindowNext, type PagesRouterPublicInstance } from "../client/window-next.js"; import { + isAbsoluteOrProtocolRelativeUrl, isHashOnlyBrowserUrlChange, toBrowserNavigationHref, toSameOriginAppPath, @@ -169,7 +170,7 @@ export function applyNavigationLocale(url: string, locale?: string): string { if (!locale || typeof window === "undefined") return url; // Absolute and protocol-relative URLs must not be prefixed — locale // only applies to local paths. - if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//")) { + if (isAbsoluteOrProtocolRelativeUrl(url)) { return url; } @@ -181,7 +182,7 @@ export function applyNavigationLocale(url: string, locale?: string): string { /** Check if a URL is external (any URL scheme per RFC 3986, or protocol-relative) */ export function isExternalUrl(url: string): boolean { - return /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//"); + return isAbsoluteOrProtocolRelativeUrl(url); } /** Resolve a hash URL to a basePath-stripped app URL for event payloads */ diff --git a/tests/shims.test.ts b/tests/shims.test.ts index d845a428c..509ac2278 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -10365,6 +10365,12 @@ describe("Pages Router router helpers", () => { expect(isExternalUrl("//cdn.example.com/img.png")).toBe(true); }); + it("detects native URI schemes as external", () => { + expect(isExternalUrl("mailto:hello@example.com")).toBe(true); + expect(isExternalUrl("tel:+123456789")).toBe(true); + expect(isExternalUrl("sms:+123456789")).toBe(true); + }); + it("returns false for relative paths", () => { expect(isExternalUrl("/about")).toBe(false); expect(isExternalUrl("/")).toBe(false); @@ -10438,6 +10444,19 @@ describe("Pages Router router helpers", () => { } }); + it("does not prefix native URI schemes", async () => { + const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js"); + (globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" }; + try { + expect(applyNavigationLocale("mailto:hello@example.com", "fr")).toBe( + "mailto:hello@example.com", + ); + expect(applyNavigationLocale("tel:+123456789", "fr")).toBe("tel:+123456789"); + } finally { + delete (globalThis as any).window; + } + }); + it("prefixes local paths with locale", async () => { const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js"); (globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" }; From 29bdd2f5df94cfa417b6a1e9d068a1b1146738c4 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 16 May 2026 19:56:18 +0100 Subject: [PATCH 3/3] test(link): add hasAttribute stub to native URI scheme click event The merge of main introduced a download-attribute check in handleClick that runs before the native-URI early return, so test click events must provide currentTarget.hasAttribute. --- tests/link-navigation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index a9043860a..9175de719 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -407,7 +407,7 @@ describe("Link App Router navigation scheduling", () => { try { const clickEvent = { button: 0, - currentTarget: { target: "" }, + currentTarget: { hasAttribute: () => false, target: "" }, defaultPrevented: false, preventDefault() { this.defaultPrevented = true;