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
6 changes: 3 additions & 3 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import React, {
import { toRscUrl, getPrefetchedUrls, storePrefetchResponse } from "./navigation.js";
import { isDangerousScheme } from "./url-safety.js";
import { toSameOriginPath } from "./url-utils.js";
import { appendSearchParamsToUrl } from "../utils/query.js";
import { appendSearchParamsToUrl, type UrlQuery, urlQueryToSearchParams } from "../utils/query.js";
import type { VinextNextData } from "../client/vinext-next-data.js";

interface NavigateEvent {
Expand All @@ -35,7 +35,7 @@ interface NavigateEvent {
}

interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
href: string | { pathname?: string; query?: Record<string, string> };
href: string | { pathname?: string; query?: UrlQuery };
/** URL displayed in the browser (when href is a route pattern like /user/[id]) */
as?: string;
/** Replace the current history entry instead of pushing */
Expand Down Expand Up @@ -79,7 +79,7 @@ function resolveHref(href: LinkProps["href"]): string {
if (typeof href === "string") return href;
let url = href.pathname ?? "/";
if (href.query) {
const params = new URLSearchParams(href.query);
const params = urlQueryToSearchParams(href.query);
url = appendSearchParamsToUrl(url, params);
}
return url;
Expand Down
11 changes: 8 additions & 3 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { RouterContext } from "./internal/router-context.js";
import { isValidModulePath } from "../client/validate-module-path.js";
import { toSameOriginPath } from "./url-utils.js";
import { stripBasePath } from "../utils/base-path.js";
import { addQueryParam, appendSearchParamsToUrl } from "../utils/query.js";
import {
addQueryParam,
appendSearchParamsToUrl,
type UrlQuery,
urlQueryToSearchParams,
} from "../utils/query.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 @@ -69,7 +74,7 @@ interface NextRouter {

interface UrlObject {
pathname?: string;
query?: Record<string, string>;
query?: UrlQuery;
}

interface TransitionOptions {
Expand Down Expand Up @@ -112,7 +117,7 @@ function resolveUrl(url: string | UrlObject): string {
if (typeof url === "string") return url;
let result = url.pathname ?? "/";
if (url.query) {
const params = new URLSearchParams(url.query);
const params = urlQueryToSearchParams(url.query);
result = appendSearchParamsToUrl(result, params);
}
return result;
Expand Down
38 changes: 38 additions & 0 deletions packages/vinext/src/utils/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
* Add a query parameter value to an object, promoting to array for duplicate keys.
* Matches Next.js behavior: ?a=1&a=2 → { a: ['1', '2'] }
*/
type UrlQueryValue = string | number | boolean | null | undefined;

export type UrlQuery = Record<string, UrlQueryValue | readonly UrlQueryValue[]>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: consider re-exporting UrlQueryValue alongside UrlQuery for consumers who might need to type individual values (e.g., building query objects dynamically). The fixture .d.ts files already duplicate this type — if it were exported from here, those could potentially import it instead of redeclaring.

Not a blocker, just something to keep in mind for future cleanup.


function setOwnQueryValue(
obj: Record<string, string | string[]>,
key: string,
Expand Down Expand Up @@ -46,6 +50,40 @@ export function parseQueryString(url: string): Record<string, string | string[]>
return query;
}

/**
* Convert a Next.js-style query object into URLSearchParams while preserving
* repeated keys for array values.
*
* Ported from Next.js `urlQueryToSearchParams()`:
* https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/querystring.ts
*/
function stringifyUrlQueryParam(param: unknown): string {
if (typeof param === "string") {
return param;
}

if ((typeof param === "number" && !isNaN(param)) || typeof param === "boolean") {
return String(param);
}

return "";
}

export function urlQueryToSearchParams(query: UrlQuery): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
for (const item of value) {
params.append(key, stringifyUrlQueryParam(item));
}
continue;
}

params.set(key, stringifyUrlQueryParam(value));
}
return params;
}

/**
* Append query parameters to a URL while preserving any existing query string
* and fragment identifier.
Expand Down
4 changes: 3 additions & 1 deletion tests/fixtures/app-basic/next-shims.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
declare module "next/link" {
import type { ComponentType, AnchorHTMLAttributes, ReactNode } from "react";
type UrlQueryValue = string | number | boolean | null | undefined;
type UrlQuery = Record<string, UrlQueryValue | readonly UrlQueryValue[]>;
interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
href: string | { pathname?: string; query?: Record<string, string> };
href: string | { pathname?: string; query?: UrlQuery };
as?: string;
replace?: boolean;
prefetch?: boolean;
Expand Down
4 changes: 3 additions & 1 deletion tests/fixtures/pages-basic/next-shims.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ declare module "next/head" {

declare module "next/link" {
import { ComponentType, AnchorHTMLAttributes, ReactNode } from "react";
type UrlQueryValue = string | number | boolean | null | undefined;
type UrlQuery = Record<string, UrlQueryValue | readonly UrlQueryValue[]>;
interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
href: string | { pathname?: string; query?: Record<string, string> };
href: string | { pathname?: string; query?: UrlQuery };
as?: string;
replace?: boolean;
prefetch?: boolean;
Expand Down
29 changes: 29 additions & 0 deletions tests/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,35 @@ describe("Link resolveHref", () => {
expect(html).toMatch(/href="\/items\?page=2&(?:amp;)?sort=name"/);
});

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&amp;tag=b&amp;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&amp;draft=false&amp;empty=&amp;missing=&amp;tag=a&amp;tag=b"',
);
});

it("object href preserves an existing query string in pathname", () => {
const html = ReactDOMServer.renderToString(
React.createElement(
Expand Down
111 changes: 111 additions & 0 deletions tests/shims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6727,6 +6727,117 @@ describe("Pages Router router helpers", () => {
expect(typeof mod.wrapWithRouterContext).toBe("function");
});

it("serializes array query values as repeated params for object-form router URLs", async () => {
const previousWindow = (globalThis as any).window;
const pushState = vi.fn();
const replaceState = vi.fn();

(globalThis as any).window = {
location: {
pathname: "/",
search: "",
hash: "",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: {
state: null,
pushState,
replaceState,
back: vi.fn(),
},
dispatchEvent: vi.fn(),
scrollTo: vi.fn(),
scrollX: 0,
scrollY: 0,
__NEXT_DATA__: {
page: "/",
query: {},
isFallback: false,
},
__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};

try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default.push(
{ pathname: "/search", query: { tag: ["a", "b"], q: "x" } },
undefined,
{ shallow: true },
);

expect(pushState).toHaveBeenCalledWith({}, "", "/search?tag=a&tag=b&q=x");
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
Comment on lines +6731 to +6779
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: the two router serialization tests have ~40 lines of identical window mock setup. If more query-serialization tests get added in the future, consider extracting a helper like withMockWindow(fn) to reduce duplication. Fine as-is for two tests though.

});

it("stringifies scalar query values like Next.js for object-form router URLs", async () => {
const previousWindow = (globalThis as any).window;
const pushState = vi.fn();
const replaceState = vi.fn();

(globalThis as any).window = {
location: {
pathname: "/",
search: "",
hash: "",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: {
state: null,
pushState,
replaceState,
back: vi.fn(),
},
dispatchEvent: vi.fn(),
scrollTo: vi.fn(),
scrollX: 0,
scrollY: 0,
__NEXT_DATA__: {
page: "/",
query: {},
isFallback: false,
},
__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};

try {
const routerModule = await import("../packages/vinext/src/shims/router.js");
await routerModule.default.push(
{
pathname: "/search",
query: { page: 2, draft: false, empty: null, missing: undefined, tag: ["a", "b"] },
},
undefined,
{ shallow: true },
);

expect(pushState).toHaveBeenCalledWith(
{},
"",
"/search?page=2&draft=false&empty=&missing=&tag=a&tag=b",
);
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});

it("exposes beforePopState on both the Router singleton and wrapped router context", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
Expand Down
Loading