Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 41 additions & 0 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,40 @@ function resolveHref(href: LinkProps["href"]): string {
return url;
}

// Track hrefs we have already warned about so we do not spam the console on
// every re-render. Mirrors React's behaviour for repeated warnings.
const warnedRepeatedSlashHrefs = new Set<string>();
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.

The dedup Set diverges from Next.js, which fires console.error on every call to resolveHref — no dedup. Consider removing it for exact parity. If the dedup is intentional, document it as a deliberate divergence and export a test helper to clear state between test runs (the module-level Set persists across tests within a Vitest worker).


/**
* Emit Next.js's "Invalid href" console.error when `href` contains repeated
* forward slashes or backslashes in its path portion.
*
* Ported from Next.js: packages/next/src/client/resolve-href.ts
* https://github.com/vercel/next.js/blob/canary/packages/next/src/client/resolve-href.ts
*
* Matches the message asserted by:
* test/e2e/repeated-forward-slashes-error/repeated-forward-slashes-error.test.ts
*/
function warnOnRepeatedSlashesInHref(urlAsString: string): void {
// Strip any protocol prefix (e.g. "https://") so we do not flag the
// legitimate `//` that separates the scheme from the authority.
const urlProtoMatch = urlAsString.match(/^[a-z][a-z0-9+.-]*:\/\//i);
const urlAsStringNoProto = urlProtoMatch
? urlAsString.slice(urlProtoMatch[0].length)
: urlAsString;
const urlParts = urlAsStringNoProto.split("?", 1);
if (!(urlParts[0] || "").match(/(\/\/|\\)/)) return;

if (warnedRepeatedSlashHrefs.has(urlAsString)) return;
warnedRepeatedSlashHrefs.add(urlAsString);

const pathname =
typeof window !== "undefined" && window.location ? window.location.pathname : "/";
console.error(
`Invalid href '${urlAsString}' passed to next/router in page: '${pathname}'. Repeated forward-slashes (//) or backslashes \\ are not valid in the href.`,
);
}
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.

Next.js also normalizes the URL after warning — it calls normalizeRepeatedSlashes() to collapse /// and \\/, then continues routing with the cleaned URL. Without that step, <Link href="/hello//world"> renders href="/hello//world" in the DOM (vinext) vs href="/hello/world" (Next.js). This affects actual navigation, not just the warning.

Fine to ship the warning first and follow up with normalization, but worth filing a follow-up issue.


export function resolveLinkPrefetchMode(
prefetchProp: LinkProps["prefetch"],
isDangerous: boolean,
Expand Down Expand Up @@ -537,6 +571,13 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
// where href is a route pattern like "/user/[id]" and as is "/user/1")
const resolvedHref = as ?? resolveHref(href);

// Mirror Next.js: emit a console.error when the href contains repeated
// forward-slashes (e.g. "/foo//bar") or backslashes. Next.js does not block
// navigation — it only warns. See packages/next/src/client/resolve-href.ts.
if (typeof resolvedHref === "string") {
warnOnRepeatedSlashesInHref(resolvedHref);
}

const isDangerous = typeof resolvedHref === "string" && isDangerousScheme(resolvedHref);

// Apply locale prefix if specified (safe even for dangerous hrefs since we
Expand Down
57 changes: 57 additions & 0 deletions tests/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,63 @@ describe("Link rendering", () => {
});
});

// ─── Repeated-slash warning (parity with Next.js) ───────────────────────
//
// Ported from Next.js: test/e2e/repeated-forward-slashes-error/repeated-forward-slashes-error.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/repeated-forward-slashes-error/repeated-forward-slashes-error.test.ts
//
// Next.js's `resolveHref` emits a `console.error` when an href contains
// repeated forward-slashes (e.g. "/hello//world") or backslashes. Navigation
// is not blocked; only a warning is surfaced.

describe("Link repeated-slash warning", () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
consoleSpy.mockRestore();
});

it("logs a console.error when href contains repeated forward slashes", () => {
ReactDOMServer.renderToString(React.createElement(Link, { href: "/hello//world" }, "Hello"));
expect(consoleSpy).toHaveBeenCalled();
const message = consoleSpy.mock.calls[0]?.[0] as string;
expect(message).toContain("Invalid href '/hello//world'");
expect(message).toContain(
"Repeated forward-slashes (//) or backslashes \\ are not valid in the href.",
);
});

it("logs a console.error when href contains a backslash", () => {
ReactDOMServer.renderToString(React.createElement(Link, { href: "/foo\\bar" }, "Bad"));
expect(consoleSpy).toHaveBeenCalled();
const message = consoleSpy.mock.calls[0]?.[0] as string;
expect(message).toContain("Invalid href '/foo\\bar'");
});

it("does not warn for absolute URLs whose only '//' is the protocol separator", () => {
ReactDOMServer.renderToString(
React.createElement(Link, { href: "https://example.com/path" }, "Ext"),
);
expect(consoleSpy).not.toHaveBeenCalled();
});

it("does not warn for hrefs without repeated slashes", () => {
ReactDOMServer.renderToString(React.createElement(Link, { href: "/normal/path" }, "Normal"));
expect(consoleSpy).not.toHaveBeenCalled();
});

it("ignores repeated slashes inside the query string", () => {
// Next.js only checks the path portion (everything before '?'), so a
// query string containing '//' must not trigger the warning.
ReactDOMServer.renderToString(React.createElement(Link, { href: "/ok?next=//foo//bar" }, "Q"));
expect(consoleSpy).not.toHaveBeenCalled();
});
});

// ─── useLinkStatus ──────────────────────────────────────────────────────

describe("useLinkStatus", () => {
Expand Down
Loading