diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 496d9228..e78422b3 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -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 { @@ -35,7 +35,7 @@ interface NavigateEvent { } interface LinkProps extends Omit, "href"> { - href: string | { pathname?: string; query?: Record }; + 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 */ @@ -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; diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 2f736756..ac81dd0c 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -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 ?? ""; @@ -69,7 +74,7 @@ interface NextRouter { interface UrlObject { pathname?: string; - query?: Record; + query?: UrlQuery; } interface TransitionOptions { @@ -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; diff --git a/packages/vinext/src/utils/query.ts b/packages/vinext/src/utils/query.ts index 830241de..1b59a66d 100644 --- a/packages/vinext/src/utils/query.ts +++ b/packages/vinext/src/utils/query.ts @@ -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; + function setOwnQueryValue( obj: Record, key: string, @@ -46,6 +50,40 @@ export function parseQueryString(url: string): Record 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. diff --git a/tests/fixtures/app-basic/next-shims.d.ts b/tests/fixtures/app-basic/next-shims.d.ts index 717a0608..a88e2ff9 100644 --- a/tests/fixtures/app-basic/next-shims.d.ts +++ b/tests/fixtures/app-basic/next-shims.d.ts @@ -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; interface LinkProps extends Omit, "href"> { - href: string | { pathname?: string; query?: Record }; + href: string | { pathname?: string; query?: UrlQuery }; as?: string; replace?: boolean; prefetch?: boolean; diff --git a/tests/fixtures/pages-basic/next-shims.d.ts b/tests/fixtures/pages-basic/next-shims.d.ts index b59e47a7..e3438701 100644 --- a/tests/fixtures/pages-basic/next-shims.d.ts +++ b/tests/fixtures/pages-basic/next-shims.d.ts @@ -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; interface LinkProps extends Omit, "href"> { - href: string | { pathname?: string; query?: Record }; + href: string | { pathname?: string; query?: UrlQuery }; as?: string; replace?: boolean; prefetch?: boolean; diff --git a/tests/link.test.ts b/tests/link.test.ts index 2ca3413e..8ba2f2e1 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -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&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( diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0af73aa2..6a6b1c5d 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -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; + } + } + }); + + 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");