Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions packages/vinext/src/shims/link-prefetch.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -86,7 +87,3 @@ export function getLinkPrefetchHref(input: {
return null;
}
}

function isAbsoluteOrProtocolRelative(href: string): boolean {
return href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//");
}
9 changes: 3 additions & 6 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
type LinkPrefetchRouterMode,
} from "./link-prefetch.js";
import {
isAbsoluteOrProtocolRelativeUrl,
normalizePathTrailingSlash,
resolveRelativeHref,
toBrowserNavigationHref,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -546,11 +547,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(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;
Expand Down
5 changes: 3 additions & 2 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 */
Expand Down
31 changes: 18 additions & 13 deletions packages/vinext/src/shims/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: since path.startsWith("/") is checked first, the isAbsoluteOrProtocolRelativeUrl(path) call can only ever match protocol-relative // paths — isAbsoluteUrl requires the first character to be a letter, which conflicts with /. So this is correct and equivalent to the old inline check, but the reader has to reason through two guards to see it. Might be worth a one-line comment like // only protocol-relative "//" can reach here; absolute schemes don't start with "/" to save the next person the mental detour. Not blocking.

return path;
}

Expand All @@ -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;
}

Expand Down
77 changes: 76 additions & 1 deletion tests/link-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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",
Expand All @@ -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: {
Expand Down Expand Up @@ -536,6 +594,7 @@ async function renderIsolatedLink(options: {
anchor,
capturedAnchorProps,
fetch,
navigate,
pagePrefetchLinks,
restoreNodeEnv,
};
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions tests/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand All @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions tests/shims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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" };
Expand Down
Loading