diff --git a/packages/vinext/src/shims/link-prefetch.ts b/packages/vinext/src/shims/link-prefetch.ts index 179bf4d11..fb0ebab26 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"; @@ -65,7 +66,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 { @@ -86,7 +87,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 2a0e3b2ca..ece70bbd3 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -39,6 +39,7 @@ import { type LinkPrefetchRouterMode, } from "./link-prefetch.js"; import { + isAbsoluteOrProtocolRelativeUrl, normalizePathTrailingSlash, resolveRelativeHref, toBrowserNavigationHref, @@ -366,7 +367,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; } @@ -546,11 +547,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 = normalizedHref; - 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/navigation.ts b/packages/vinext/src/shims/navigation.ts index 3ec48ded7..7649be0fc 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -22,6 +22,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, @@ -1094,7 +1095,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); } /** @@ -1419,7 +1420,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 2e3ca1ad8..8abecdf52 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -20,6 +20,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, @@ -172,7 +173,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; } @@ -184,7 +185,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/packages/vinext/src/shims/url-utils.ts b/packages/vinext/src/shims/url-utils.ts index 6d908aa24..2ce3ad5f3 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 @@ -118,13 +134,7 @@ export function normalizePathTrailingSlash(path: string, trailingSlash: boolean) * 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; } @@ -140,12 +150,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 e11f51290..9175de719 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -256,6 +256,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: { @@ -373,6 +392,44 @@ describe("Link App Router navigation scheduling", () => { 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: { hasAttribute: () => false, 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(); + } + } + }); + it("lets the browser handle download links without app-router navigation", async () => { vi.resetModules(); @@ -476,6 +533,7 @@ async function renderIsolatedLink(options: { }); const fetch = vi.fn(() => Promise.resolve(new Response(""))); + const navigate = vi.fn(); const pagePrefetchLinks: CapturedPrefetchLinkElement[] = []; const location = { href: "https://example.com/current", @@ -492,7 +550,7 @@ async function renderIsolatedLink(options: { }, }); vi.stubGlobal("window", { - __VINEXT_RSC_NAVIGATE__: vi.fn(), + __VINEXT_RSC_NAVIGATE__: navigate, addEventListener: vi.fn(), dispatchEvent: vi.fn(), history: { @@ -536,6 +594,7 @@ async function renderIsolatedLink(options: { anchor, capturedAnchorProps, fetch, + navigate, pagePrefetchLinks, restoreNodeEnv, }; @@ -878,6 +937,22 @@ describe("Link 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 1d3fd1c30..085ecbb14 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, normalizePathTrailingSlash, resolveRelativeHref, toBrowserNavigationHref, @@ -380,6 +382,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", @@ -624,6 +637,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"); @@ -645,6 +677,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", diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 6411ac8ec..a98b2470b 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -10646,6 +10646,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); @@ -10719,6 +10725,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" };