From 5e2369f80d88436aa960683beb6aa27660171323 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:58:20 +1100 Subject: [PATCH 1/2] fix: skip locale prefix for absolute and protocol-relative Link hrefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyLocaleToHref() blindly prepended /${locale} to any href string, producing malformed URLs like /fr/https://example.com/about or /fr///example.com/about for absolute and protocol-relative hrefs. Guard against http://, https://, and // URLs before applying the locale prefix — these are external or same-origin absolute URLs that should not be rewritten. --- packages/vinext/src/shims/link.tsx | 6 ++++++ tests/link.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 496d9228..2ffd865c 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -283,6 +283,12 @@ function applyLocaleToHref(href: string, locale: string | false | undefined): st return href; } + // Absolute and protocol-relative URLs must not be prefixed — locale + // only applies to local paths. + if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { + return href; + } + // locale is a string: prepend the locale prefix if not already present const defaultLocale = getDefaultLocale(); // For the default locale, Next.js doesn't add a prefix diff --git a/tests/link.test.ts b/tests/link.test.ts index 2ca3413e..f2da5a2c 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -242,6 +242,30 @@ describe("Link locale handling", () => { // Should not become /fr/fr/about expect(html).toContain('href="/fr/about"'); }); + + it("locale does not mangle absolute same-origin URLs", () => { + // An absolute URL like https://example.com/about should not become + // /fr/https://example.com/about — locale prefix only applies to paths + const html = ReactDOMServer.renderToString( + React.createElement(Link, { href: "https://example.com/about", locale: "fr" } as any, "x"), + ); + expect(html).toContain('href="https://example.com/about"'); + }); + + it("locale does not mangle protocol-relative URLs", () => { + // //example.com/about should not become /fr///example.com/about + const html = ReactDOMServer.renderToString( + React.createElement(Link, { href: "//example.com/about", locale: "fr" } as any, "x"), + ); + expect(html).toContain('href="//example.com/about"'); + }); + + it("locale does not mangle http:// URLs", () => { + const html = ReactDOMServer.renderToString( + React.createElement(Link, { href: "http://example.com/path", locale: "de" } as any, "x"), + ); + expect(html).toContain('href="http://example.com/path"'); + }); }); // ─── toSameOriginPath ──────────────────────────────────────────────────── From ba458abde322579252884451c6270cec972c5879 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 08:34:01 +0000 Subject: [PATCH 2/2] fix: skip locale prefix for absolute and protocol-relative URLs in applyNavigationLocale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyNavigationLocale() in router.ts (used by Router.push() / Router.replace()) had the same bug as applyLocaleToHref() in link.tsx — it would prepend /{locale} to absolute and protocol-relative URLs, producing malformed hrefs like /fr/https://example.com/about or /fr///cdn.example.com/img.png. Add the same guard that was applied to applyLocaleToHref(), and add corresponding tests covering https://, http://, and // URL patterns. --- packages/vinext/src/shims/router.ts | 5 +++ tests/shims.test.ts | 49 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 2f736756..8ba77a25 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -124,6 +124,11 @@ function resolveUrl(url: string | UrlObject): string { */ 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("//")) { + return url; + } const defaultLocale = window.__VINEXT_DEFAULT_LOCALE__; // Default locale doesn't get a prefix if (locale === defaultLocale) return url; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0af73aa2..642e0979 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6795,6 +6795,55 @@ describe("Pages Router router helpers", () => { expect(isHashOnlyChange("https://example.com#foo")).toBe(false); }); }); + + describe("applyNavigationLocale", () => { + it("does not prefix absolute https:// URLs", async () => { + const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js"); + // Simulate a browser-like window so the locale guard is reached + (globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" }; + try { + expect(applyNavigationLocale("https://example.com/about", "fr")).toBe( + "https://example.com/about", + ); + } finally { + delete (globalThis as any).window; + } + }); + + it("does not prefix absolute http:// URLs", async () => { + const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js"); + (globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" }; + try { + expect(applyNavigationLocale("http://example.com/path", "de")).toBe( + "http://example.com/path", + ); + } finally { + delete (globalThis as any).window; + } + }); + + it("does not prefix protocol-relative // URLs", async () => { + const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js"); + (globalThis as any).window = { __VINEXT_DEFAULT_LOCALE__: "en" }; + try { + expect(applyNavigationLocale("//cdn.example.com/img.png", "fr")).toBe( + "//cdn.example.com/img.png", + ); + } 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" }; + try { + expect(applyNavigationLocale("/about", "fr")).toBe("/fr/about"); + } finally { + delete (globalThis as any).window; + } + }); + }); }); describe("next/server enhancements", () => {