diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index f71800d9d..9406e1c78 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -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; +} + export function resolveLinkPrefetchMode( prefetchProp: LinkProps["prefetch"], isDangerous: boolean, @@ -535,7 +601,16 @@ const Link = forwardRef(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); diff --git a/tests/link.test.ts b/tests/link.test.ts index 99d480626..d08152fb2 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -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; + + 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&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", () => {