Skip to content
Merged
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
34 changes: 25 additions & 9 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -318,13 +324,18 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: prefetchUrl() already normalizes same-origin URLs internally (lines 150-156). When the IntersectionObserver fires this callback, toSameOriginPath will be called a second time inside prefetchUrl. This is harmless (cheap, idempotent, correct) but the double normalization could be avoided by passing hrefToPrefetch here, which you already do — so it only re-normalizes if the href was already local. Just noting the interaction; no change needed.

observer.observe(node);

return () => {
Expand Down Expand Up @@ -353,13 +364,18 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice detail: using resolvedHref (which incorporates the as prop) for the external check, and then feeding the normalized path into resolveRelativeHref / withBasePath downstream. The as-prop case is handled correctly because as would typically be a local path, but if someone passes an absolute URL as as, this now does the right thing.

if (
resolvedHref.startsWith("http://") ||
resolvedHref.startsWith("https://") ||
resolvedHref.startsWith("//")
) {
return;
const localPath = toSameOriginPath(resolvedHref);
if (localPath == null) return; // truly external
navigateHref = localPath;
}

e.preventDefault();
Expand Down Expand Up @@ -395,7 +411,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(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
Expand Down
21 changes: 14 additions & 7 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -448,17 +449,23 @@ async function navigateImpl(
mode: "push" | "replace",
scroll: boolean,
): Promise<void> {
// 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") {
Expand Down
49 changes: 33 additions & 16 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "";
Expand Down Expand Up @@ -481,12 +482,16 @@ export function useRouter(): NextRouter {

const push = useCallback(
async (url: string | UrlObject, _as?: string, options?: TransitionOptions): Promise<boolean> => {
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
Expand Down Expand Up @@ -524,12 +529,16 @@ export function useRouter(): NextRouter {

const replace = useCallback(
async (url: string | UrlObject, _as?: string, options?: TransitionOptions): Promise<boolean> => {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/vinext/src/shims/url-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* 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 = url.startsWith("//")
? new URL(url, window.location.origin)
: 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;
}
96 changes: 95 additions & 1 deletion tests/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -241,3 +241,97 @@ 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/cloudflare/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 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);
});

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 <Link href="http://..."> 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 <a> 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"');
});
});
Loading