diff --git a/packages/nuqs/src/adapters/tanstack-router.ts b/packages/nuqs/src/adapters/tanstack-router.ts index 3ec90639b..b7879ce69 100644 --- a/packages/nuqs/src/adapters/tanstack-router.ts +++ b/packages/nuqs/src/adapters/tanstack-router.ts @@ -1,10 +1,42 @@ -import { useLocation, useMatches, useNavigate } from '@tanstack/react-router' -import { startTransition, useCallback, useMemo } from 'react' +import { + stringifySearchWith, + parseSearchWith, + useLocation, + useMatches, + useNavigate, + type AnySchema +} from '@tanstack/react-router' +import { + createContext, + createElement, + startTransition, + useCallback, + useContext, + useMemo, + type ReactElement, + type ReactNode +} from 'react' import { renderQueryString } from '../lib/url-encoding' -import { createAdapterProvider, type AdapterProvider } from './lib/context' +import { createAdapterProvider, type AdapterProps } from './lib/context' import type { AdapterInterface, UpdateUrlFunction } from './lib/defs' +// Use TanStack Router's default JSON-based search param serialization +// The default behavior is compatible with nuqs' expected behavior +const defaultStringifySearch = stringifySearchWith(JSON.stringify) +const defaultParseSearch = parseSearchWith(JSON.parse) + +type TanstackRouterAdapterContextType = { + stringifySearchWith?: (search: Record) => string +} + +const NuqsTanstackRouterAdapterContext = + createContext({ + stringifySearchWith: undefined + }) + function useNuqsTanstackRouterAdapter(watchKeys: string[]): AdapterInterface { + const { stringifySearchWith } = useContext(NuqsTanstackRouterAdapterContext) + const search = useLocation({ select: state => Object.fromEntries( @@ -18,32 +50,33 @@ function useNuqsTanstackRouterAdapter(watchKeys: string[]): AdapterInterface { ? (matches[matches.length - 1]?.fullPath as string) : undefined }) - const searchParams = useMemo( - () => - // search is a Record>, - // so we need to flatten it into a list of key/value pairs, - // replicating keys that have multiple values before passing it - // to URLSearchParams, otherwise { foo: ['bar', 'baz'] } - // ends up as { foo → 'bar,baz' } instead of { foo → 'bar', foo → 'baz' } - new URLSearchParams( - Object.entries(search).flatMap(([key, value]) => { - if (Array.isArray(value)) { - return value.map(v => [key, v]) - } else if (typeof value === 'object' && value !== null) { - // TSR JSON.parses objects in the search params, - // but parseAsJson expects a JSON string, - // so we need to re-stringify it first. - return [[key, JSON.stringify(value)]] - } else { - return [[key, value]] - } - }) - ), - [search, watchKeys.join(',')] - ) + const searchParams = useMemo(() => { + // Regardless of whether the user specified a custom parseSearchWith, + // the search object here is already the result after parsing. + // We use the default defaultStringifySearch to convert the search + // to search params that nuqs can handle correctly. + // + // Use TSR's default stringify to convert search object → URLSearchParams. + // This avoids issues where arrays/objects were previously flattened + // into invalid values like "[object Object]". + return new URLSearchParams(defaultStringifySearch(search)) + }, [search, watchKeys.join(',')]) const updateUrl: UpdateUrlFunction = useCallback( (search, options) => { + let processedSearch: URLSearchParams + if (stringifySearchWith) { + // When updating, the search (URLSearchParams) here is in nuqs-generated format. + // We first use defaultParseSearch to parse it into a search object, + // then use the custom stringifySearchWith to convert it to a new URLSearchParams. + const searchObject = defaultParseSearch(search.toString()) + const customQueryString = stringifySearchWith(searchObject) + processedSearch = new URLSearchParams(customQueryString) + } else { + // Use default behavior which is compatible with nuqs' expected behavior + processedSearch = search + } + // Wrapping in a startTransition seems to be necessary // to support scroll restoration startTransition(() => { @@ -59,7 +92,7 @@ function useNuqsTanstackRouterAdapter(watchKeys: string[]): AdapterInterface { // When we clear the search, passing an empty string causes // a type error and possible basepath issues, so we switch it to '.' instead. // See https://github.com/47ng/nuqs/pull/953#issuecomment-3003583471 - to: renderQueryString(search) || '.', + to: renderQueryString(processedSearch) || '.', // `from` will be handled by tanstack router match resolver, code snippet: // https://github.com/TanStack/router/blob/5d940e2d8bdb12e213eede0abe8012855433ec4b/packages/react-router/src/link.tsx#L108-L112 ...(from ? { from } : {}), @@ -69,7 +102,7 @@ function useNuqsTanstackRouterAdapter(watchKeys: string[]): AdapterInterface { }) }) }, - [navigate, from] + [navigate, from, stringifySearchWith] ) return { @@ -79,6 +112,21 @@ function useNuqsTanstackRouterAdapter(watchKeys: string[]): AdapterInterface { } } -export const NuqsAdapter: AdapterProvider = createAdapterProvider( +const NuqsTanstackRouterAdapter = createAdapterProvider( useNuqsTanstackRouterAdapter ) + +export function NuqsAdapter({ + children, + stringifySearchWith, + ...adapterProps +}: AdapterProps & { + children: ReactNode + stringifySearchWith?: (search: Record) => string +}): ReactElement { + return createElement( + NuqsTanstackRouterAdapterContext.Provider, + { value: { stringifySearchWith } }, + createElement(NuqsTanstackRouterAdapter, { ...adapterProps, children }) + ) +}