From 0f912004e1a9803d96adf7fde7dd6361dc17c02e Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sat, 7 Mar 2026 17:24:43 -0700 Subject: [PATCH 1/3] fix: normalize same-origin absolute URLs for client-side navigation When is used and example.com matches the current origin, vinext now does SPA navigation instead of a full page reload. This matches Next.js behavior. Adds toSameOriginPath() utility that extracts the local path from a same-origin absolute URL. Applied consistently across: - link.tsx handleClick() and prefetch paths - navigation.ts navigateImpl() (App Router) - router.ts push()/replace() (Pages Router hook + singleton) Closes #335 --- packages/vinext/src/shims/link.tsx | 34 ++++++--- packages/vinext/src/shims/navigation.ts | 21 ++++-- packages/vinext/src/shims/router.ts | 49 ++++++++----- packages/vinext/src/shims/url-utils.ts | 24 +++++++ tests/link.test.ts | 93 ++++++++++++++++++++++++- 5 files changed, 188 insertions(+), 33 deletions(-) create mode 100644 packages/vinext/src/shims/url-utils.ts diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index f90a8391..67b7e260 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -12,6 +12,7 @@ import React, { forwardRef, useRef, useEffect, useCallback, useContext, createCo // so this resolves both via the Vite plugin and in direct vitest imports) import { toRscUrl, getPrefetchedUrls, storePrefetchResponse } from "./navigation.js"; import { isDangerousScheme } from "./url-safety.js"; +import { toSameOriginPath } from "./url-utils.js"; interface NavigateEvent { url: URL; @@ -146,10 +147,15 @@ function scrollToHash(hash: string): void { function prefetchUrl(href: string): void { if (typeof window === "undefined") return; - const fullHref = withBasePath(href); + // Normalize same-origin absolute URLs to local paths before prefetching + let prefetchHref = href; + if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) { + const localPath = toSameOriginPath(href); + if (localPath == null) return; // truly external — don't prefetch + prefetchHref = localPath; + } - // Don't prefetch external URLs - if (fullHref.startsWith("http://") || fullHref.startsWith("https://") || fullHref.startsWith("//")) return; + const fullHref = withBasePath(prefetchHref); // Don't prefetch the same URL twice (keyed by rscUrl so the browser // entry can clear the key when a cache entry is consumed) @@ -318,13 +324,18 @@ const Link = forwardRef(function Link( const node = internalRef.current; if (!node) return; - // Don't prefetch external URLs - if (localizedHref.startsWith("http://") || localizedHref.startsWith("https://") || localizedHref.startsWith("//")) return; + // Normalize same-origin absolute URLs; skip truly external ones + let hrefToPrefetch = localizedHref; + if (localizedHref.startsWith("http://") || localizedHref.startsWith("https://") || localizedHref.startsWith("//")) { + const localPath = toSameOriginPath(localizedHref); + if (localPath == null) return; // truly external + hrefToPrefetch = localPath; + } const observer = getSharedObserver(); if (!observer) return; - observerCallbacks.set(node, () => prefetchUrl(localizedHref)); + observerCallbacks.set(node, () => prefetchUrl(hrefToPrefetch)); observer.observe(node); return () => { @@ -353,13 +364,18 @@ const Link = forwardRef(function Link( return; } - // External links: let the browser handle it + // External links: let the browser handle it. + // Same-origin absolute URLs (e.g. http://localhost:3000/about) are + // normalized to local paths so they get client-side navigation. + let navigateHref = resolvedHref; if ( resolvedHref.startsWith("http://") || resolvedHref.startsWith("https://") || resolvedHref.startsWith("//") ) { - return; + const localPath = toSameOriginPath(resolvedHref); + if (localPath == null) return; // truly external + navigateHref = localPath; } e.preventDefault(); @@ -395,7 +411,7 @@ const Link = forwardRef(function Link( } // Resolve relative hrefs (#hash, ?query) against current URL - const absoluteHref = resolveRelativeHref(resolvedHref); + const absoluteHref = resolveRelativeHref(navigateHref); const absoluteFullHref = withBasePath(absoluteHref); // Hash-only change: update URL and scroll to target, skip RSC fetch diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 9106e24f..79321452 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -11,6 +11,7 @@ // would throw at link time for missing bindings. With `import * as React`, the // bindings are just `undefined` on the namespace object and we can guard at runtime. import * as React from "react"; +import { toSameOriginPath } from "./url-utils.js"; // ─── Layout segment depth context ───────────────────────────────────────────── // Used by useSelectedLayoutSegments() to know which layout it's inside. @@ -448,17 +449,23 @@ async function navigateImpl( mode: "push" | "replace", scroll: boolean, ): Promise { - // External URLs: use full page navigation + // Normalize same-origin absolute URLs to local paths for SPA navigation + let normalizedHref = href; if (isExternalUrl(href)) { - if (mode === "replace") { - window.location.replace(href); - } else { - window.location.assign(href); + const localPath = toSameOriginPath(href); + if (localPath == null) { + // Truly external: use full page navigation + if (mode === "replace") { + window.location.replace(href); + } else { + window.location.assign(href); + } + return; } - return; + normalizedHref = localPath; } - const fullHref = withBasePath(href); + const fullHref = withBasePath(normalizedHref); // Save scroll position before navigating (for back/forward restoration) if (mode === "push") { diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 0d252da9..8a03b811 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -8,6 +8,7 @@ import { useState, useEffect, useCallback, useMemo, createElement, type ReactElement } from "react"; import { RouterContext } from "./internal/router-context.js"; import { isValidModulePath } from "../client/validate-module-path.js"; +import { toSameOriginPath } from "./url-utils.js"; /** basePath from next.config.js, injected by the plugin at build time */ const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? ""; @@ -481,12 +482,16 @@ export function useRouter(): NextRouter { const push = useCallback( async (url: string | UrlObject, _as?: string, options?: TransitionOptions): Promise => { - const resolved = applyNavigationLocale(resolveUrl(url), options?.locale); + let resolved = applyNavigationLocale(resolveUrl(url), options?.locale); - // External URLs — delegate to browser + // External URLs — delegate to browser (unless same-origin) if (isExternalUrl(resolved)) { - window.location.assign(resolved); - return true; + const localPath = toSameOriginPath(resolved); + if (localPath == null) { + window.location.assign(resolved); + return true; + } + resolved = localPath; } // Hash-only change — no page fetch needed @@ -524,12 +529,16 @@ export function useRouter(): NextRouter { const replace = useCallback( async (url: string | UrlObject, _as?: string, options?: TransitionOptions): Promise => { - const resolved = applyNavigationLocale(resolveUrl(url), options?.locale); + let resolved = applyNavigationLocale(resolveUrl(url), options?.locale); - // External URLs — delegate to browser + // External URLs — delegate to browser (unless same-origin) if (isExternalUrl(resolved)) { - window.location.replace(resolved); - return true; + const localPath = toSameOriginPath(resolved); + if (localPath == null) { + window.location.replace(resolved); + return true; + } + resolved = localPath; } // Hash-only change — no page fetch needed @@ -652,12 +661,16 @@ export function wrapWithRouterContext(element: ReactElement): ReactElement { // Also export a default Router singleton for `import Router from 'next/router'` const Router = { push: async (url: string | UrlObject, _as?: string, options?: TransitionOptions) => { - const resolved = applyNavigationLocale(resolveUrl(url), options?.locale); + let resolved = applyNavigationLocale(resolveUrl(url), options?.locale); - // External URLs + // External URLs (unless same-origin) if (isExternalUrl(resolved)) { - window.location.assign(resolved); - return true; + const localPath = toSameOriginPath(resolved); + if (localPath == null) { + window.location.assign(resolved); + return true; + } + resolved = localPath; } // Hash-only change @@ -688,12 +701,16 @@ const Router = { return true; }, replace: async (url: string | UrlObject, _as?: string, options?: TransitionOptions) => { - const resolved = applyNavigationLocale(resolveUrl(url), options?.locale); + let resolved = applyNavigationLocale(resolveUrl(url), options?.locale); - // External URLs + // External URLs (unless same-origin) if (isExternalUrl(resolved)) { - window.location.replace(resolved); - return true; + const localPath = toSameOriginPath(resolved); + if (localPath == null) { + window.location.replace(resolved); + return true; + } + resolved = localPath; } // Hash-only change diff --git a/packages/vinext/src/shims/url-utils.ts b/packages/vinext/src/shims/url-utils.ts new file mode 100644 index 00000000..39ff2957 --- /dev/null +++ b/packages/vinext/src/shims/url-utils.ts @@ -0,0 +1,24 @@ +/** + * Shared URL utilities for same-origin detection. + * + * Used by link.tsx, navigation.ts, and router.ts to normalize + * same-origin absolute URLs to local paths for client-side navigation. + */ + +/** + * If `url` is an absolute same-origin URL, return the local path + * (pathname + search + hash). Returns null for truly external URLs + * or on the server (where origin is unknown). + */ +export function toSameOriginPath(url: string): string | null { + if (typeof window === "undefined") return null; + try { + const parsed = new URL(url); + if (parsed.origin === window.location.origin) { + return parsed.pathname + parsed.search + parsed.hash; + } + } catch { + // not a valid absolute URL — ignore + } + return null; +} diff --git a/tests/link.test.ts b/tests/link.test.ts index 93461149..9ddc3712 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -9,7 +9,7 @@ * These tests verify SSR output matches Next.js expectations and that * pure helper functions work correctly. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import React from "react"; import ReactDOMServer from "react-dom/server"; @@ -241,3 +241,94 @@ describe("Link locale handling", () => { expect(html).toContain('href="/fr/about"'); }); }); + +// ─── toSameOriginPath ──────────────────────────────────────────────────── +// Tests for the shared same-origin URL normalization utility. +// Related to: https://github.com/anthropics/vinext/issues/335 + +import { toSameOriginPath } from "../packages/vinext/src/shims/url-utils.js"; + +describe("toSameOriginPath", () => { + it("returns null on the server (no window)", () => { + // In vitest (Node.js), typeof window === 'undefined' by default + // unless jsdom is configured. Our tests run in node env. + expect(toSameOriginPath("https://example.com/path")).toBe(null); + }); + + it("returns null for invalid URLs", () => { + expect(toSameOriginPath("not a url")).toBe(null); + }); + + describe("with window (client-side)", () => { + const originalWindow = globalThis.window; + + beforeEach(() => { + // Simulate a browser window with a known origin + (globalThis as any).window = { + location: { + origin: "http://localhost:3000", + href: "http://localhost:3000/current", + }, + }; + }); + + afterEach(() => { + if (originalWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = originalWindow; + } + }); + + it("returns pathname for same-origin http:// URL", () => { + expect(toSameOriginPath("http://localhost:3000/about")).toBe("/about"); + }); + + it("returns pathname + search + hash for same-origin URL", () => { + expect(toSameOriginPath("http://localhost:3000/search?q=test#results")).toBe("/search?q=test#results"); + }); + + it("returns null for cross-origin URL", () => { + expect(toSameOriginPath("https://example.com/path")).toBe(null); + }); + + it("returns null for protocol-relative URLs (cannot determine origin)", () => { + // Protocol-relative URLs resolve against the page's protocol, + // but //other.com would have a different origin + expect(toSameOriginPath("//other.com/path")).toBe(null); + }); + + it("preserves the root path /", () => { + expect(toSameOriginPath("http://localhost:3000/")).toBe("/"); + }); + + it("returns null for different port (different origin)", () => { + expect(toSameOriginPath("http://localhost:5173/about")).toBe(null); + }); + + it("returns null for same host but different scheme (different origin)", () => { + expect(toSameOriginPath("https://localhost:3000/about")).toBe(null); + }); + }); +}); + +// ─── Link with same-origin absolute URL (SSR rendering) ───────────────── +// Verifies that renders the absolute URL as the +// href attribute (the normalization happens at click time, not render time). + +describe("Link with absolute URL", () => { + it("renders absolute http:// URL as href", () => { + const html = ReactDOMServer.renderToString( + React.createElement(Link, { href: "http://example.com/path" }, "External"), + ); + // The tag should have the full absolute URL as href + expect(html).toContain('href="http://example.com/path"'); + }); + + it("renders absolute https:// URL as href", () => { + const html = ReactDOMServer.renderToString( + React.createElement(Link, { href: "https://example.com/path" }, "Secure External"), + ); + expect(html).toContain('href="https://example.com/path"'); + }); +}); From 4f0ac02b0c9c545b8f97a73330265bddfc6d6ad6 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sat, 7 Mar 2026 17:39:51 -0700 Subject: [PATCH 2/3] fix: handle protocol-relative URLs in toSameOriginPath() Use window.location.origin as base for `//` URLs so they resolve correctly for same-origin detection, while keeping `new URL(url)` (no base) for http/https URLs to preserve existing behavior. Addresses Copilot review comment on PR #336. --- packages/vinext/src/shims/url-utils.ts | 4 +++- tests/link.test.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/shims/url-utils.ts b/packages/vinext/src/shims/url-utils.ts index 39ff2957..a543f47d 100644 --- a/packages/vinext/src/shims/url-utils.ts +++ b/packages/vinext/src/shims/url-utils.ts @@ -13,7 +13,9 @@ export function toSameOriginPath(url: string): string | null { if (typeof window === "undefined") return null; try { - const parsed = new URL(url); + const parsed = url.startsWith("//") + ? new URL(url, window.location.origin) + : new URL(url); if (parsed.origin === window.location.origin) { return parsed.pathname + parsed.search + parsed.hash; } diff --git a/tests/link.test.ts b/tests/link.test.ts index 9ddc3712..63be4883 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -292,9 +292,12 @@ describe("toSameOriginPath", () => { expect(toSameOriginPath("https://example.com/path")).toBe(null); }); - it("returns null for protocol-relative URLs (cannot determine origin)", () => { - // Protocol-relative URLs resolve against the page's protocol, - // but //other.com would have a different origin + it("returns pathname for same-origin protocol-relative URL", () => { + // //localhost:3000/about resolves to the page's protocol + localhost:3000 + expect(toSameOriginPath("//localhost:3000/about")).toBe("/about"); + }); + + it("returns null for cross-origin protocol-relative URL", () => { expect(toSameOriginPath("//other.com/path")).toBe(null); }); From 9c2a13207bdb4aecf23dbb74e1240cffd1c37be6 Mon Sep 17 00:00:00 2001 From: James Anderson Date: Sun, 8 Mar 2026 08:51:25 +0000 Subject: [PATCH 3/3] Update tests/link.test.ts Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com> --- tests/link.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/link.test.ts b/tests/link.test.ts index 63be4883..f86435df 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -244,7 +244,7 @@ describe("Link locale handling", () => { // ─── toSameOriginPath ──────────────────────────────────────────────────── // Tests for the shared same-origin URL normalization utility. -// Related to: https://github.com/anthropics/vinext/issues/335 +// Related to: https://github.com/cloudflare/vinext/issues/335 import { toSameOriginPath } from "../packages/vinext/src/shims/url-utils.js";