mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
1867 lines
69 KiB
TypeScript
1867 lines
69 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, vi } from "vite-plus/test";
|
|
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, {
|
|
canAutoPrefetchFullAppRoute,
|
|
resolveAutoAppRoutePrefetch,
|
|
resolveLinkPrefetchMode,
|
|
useLinkStatus,
|
|
} from "../packages/vinext/src/shims/link.js";
|
|
import { RouterContext } from "../packages/vinext/src/shims/internal/router-context.js";
|
|
import {
|
|
navigatePagesRouterLink,
|
|
navigatePagesRouterLinkWithFallback,
|
|
resolvePagesRouterQueryOnlyHref,
|
|
} from "../packages/vinext/src/client/pages-router-link-navigation.js";
|
|
|
|
// Internal helpers re-exported or accessible via the router shim
|
|
import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/router.js";
|
|
|
|
// Import server-only i18n state to register ALS-backed accessors before any
|
|
// rendering occurs (same as dev-server.ts and pages-server-entry.ts do).
|
|
import { runWithI18nState } from "../packages/vinext/src/shims/i18n-state.js";
|
|
import { setI18nContext } from "../packages/vinext/src/shims/i18n-context.js";
|
|
import { addLocalePrefix } from "../packages/vinext/src/utils/domain-locale.js";
|
|
|
|
import {
|
|
isAbsoluteOrProtocolRelativeUrl,
|
|
isAbsoluteUrl,
|
|
normalizePathTrailingSlash,
|
|
resolveRelativeHref,
|
|
toBrowserNavigationHref,
|
|
toSameOriginAppPath,
|
|
toSameOriginPath,
|
|
} from "../packages/vinext/src/shims/url-utils.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 as a relative query href", () => {
|
|
// An href object without a `pathname` must resolve as a query-only href
|
|
// (e.g. `?tab=settings`) so the browser/router applies it against the
|
|
// *current* path, mirroring Next.js's `formatUrl()` (`pathname || ''`).
|
|
// Collapsing onto the root (`/?tab=settings`) recorded the wrong history
|
|
// entry for shallow links and broke back/forward traversal (issue #1540).
|
|
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("does not render shallow as an HTML attribute", () => {
|
|
// Regression for #1332 sub-problem 3: the `shallow` boolean is consumed
|
|
// by the click handler and must not leak onto the rendered <a>.
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/test", shallow: true }, "Test"),
|
|
);
|
|
expect(html).not.toContain("shallow");
|
|
expect(html).toContain('href="/test"');
|
|
});
|
|
|
|
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"');
|
|
});
|
|
});
|
|
|
|
// ─── Repeated-slash warning (parity with Next.js) ───────────────────────
|
|
//
|
|
// Ported from Next.js: test/e2e/repeated-forward-slashes-error/repeated-forward-slashes-error.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/repeated-forward-slashes-error/repeated-forward-slashes-error.test.ts
|
|
//
|
|
// Next.js's `resolveHref` emits a `console.error` when an href contains
|
|
// repeated forward-slashes (e.g. "/hello//world") or backslashes. Navigation
|
|
// is not blocked; only a warning is surfaced.
|
|
|
|
describe("Link repeated-slash warning", () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it("logs a console.error when href contains repeated forward slashes", () => {
|
|
ReactDOMServer.renderToString(React.createElement(Link, { href: "/hello//world" }, "Hello"));
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
const message = consoleSpy.mock.calls[0]?.[0] as string;
|
|
expect(message).toContain("Invalid href '/hello//world'");
|
|
expect(message).toContain(
|
|
"Repeated forward-slashes (//) or backslashes \\ are not valid in the href.",
|
|
);
|
|
});
|
|
|
|
it("logs a console.error when href contains a backslash", () => {
|
|
ReactDOMServer.renderToString(React.createElement(Link, { href: "/foo\\bar" }, "Bad"));
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
const message = consoleSpy.mock.calls[0]?.[0] as string;
|
|
expect(message).toContain("Invalid href '/foo\\bar'");
|
|
});
|
|
|
|
it("does not warn for absolute URLs whose only '//' is the protocol separator", () => {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "https://example.com/path" }, "Ext"),
|
|
);
|
|
expect(consoleSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not warn for hrefs without repeated slashes", () => {
|
|
ReactDOMServer.renderToString(React.createElement(Link, { href: "/normal/path" }, "Normal"));
|
|
expect(consoleSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores repeated slashes inside the query string", () => {
|
|
// Next.js only checks the path portion (everything before '?'), so a
|
|
// query string containing '//' must not trigger the warning.
|
|
ReactDOMServer.renderToString(React.createElement(Link, { href: "/ok?next=//foo//bar" }, "Q"));
|
|
expect(consoleSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fires on every render (no dedup, matches Next.js behaviour)", () => {
|
|
// Next.js's resolve-href.ts does NOT dedupe these warnings — every call
|
|
// emits a console.error. Confirm we do the same so repeated renders
|
|
// surface every offending href.
|
|
const el = React.createElement(Link, { href: "/dup//slash" }, "Dup");
|
|
ReactDOMServer.renderToString(el);
|
|
ReactDOMServer.renderToString(el);
|
|
expect(consoleSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("normalises repeated forward slashes in the rendered href", () => {
|
|
// Next.js mirrors Vercel's gateway behaviour: after warning, the href is
|
|
// collapsed so the browser navigates to the canonical path.
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/hello//world" }, "Hello"),
|
|
);
|
|
expect(html).toContain('href="/hello/world"');
|
|
expect(html).not.toContain("//world");
|
|
});
|
|
|
|
it("normalises backslashes to forward slashes in the rendered href", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/foo\\bar" }, "Bad"),
|
|
);
|
|
expect(html).toContain('href="/foo/bar"');
|
|
expect(html).not.toContain("\\");
|
|
});
|
|
|
|
it("preserves the query string when normalising repeated slashes", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/a//b?x=1&y=2" }, "Q"),
|
|
);
|
|
expect(html).toContain('href="/a/b?x=1&y=2"');
|
|
});
|
|
|
|
it("preserves the protocol when normalising absolute URLs", () => {
|
|
// The "//" between scheme and authority must survive normalisation, but
|
|
// a duplicate slash in the *path* portion must still collapse.
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "https://example.com//foo//bar" }, "Ext"),
|
|
);
|
|
expect(html).toContain('href="https://example.com/foo/bar"');
|
|
});
|
|
});
|
|
|
|
// ─── 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 });
|
|
});
|
|
});
|
|
|
|
describe("Link App Router prefetch mode", () => {
|
|
it("distinguishes automatic prefetch from explicit full prefetch", () => {
|
|
expect(resolveLinkPrefetchMode(undefined, false)).toBe("auto");
|
|
expect(resolveLinkPrefetchMode(null, false)).toBe("auto");
|
|
expect(resolveLinkPrefetchMode("auto", false)).toBe("auto");
|
|
expect(resolveLinkPrefetchMode(true, false)).toBe("full");
|
|
expect(resolveLinkPrefetchMode(false, false)).toBe("disabled");
|
|
expect(resolveLinkPrefetchMode(true, true)).toBe("disabled");
|
|
});
|
|
|
|
it("allows automatic full RSC prefetch for routes that do not require fresh navigation", () => {
|
|
const originalWindow = globalThis.window;
|
|
(globalThis as any).window = {
|
|
location: {
|
|
href: "http://localhost/blog",
|
|
origin: "http://localhost",
|
|
},
|
|
__VINEXT_LINK_PREFETCH_ROUTES__: [
|
|
{ canPrefetchLoadingShell: false, patternParts: ["about"], isDynamic: false },
|
|
{ canPrefetchLoadingShell: true, patternParts: ["blog", ":slug"], isDynamic: true },
|
|
{ canPrefetchLoadingShell: true, patternParts: ["docs", ":slug+"], isDynamic: true },
|
|
{ canPrefetchLoadingShell: false, patternParts: ["products", ":id"], isDynamic: true },
|
|
{
|
|
canPrefetchLoadingShell: false,
|
|
patternParts: ["teams", ":team", "dashboard"],
|
|
isDynamic: true,
|
|
requiresDynamicNavigationRequest: true,
|
|
},
|
|
{ canPrefetchLoadingShell: true, patternParts: ["settings"], isDynamic: false },
|
|
],
|
|
};
|
|
|
|
try {
|
|
expect(canAutoPrefetchFullAppRoute("/about")).toBe(true);
|
|
expect(canAutoPrefetchFullAppRoute("/blog/hello-world")).toBe(false);
|
|
expect(canAutoPrefetchFullAppRoute("/docs/a/b")).toBe(false);
|
|
expect(canAutoPrefetchFullAppRoute("/products/1")).toBe(true);
|
|
expect(canAutoPrefetchFullAppRoute("/teams/vercel/dashboard")).toBe(false);
|
|
expect(canAutoPrefetchFullAppRoute("/settings")).toBe(false);
|
|
expect(canAutoPrefetchFullAppRoute("/missing")).toBe(false);
|
|
} finally {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("shell-prefetches dynamic routes that require fresh navigation and routes with loading boundaries", () => {
|
|
const originalWindow = globalThis.window;
|
|
(globalThis as any).window = {
|
|
location: {
|
|
href: "http://localhost/blog",
|
|
origin: "http://localhost",
|
|
},
|
|
__VINEXT_LINK_PREFETCH_ROUTES__: [
|
|
{ canPrefetchLoadingShell: false, patternParts: ["about"], isDynamic: false },
|
|
{ canPrefetchLoadingShell: true, patternParts: ["blog", ":slug"], isDynamic: true },
|
|
{ canPrefetchLoadingShell: false, patternParts: ["products", ":id"], isDynamic: true },
|
|
{ canPrefetchLoadingShell: false, patternParts: ["clothing", ":product"], isDynamic: true },
|
|
{
|
|
canPrefetchLoadingShell: true,
|
|
patternParts: ["root-param", ":value"],
|
|
isDynamic: true,
|
|
hasRootParams: true,
|
|
},
|
|
{
|
|
canPrefetchLoadingShell: false,
|
|
patternParts: ["teams", ":team", "dashboard"],
|
|
isDynamic: true,
|
|
requiresDynamicNavigationRequest: true,
|
|
},
|
|
{ canPrefetchLoadingShell: true, patternParts: ["settings"], isDynamic: false },
|
|
],
|
|
};
|
|
|
|
vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true");
|
|
try {
|
|
expect(resolveAutoAppRoutePrefetch("/about")).toEqual({
|
|
cacheForNavigation: true,
|
|
dynamicStaleTime: "verbatim",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: true,
|
|
shouldPrefetch: true,
|
|
});
|
|
expect(resolveAutoAppRoutePrefetch("/blog/hello-world")).toEqual({
|
|
cacheForNavigation: false,
|
|
dynamicStaleTime: "ignore",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: false,
|
|
shouldPrefetch: true,
|
|
});
|
|
expect(resolveAutoAppRoutePrefetch("/settings")).toEqual({
|
|
cacheForNavigation: false,
|
|
dynamicStaleTime: "ignore",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: true,
|
|
shouldPrefetch: true,
|
|
});
|
|
expect(resolveAutoAppRoutePrefetch("/products/1")).toEqual({
|
|
cacheForNavigation: true,
|
|
dynamicStaleTime: "verbatim",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: false,
|
|
shouldPrefetch: true,
|
|
});
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/segment-cache/client-params/client-params.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/segment-cache/client-params/client-params.test.ts
|
|
expect(resolveAutoAppRoutePrefetch("/clothing/1")).toEqual({
|
|
cacheForNavigation: true,
|
|
dynamicStaleTime: "verbatim",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: false,
|
|
shouldPrefetch: true,
|
|
});
|
|
expect(resolveAutoAppRoutePrefetch("/root-param/aaa")).toEqual({
|
|
cacheForNavigation: true,
|
|
dynamicStaleTime: "verbatim",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: true,
|
|
requiresRouteTreePrefetch: true,
|
|
shouldPrefetch: true,
|
|
});
|
|
expect(resolveAutoAppRoutePrefetch("/root-param/aaa?q=1")).toEqual({
|
|
cacheForNavigation: false,
|
|
dynamicStaleTime: "ignore",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: true,
|
|
requiresRouteTreePrefetch: true,
|
|
shouldPrefetch: true,
|
|
});
|
|
expect(resolveAutoAppRoutePrefetch("/teams/vercel/dashboard")).toEqual({
|
|
cacheForNavigation: false,
|
|
dynamicStaleTime: "ignore",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: false,
|
|
shouldPrefetch: true,
|
|
});
|
|
expect(resolveAutoAppRoutePrefetch("/missing")).toEqual({
|
|
cacheForNavigation: false,
|
|
dynamicStaleTime: "verbatim",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: false,
|
|
shouldPrefetch: false,
|
|
});
|
|
} finally {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it("keeps root-param loading routes shell-only without Cache Components", () => {
|
|
const originalWindow = globalThis.window;
|
|
vi.stubEnv("__NEXT_CACHE_COMPONENTS", "false");
|
|
(globalThis as any).window = {
|
|
location: {
|
|
href: "http://localhost/root-params",
|
|
origin: "http://localhost",
|
|
},
|
|
__VINEXT_LINK_PREFETCH_ROUTES__: [
|
|
{
|
|
canPrefetchLoadingShell: true,
|
|
patternParts: ["root-param", ":value"],
|
|
isDynamic: true,
|
|
hasRootParams: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
try {
|
|
expect(resolveAutoAppRoutePrefetch("/root-param/aaa")).toEqual({
|
|
cacheForNavigation: false,
|
|
dynamicStaleTime: "ignore",
|
|
fallbackTtl: "static",
|
|
prefetchShellFirst: false,
|
|
shouldPrefetch: true,
|
|
});
|
|
} finally {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it("keeps Cache Components encoded delimiters and fully dynamic roots learning-only", () => {
|
|
const originalWindow = globalThis.window;
|
|
const originalCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
|
|
process.env.__NEXT_CACHE_COMPONENTS = "true";
|
|
(globalThis as any).window = {
|
|
location: {
|
|
href: "http://localhost/",
|
|
origin: "http://localhost",
|
|
},
|
|
__VINEXT_LINK_PREFETCH_ROUTES__: [
|
|
{ canPrefetchLoadingShell: false, patternParts: [":slug"], isDynamic: true },
|
|
{
|
|
canPrefetchLoadingShell: false,
|
|
patternParts: ["products", ":slug"],
|
|
isDynamic: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
try {
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/segment-cache/encoded-slash-params/encoded-slash-params.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/segment-cache/encoded-slash-params/encoded-slash-params.test.ts
|
|
expect(resolveAutoAppRoutePrefetch("/foo").cacheForNavigation).toBe(false);
|
|
expect(resolveAutoAppRoutePrefetch("/products/foo").cacheForNavigation).toBe(true);
|
|
expect(resolveAutoAppRoutePrefetch("/products/foo%2Fbar").cacheForNavigation).toBe(false);
|
|
expect(resolveAutoAppRoutePrefetch("/products/foo%5Cbar").cacheForNavigation).toBe(false);
|
|
} finally {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
if (originalCacheComponents === undefined) {
|
|
delete process.env.__NEXT_CACHE_COMPONENTS;
|
|
} else {
|
|
process.env.__NEXT_CACHE_COMPONENTS = originalCacheComponents;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── 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"/);
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/dynamic-routing/pages/index.js
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/dynamic-routing/pages/index.js
|
|
it("interpolates dynamic segments from object href query values", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
RouterContext.Provider,
|
|
{ value: {} as never },
|
|
React.createElement(
|
|
Link,
|
|
{
|
|
href: {
|
|
pathname: "/[a]/[b]/c",
|
|
query: { a: "a", b: "b", q: "q" },
|
|
},
|
|
},
|
|
"hello",
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(html).toContain('href="/a/b/c?q=q"');
|
|
});
|
|
|
|
it("does not interpolate dynamic object hrefs outside the Pages Router context", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: { pathname: "/posts/[id]", query: { id: "42" } } }, "post"),
|
|
);
|
|
|
|
expect(html).toContain('href="/posts/[id]?id=42"');
|
|
});
|
|
|
|
it("does not interpolate dynamic-looking external hrefs in the Pages Router", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
RouterContext.Provider,
|
|
{ value: {} as never },
|
|
React.createElement(Link, { href: "https://example.com/[id]?id=42" }, "external"),
|
|
),
|
|
);
|
|
|
|
expect(html).toContain('href="https://example.com/[id]?id=42"');
|
|
});
|
|
|
|
it("interpolates same-origin absolute dynamic hrefs in the Pages Router", () => {
|
|
const previousWindow = (globalThis as any).window;
|
|
(globalThis as any).window = {
|
|
addEventListener() {},
|
|
location: {
|
|
hash: "",
|
|
href: "https://local.test/current",
|
|
hostname: "local.test",
|
|
origin: "https://local.test",
|
|
pathname: "/current",
|
|
search: "",
|
|
},
|
|
};
|
|
|
|
try {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
RouterContext.Provider,
|
|
{ value: {} as never },
|
|
React.createElement(Link, { href: "https://local.test/posts/[id]?id=42" }, "post"),
|
|
),
|
|
);
|
|
|
|
expect(html).toContain('href="/posts/42"');
|
|
} finally {
|
|
if (previousWindow === undefined) delete (globalThis as any).window;
|
|
else (globalThis as any).window = previousWindow;
|
|
}
|
|
});
|
|
|
|
it("object href preserves array query values as repeated params", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: { pathname: "/search", query: { tag: ["a", "b"], q: "x" } } },
|
|
"x",
|
|
),
|
|
);
|
|
expect(html).toContain('href="/search?tag=a&tag=b&q=x"');
|
|
});
|
|
|
|
it("object href stringifies scalar query values like Next.js", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{
|
|
href: {
|
|
pathname: "/search",
|
|
query: { page: 2, draft: false, empty: null, missing: undefined, tag: ["a", "b"] },
|
|
},
|
|
},
|
|
"x",
|
|
),
|
|
);
|
|
expect(html).toContain(
|
|
'href="/search?page=2&draft=false&empty=&missing=&tag=a&tag=b"',
|
|
);
|
|
});
|
|
|
|
it("object href preserves an existing query string in pathname", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: { pathname: "/items?lang=en", query: { page: "2", sort: "name" } } },
|
|
"x",
|
|
),
|
|
);
|
|
expect(html).toMatch(/href="\/items\?lang=en&(?:amp;)?page=2&(?:amp;)?sort=name"/);
|
|
});
|
|
|
|
it("object href preserves hash fragments when pathname already has a query string", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: { pathname: "/items?lang=en#results", query: { page: "2" } } },
|
|
"x",
|
|
),
|
|
);
|
|
expect(html).toContain('href="/items?lang=en&page=2#results"');
|
|
});
|
|
|
|
it("object href with only pathname", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: { pathname: "/dashboard" } }, "x"),
|
|
);
|
|
expect(html).toContain('href="/dashboard"');
|
|
});
|
|
|
|
it("object href with only query resolves as a relative query href", () => {
|
|
// No `pathname` -> query-only href (not rooted at `/`), so the router
|
|
// resolves it against the current path. Mirrors Next.js's `formatUrl()`
|
|
// (`pathname = urlObj.pathname || ''`). Regression guard for issue #1540.
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: { query: { page: "2", sort: "name" } } }, "x"),
|
|
);
|
|
expect(html).toMatch(/href="\?page=2&(?:amp;)?sort=name"/);
|
|
});
|
|
|
|
it("resolves query-only Pages Links against a rewritten path before locale application", async () => {
|
|
const previousWindow = (globalThis as any).window;
|
|
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
|
|
process.env.__NEXT_ROUTER_BASEPATH = "/docs";
|
|
(globalThis as any).window = {
|
|
location: {
|
|
pathname: "/docs/fr/rewrite-navigation/0",
|
|
search: "?existing=1",
|
|
hash: "",
|
|
href: "http://localhost/docs/fr/rewrite-navigation/0?existing=1",
|
|
origin: "http://localhost",
|
|
hostname: "localhost",
|
|
},
|
|
history: {
|
|
state: null,
|
|
pushState() {},
|
|
replaceState() {},
|
|
},
|
|
addEventListener() {},
|
|
next: { router: { asPath: "/rewrite-navigation/0?existing=1", reload() {} } },
|
|
__VINEXT_LOCALE__: "fr",
|
|
__VINEXT_LOCALES__: ["en", "fr", "de"],
|
|
__VINEXT_DEFAULT_LOCALE__: "en",
|
|
};
|
|
try {
|
|
const resolvedHref = resolvePagesRouterQueryOnlyHref("?id=1", {
|
|
asPath: "/rewrite-navigation/0?existing=1",
|
|
basePath: "/docs",
|
|
fallbackHref: (globalThis as any).window.location.href,
|
|
locales: ["en", "fr", "de"],
|
|
});
|
|
const localizedHref = addLocalePrefix(resolvedHref, "de", "en");
|
|
|
|
expect(
|
|
toBrowserNavigationHref(localizedHref, (globalThis as any).window.location.href, "/docs"),
|
|
).toBe("/docs/de/rewrite-navigation/0?id=1");
|
|
} finally {
|
|
if (previousWindow === undefined) delete (globalThis as any).window;
|
|
else (globalThis as any).window = previousWindow;
|
|
if (previousBasePath === undefined) delete process.env.__NEXT_ROUTER_BASEPATH;
|
|
else process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
|
|
}
|
|
});
|
|
|
|
it("preserves a bare query delimiter when resolving Pages Links", () => {
|
|
expect(
|
|
resolvePagesRouterQueryOnlyHref("?", {
|
|
asPath: "/rewrite-navigation/0?existing=1",
|
|
basePath: "",
|
|
fallbackHref: "http://localhost/rewrite-navigation/0?existing=1",
|
|
}),
|
|
).toBe("/rewrite-navigation/0?");
|
|
});
|
|
|
|
it("resolves hash-only Pages Links against locale-free router.asPath", () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/i18n-support-same-page-hash-change/i18n-support-same-page-hash-change.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-support-same-page-hash-change/i18n-support-same-page-hash-change.test.ts
|
|
expect(
|
|
resolvePagesRouterQueryOnlyHref("#newhash", {
|
|
asPath: "/about?tab=details",
|
|
basePath: "",
|
|
fallbackHref: "http://localhost/fr/about?tab=details#hash",
|
|
locales: ["en", "fr"],
|
|
}),
|
|
).toBe("/about?tab=details#newhash");
|
|
});
|
|
});
|
|
|
|
// ─── 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", () => {
|
|
const originalWindow = globalThis.window;
|
|
const originalNextData = (globalThis as any).__NEXT_DATA__;
|
|
const originalDomainLocales = (globalThis as any).__VINEXT_DOMAIN_LOCALES__;
|
|
const originalHostname = (globalThis as any).__VINEXT_HOSTNAME__;
|
|
|
|
afterEach(() => {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
if (originalNextData === undefined) {
|
|
delete (globalThis as any).__NEXT_DATA__;
|
|
} else {
|
|
(globalThis as any).__NEXT_DATA__ = originalNextData;
|
|
}
|
|
if (originalDomainLocales === undefined) {
|
|
delete (globalThis as any).__VINEXT_DOMAIN_LOCALES__;
|
|
} else {
|
|
(globalThis as any).__VINEXT_DOMAIN_LOCALES__ = originalDomainLocales;
|
|
}
|
|
if (originalHostname === undefined) {
|
|
delete (globalThis as any).__VINEXT_HOSTNAME__;
|
|
} else {
|
|
(globalThis as any).__VINEXT_HOSTNAME__ = originalHostname;
|
|
}
|
|
});
|
|
|
|
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=undefined uses the current non-default locale", async () => {
|
|
delete (globalThis as any).window;
|
|
|
|
const html = await runWithI18nState(async () => {
|
|
setI18nContext({
|
|
locale: "id",
|
|
locales: ["en", "id"],
|
|
defaultLocale: "en",
|
|
});
|
|
return ReactDOMServer.renderToString(React.createElement(Link, { href: "/" }, "x"));
|
|
});
|
|
|
|
expect(html).toContain('href="/id"');
|
|
});
|
|
|
|
it("locale=undefined renders a default-locale root fallback during SSR", async () => {
|
|
delete (globalThis as any).window;
|
|
|
|
const html = await runWithI18nState(async () => {
|
|
setI18nContext({
|
|
locale: "en",
|
|
locales: ["en", "id"],
|
|
defaultLocale: "en",
|
|
});
|
|
return ReactDOMServer.renderToString(React.createElement(Link, { href: "/" }, "x"));
|
|
});
|
|
|
|
expect(html).toContain('href="/en"');
|
|
});
|
|
|
|
it("locale=undefined keeps the active locale for a non-locale-prefixed browser path", () => {
|
|
// i18n sticky-locale (issue #1336): a default-locale path served under a
|
|
// non-default locale must keep reporting its active `__VINEXT_LOCALE__`
|
|
// for Link href resolution so the user stays in their current locale.
|
|
(globalThis as any).window = {
|
|
location: {
|
|
pathname: "/new",
|
|
hostname: "localhost",
|
|
},
|
|
__VINEXT_LOCALE__: "id",
|
|
__VINEXT_LOCALES__: ["en", "id"],
|
|
__VINEXT_DEFAULT_LOCALE__: "en",
|
|
__NEXT_DATA__: {},
|
|
};
|
|
|
|
const html = ReactDOMServer.renderToString(React.createElement(Link, { href: "/" }, "x"));
|
|
|
|
expect(html).toContain('href="/id"');
|
|
});
|
|
|
|
it("locale=undefined keeps the current locale for locale-prefixed browser paths", () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts
|
|
(globalThis as any).window = {
|
|
location: {
|
|
pathname: "/id/new",
|
|
hostname: "localhost",
|
|
},
|
|
__VINEXT_LOCALE__: "id",
|
|
__VINEXT_LOCALES__: ["en", "id"],
|
|
__VINEXT_DEFAULT_LOCALE__: "en",
|
|
__NEXT_DATA__: {},
|
|
};
|
|
|
|
const html = ReactDOMServer.renderToString(React.createElement(Link, { href: "/" }, "x"));
|
|
|
|
expect(html).toContain('href="/id"');
|
|
});
|
|
|
|
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("preserves URL-object hashes while applying a locale", () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/i18n-support-same-page-hash-change/i18n-support-same-page-hash-change.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-support-same-page-hash-change/i18n-support-same-page-hash-change.test.ts
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: { pathname: "/about", hash: "#hash" }, locale: "fr" } as any,
|
|
"x",
|
|
),
|
|
);
|
|
expect(html).toContain('href="/fr/about#hash"');
|
|
});
|
|
|
|
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"');
|
|
});
|
|
|
|
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"');
|
|
});
|
|
|
|
it("locale does not mangle native URI schemes", () => {
|
|
const cases = ["mailto:hello@example.com", "tel:+123456789", "sms:+123456789"];
|
|
|
|
for (const href of cases) {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href, locale: "fr" } as any, "x"),
|
|
);
|
|
expect(html).toContain(`href="${href}"`);
|
|
}
|
|
});
|
|
|
|
it("locale string uses configured locale domains for cross-domain links", () => {
|
|
(globalThis as any).window = {
|
|
__VINEXT_DEFAULT_LOCALE__: "en",
|
|
__NEXT_DATA__: {
|
|
domainLocales: [
|
|
{ domain: "example.com", defaultLocale: "en" },
|
|
{ domain: "example.fr", defaultLocale: "fr", http: true },
|
|
],
|
|
locale: "en",
|
|
defaultLocale: "en",
|
|
},
|
|
location: {
|
|
protocol: "https:",
|
|
hostname: "example.com",
|
|
host: "example.com",
|
|
},
|
|
};
|
|
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", locale: "fr" } as any, "x"),
|
|
);
|
|
|
|
expect(html).toContain('href="http://example.fr/about"');
|
|
});
|
|
|
|
it("uses configured locale domains during SSR output", () => {
|
|
delete (globalThis as any).window;
|
|
setI18nContext({
|
|
defaultLocale: "en",
|
|
domainLocales: [
|
|
{ domain: "example.com", defaultLocale: "en" },
|
|
{ domain: "example.fr", defaultLocale: "fr", http: true },
|
|
],
|
|
hostname: "example.com",
|
|
});
|
|
|
|
try {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", locale: "fr" } as any, "x"),
|
|
);
|
|
|
|
expect(html).toContain('href="http://example.fr/about"');
|
|
} finally {
|
|
setI18nContext(null);
|
|
}
|
|
});
|
|
|
|
it("uses configured locale domains with basePath for cross-domain links", async () => {
|
|
const originalBasePath = process.env.__NEXT_ROUTER_BASEPATH;
|
|
process.env.__NEXT_ROUTER_BASEPATH = "/app";
|
|
vi.resetModules();
|
|
|
|
try {
|
|
const { default: LinkWithBasePath } = await import("../packages/vinext/src/shims/link.js");
|
|
(globalThis as any).window = {
|
|
__VINEXT_DEFAULT_LOCALE__: "en",
|
|
__NEXT_DATA__: {
|
|
domainLocales: [
|
|
{ domain: "example.com", defaultLocale: "en" },
|
|
{ domain: "example.fr", defaultLocale: "fr", http: true },
|
|
],
|
|
},
|
|
location: {
|
|
hostname: "example.com",
|
|
},
|
|
};
|
|
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(LinkWithBasePath, { href: "/about", locale: "fr" } as any, "x"),
|
|
);
|
|
|
|
expect(html).toContain('href="http://example.fr/app/about"');
|
|
} finally {
|
|
if (originalBasePath === undefined) {
|
|
delete process.env.__NEXT_ROUTER_BASEPATH;
|
|
} else {
|
|
process.env.__NEXT_ROUTER_BASEPATH = originalBasePath;
|
|
}
|
|
vi.resetModules();
|
|
}
|
|
});
|
|
|
|
it("passes locale=false through the Pages Router Link handoff", async () => {
|
|
const push = vi.fn(async () => true);
|
|
const replace = vi.fn(async () => true);
|
|
|
|
await navigatePagesRouterLink(
|
|
{ push, replace },
|
|
{ href: "/", replace: false, scroll: true, locale: false },
|
|
);
|
|
|
|
expect(push).toHaveBeenCalledWith("/", undefined, { scroll: true, locale: false });
|
|
expect(replace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes explicit locale through the Pages Router Link handoff", async () => {
|
|
const push = vi.fn(async () => true);
|
|
const replace = vi.fn(async () => true);
|
|
|
|
await navigatePagesRouterLink(
|
|
{ push, replace },
|
|
{ href: "/fr/about", replace: false, scroll: true, locale: "fr" },
|
|
);
|
|
|
|
expect(push).toHaveBeenCalledWith("/fr/about", undefined, { scroll: true, locale: "fr" });
|
|
expect(replace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rethrows missing-required-param interpolation errors instead of using Link fallback", async () => {
|
|
const interpolationError = new Error(
|
|
"The provided `href` (/catalog/[category]/[item]?category=music) value is missing query values (item) to be interpolated properly. Read more: https://nextjs.org/docs/messages/href-interpolation-failed",
|
|
);
|
|
const fallback = vi.fn();
|
|
const loadRouter = vi.fn();
|
|
const router = {
|
|
push: vi.fn(async () => {
|
|
throw interpolationError;
|
|
}),
|
|
replace: vi.fn(async () => true),
|
|
};
|
|
|
|
await expect(
|
|
navigatePagesRouterLinkWithFallback({
|
|
router,
|
|
loadRouter,
|
|
navigation: {
|
|
href: "/catalog/books/old?category=music",
|
|
replace: false,
|
|
scroll: true,
|
|
interpolateDynamicRoute: true,
|
|
},
|
|
fallback,
|
|
}),
|
|
).rejects.toBe(interpolationError);
|
|
expect(loadRouter).not.toHaveBeenCalled();
|
|
expect(fallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Regression for #1332 sub-problem 3: `<Link shallow>` must reach
|
|
// Router.push with `shallow: true` so the Pages Router skips the
|
|
// _next/data fetch and only updates the URL. Mirrors Next.js's
|
|
// test/e2e/middleware-trailing-slash shallow-link assertion at
|
|
// packages/next/src/client/link.tsx.
|
|
it("forwards shallow=true through the Pages Router Link handoff (push)", async () => {
|
|
const push = vi.fn(async () => true);
|
|
const replace = vi.fn(async () => true);
|
|
|
|
await navigatePagesRouterLink(
|
|
{ push, replace },
|
|
{ href: "/sha?hello=world", replace: false, scroll: true, shallow: true },
|
|
);
|
|
|
|
expect(push).toHaveBeenCalledWith("/sha?hello=world", undefined, {
|
|
scroll: true,
|
|
locale: undefined,
|
|
shallow: true,
|
|
});
|
|
expect(replace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards shallow=true through the Pages Router Link handoff (replace)", async () => {
|
|
const push = vi.fn(async () => true);
|
|
const replace = vi.fn(async () => true);
|
|
|
|
await navigatePagesRouterLink(
|
|
{ push, replace },
|
|
{ href: "/sha?hello=world", replace: true, scroll: true, shallow: true },
|
|
);
|
|
|
|
expect(replace).toHaveBeenCalledWith("/sha?hello=world", undefined, {
|
|
scroll: true,
|
|
locale: undefined,
|
|
shallow: true,
|
|
});
|
|
expect(push).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards shallow=false explicitly when the default is used", async () => {
|
|
const push = vi.fn(async () => true);
|
|
const replace = vi.fn(async () => true);
|
|
|
|
await navigatePagesRouterLink(
|
|
{ push, replace },
|
|
{ href: "/sha", replace: false, scroll: true, shallow: false },
|
|
);
|
|
|
|
expect(push).toHaveBeenCalledWith("/sha", undefined, {
|
|
scroll: true,
|
|
locale: undefined,
|
|
shallow: false,
|
|
});
|
|
});
|
|
|
|
it("omits shallow from router options when the caller does not pass it", async () => {
|
|
const push = vi.fn(async () => true);
|
|
const replace = vi.fn(async () => true);
|
|
|
|
await navigatePagesRouterLink({ push, replace }, { href: "/", replace: false, scroll: true });
|
|
|
|
expect(push).toHaveBeenCalledWith("/", undefined, { scroll: true, locale: undefined });
|
|
});
|
|
});
|
|
|
|
// ─── i18n ALS isolation ─────────────────────────────────────────────────
|
|
// Verifies that concurrent SSR renders with different i18n contexts
|
|
// don't leak locale state between requests.
|
|
|
|
describe("Link i18n ALS isolation", () => {
|
|
const originalWindow = globalThis.window;
|
|
|
|
afterEach(() => {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
});
|
|
|
|
it("concurrent renders in different ALS scopes see their own locale context", async () => {
|
|
delete (globalThis as any).window;
|
|
|
|
// Simulate two concurrent requests with different locales.
|
|
// Each runs in its own ALS scope. If the state leaked via globalThis,
|
|
// the second request's locale would overwrite the first's.
|
|
const results = await Promise.all([
|
|
runWithI18nState(async () => {
|
|
setI18nContext({
|
|
defaultLocale: "en",
|
|
domainLocales: [
|
|
{ domain: "example.com", defaultLocale: "en" },
|
|
{ domain: "example.fr", defaultLocale: "fr", http: true },
|
|
],
|
|
hostname: "example.com",
|
|
});
|
|
// Yield to let the other scope set its context
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", locale: "fr" } as any, "x"),
|
|
);
|
|
}),
|
|
runWithI18nState(async () => {
|
|
setI18nContext({
|
|
defaultLocale: "de",
|
|
domainLocales: [
|
|
{ domain: "example.de", defaultLocale: "de" },
|
|
{ domain: "example.jp", defaultLocale: "ja", http: true },
|
|
],
|
|
hostname: "example.de",
|
|
});
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", locale: "ja" } as any, "x"),
|
|
);
|
|
}),
|
|
]);
|
|
|
|
// Request 1 (en domain, switching to fr) should link to example.fr
|
|
expect(results[0]).toContain('href="http://example.fr/about"');
|
|
// Request 2 (de domain, switching to ja) should link to example.jp
|
|
expect(results[1]).toContain('href="http://example.jp/about"');
|
|
});
|
|
|
|
it("SSR locale prefix uses ALS-scoped defaultLocale, not stale globalThis", async () => {
|
|
delete (globalThis as any).window;
|
|
|
|
const results = await Promise.all([
|
|
runWithI18nState(async () => {
|
|
// defaultLocale "en" → locale "fr" should get /fr prefix
|
|
setI18nContext({ defaultLocale: "en" });
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", locale: "fr" } as any, "x"),
|
|
);
|
|
}),
|
|
runWithI18nState(async () => {
|
|
// defaultLocale "fr" → locale "fr" should NOT get prefix (it's the default)
|
|
setI18nContext({ defaultLocale: "fr" });
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", locale: "fr" } as any, "x"),
|
|
);
|
|
}),
|
|
]);
|
|
|
|
// First scope: fr is non-default, so it gets /fr prefix
|
|
expect(results[0]).toContain('href="/fr/about"');
|
|
// Second scope: fr IS the default, so no prefix
|
|
expect(results[1]).toContain('href="/about"');
|
|
});
|
|
});
|
|
|
|
// ─── toSameOriginPath ────────────────────────────────────────────────────
|
|
// Tests for the shared same-origin URL normalization utility.
|
|
// Related to: https://github.com/cloudflare/vinext/issues/335
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("absolute URL classification", () => {
|
|
it("matches Next.js scheme classification for native URI schemes", () => {
|
|
expect(isAbsoluteUrl("mailto:hello@example.com")).toBe(true);
|
|
expect(isAbsoluteUrl("tel:+123456789")).toBe(true);
|
|
expect(isAbsoluteUrl("sms:+123456789")).toBe(true);
|
|
expect(isAbsoluteUrl("ftp://example.com/file")).toBe(true);
|
|
expect(isAbsoluteUrl("/local")).toBe(false);
|
|
expect(isAbsoluteUrl("?page=2")).toBe(false);
|
|
expect(isAbsoluteUrl("#section")).toBe(false);
|
|
expect(isAbsoluteUrl("//example.com/path")).toBe(false);
|
|
});
|
|
|
|
it("treats protocol-relative URLs as browser-owned absolute-like hrefs", () => {
|
|
expect(isAbsoluteOrProtocolRelativeUrl("//example.com/path")).toBe(true);
|
|
expect(isAbsoluteOrProtocolRelativeUrl("mailto:hello@example.com")).toBe(true);
|
|
expect(isAbsoluteOrProtocolRelativeUrl("/local")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveRelativeHref", () => {
|
|
it("resolves relative search params against the current page", () => {
|
|
expect(resolveRelativeHref("?page=2", "http://localhost:3000/posts/1")).toBe("/posts/1?page=2");
|
|
});
|
|
|
|
it("preserves the current pathname and search when resolving hash-only hrefs", () => {
|
|
expect(resolveRelativeHref("#comments", "http://localhost:3000/posts/1?page=2")).toBe(
|
|
"/posts/1?page=2#comments",
|
|
);
|
|
});
|
|
|
|
it("resolves dot-segment relative paths against the current page", () => {
|
|
expect(resolveRelativeHref("../archive?year=2026", "http://localhost:3000/blog/post-1")).toBe(
|
|
"/archive?year=2026",
|
|
);
|
|
});
|
|
|
|
it("leaves absolute paths unchanged", () => {
|
|
expect(resolveRelativeHref("/about", "http://localhost:3000/posts/1")).toBe("/about");
|
|
});
|
|
|
|
it("leaves native URI schemes unchanged", () => {
|
|
expect(resolveRelativeHref("mailto:hello@example.com", "http://localhost:3000/posts/1")).toBe(
|
|
"mailto:hello@example.com",
|
|
);
|
|
});
|
|
|
|
it("strips the current basePath before returning the app-relative href", () => {
|
|
expect(resolveRelativeHref("?page=2", "http://localhost:3000/base/fr/posts/1", "/base")).toBe(
|
|
"/fr/posts/1?page=2",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("toBrowserNavigationHref", () => {
|
|
it("keeps relative hrefs aligned with basePath and locale-prefixed browser URLs", () => {
|
|
expect(
|
|
toBrowserNavigationHref("?page=2", "http://localhost:3000/base/fr/posts/1", "/base"),
|
|
).toBe("/base/fr/posts/1?page=2");
|
|
});
|
|
|
|
it("does not add a trailing slash for query/hash-only navigations from the bare basePath root", () => {
|
|
expect(toBrowserNavigationHref("?page=2", "http://localhost:3000/base", "/base")).toBe(
|
|
"/base?page=2",
|
|
);
|
|
expect(toBrowserNavigationHref("#comments", "http://localhost:3000/base", "/base")).toBe(
|
|
"/base#comments",
|
|
);
|
|
});
|
|
|
|
it("does not double-prefix same-origin absolute URLs that already include the basePath", () => {
|
|
const originalWindow = globalThis.window;
|
|
(globalThis as any).window = {
|
|
location: {
|
|
origin: "http://localhost:3000",
|
|
href: "http://localhost:3000/base/posts/1",
|
|
},
|
|
};
|
|
|
|
try {
|
|
const normalized = toSameOriginAppPath("http://localhost:3000/base/about", "/base");
|
|
expect(normalized).toBe("/about");
|
|
expect(
|
|
toBrowserNavigationHref(normalized!, "http://localhost:3000/base/posts/1", "/base"),
|
|
).toBe("/base/about");
|
|
} finally {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("still prefixes app-relative paths that happen to start with the basePath segment", () => {
|
|
expect(
|
|
toBrowserNavigationHref("/docs/getting-started", "http://localhost:3000/docs", "/docs"),
|
|
).toBe("/docs/docs/getting-started");
|
|
});
|
|
});
|
|
|
|
// ─── 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"');
|
|
});
|
|
|
|
it("preserves relative href rendering under basePath while still prefixing absolute paths", async () => {
|
|
const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
|
|
process.env.__NEXT_ROUTER_BASEPATH = "/base";
|
|
vi.resetModules();
|
|
|
|
try {
|
|
const { default: BasePathLink } = await import("../packages/vinext/src/shims/link.js");
|
|
|
|
const relativeHtml = ReactDOMServer.renderToString(
|
|
React.createElement(BasePathLink, { href: "?page=2" }, "Relative Query"),
|
|
);
|
|
expect(relativeHtml).toContain('href="?page=2"');
|
|
|
|
const absoluteHtml = ReactDOMServer.renderToString(
|
|
React.createElement(BasePathLink, { href: "/about" }, "About"),
|
|
);
|
|
expect(absoluteHtml).toContain('href="/base/about"');
|
|
|
|
const sharedPrefixHtml = ReactDOMServer.renderToString(
|
|
React.createElement(BasePathLink, { href: "/base/getting-started" }, "Shared Prefix"),
|
|
);
|
|
expect(sharedPrefixHtml).toContain('href="/base/base/getting-started"');
|
|
} finally {
|
|
if (previousBasePath === undefined) {
|
|
delete process.env.__NEXT_ROUTER_BASEPATH;
|
|
} else {
|
|
process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
|
|
}
|
|
vi.resetModules();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("toSameOriginAppPath", () => {
|
|
it("strips the configured basePath from same-origin absolute URLs", () => {
|
|
const originalWindow = globalThis.window;
|
|
(globalThis as any).window = {
|
|
location: {
|
|
origin: "http://localhost:3000",
|
|
href: "http://localhost:3000/base/posts/1",
|
|
},
|
|
};
|
|
|
|
try {
|
|
expect(toSameOriginAppPath("http://localhost:3000/base/about", "/base")).toBe("/about");
|
|
} finally {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("falls back to location.href when location.origin is unavailable", () => {
|
|
const originalWindow = globalThis.window;
|
|
(globalThis as any).window = {
|
|
location: {
|
|
href: "http://localhost:3000/base/posts/1",
|
|
},
|
|
};
|
|
|
|
try {
|
|
expect(toSameOriginAppPath("http://localhost:3000/base/about", "/base")).toBe("/about");
|
|
expect(toSameOriginAppPath("//localhost:3000/base/about?tab=1#top", "/base")).toBe(
|
|
"/about?tab=1#top",
|
|
);
|
|
} finally {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("treats same-origin URLs outside the configured basePath as external", () => {
|
|
const originalWindow = globalThis.window;
|
|
(globalThis as any).window = {
|
|
location: {
|
|
origin: "http://localhost:3000",
|
|
href: "http://localhost:3000/base/posts/1",
|
|
},
|
|
};
|
|
|
|
try {
|
|
expect(toSameOriginAppPath("http://localhost:3000/other", "/base")).toBeNull();
|
|
} finally {
|
|
if (originalWindow === undefined) {
|
|
delete (globalThis as any).window;
|
|
} else {
|
|
(globalThis as any).window = originalWindow;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── normalizePathTrailingSlash ─────────────────────────────────────────
|
|
//
|
|
// Ports the behaviour of Next.js's client `normalizePathTrailingSlash`:
|
|
// packages/next/src/client/normalize-trailing-slash.ts
|
|
// Validates Link `href` rewriting expectations from upstream e2e:
|
|
// test/e2e/trailing-slashes/with-trailing-slash.test.ts
|
|
// test/e2e/trailing-slashes/without-trailing-slash.test.ts
|
|
|
|
describe("normalizePathTrailingSlash", () => {
|
|
describe("trailingSlash: true", () => {
|
|
it("adds a trailing slash to a path without one", () => {
|
|
expect(normalizePathTrailingSlash("/about", true)).toBe("/about/");
|
|
});
|
|
|
|
it("is idempotent for already-canonical paths", () => {
|
|
expect(normalizePathTrailingSlash("/about/", true)).toBe("/about/");
|
|
});
|
|
|
|
it("leaves the bare root unchanged", () => {
|
|
expect(normalizePathTrailingSlash("/", true)).toBe("/");
|
|
});
|
|
|
|
it("preserves query strings", () => {
|
|
expect(normalizePathTrailingSlash("/about?hello=world", true)).toBe("/about/?hello=world");
|
|
expect(normalizePathTrailingSlash("/about/?hello=world", true)).toBe("/about/?hello=world");
|
|
});
|
|
|
|
it("preserves hash fragments", () => {
|
|
expect(normalizePathTrailingSlash("/about#section", true)).toBe("/about/#section");
|
|
expect(normalizePathTrailingSlash("/about/#section", true)).toBe("/about/#section");
|
|
});
|
|
|
|
it("preserves query + hash", () => {
|
|
expect(normalizePathTrailingSlash("/about?x=1#y", true)).toBe("/about/?x=1#y");
|
|
});
|
|
|
|
it("strips trailing slash from filename-looking paths", () => {
|
|
// Matches Next.js's routes-manifest rule: paths ending in `.ext` are
|
|
// treated as files and keep the no-trailing-slash form.
|
|
expect(normalizePathTrailingSlash("/catch-all/hello.world/", true)).toBe(
|
|
"/catch-all/hello.world",
|
|
);
|
|
expect(normalizePathTrailingSlash("/catch-all/hello.world", true)).toBe(
|
|
"/catch-all/hello.world",
|
|
);
|
|
});
|
|
|
|
it("returns absolute URLs unchanged", () => {
|
|
// Only paths that start with `/` are touched; absolute URLs with a
|
|
// scheme are skipped entirely (matches Next.js behaviour).
|
|
expect(normalizePathTrailingSlash("https://nextjs.org", true)).toBe("https://nextjs.org");
|
|
expect(normalizePathTrailingSlash("https://nextjs.org/", true)).toBe("https://nextjs.org/");
|
|
});
|
|
});
|
|
|
|
describe("trailingSlash: false", () => {
|
|
it("strips a trailing slash from a non-root path", () => {
|
|
expect(normalizePathTrailingSlash("/about/", false)).toBe("/about");
|
|
});
|
|
|
|
it("is idempotent for already-canonical paths", () => {
|
|
expect(normalizePathTrailingSlash("/about", false)).toBe("/about");
|
|
});
|
|
|
|
it("leaves the bare root unchanged", () => {
|
|
expect(normalizePathTrailingSlash("/", false)).toBe("/");
|
|
});
|
|
|
|
it("preserves query strings", () => {
|
|
expect(normalizePathTrailingSlash("/about/?hello=world", false)).toBe("/about?hello=world");
|
|
expect(normalizePathTrailingSlash("/about?hello=world", false)).toBe("/about?hello=world");
|
|
});
|
|
|
|
it("preserves hash fragments", () => {
|
|
expect(normalizePathTrailingSlash("/about/#section", false)).toBe("/about#section");
|
|
expect(normalizePathTrailingSlash("/about#section", false)).toBe("/about#section");
|
|
});
|
|
|
|
it("returns absolute URLs unchanged", () => {
|
|
expect(normalizePathTrailingSlash("https://nextjs.org/", false)).toBe("https://nextjs.org/");
|
|
expect(normalizePathTrailingSlash("https://nextjs.org", false)).toBe("https://nextjs.org");
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Link href trailing-slash rendering ─────────────────────────────────
|
|
//
|
|
// Ports cases from upstream `testLinkShouldRewriteTo` in:
|
|
// test/e2e/trailing-slashes/with-trailing-slash.test.ts
|
|
// test/e2e/trailing-slashes/without-trailing-slash.test.ts
|
|
//
|
|
// Note: the build-time define `process.env.__VINEXT_TRAILING_SLASH` is what
|
|
// drives this in real builds. Tests run with the unbuilt source and therefore
|
|
// observe the `trailingSlash: false` default (env var is unset). That is the
|
|
// inverse half of the upstream matrix — the `with-trailing-slash` direction
|
|
// is covered by `normalizePathTrailingSlash` above and exercised end-to-end
|
|
// in CI.
|
|
|
|
describe("Link href trailing-slash (trailingSlash: false default)", () => {
|
|
it("strips a trailing slash from the rendered href", () => {
|
|
const html = ReactDOMServer.renderToString(React.createElement(Link, { href: "/about/" }, "x"));
|
|
expect(html).toContain('href="/about"');
|
|
});
|
|
|
|
it("preserves query when stripping the trailing slash", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about/?hello=world" }, "x"),
|
|
);
|
|
expect(html).toContain('href="/about?hello=world"');
|
|
});
|
|
|
|
it("preserves the bare root", () => {
|
|
const html = ReactDOMServer.renderToString(React.createElement(Link, { href: "/" }, "x"));
|
|
expect(html).toContain('href="/"');
|
|
});
|
|
|
|
it("leaves filename-looking paths untouched", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/catch-all/hello.world" }, "x"),
|
|
);
|
|
expect(html).toContain('href="/catch-all/hello.world"');
|
|
});
|
|
|
|
it("leaves absolute URLs unchanged", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "https://nextjs.org" }, "x"),
|
|
);
|
|
expect(html).toContain('href="https://nextjs.org"');
|
|
|
|
const trailing = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "https://nextjs.org/" }, "x"),
|
|
);
|
|
expect(trailing).toContain('href="https://nextjs.org/"');
|
|
});
|
|
});
|
|
|
|
// ─── legacyBehavior (parity with Next.js) ───────────────────────────────
|
|
//
|
|
// Ported from Next.js: test/e2e/legacy-link-behavior-pages/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/legacy-link-behavior-pages/index.test.ts
|
|
//
|
|
// In legacy behavior, <Link> must not wrap its child in an extra <a>.
|
|
// Instead it forwards `href` (and click handlers) to the single child
|
|
// element via React.cloneElement. Otherwise, a Link wrapping a custom
|
|
// `<a id="custom-button">` produces nested anchors which are illegal HTML
|
|
// and break onClick propagation.
|
|
|
|
describe("Link legacyBehavior", () => {
|
|
it("does not wrap a child <a> in an extra anchor and forwards href", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/about", legacyBehavior: true } as any,
|
|
React.createElement("a", { id: "custom-button" }, "About"),
|
|
),
|
|
);
|
|
|
|
// Exactly one anchor in the output (no nested <a><a></a></a>).
|
|
const anchorOpens = (html.match(/<a\b/g) ?? []).length;
|
|
expect(anchorOpens).toBe(1);
|
|
|
|
// The child anchor must keep its id and visible text.
|
|
expect(html).toContain('id="custom-button"');
|
|
expect(html).toContain("About");
|
|
|
|
// The href is forwarded to the child anchor.
|
|
expect(html).toContain('href="/about"');
|
|
|
|
// No duplicated children text (e.g. "AboutAbout").
|
|
expect(html).not.toMatch(/About\s*About/);
|
|
});
|
|
|
|
it("wraps a string child in an <a> with the forwarded href", () => {
|
|
// Per Next.js: when the child is a string or number, Next wraps it in an
|
|
// <a> so the legacy behaviour still works. We mirror that here.
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", legacyBehavior: true } as any, "About"),
|
|
);
|
|
|
|
const anchorOpens = (html.match(/<a\b/g) ?? []).length;
|
|
expect(anchorOpens).toBe(1);
|
|
expect(html).toContain('href="/about"');
|
|
expect(html).toContain("About");
|
|
});
|
|
|
|
it("wraps a numeric child in an <a> with the forwarded href", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Link, { href: "/about", legacyBehavior: true } as any, 1000),
|
|
);
|
|
|
|
const anchorOpens = (html.match(/<a\b/g) ?? []).length;
|
|
expect(anchorOpens).toBe(1);
|
|
expect(html).toContain('href="/about"');
|
|
expect(html).toContain("1000");
|
|
});
|
|
|
|
it("does not forward href when the child <a> already has one (no passHref)", () => {
|
|
// Next.js: when legacyBehavior is set, href is only forwarded if
|
|
// `passHref` is true OR the child is an <a> without a href.
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/about", legacyBehavior: true } as any,
|
|
React.createElement("a", { href: "/manual", id: "custom-button" }, "Manual"),
|
|
),
|
|
);
|
|
|
|
const anchorOpens = (html.match(/<a\b/g) ?? []).length;
|
|
expect(anchorOpens).toBe(1);
|
|
expect(html).toContain('href="/manual"');
|
|
expect(html).toContain('id="custom-button"');
|
|
expect(html).not.toContain('href="/about"');
|
|
});
|
|
|
|
it("forwards href to a custom component child when passHref is set", () => {
|
|
// Matches the Next.js passHref/legacyBehavior fixture: a custom anchor
|
|
// component should receive `href` via React.cloneElement.
|
|
function CustomAnchor(props: { href?: string; children?: React.ReactNode }) {
|
|
return React.createElement("a", { href: props.href, id: "custom-button" }, props.children);
|
|
}
|
|
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/about", legacyBehavior: true, passHref: true } as any,
|
|
React.createElement(CustomAnchor, null, "About"),
|
|
),
|
|
);
|
|
|
|
const anchorOpens = (html.match(/<a\b/g) ?? []).length;
|
|
expect(anchorOpens).toBe(1);
|
|
expect(html).toContain('href="/about"');
|
|
expect(html).toContain('id="custom-button"');
|
|
expect(html).toContain("About");
|
|
});
|
|
|
|
it("does not call the onClick prop passed to <Link> (warns in dev)", () => {
|
|
// Next.js parity: in legacyBehavior, <Link>'s own onClick prop is ignored
|
|
// (the child's onClick is the canonical handler) and a dev console.warn
|
|
// is emitted. Asserting the warning here also implicitly confirms we are
|
|
// not silently dropping the user's intent.
|
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/about", legacyBehavior: true, onClick: () => {} } as any,
|
|
React.createElement("a", { id: "custom-button" }, "About"),
|
|
),
|
|
);
|
|
const messages = consoleSpy.mock.calls.map((args) => String(args[0]));
|
|
expect(
|
|
messages.some(
|
|
(m) =>
|
|
m.includes(`"onClick" was passed to <Link>`) && m.includes(`"legacyBehavior" was set`),
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("treats `href={undefined}` on the child <a> as the child owning its href", () => {
|
|
// Next.js uses `!('href' in child.props)` rather than `child.props.href !== undefined`.
|
|
// If the child explicitly passed `href={undefined}`, we must NOT forward
|
|
// Link's href onto it — the developer indicated they want to control it.
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/about", legacyBehavior: true } as any,
|
|
React.createElement("a", { href: undefined, id: "custom-button" }, "About"),
|
|
),
|
|
);
|
|
|
|
const anchorOpens = (html.match(/<a\b/g) ?? []).length;
|
|
expect(anchorOpens).toBe(1);
|
|
// The forwarded href should NOT be set, because the child's props
|
|
// include the key `href` (even if its value is undefined).
|
|
expect(html).not.toContain('href="/about"');
|
|
});
|
|
|
|
it("clones the child (does not wrap) when the href is a blocked dangerous scheme", () => {
|
|
// Even when Link blocks the href (javascript:, data:, vbscript:), the
|
|
// legacyBehavior contract still applies: we must clone the child rather
|
|
// than wrap it. Otherwise developers would see a hidden second anchor.
|
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "javascript:alert(1)", legacyBehavior: true } as any,
|
|
React.createElement("a", { id: "custom-button" }, "Blocked"),
|
|
),
|
|
);
|
|
const anchorOpens = (html.match(/<a\b/g) ?? []).length;
|
|
expect(anchorOpens).toBe(1);
|
|
expect(html).toContain('id="custom-button"');
|
|
// The dangerous href is NOT propagated to the child.
|
|
expect(html).not.toContain("javascript:");
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("preserves the child's onClick handler (parity-shape check via cloneElement props)", () => {
|
|
// We can't dispatch a real DOM click in this Node-only test environment.
|
|
// Instead, verify that React.cloneElement preserves the child's onClick
|
|
// by introspecting the rendered tree via a custom child that records its
|
|
// own props on render. If the legacyBehavior path were still wrapping the
|
|
// child in an extra <a>, the child's onClick would NOT be installed on
|
|
// the outer <a> the user actually clicks; the wrapper would swallow it.
|
|
let receivedProps: { href?: string; onClick?: unknown } | null = null;
|
|
|
|
function Probe(props: { href?: string; onClick?: () => void; children?: React.ReactNode }) {
|
|
receivedProps = props;
|
|
return React.createElement("a", props, props.children);
|
|
}
|
|
|
|
const childOnClick = (): void => {};
|
|
ReactDOMServer.renderToString(
|
|
React.createElement(
|
|
Link,
|
|
{ href: "/about", legacyBehavior: true, passHref: true } as any,
|
|
React.createElement(Probe, { onClick: childOnClick }, "About"),
|
|
),
|
|
);
|
|
|
|
// The legacy path must clone the child with Link's href.
|
|
expect(receivedProps).not.toBeNull();
|
|
expect(receivedProps!.href).toBe("/about");
|
|
// The child's own onClick is preserved (the Link wraps it inside its
|
|
// synthesized handler — verifying the prop exists on the child).
|
|
expect(typeof receivedProps!.onClick).toBe("function");
|
|
});
|
|
});
|