Skip to content

Commit dd9f046

Browse files
committed
fix(pages-router): normalize repeated URL slashes
1 parent 572b869 commit dd9f046

5 files changed

Lines changed: 93 additions & 84 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Warn about and normalize repeated path separators like Next.js `resolveHref`.
3+
* The protocol separator is preserved and query strings are left untouched.
4+
*
5+
* Ported from Next.js: packages/next/src/client/resolve-href.ts
6+
* https://github.com/vercel/next.js/blob/canary/packages/next/src/client/resolve-href.ts
7+
*/
8+
export function normalizeRouterHref(href: string, routePathname: string): string {
9+
const protocol = href.match(/^[a-z][a-z0-9+.-]*:\/\//i)?.[0] ?? "";
10+
const withoutProtocol = protocol ? href.slice(protocol.length) : href;
11+
if (!/(\/\/|\\)/.test(withoutProtocol.split("?", 1)[0] ?? "")) return href;
12+
13+
console.error(
14+
`Invalid href '${href}' passed to next/router in page: '${routePathname}'. Repeated forward-slashes (//) or backslashes \\ are not valid in the href.`,
15+
);
16+
17+
const [pathname, ...query] = withoutProtocol.split("?");
18+
const normalizedPathname = pathname.replace(/\\/g, "/").replace(/\/\/+/g, "/");
19+
return protocol + normalizedPathname + (query[0] ? `?${query.join("?")}` : "");
20+
}

packages/vinext/src/shims/link.tsx

Lines changed: 2 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
type PendingLinkSetter,
7070
} from "./internal/link-status-registry.js";
7171
import { getCurrentRoutePathnameForWarning } from "./internal/route-pattern-for-warning.js";
72+
import { normalizeRouterHref } from "./internal/normalize-router-href.js";
7273
import { scheduleAppPrefetchFetch } from "./internal/app-prefetch-fetch-queue.js";
7374

