Skip to content
Open
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
77 changes: 76 additions & 1 deletion packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,72 @@ function resolveHref(href: LinkProps["href"]): string {
return url;
}

/**
* Collapse repeated forward-slashes (and convert backslashes to forward-slashes)
* in the path portion of a URL, preserving any query string.
*
* Ported from Next.js: packages/next/src/shared/lib/utils/normalize-repeated-slashes.ts
* https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/utils/normalize-repeated-slashes.ts
*/
function normalizeRepeatedSlashes(url: string): string {
const urlParts = url.split("?");
const urlNoQueryString = urlParts.shift() ?? "";
const queryString = urlParts.join("?");
return (
urlNoQueryString.replace(/\\/g, "/").replace(/\/\/+/g, "/") +
(queryString ? `?${queryString}` : "")
);
}

/**
* Emit Next.js's "Invalid href" `console.error` when `href` contains repeated
* forward slashes or backslashes in its path portion, and return the
* normalized URL (with `\\` converted to `/` and runs of `/` collapsed). If
* the href is already well-formed, the original string is returned unchanged.
*
* 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
*
* Note: Next.js fires this warning unconditionally on every call to
* `resolveHref`. We mirror that behaviour (no dedup) for exact parity.
*
* Note: Next.js uses `router.pathname` (the route pattern, e.g.
* `/posts/[id]`) for the "in page" segment of the message. We do not have
* cheap access to the route pattern from inside the Link shim, so we
* fall back to `window.location.pathname` (or `"/"` during SSR). The text
* is cosmetic and is not asserted by the Next.js compat test.
*/
function warnAndNormalizeRepeatedSlashesInHref(urlAsString: string): string {
// Protocol-relative URLs (e.g. "//example.com/path") are treated by vinext
// as external — see `isAbsoluteOrProtocolRelativeUrl` in url-utils. We
// intentionally skip the repeated-slash warning and normalization for them
// so that locale prefixing and same-origin detection elsewhere in this
// shim continue to receive the original href. (Next.js itself does flag
// these, but our external-URL handling supersedes that behaviour.)
if (urlAsString.startsWith("//")) return urlAsString;

// 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 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.`,
);

const normalizedNoProto = normalizeRepeatedSlashes(urlAsStringNoProto);
return (urlProtoMatch ? urlProtoMatch[0] : "") + normalizedNoProto;
}
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 @@ -535,7 +601,16 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(

// If `as` is provided, use it as the actual URL (legacy Next.js pattern
// where href is a route pattern like "/user/[id]" and as is "/user/1")
const resolvedHref = as ?? resolveHref(href);
const rawResolvedHref = as ?? resolveHref(href);

// Mirror Next.js: emit a console.error when the href contains repeated
// forward-slashes (e.g. "/foo//bar") or backslashes, and then normalize the
// href so navigation targets the collapsed path rather than the raw one.
// See packages/next/src/client/resolve-href.ts.
const resolvedHref =
typeof rawResolvedHref === "string"
? warnAndNormalizeRepeatedSlashesInHref(rawResolvedHref)
: rawResolvedHref;

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

Expand Down
101 changes: 101 additions & 0 deletions tests/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,107 @@ 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();
});

it("fires on every render (no dedup, matches Next.js behaviour)", () => {
// Next.js's resolve-href.ts does NOT dedupe these warnings — every call
// emits a console.error. Confirm we do the same so repeated renders
// surface every offending href.
const el = React.createElement(Link, { href: "/dup//slash" }, "Dup");
ReactDOMServer.renderToString(el);
ReactDOMServer.renderToString(el);
expect(consoleSpy).toHaveBeenCalledTimes(2);
});

it("normalises repeated forward slashes in the rendered href", () => {
// Next.js mirrors Vercel's gateway behaviour: after warning, the href is
// collapsed so the browser navigates to the canonical path.
const html = ReactDOMServer.renderToString(
React.createElement(Link, { href: "/hello//world" }, "Hello"),
);
expect(html).toContain('href="/hello/world"');
expect(html).not.toContain("//world");
});

it("normalises backslashes to forward slashes in the rendered href", () => {
const html = ReactDOMServer.renderToString(
React.createElement(Link, { href: "/foo\\bar" }, "Bad"),
);
expect(html).toContain('href="/foo/bar"');
expect(html).not.toContain("\\");
});

it("preserves the query string when normalising repeated slashes", () => {
const html = ReactDOMServer.renderToString(
React.createElement(Link, { href: "/a//b?x=1&y=2" }, "Q"),
);
expect(html).toContain('href="/a/b?x=1&amp;y=2"');
});

it("preserves the protocol when normalising absolute URLs", () => {
// The "//" between scheme and authority must survive normalisation, but
// a duplicate slash in the *path* portion must still collapse.
const html = ReactDOMServer.renderToString(
React.createElement(Link, { href: "https://example.com//foo//bar" }, "Ext"),
);
expect(html).toContain('href="https://example.com/foo/bar"');
});
});

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

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