mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
6762472e70
* fix: normalize same-origin absolute URLs for client-side navigation When <Link href="https://example.com/path"> 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 * 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. * Update tests/link.test.ts Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com> --------- Co-authored-by: James Anderson <james@eli.cx> Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
/**
|
|
* next/link shim unit tests.
|
|
*
|
|
* Mirrors test cases from Next.js test/unit/link-rendering.test.ts and
|
|
* test/unit/link-warnings.test.tsx, plus additional coverage for vinext's
|
|
* Link internals: resolveHref(), withBasePath(), applyLocaleToHref(), and
|
|
* isHashOnlyChange().
|
|
*
|
|
* These tests verify SSR output matches Next.js expectations and that
|
|
* pure helper functions work correctly.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import React from "react";
|
|
import ReactDOMServer from "react-dom/server";
|
|
|
|
// We test the Link component and its internal helpers.
|
|
// Link is a "use client" component but renderToString still works for SSR output.
|
|
import Link, { useLinkStatus } from "../packages/vinext/src/shims/link.js";
|
|
|
|
// Internal helpers re-exported or accessible via the router shim
|
|
import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/router.js";
|
|
|
|
// ─── SSR rendering (mirrors Next.js test/unit/link-rendering.test.ts) ────
|
|
|
|
describe("Link rendering", () => {
|
|
it("should render Link on its own", () => {
|
|
// Next.js test: <Link href="/my-path">to another page</Link>
|
|
// Expected: <a href="/my-path">to another page</a>
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/my-path" }, "to another page"),
|
|
);
|
|
expect(html).toContain('href="/my-path"');
|
|
expect(html).toContain("to another page");
|
|
// Should be an <a> tag
|
|
expect(html).toMatch(/^<a\s/);
|
|
});
|
|
|
|
it("renders children as anchor content", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about" }, "About Us"),
|
|
);
|
|
expect(html).toContain("About Us");
|
|
expect(html).toContain('href="/about"');
|
|
});
|
|
|
|
it("renders with object href", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: { pathname: "/search", query: { q: "test" } } },
|
|
"Search",
|
|
),
|
|
);
|
|
// resolveHref({ pathname: "/search", query: { q: "test" } }) -> "/search?q=test"
|
|
expect(html).toContain('href="/search?q=test"');
|
|
});
|
|
|
|
it("renders object href with only query (defaults to /)", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: { query: { tab: "settings" } } },
|
|
"Settings",
|
|
),
|
|
);
|
|
expect(html).toContain('href="/?tab=settings"');
|
|
});
|
|
|
|
it("renders with as prop overriding href", () => {
|
|
// Legacy pattern: href is the route pattern, as is the actual URL
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/user/[id]", as: "/user/42" },
|
|
"User 42",
|
|
),
|
|
);
|
|
expect(html).toContain('href="/user/42"');
|
|
});
|
|
|
|
it("does not render passHref as an HTML attribute", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/test", passHref: true }, "Test"),
|
|
);
|
|
expect(html).not.toContain("passHref");
|
|
expect(html).toContain('href="/test"');
|
|
});
|
|
|
|
it("does not render locale as an HTML attribute", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/test", locale: "fr" } as any, "Test"),
|
|
);
|
|
expect(html).not.toContain('locale=');
|
|
});
|
|
|
|
it("passes through standard anchor attributes", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/test", className: "nav-link", id: "my-link", "aria-label": "Test link" },
|
|
"Test",
|
|
),
|
|
);
|
|
expect(html).toContain('class="nav-link"');
|
|
expect(html).toContain('id="my-link"');
|
|
expect(html).toContain('aria-label="Test link"');
|
|
});
|
|
|
|
it("renders with React element children", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/nested" },
|
|
React.createElement("span", null, "Nested child"),
|
|
),
|
|
);
|
|
expect(html).toContain("<span>Nested child</span>");
|
|
expect(html).toContain('href="/nested"');
|
|
});
|
|
});
|
|
|
|
// ─── useLinkStatus ──────────────────────────────────────────────────────
|
|
|
|
describe("useLinkStatus", () => {
|
|
it("returns { pending: false } by default", () => {
|
|
let status: { pending: boolean } | undefined;
|
|
function TestComponent() {
|
|
status = useLinkStatus();
|
|
return null;
|
|
}
|
|
ReactDOMServer.renderToString(React.createElement(TestComponent));
|
|
expect(status).toEqual({ pending: false });
|
|
});
|
|
});
|
|
|
|
// ─── resolveHref (internal helper, tested via component output) ─────────
|
|
|
|
describe("Link resolveHref", () => {
|
|
it("string href passes through unchanged", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about" }, "x"),
|
|
);
|
|
expect(html).toContain('href="/about"');
|
|
});
|
|
|
|
it("object href with pathname and query", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: { pathname: "/items", query: { page: "2", sort: "name" } } },
|
|
"x",
|
|
),
|
|
);
|
|
// URLSearchParams preserves insertion order
|
|
expect(html).toMatch(/href="\/items\?page=2&(?:amp;)?sort=name"/);
|
|
});
|
|
|
|
it("object href with only pathname", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: { pathname: "/dashboard" } },
|
|
"x",
|
|
),
|
|
);
|
|
expect(html).toContain('href="/dashboard"');
|
|
});
|
|
});
|
|
|
|
// ─── isExternalUrl ──────────────────────────────────────────────────────
|
|
|
|
describe("isExternalUrl", () => {
|
|
it("detects http:// as external", () => {
|
|
expect(isExternalUrl("http://example.com")).toBe(true);
|
|
});
|
|
|
|
it("detects https:// as external", () => {
|
|
expect(isExternalUrl("https://example.com")).toBe(true);
|
|
});
|
|
|
|
it("detects protocol-relative // as external", () => {
|
|
expect(isExternalUrl("//cdn.example.com/image.png")).toBe(true);
|
|
});
|
|
|
|
it("internal paths are not external", () => {
|
|
expect(isExternalUrl("/about")).toBe(false);
|
|
expect(isExternalUrl("/")).toBe(false);
|
|
expect(isExternalUrl("about")).toBe(false);
|
|
});
|
|
|
|
it("hash-only is not external", () => {
|
|
expect(isExternalUrl("#section")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── isHashOnlyChange ───────────────────────────────────────────────────
|
|
|
|
describe("isHashOnlyChange", () => {
|
|
it("returns true for #fragment", () => {
|
|
expect(isHashOnlyChange("#foo")).toBe(true);
|
|
expect(isHashOnlyChange("#")).toBe(true);
|
|
});
|
|
|
|
// Server-side (no window) — should return false for non-hash-only
|
|
it("returns false for absolute paths on server", () => {
|
|
expect(isHashOnlyChange("/other")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── applyLocaleToHref (tested via component output) ────────────────────
|
|
|
|
describe("Link locale handling", () => {
|
|
it("locale=false keeps href as-is", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", locale: false } as any, "x"),
|
|
);
|
|
expect(html).toContain('href="/about"');
|
|
});
|
|
|
|
it("locale=undefined keeps href as-is", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about" }, "x"),
|
|
);
|
|
expect(html).toContain('href="/about"');
|
|
});
|
|
|
|
it("locale string prepends locale prefix", () => {
|
|
// When locale is a non-default locale string, it prepends /{locale}
|
|
// Note: default locale check uses __VINEXT_DEFAULT_LOCALE__ which is undefined in tests
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", locale: "fr" } as any, "x"),
|
|
);
|
|
expect(html).toContain('href="/fr/about"');
|
|
});
|
|
|
|
it("locale string does not double-prefix", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/fr/about", locale: "fr" } as any, "x"),
|
|
);
|
|
// Should not become /fr/fr/about
|
|
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"');
|
|
});
|
|
});
|