7475
type NavigateEvent = {
@@ -258,73 +259,6 @@ function applyPagesNavigationFallback(href: string, replace: boolean): void {
258259
window.dispatchEvent(new PopStateEvent("popstate"));
259260
}
260261

261-
/**
262-
* Collapse repeated forward-slashes (and convert backslashes to forward-slashes)
263-
* in the path portion of a URL, preserving any query string.
264-
*
265-
* Ported from Next.js: packages/next/src/shared/lib/utils/normalize-repeated-slashes.ts
266-
* https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/utils/normalize-repeated-slashes.ts
267-
*/
268-
function normalizeRepeatedSlashes(url: string): string {
269-
const urlParts = url.split("?");
270-
const urlNoQueryString = urlParts.shift() ?? "";
271-
const queryString = urlParts.join("?");
272-
return (
273-
urlNoQueryString.replace(/\\/g, "/").replace(/\/\/+/g, "/") +
274-
(queryString ? `?${queryString}` : "")
275-
);
276-
}
277-
278-
/**
279-
* Emit Next.js's "Invalid href" `console.error` when `href` contains repeated
280-
* forward slashes or backslashes in its path portion, and return the
281-
* normalized URL (with `\\` converted to `/` and runs of `/` collapsed). If
282-
* the href is already well-formed, the original string is returned unchanged.
283-
*
284-
* Ported from Next.js: packages/next/src/client/resolve-href.ts
285-
* https://github.com/vercel/next.js/blob/canary/packages/next/src/client/resolve-href.ts
286-
*
287-
* Matches the message asserted by:
288-
* test/e2e/repeated-forward-slashes-error/repeated-forward-slashes-error.test.ts
289-
*
290-
* Note: Next.js fires this warning unconditionally on every call to
291-
* `resolveHref`. We mirror that behaviour (no dedup) for exact parity.
292-
*
293-
* Note: Next.js uses `router.pathname` (the route pattern, e.g.
294-
* `/posts/[id]`) for the "in page" segment of the message. The Next.js
295-
* compat test asserts this exact text (`in page: '/my/path/[name]'`), so we
296-
* source it from the current render's route pattern via
297-
* `getCurrentRoutePathnameForWarning()`: the Pages Router SSR context's route
298-
* pattern on the server, `window.location.pathname` on the client, falling
299-
* back to `"/"`.
300-
*/
301-
function warnAndNormalizeRepeatedSlashesInHref(urlAsString: string): string {
302-
// Protocol-relative URLs (e.g. "//example.com/path") are treated by vinext
303-
// as external — see `isAbsoluteOrProtocolRelativeUrl` in url-utils. We
304-
// intentionally skip the repeated-slash warning and normalization for them
305-
// so that locale prefixing and same-origin detection elsewhere in this
306-
// shim continue to receive the original href. (Next.js itself does flag
307-
// these, but our external-URL handling supersedes that behaviour.)
308-
if (urlAsString.startsWith("//")) return urlAsString;
309-
310-
// Strip any protocol prefix (e.g. "https://") so we do not flag the
311-
// legitimate `//` that separates the scheme from the authority.
312-
const urlProtoMatch = urlAsString.match(/^[a-z][a-z0-9+.-]*:\/\//i);
313-
const urlAsStringNoProto = urlProtoMatch
314-
? urlAsString.slice(urlProtoMatch[0].length)
315-
: urlAsString;
316-
const urlParts = urlAsStringNoProto.split("?", 1);
317-
if (!(urlParts[0] || "").match(/(\/\/|\\)/)) return urlAsString;
318-
319-
const pathname = getCurrentRoutePathnameForWarning();
320-
console.error(
321-
`Invalid href '${urlAsString}' passed to next/router in page: '${pathname}'. Repeated forward-slashes (//) or backslashes \\ are not valid in the href.`,
322-
);
323-
324-
const normalizedNoProto = normalizeRepeatedSlashes(urlAsStringNoProto);
325-
return (urlProtoMatch ? urlProtoMatch[0] : "") + normalizedNoProto;
326-
}
327-
328262
export function resolveLinkPrefetchMode(
329263
prefetchProp: LinkProps["prefetch"],
330264
isDangerous: boolean,
@@ -1138,7 +1072,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
11381072
// See packages/next/src/client/resolve-href.ts.
11391073
const resolvedHref =
11401074
typeof rawResolvedHref === "string"
1141-
? warnAndNormalizeRepeatedSlashesInHref(rawResolvedHref)
1075+
? normalizeRouterHref(rawResolvedHref, getCurrentRoutePathnameForWarning())
11421076
: rawResolvedHref;
11431077

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

packages/vinext/src/shims/router.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ import {
9797
} from "./pages-router-runtime.js";
9898
import { assertSafeNavigationUrl } from "./url-safety.js";
9999
import { interpolateDynamicRouteHref } from "./internal/interpolate-as.js";
100+
import { normalizeRouterHref } from "./internal/normalize-router-href.js";
101+
import { getCurrentRoutePathnameForWarning } from "./internal/route-pattern-for-warning.js";
100102
import { getCurrentBrowserLocale } from "./client-locale.js";
101103
import { getDeploymentId, NEXT_DEPLOYMENT_ID_HEADER } from "../utils/deployment-id.js";
102104
import type { RequestContext } from "../config/config-matchers.js";
@@ -547,6 +549,12 @@ function resolveUrl(url: string | UrlObject): string {
547549
return result;
548550
}
549551

552+
function prepareUrl(url: Url): Url {
553+
return typeof url === "string"
554+
? normalizeRouterHref(url, getCurrentRoutePathnameForWarning())
555+
: url;
556+
}
557+
550558
/**
551559
* When `as` is provided, use it as the navigation target. This is a
552560
* simplification: Next.js keeps `url` and `as` as separate values (url for
@@ -4200,20 +4208,20 @@ const RouterMethods = {
42004208
// (an async function) becomes a rejected Promise that React does not
42014209
// observe from an event handler that does not await it (e.g.
42024210
// `<button onClick={() => router.push(...)}>`).
4203-
assertSafeNavigationUrl(resolveUrl(url));
4204-
if (as) {
4205-
assertSafeNavigationUrl(resolveUrl(as));
4206-
}
4207-
return performNavigation(url, as, options, "push");
4211+
const preparedUrl = prepareUrl(url);
4212+
const preparedAs = as ? prepareUrl(as) : undefined;
4213+
assertSafeNavigationUrl(resolveUrl(preparedUrl));
4214+
if (preparedAs) assertSafeNavigationUrl(resolveUrl(preparedAs));
4215+
return performNavigation(preparedUrl, preparedAs, options, "push");
42084216
},
42094217
replace: (url: Url, as?: Url, options?: TransitionOptions) => {
42104218
if (typeof window === "undefined") throwNoRouterInstance();
42114219
// See `push` above for the rationale on the synchronous guard.
4212-
assertSafeNavigationUrl(resolveUrl(url));
4213-
if (as) {
4214-
assertSafeNavigationUrl(resolveUrl(as));
4215-
}
4216-
return performNavigation(url, as, options, "replace");
4220+
const preparedUrl = prepareUrl(url);
4221+
const preparedAs = as ? prepareUrl(as) : undefined;
4222+
assertSafeNavigationUrl(resolveUrl(preparedUrl));
4223+
if (preparedAs) assertSafeNavigationUrl(resolveUrl(preparedAs));
4224+
return performNavigation(preparedUrl, preparedAs, options, "replace");
42174225
},
42184226
back: () => {
42194227
if (typeof window === "undefined") throwNoRouterInstance();

tests/link.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -953,12 +953,17 @@ describe("Link locale handling", () => {
953953
expect(html).toContain('href="https://example.com/about"');
954954
});
955955

956-
it("locale does not mangle protocol-relative URLs", () => {
957-
// //example.com/about should not become /fr///example.com/about
958-
const html = ReactDOMServer.renderToString(
959-
React.createElement(Link, { href: "//example.com/about", locale: "fr" } as any, "x"),
960-
);
961-
expect(html).toContain('href="//example.com/about"');
956+
it("normalizes protocol-relative URLs before applying locale", () => {
957+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
958+
try {
959+
const html = ReactDOMServer.renderToString(
960+
React.createElement(Link, { href: "//example.com/about", locale: "fr" } as any, "x"),
961+
);
962+
expect(html).toContain('href="/fr/example.com/about"');
963+
expect(consoleError).toHaveBeenCalledTimes(1);
964+
} finally {
965+
consoleError.mockRestore();
966+
}
962967
});
963968

964969
it("locale does not mangle http:// URLs", () => {

tests/shims.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2886,6 +2886,48 @@ describe("window.next debug global", () => {
28862886
}
28872887
});
28882888

2889+
it("normalizes protocol-relative router hrefs before applying basePath", async () => {
2890+
const previousWindow = (globalThis as any).window;
2891+
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
2892+
const pushState = vi.fn();
2893+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
2894+
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
2895+
(globalThis as any).window = {
2896+
location: {
2897+
pathname: "/docs/start",
2898+
search: "",
2899+
hash: "",
2900+
href: "http://localhost/docs/start",
2901+
origin: "http://localhost",
2902+
},
2903+
history: { state: null, pushState, replaceState() {} },
2904+
addEventListener() {},
2905+
dispatchEvent() {},
2906+
scrollTo() {},
2907+
};
2908+
2909+
try {
2910+
vi.resetModules();
2911+
const routerModule = await import("../packages/vinext/src/shims/router.js");
2912+
2913+
await routerModule.default.push("//localhost/outside", undefined, { shallow: true });
2914+
2915+
expect(pushState).toHaveBeenCalledWith(
2916+
expect.objectContaining({ url: "/localhost/outside", as: "/localhost/outside" }),
2917+
"",
2918+
"/docs/localhost/outside",
2919+
);
2920+
expect(consoleError).toHaveBeenCalledTimes(1);
2921+
} finally {
2922+
consoleError.mockRestore();
2923+
if (previousBasePath === undefined) delete process.env.__NEXT_ROUTER_BASEPATH;
2924+
else process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
2925+
if (previousWindow === undefined) delete (globalThis as any).window;
2926+
else (globalThis as any).window = previousWindow;
2927+
vi.resetModules();
2928+
}
2929+
});
2930+
28892931
it.each([
28902932
[{}, "/rewrite-navigation/[id]/destination", "/rewrite-navigation/[id]/destination"],
28912933
[{ query: {} }, "/rewrite-navigation/[id]/destination", "/rewrite-navigation/[id]/destination"],

0 commit comments

Comments
 (0)