From 4330d00bd86125fcd08c7428bcdf8da0a6f67473 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 01:05:39 -0500 Subject: [PATCH 1/2] Fix query param handling --- packages/vinext/src/shims/link.tsx | 6 +-- packages/vinext/src/shims/router.ts | 11 +++-- packages/vinext/src/utils/query.ts | 24 ++++++++++ tests/fixtures/app-basic/next-shims.d.ts | 3 +- tests/fixtures/pages-basic/next-shims.d.ts | 3 +- tests/link.test.ts | 11 +++++ tests/shims.test.ts | 52 ++++++++++++++++++++++ 7 files changed, 102 insertions(+), 8 deletions(-) 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..285ee033 100644 --- a/packages/vinext/src/utils/query.ts +++ b/packages/vinext/src/utils/query.ts @@ -2,6 +2,8 @@ * 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'] } */ +export type UrlQuery = Record; + function setOwnQueryValue( obj: Record, key: string, @@ -46,6 +48,28 @@ 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 + */ +export function urlQueryToSearchParams(query: UrlQuery): URLSearchParams { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (typeof value === "string") { + params.set(key, value); + continue; + } + + for (const item of value) { + params.append(key, item); + } + } + 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..4916179f 100644 --- a/tests/fixtures/app-basic/next-shims.d.ts +++ b/tests/fixtures/app-basic/next-shims.d.ts @@ -1,7 +1,8 @@ declare module "next/link" { import type { ComponentType, AnchorHTMLAttributes, ReactNode } from "react"; + 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..40811f52 100644 --- a/tests/fixtures/pages-basic/next-shims.d.ts +++ b/tests/fixtures/pages-basic/next-shims.d.ts @@ -15,8 +15,9 @@ declare module "next/head" { declare module "next/link" { import { ComponentType, AnchorHTMLAttributes, ReactNode } from "react"; + 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..e72d7bf2 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -141,6 +141,17 @@ 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 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..8e9eaaa5 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6727,6 +6727,58 @@ 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("exposes beforePopState on both the Router singleton and wrapped router context", async () => { const React = await import("react"); const { renderToStaticMarkup } = await import("react-dom/server"); From bde4c634473b7d132f956d0b58e5574eb08a7301 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 01:19:04 -0500 Subject: [PATCH 2/2] Fix scalar query serialization --- packages/vinext/src/utils/query.ts | 26 +++++++--- tests/fixtures/app-basic/next-shims.d.ts | 3 +- tests/fixtures/pages-basic/next-shims.d.ts | 3 +- tests/link.test.ts | 18 +++++++ tests/shims.test.ts | 59 ++++++++++++++++++++++ 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/utils/query.ts b/packages/vinext/src/utils/query.ts index 285ee033..1b59a66d 100644 --- a/packages/vinext/src/utils/query.ts +++ b/packages/vinext/src/utils/query.ts @@ -2,7 +2,9 @@ * 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'] } */ -export type UrlQuery = Record; +type UrlQueryValue = string | number | boolean | null | undefined; + +export type UrlQuery = Record; function setOwnQueryValue( obj: Record, @@ -55,17 +57,29 @@ export function parseQueryString(url: string): Record * 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 (typeof value === "string") { - params.set(key, value); + if (Array.isArray(value)) { + for (const item of value) { + params.append(key, stringifyUrlQueryParam(item)); + } continue; } - for (const item of value) { - params.append(key, item); - } + params.set(key, stringifyUrlQueryParam(value)); } return params; } diff --git a/tests/fixtures/app-basic/next-shims.d.ts b/tests/fixtures/app-basic/next-shims.d.ts index 4916179f..a88e2ff9 100644 --- a/tests/fixtures/app-basic/next-shims.d.ts +++ b/tests/fixtures/app-basic/next-shims.d.ts @@ -1,6 +1,7 @@ declare module "next/link" { import type { ComponentType, AnchorHTMLAttributes, ReactNode } from "react"; - type UrlQuery = Record; + type UrlQueryValue = string | number | boolean | null | undefined; + type UrlQuery = Record; interface LinkProps extends Omit, "href"> { href: string | { pathname?: string; query?: UrlQuery }; as?: string; diff --git a/tests/fixtures/pages-basic/next-shims.d.ts b/tests/fixtures/pages-basic/next-shims.d.ts index 40811f52..e3438701 100644 --- a/tests/fixtures/pages-basic/next-shims.d.ts +++ b/tests/fixtures/pages-basic/next-shims.d.ts @@ -15,7 +15,8 @@ declare module "next/head" { declare module "next/link" { import { ComponentType, AnchorHTMLAttributes, ReactNode } from "react"; - type UrlQuery = Record; + type UrlQueryValue = string | number | boolean | null | undefined; + type UrlQuery = Record; interface LinkProps extends Omit, "href"> { href: string | { pathname?: string; query?: UrlQuery }; as?: string; diff --git a/tests/link.test.ts b/tests/link.test.ts index e72d7bf2..8ba2f2e1 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -152,6 +152,24 @@ describe("Link resolveHref", () => { 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 8e9eaaa5..6a6b1c5d 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6779,6 +6779,65 @@ describe("Pages Router router helpers", () => { } }); + 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");