Skip to content
Open
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
106 changes: 77 additions & 29 deletions packages/nuqs/src/adapters/tanstack-router.ts
Original file line number Diff line number Diff line change
@@ -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, any>) => string
}

const NuqsTanstackRouterAdapterContext =
createContext<TanstackRouterAdapterContextType>({
stringifySearchWith: undefined
})

function useNuqsTanstackRouterAdapter(watchKeys: string[]): AdapterInterface {
const { stringifySearchWith } = useContext(NuqsTanstackRouterAdapterContext)

const search = useLocation({
select: state =>
Object.fromEntries(
Expand All @@ -18,32 +50,33 @@ function useNuqsTanstackRouterAdapter(watchKeys: string[]): AdapterInterface {
? (matches[matches.length - 1]?.fullPath as string)
: undefined
})
const searchParams = useMemo(
() =>
// search is a Record<string, string | number | object | Array<string | number>>,
// 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))
Copy link
Contributor

Choose a reason for hiding this comment

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

I looked at this PR in the context of multi-parsers (#1134), but they completely stop working in TanStack Router when I apply the changes from this PR.

The problem seems to be this call to defaultStringifySearch. For example, if search is:

{
  test: [1,2]
}

The call to defaultStringifySearch turns that into "?test=%5B1%2C2%5D", which gives us searchParams with a size of one where the value for test is the string [1,2].

This wasn’t the case with the previous implementation, where searchParams would correctly have a size of two.

}, [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(() => {
Expand All @@ -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 } : {}),
Expand All @@ -69,7 +102,7 @@ function useNuqsTanstackRouterAdapter(watchKeys: string[]): AdapterInterface {
})
})
},
[navigate, from]
[navigate, from, stringifySearchWith]
)

return {
Expand All @@ -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, any>) => string
}): ReactElement {
return createElement(
NuqsTanstackRouterAdapterContext.Provider,
{ value: { stringifySearchWith } },
createElement(NuqsTanstackRouterAdapter, { ...adapterProps, children })
)
}
Loading