From e8af13624d50e7461004c414d495bbaeb41c1f51 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 13 Sep 2025 15:53:40 +0200 Subject: [PATCH 01/41] feat: multi-parsers --- packages/nuqs/src/api.test.ts | 4 + packages/nuqs/src/lib/queues/debounce.ts | 6 +- packages/nuqs/src/lib/queues/throttle.ts | 9 +- packages/nuqs/src/lib/safe-parse.ts | 9 +- packages/nuqs/src/lib/search-params.ts | 55 ++++++++++ packages/nuqs/src/lib/sync.ts | 2 +- packages/nuqs/src/loader.ts | 25 +---- packages/nuqs/src/parsers.ts | 127 +++++++++++++++++++---- packages/nuqs/src/serializer.ts | 4 +- packages/nuqs/src/testing.ts | 8 +- packages/nuqs/src/useQueryState.ts | 15 +-- packages/nuqs/src/useQueryStates.ts | 16 +-- 12 files changed, 203 insertions(+), 77 deletions(-) create mode 100644 packages/nuqs/src/lib/search-params.ts diff --git a/packages/nuqs/src/api.test.ts b/packages/nuqs/src/api.test.ts index b569a92eb..c17f6e61f 100644 --- a/packages/nuqs/src/api.test.ts +++ b/packages/nuqs/src/api.test.ts @@ -8,6 +8,7 @@ const exports = ` { ".": { "createLoader": "function", + "createMultiParser": "function", "createParser": "function", "createSerializer": "function", "createStandardSchemaV1": "function", @@ -22,6 +23,7 @@ const exports = ` "parseAsIsoDate": "object", "parseAsIsoDateTime": "object", "parseAsJson": "function", + "parseAsNativeArrayOf": "function", "parseAsNumberLiteral": "function", "parseAsString": "object", "parseAsStringEnum": "function", @@ -73,6 +75,7 @@ const exports = ` }, "./server": { "createLoader": "function", + "createMultiParser": "function", "createParser": "function", "createSearchParamsCache": "function", "createSerializer": "function", @@ -88,6 +91,7 @@ const exports = ` "parseAsIsoDate": "object", "parseAsIsoDateTime": "object", "parseAsJson": "function", + "parseAsNativeArrayOf": "function", "parseAsNumberLiteral": "function", "parseAsString": "object", "parseAsStringEnum": "function", diff --git a/packages/nuqs/src/lib/queues/debounce.ts b/packages/nuqs/src/lib/queues/debounce.ts index 274b13fe7..679283f24 100644 --- a/packages/nuqs/src/lib/queues/debounce.ts +++ b/packages/nuqs/src/lib/queues/debounce.ts @@ -73,7 +73,9 @@ export class DebounceController { this.throttleQueue = throttleQueue } - useQueuedQueries(keys: string[]): Record { + useQueuedQueries( + keys: string[] + ): Record | null | undefined> { return useSyncExternalStores( keys, (key, callback) => this.queuedQuerySync.on(key, callback), @@ -153,7 +155,7 @@ export class DebounceController { this.queues.clear() } - getQueuedQuery(key: string): string | null | undefined { + getQueuedQuery(key: string): Iterable | null | undefined { // The debounced queued values are more likely to be up-to-date // than any updates pending in the throttle queue, which comes last // in the update chain. diff --git a/packages/nuqs/src/lib/queues/throttle.ts b/packages/nuqs/src/lib/queues/throttle.ts index fb5d3fce7..7782a3862 100644 --- a/packages/nuqs/src/lib/queues/throttle.ts +++ b/packages/nuqs/src/lib/queues/throttle.ts @@ -6,8 +6,9 @@ import { error } from '../errors' import { timeout } from '../timeout' import { withResolvers, type Resolvers } from '../with-resolvers' import { defaultRateLimit } from './rate-limiting' +import { write } from '../search-params' -type UpdateMap = Map +type UpdateMap = Map | null> type TransitionSet = Set export type UpdateQueueAdapterContext = Pick< AdapterInterface, @@ -19,7 +20,7 @@ export type UpdateQueueAdapterContext = Pick< export type UpdateQueuePushArgs = { key: string - query: string | null + query: Iterable | null options: AdapterOptions & Pick } @@ -65,7 +66,7 @@ export class ThrottledQueue { } } - getQueuedQuery(key: string): string | null | undefined { + getQueuedQuery(key: string): Iterable | null | undefined { return this.updateMap.get(key) } @@ -185,7 +186,7 @@ export class ThrottledQueue { if (value === null) { search.delete(key) } else { - search.set(key, value) + search = write(value, key, search) } } if (processUrlSearchParams) { diff --git a/packages/nuqs/src/lib/safe-parse.ts b/packages/nuqs/src/lib/safe-parse.ts index d82e4b3f6..b0df8999b 100644 --- a/packages/nuqs/src/lib/safe-parse.ts +++ b/packages/nuqs/src/lib/safe-parse.ts @@ -1,11 +1,10 @@ -import type { Parser } from '../parsers' import { warn } from './debug' -export function safeParse( - parser: Parser['parse'], - value: string, +export function safeParse( + parser: (arg: I) => R, + value: I, key?: string -): T | null { +): R | null { try { return parser(value) } catch (error) { diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts new file mode 100644 index 000000000..09d1b234e --- /dev/null +++ b/packages/nuqs/src/lib/search-params.ts @@ -0,0 +1,55 @@ +import type { Options } from '../defs' +import type { Parser } from '../parsers' + +export function read( + parser: Parser & + Options & { + defaultValue?: T + }, + key: string, + searchParams: URLSearchParams, + strict: boolean +): T | null { + let result + const query = + parser.type === 'multi' + ? searchParams.getAll(key) // empty key to get all values + : searchParams.get(key) + if (query === null || (Array.isArray(query) && query.length === 0)) { + return parser.defaultValue ?? null + } + try { + // we have properly narrowed `query` here, but TS doesn't keep track of that + // there are probably better ways to do this than a type assertion ¯\_(ツ)_/¯ + result = parser.parse(query as string & readonly string[]) + } catch (error) { + if (strict) { + throw new Error( + `[nuqs] Error while parsing query \`${query}\` for key \`${key}\`: ${error}` + ) + } + result = null + } + if (strict && query && result === null) { + throw new Error( + `[nuqs] Failed to parse query \`${query}\` for key \`${key}\` (got null)` + ) + } + return result +} + +export function write( + serialized: Iterable, + key: string, + searchParams: URLSearchParams +): URLSearchParams { + if (typeof serialized === 'string') { + searchParams.set(key, serialized) + } else { + searchParams.delete(key) + for (const v of serialized) { + searchParams.append(key, v) + } + } + return searchParams +} diff --git a/packages/nuqs/src/lib/sync.ts b/packages/nuqs/src/lib/sync.ts index c9d751049..2f08c3b16 100644 --- a/packages/nuqs/src/lib/sync.ts +++ b/packages/nuqs/src/lib/sync.ts @@ -2,7 +2,7 @@ import { createEmitter, type Emitter } from './emitter' export type CrossHookSyncPayload = { state: any - query: string | null + query: Iterable | null } type EventMap = { diff --git a/packages/nuqs/src/loader.ts b/packages/nuqs/src/loader.ts index 68b569dd0..69a850775 100644 --- a/packages/nuqs/src/loader.ts +++ b/packages/nuqs/src/loader.ts @@ -1,5 +1,6 @@ import type { UrlKeys } from './defs' -import type { inferParserType, ParserMap } from './parsers' +import { type inferParserType, type ParserMap } from './parsers' +import { read } from './lib/search-params' export type LoaderInput = | URL @@ -96,27 +97,7 @@ export function createLoader( const result = {} as any for (const [key, parser] of Object.entries(parsers)) { const urlKey = urlKeys[key] ?? key - const query = searchParams.get(urlKey) - if (query === null) { - result[key] = parser.defaultValue ?? null - continue - } - let parsedValue - try { - parsedValue = parser.parse(query) - } catch (error) { - if (strict) { - throw new Error( - `[nuqs] Error while parsing query \`${query}\` for key \`${key}\`: ${error}` - ) - } - parsedValue = null - } - if (strict && query && parsedValue === null) { - throw new Error( - `[nuqs] Failed to parse query \`${query}\` for key \`${key}\` (got null)` - ) - } + const parsedValue = read(parser, urlKey, searchParams, strict) result[key] = parsedValue ?? parser.defaultValue ?? null } return result diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index c85580615..dc2363d77 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -4,7 +4,8 @@ import { safeParse } from './lib/safe-parse' type Require = Pick, Keys> & Omit -export type Parser = { +export type SingleParser = { + type?: 'single' /** * Convert a query string value into a state value. * @@ -30,7 +31,17 @@ export type Parser = { eq?: (a: T, b: T) => boolean } -export type ParserBuilder = Required> & +export type MultiParser = { + type: 'multi' + parse: (value: ReadonlyArray) => T | null + serialize?: (value: T) => Iterable + eq?: (a: T, b: T) => boolean +} + +export type Parser = SingleParser | MultiParser + +export type ParserBuilder = SingleParserBuilder | MultiParserBuilder +export type SingleParserBuilder = Required> & Options & { /** * Set history type, shallow routing and scroll restoration options @@ -58,9 +69,9 @@ export type ParserBuilder = Required> & * @param defaultValue */ withDefault( - this: ParserBuilder, + this: SingleParserBuilder, defaultValue: NonNullable - ): Omit, 'parseServerSide'> & { + ): Omit, 'parseServerSide'> & { readonly defaultValue: NonNullable /** @@ -103,13 +114,26 @@ export type ParserBuilder = Required> & parseServerSide(value: string | string[] | undefined): T | null } +export type MultiParserBuilder = Required> & + Options & { + withOptions(this: This, options: Options): This + withDefault( + this: MultiParserBuilder, + defaultValue: NonNullable + ): Omit, 'parseServerSide'> & { + readonly defaultValue: NonNullable + parseServerSide(value: string | string[] | undefined): NonNullable + } + parseServerSide(value: string | string[] | undefined): T | null + } + /** * Wrap a set of parse/serialize functions into a builder pattern parser * you can pass to one of the hooks, making its default value type safe. */ export function createParser( - parser: Require, 'parse' | 'serialize'> -): ParserBuilder { + parser: Require, 'parse' | 'serialize'> +): SingleParserBuilder { function parseServerSideNullable(value: string | string[] | undefined) { if (typeof value === 'undefined') { return null @@ -130,6 +154,40 @@ export function createParser( } return { + type: 'single', + eq: (a, b) => a === b, + ...parser, + parseServerSide: parseServerSideNullable, + withDefault(defaultValue) { + return { + ...this, + defaultValue, + parseServerSide(value) { + return parseServerSideNullable(value) ?? defaultValue + } + } + }, + withOptions(options: Options) { + return { + ...this, + ...options + } + } + } +} + +export function createMultiParser( + parser: Omit, 'parse' | 'serialize'>, 'type'> +): MultiParserBuilder { + function parseServerSideNullable(value: string | string[] | undefined) { + if (typeof value === 'undefined') { + return null + } + return safeParse(parser.parse, Array.isArray(value) ? value : [value]) + } + + return { + type: 'multi', eq: (a, b) => a === b, ...parser, parseServerSide: parseServerSideNullable, @@ -153,12 +211,12 @@ export function createParser( // Parsers implementations ----------------------------------------------------- -export const parseAsString: ParserBuilder = createParser({ +export const parseAsString: SingleParserBuilder = createParser({ parse: v => v, serialize: String }) -export const parseAsInteger: ParserBuilder = createParser({ +export const parseAsInteger: SingleParserBuilder = createParser({ parse: v => { const int = parseInt(v) return int == int ? int : null // NaN check at low bundle size cost @@ -166,7 +224,7 @@ export const parseAsInteger: ParserBuilder = createParser({ serialize: v => '' + Math.round(v) }) -export const parseAsIndex: ParserBuilder = createParser({ +export const parseAsIndex: SingleParserBuilder = createParser({ parse: v => { const int = parseInt(v) return int == int ? int - 1 : null // NaN check at low bundle size cost @@ -174,7 +232,7 @@ export const parseAsIndex: ParserBuilder = createParser({ serialize: v => '' + Math.round(v + 1) }) -export const parseAsHex: ParserBuilder = createParser({ +export const parseAsHex: SingleParserBuilder = createParser({ parse: v => { const int = parseInt(v, 16) return int == int ? int : null // NaN check at low bundle size cost @@ -185,7 +243,7 @@ export const parseAsHex: ParserBuilder = createParser({ } }) -export const parseAsFloat: ParserBuilder = createParser({ +export const parseAsFloat: SingleParserBuilder = createParser({ parse: v => { const float = parseFloat(v) return float == float ? float : null // NaN check at low bundle size cost @@ -193,7 +251,7 @@ export const parseAsFloat: ParserBuilder = createParser({ serialize: String }) -export const parseAsBoolean: ParserBuilder = createParser({ +export const parseAsBoolean: SingleParserBuilder = createParser({ parse: v => v === 'true', serialize: String }) @@ -206,7 +264,7 @@ function compareDates(a: Date, b: Date) { * Querystring encoded as the number of milliseconds since epoch, * and returned as a Date object. */ -export const parseAsTimestamp: ParserBuilder = createParser({ +export const parseAsTimestamp: SingleParserBuilder = createParser({ parse: v => { const ms = parseInt(v) return ms == ms ? new Date(ms) : null // NaN check at low bundle size cost @@ -219,7 +277,7 @@ export const parseAsTimestamp: ParserBuilder = createParser({ * Querystring encoded as an ISO-8601 string (UTC), * and returned as a Date object. */ -export const parseAsIsoDateTime: ParserBuilder = createParser({ +export const parseAsIsoDateTime: SingleParserBuilder = createParser({ parse: v => { const date = new Date(v) // NaN check at low bundle size cost @@ -237,7 +295,7 @@ export const parseAsIsoDateTime: ParserBuilder = createParser({ * The Date is parsed without the time zone offset, * making it at 00:00:00 UTC. */ -export const parseAsIsoDate: ParserBuilder = createParser({ +export const parseAsIsoDate: SingleParserBuilder = createParser({ parse: v => { const date = new Date(v.slice(0, 10)) // NaN check at low bundle size cost @@ -276,7 +334,7 @@ export const parseAsIsoDate: ParserBuilder = createParser({ */ export function parseAsStringEnum( validValues: Enum[] -): ParserBuilder { +): SingleParserBuilder { // Delegate implementation to parseAsStringLiteral to avoid duplication. return parseAsStringLiteral(validValues as readonly Enum[]) } @@ -302,7 +360,7 @@ export function parseAsStringEnum( */ export function parseAsStringLiteral( validValues: readonly Literal[] -): ParserBuilder { +): SingleParserBuilder { return createParser({ parse: (query: string) => { const asConst = query as unknown as Literal @@ -333,7 +391,7 @@ export function parseAsStringLiteral( */ export function parseAsNumberLiteral( validValues: readonly Literal[] -): ParserBuilder { +): SingleParserBuilder { return createParser({ parse: (query: string) => { const asConst = parseFloat(query) as unknown as Literal @@ -355,7 +413,7 @@ export function parseAsNumberLiteral( */ export function parseAsJson( validator: ((value: unknown) => T | null) | StandardSchemaV1 -): ParserBuilder { +): SingleParserBuilder { return createParser({ parse: query => { try { @@ -390,9 +448,9 @@ export function parseAsJson( * @param separator The character to use to separate items (default ',') */ export function parseAsArrayOf( - itemParser: Parser, + itemParser: SingleParser, separator = ',' -): ParserBuilder { +): SingleParserBuilder { const itemEq = itemParser.eq ?? ((a: ItemType, b: ItemType) => a === b) const encodedSeparator = encodeURIComponent(separator) // todo: Handle default item values and make return type non-nullable @@ -435,6 +493,33 @@ export function parseAsArrayOf( }) } +export function parseAsNativeArrayOf( + itemParser: SingleParser +): MultiParserBuilder { + const itemEq = itemParser.eq ?? ((a: ItemType, b: ItemType) => a === b) + return createMultiParser({ + parse: query => { + return query + .map((item, index) => safeParse(itemParser.parse, item, `[${index}]`)) + .filter(value => value !== null && value !== undefined) as ItemType[] + }, + serialize: values => + values.flatMap(value => { + const serialized = itemParser.serialize?.(value) ?? String(value) + return typeof serialized === 'string' ? [serialized] : [...serialized] + }), + eq(a, b) { + if (a === b) { + return true // Referentially stable + } + if (a.length !== b.length) { + return false + } + return a.every((value, index) => itemEq(value, b[index]!)) + } + }) +} + type inferSingleParserType = Parser extends ParserBuilder< infer Value > & { diff --git a/packages/nuqs/src/serializer.ts b/packages/nuqs/src/serializer.ts index 9278c200e..3e9817497 100644 --- a/packages/nuqs/src/serializer.ts +++ b/packages/nuqs/src/serializer.ts @@ -1,6 +1,7 @@ import type { Nullable, Options, UrlKeys } from './defs' import { renderQueryString } from './lib/url-encoding' import type { inferParserType, ParserMap } from './parsers' +import { write } from './lib/search-params' type Base = string | URLSearchParams | URL @@ -97,7 +98,8 @@ export function createSerializer< ) { search.delete(urlKey) } else { - search.set(urlKey, parser.serialize(value)) + const serialized = parser.serialize(value) + search = write(serialized, urlKey, search) } } if (processUrlSearchParams) { diff --git a/packages/nuqs/src/testing.ts b/packages/nuqs/src/testing.ts index ca5091e5d..f998cc7e9 100644 --- a/packages/nuqs/src/testing.ts +++ b/packages/nuqs/src/testing.ts @@ -1,4 +1,4 @@ -import type { ParserBuilder } from './parsers' +import type { ParserBuilder, SingleParserBuilder } from './parsers' /** * Test that a parser is bijective (serialize then parse gives back the same value). @@ -21,7 +21,7 @@ import type { ParserBuilder } from './parsers' * @returns `true` if the test passes, otherwise it will throw. */ export function isParserBijective( - parser: ParserBuilder, + parser: SingleParserBuilder, serialized: string, input: T ): boolean { @@ -69,7 +69,7 @@ export function isParserBijective( * @returns `true` if the test passes, otherwise it will throw. */ export function testSerializeThenParse( - parser: ParserBuilder, + parser: SingleParserBuilder, input: T ): boolean { const serialized = parser.serialize(input) @@ -109,7 +109,7 @@ export function testSerializeThenParse( * @returns `true` if the test passes, otherwise it will throw. */ export function testParseThenSerialize( - parser: ParserBuilder, + parser: SingleParserBuilder, input: string ): boolean { const parsed = parser.parse(input) diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index d5bc6879d..0da1ede93 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -3,7 +3,7 @@ import type { Options } from './defs' import type { Parser } from './parsers' import { useQueryStates } from './useQueryStates' -export interface UseQueryStateOptions extends Parser, Options {} +export type UseQueryStateOptions = Parser & Options export type UseQueryStateReturn = [ Default extends undefined @@ -197,21 +197,16 @@ export function useQueryState( defaultValue?: T } = {} ) { - const { - parse = x => x as unknown as T, - serialize, - eq, - defaultValue, - ...hookOptions - } = options + const { parse, type, serialize, eq, defaultValue, ...hookOptions } = options const [{ [key]: state }, setState] = useQueryStates( { [key]: { - parse, + parse: parse ?? ((x: any) => x as unknown as T), + type, serialize, eq, defaultValue - } + } as Parser }, hookOptions ) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 2c44bf687..9baeecf59 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -13,9 +13,9 @@ import { globalThrottleQueue, type UpdateQueuePushArgs } from './lib/queues/throttle' -import { safeParse } from './lib/safe-parse' import { emitter, type CrossHookSyncPayload } from './lib/sync' -import type { Parser } from './parsers' +import { type Parser } from './parsers' +import { read } from './lib/search-params' type KeyMapValue = Parser & Options & { @@ -97,7 +97,7 @@ export function useQueryStates( ) const adapter = useAdapter(Object.values(resolvedUrlKeys)) const initialSearchParams = adapter.searchParams - const queryRef = useRef>({}) + const queryRef = useRef | null>>({}) const defaultValues = useMemo( () => Object.fromEntries( @@ -373,8 +373,8 @@ function parseMap( keyMap: KeyMap, urlKeys: Partial>, searchParams: URLSearchParams, - queuedQueries: Record, - cachedQuery?: Record, + queuedQueries: Record | null | undefined>, + cachedQuery?: Record | null>, cachedState?: NullableValues ): { state: NullableValues @@ -383,7 +383,7 @@ function parseMap( let hasChanged = false const state = Object.keys(keyMap).reduce((out, stateKey) => { const urlKey = urlKeys?.[stateKey] ?? stateKey - const { parse } = keyMap[stateKey]! + const parser = keyMap[stateKey]! const queuedQuery = queuedQueries[urlKey] const query = queuedQuery === undefined @@ -396,7 +396,9 @@ function parseMap( } // Cache miss hasChanged = true - const value = query === null ? null : safeParse(parse, query, stateKey) + const value = + query === null ? null : read(parser, urlKey, searchParams, false) + out[stateKey as keyof KeyMap] = value ?? null if (cachedQuery) { cachedQuery[urlKey] = query From db3c11e245f50d549e3e3050596308bdac021823 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 13 Sep 2025 16:54:25 +0200 Subject: [PATCH 02/41] chore: increase size limit --- packages/nuqs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 37118c76f..a499f2322 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -207,7 +207,7 @@ { "name": "Server", "path": "dist/server.js", - "limit": "3 kB" + "limit": "3.05 kB" } ] } From 2fd2a23702c1f96aa71dbb9780a230cbbc48c41d Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 13 Sep 2025 17:45:35 +0200 Subject: [PATCH 03/41] fix: read is too broad for useQueryStates --- packages/nuqs/src/lib/search-params.ts | 6 +++++- packages/nuqs/src/useQueryStates.ts | 14 ++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts index 09d1b234e..3c0c50576 100644 --- a/packages/nuqs/src/lib/search-params.ts +++ b/packages/nuqs/src/lib/search-params.ts @@ -1,6 +1,10 @@ import type { Options } from '../defs' import type { Parser } from '../parsers' +export function isEmpty(query: string | Iterable | null): boolean { + return query === null || (Array.isArray(query) && query.length === 0) +} + export function read( parser: Parser & Options & { @@ -15,7 +19,7 @@ export function read( parser.type === 'multi' ? searchParams.getAll(key) // empty key to get all values : searchParams.get(key) - if (query === null || (Array.isArray(query) && query.length === 0)) { + if (isEmpty(query)) { return parser.defaultValue ?? null } try { diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 9baeecf59..f20d0fb51 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -15,7 +15,8 @@ import { } from './lib/queues/throttle' import { emitter, type CrossHookSyncPayload } from './lib/sync' import { type Parser } from './parsers' -import { read } from './lib/search-params' +import { isEmpty, read } from './lib/search-params' +import { safeParse } from './lib/safe-parse' type KeyMapValue = Parser & Options & { @@ -387,8 +388,11 @@ function parseMap( const queuedQuery = queuedQueries[urlKey] const query = queuedQuery === undefined - ? (searchParams?.get(urlKey) ?? null) + ? parser.type === 'multi' + ? (searchParams?.getAll(urlKey) ?? null) + : (searchParams?.get(urlKey) ?? null) : queuedQuery + // todo this === comparison likely won't work with arrays if (cachedQuery && cachedState && (cachedQuery[urlKey] ?? null) === query) { // Cache hit out[stateKey as keyof KeyMap] = cachedState[stateKey] ?? null @@ -396,8 +400,10 @@ function parseMap( } // Cache miss hasChanged = true - const value = - query === null ? null : read(parser, urlKey, searchParams, false) + const value = isEmpty(query) + ? null + : // todo same narrowing problem as in read() + safeParse(parser.parse, query as string & Array, urlKey) out[stateKey as keyof KeyMap] = value ?? null if (cachedQuery) { From 44820f5eabd5690e5a6163c95a87ad32b7c02b7e Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 13 Sep 2025 18:05:36 +0200 Subject: [PATCH 04/41] ref: remove read abstraction that was probably premature, as useQueryStates needs something different --- packages/nuqs/src/lib/search-params.ts | 40 -------------------------- packages/nuqs/src/loader.ts | 28 ++++++++++++++++-- packages/nuqs/src/useQueryStates.ts | 6 ++-- 3 files changed, 29 insertions(+), 45 deletions(-) diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts index 3c0c50576..fe6dd5993 100644 --- a/packages/nuqs/src/lib/search-params.ts +++ b/packages/nuqs/src/lib/search-params.ts @@ -1,47 +1,7 @@ -import type { Options } from '../defs' -import type { Parser } from '../parsers' - export function isEmpty(query: string | Iterable | null): boolean { return query === null || (Array.isArray(query) && query.length === 0) } -export function read( - parser: Parser & - Options & { - defaultValue?: T - }, - key: string, - searchParams: URLSearchParams, - strict: boolean -): T | null { - let result - const query = - parser.type === 'multi' - ? searchParams.getAll(key) // empty key to get all values - : searchParams.get(key) - if (isEmpty(query)) { - return parser.defaultValue ?? null - } - try { - // we have properly narrowed `query` here, but TS doesn't keep track of that - // there are probably better ways to do this than a type assertion ¯\_(ツ)_/¯ - result = parser.parse(query as string & readonly string[]) - } catch (error) { - if (strict) { - throw new Error( - `[nuqs] Error while parsing query \`${query}\` for key \`${key}\`: ${error}` - ) - } - result = null - } - if (strict && query && result === null) { - throw new Error( - `[nuqs] Failed to parse query \`${query}\` for key \`${key}\` (got null)` - ) - } - return result -} - export function write( serialized: Iterable, key: string, diff --git a/packages/nuqs/src/loader.ts b/packages/nuqs/src/loader.ts index 69a850775..fd7fb484f 100644 --- a/packages/nuqs/src/loader.ts +++ b/packages/nuqs/src/loader.ts @@ -1,6 +1,6 @@ import type { UrlKeys } from './defs' import { type inferParserType, type ParserMap } from './parsers' -import { read } from './lib/search-params' +import { isEmpty } from './lib/search-params' export type LoaderInput = | URL @@ -97,7 +97,31 @@ export function createLoader( const result = {} as any for (const [key, parser] of Object.entries(parsers)) { const urlKey = urlKeys[key] ?? key - const parsedValue = read(parser, urlKey, searchParams, strict) + const query = + parser.type === 'multi' + ? searchParams.getAll(urlKey) + : searchParams.get(urlKey) + if (isEmpty(query)) { + result[key] = parser.defaultValue ?? null + continue + } + let parsedValue + try { + // we have properly narrowed `query` here, but TS doesn't keep track of that + parsedValue = parser.parse(query as string & readonly string[]) + } catch (error) { + if (strict) { + throw new Error( + `[nuqs] Error while parsing query \`${query}\` for key \`${key}\`: ${error}` + ) + } + parsedValue = null + } + if (strict && query && parsedValue === null) { + throw new Error( + `[nuqs] Failed to parse query \`${query}\` for key \`${key}\` (got null)` + ) + } result[key] = parsedValue ?? parser.defaultValue ?? null } return result diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index f20d0fb51..6353b6380 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -15,7 +15,7 @@ import { } from './lib/queues/throttle' import { emitter, type CrossHookSyncPayload } from './lib/sync' import { type Parser } from './parsers' -import { isEmpty, read } from './lib/search-params' +import { isEmpty } from './lib/search-params' import { safeParse } from './lib/safe-parse' type KeyMapValue = Parser & @@ -389,7 +389,7 @@ function parseMap( const query = queuedQuery === undefined ? parser.type === 'multi' - ? (searchParams?.getAll(urlKey) ?? null) + ? (searchParams?.getAll(urlKey) ?? []) : (searchParams?.get(urlKey) ?? null) : queuedQuery // todo this === comparison likely won't work with arrays @@ -402,7 +402,7 @@ function parseMap( hasChanged = true const value = isEmpty(query) ? null - : // todo same narrowing problem as in read() + : // we have properly narrowed `query` here, but TS doesn't keep track of that safeParse(parser.parse, query as string & Array, urlKey) out[stateKey as keyof KeyMap] = value ?? null From d8e26a9008085c2c4b8cde380e6c3c0271268a2d Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 13 Sep 2025 18:13:19 +0200 Subject: [PATCH 05/41] ref: bump size limit some more --- packages/nuqs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index a499f2322..a76e5d34f 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -207,7 +207,7 @@ { "name": "Server", "path": "dist/server.js", - "limit": "3.05 kB" + "limit": "3.1 kB" } ] } From bc9786d6f1f250d826db640123fde72f0be48839 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 13 Sep 2025 20:21:25 +0200 Subject: [PATCH 06/41] test: add some tests for parseAsNativeArray --- packages/nuqs/src/loader.test.ts | 32 +++++++++++++++++++++++- packages/nuqs/src/parsers.test.ts | 18 +++++++++++++ packages/nuqs/src/serializer.test.ts | 9 ++++++- packages/nuqs/src/useQueryStates.test.ts | 19 ++++++++++++-- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/nuqs/src/loader.test.ts b/packages/nuqs/src/loader.test.ts index cfb00a487..e440bbb43 100644 --- a/packages/nuqs/src/loader.test.ts +++ b/packages/nuqs/src/loader.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { createLoader } from './loader' -import { createParser, parseAsInteger } from './parsers' +import { + createParser, + parseAsInteger, + parseAsNativeArrayOf, + parseAsString +} from './parsers' describe('loader', () => { describe('sync', () => { @@ -219,4 +224,29 @@ describe('loader', () => { }) }) }) + + describe('multi-parser', () => { + it('supports multi-parsers', () => { + const load = createLoader({ + a: parseAsNativeArrayOf(parseAsInteger) + }) + const result = load(new Request('http://example.com/?a=1&a=2&a=3')) + expect(result).toStrictEqual({ a: [1, 2, 3] }) + }) + + it('removes un-parseable values', () => { + const load = createLoader({ + a: parseAsNativeArrayOf(parseAsInteger) + }) + const result = load(new Request('http://example.com/?a=foo&a=1')) + expect(result).toStrictEqual({ a: [1] }) + }) + it('defaults if everything is unparseable', () => { + const load = createLoader({ + a: parseAsNativeArrayOf(parseAsInteger).withDefault([42]) + }) + const result = load(new Request('http://example.com/?a=foo&a=bar')) + expect(result).toStrictEqual({ a: [42] }) + }) + }) }) diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts index ad186b36a..de16e3266 100644 --- a/packages/nuqs/src/parsers.test.ts +++ b/packages/nuqs/src/parsers.test.ts @@ -12,6 +12,7 @@ import { parseAsIsoDate, parseAsIsoDateTime, parseAsJson, + parseAsNativeArrayOf, parseAsNumberLiteral, parseAsString, parseAsStringEnum, @@ -300,6 +301,23 @@ describe('parsers', () => { ).toThrow() }) + describe('parseAsNativeArrayOf', () => { + it('serializes', () => { + const parser = parseAsNativeArrayOf(parseAsString) + expect(parser.serialize([])).toStrictEqual([]) + expect(parser.serialize(['a', ',', 'b'])).toStrictEqual(['a', ',', 'b']) + }) + it('parses', () => { + const parser = parseAsNativeArrayOf(parseAsInteger) + expect(parser.parse([])).toStrictEqual([]) + expect(parser.parse(['1', '2'])).toStrictEqual([1, 2]) + }) + it('defaults to empty array', () => { + const parser = parseAsNativeArrayOf(parseAsInteger) + expect(parser.parse(['not', 'a', 'number'])).toStrictEqual([]) + }) + }) + it('parseServerSide with default (#384)', () => { const p = parseAsString.withDefault('default') const searchParams = { diff --git a/packages/nuqs/src/serializer.test.ts b/packages/nuqs/src/serializer.test.ts index ceaec0afd..2b5b82e4d 100644 --- a/packages/nuqs/src/serializer.test.ts +++ b/packages/nuqs/src/serializer.test.ts @@ -5,6 +5,7 @@ import { parseAsBoolean, parseAsInteger, parseAsJson, + parseAsNativeArrayOf, parseAsString } from './parsers' import { createSerializer } from './serializer' @@ -12,7 +13,8 @@ import { createSerializer } from './serializer' const parsers = { str: parseAsString, int: parseAsInteger, - bool: parseAsBoolean + bool: parseAsBoolean, + multi: parseAsNativeArrayOf(parseAsString) } describe('serializer', () => { @@ -201,6 +203,11 @@ describe('serializer', () => { }) expect(result).toBe('https://example.com/path?issue=is?here&str=foo?bar') }) + it('supports native array values', () => { + const serialize = createSerializer(parsers) + const result = serialize({ multi: ['a', 'b', 'c'] }) + expect(result).toBe('?multi=a&multi=b&multi=c') + }) describe('supports processUrlSearchParams', () => { it('modifies search params in place', () => { const serialize = createSerializer(parsers, { diff --git a/packages/nuqs/src/useQueryStates.test.ts b/packages/nuqs/src/useQueryStates.test.ts index af54cf4ab..788c2c2a6 100644 --- a/packages/nuqs/src/useQueryStates.test.ts +++ b/packages/nuqs/src/useQueryStates.test.ts @@ -11,6 +11,7 @@ import { parseAsArrayOf, parseAsInteger, parseAsJson, + parseAsNativeArrayOf, parseAsString } from './parsers' import { useQueryState } from './useQueryState' @@ -107,6 +108,11 @@ describe('useQueryStates: referential equality', () => { { initial: 'state' } + ], + multi: [ + { + initial: 'state' + } ] } @@ -116,7 +122,10 @@ describe('useQueryStates: referential equality', () => { return useQueryStates({ str: parseAsString.withDefault(defaultValue), obj: parseAsJson(x => x).withDefault(defaults.obj), - arr: parseAsArrayOf(parseAsJson(x => x)).withDefault(defaults.arr) + arr: parseAsArrayOf(parseAsJson(x => x)).withDefault(defaults.arr), + multi: parseAsNativeArrayOf(parseAsJson(x => x)).withDefault( + defaults.multi + ) }) } @@ -129,6 +138,7 @@ describe('useQueryStates: referential equality', () => { expect(state.obj).toBe(defaults.obj) expect(state.arr).toBe(defaults.arr) expect(state.arr[0]).toBe(defaults.arr[0]) + expect(state.multi[0]).toBe(defaults.multi[0]) }) it('should keep referential equality when resetting to defaults', async () => { @@ -137,7 +147,8 @@ describe('useQueryStates: referential equality', () => { searchParams: { str: 'foo', obj: '{"hello":"world"}', - arr: '{"obj":true},{"arr":true}' + arr: '{"obj":true},{"arr":true}', + multi: '{"obj":true},{"arr":true}' } }) }) @@ -147,6 +158,8 @@ describe('useQueryStates: referential equality', () => { expect(state.obj).toBe(defaults.obj) expect(state.arr).toBe(defaults.arr) expect(state.arr[0]).toBe(defaults.arr[0]) + expect(state.multi).toBe(defaults.multi) + expect(state.multi[0]).toBe(defaults.multi[0]) }) it('should keep referential equality when unrelated keys change', async () => { @@ -178,6 +191,8 @@ describe('useQueryStates: referential equality', () => { expect(state.obj).toBe(defaults.obj) expect(state.arr).toBe(defaults.arr) expect(state.arr[0]).toBe(defaults.arr[0]) + expect(state.multi).toBe(defaults.multi) + expect(state.multi[0]).toBe(defaults.multi[0]) }) }) From d622ceae444c0fdfaf05fd0953e0462083f7eabd Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 13 Sep 2025 21:21:21 +0200 Subject: [PATCH 07/41] test: native-array e2e tests --- .../app/app/(shared)/native-array/page.tsx | 10 ++++++++ .../e2e/next/src/pages/pages/native-array.tsx | 3 +++ .../e2e/react-router/v6/src/react-router.tsx | 1 + .../v6/src/routes/native-array.tsx | 3 +++ packages/e2e/react-router/v7/app/routes.ts | 1 + .../v7/app/routes/native-array.tsx | 3 +++ packages/e2e/react/src/routes.tsx | 1 + .../e2e/react/src/routes/native-array.tsx | 3 +++ .../e2e/remix/app/routes/native-array.tsx | 3 +++ packages/e2e/shared/shared.cy.ts | 8 +++++++ packages/e2e/shared/specs/native-array.cy.ts | 20 ++++++++++++++++ packages/e2e/shared/specs/native-array.tsx | 24 +++++++++++++++++++ .../cypress/e2e/shared/native-array.cy.ts | 3 +++ .../src/routes/native-array.tsx | 13 ++++++++++ 14 files changed, 96 insertions(+) create mode 100644 packages/e2e/next/src/app/app/(shared)/native-array/page.tsx create mode 100644 packages/e2e/next/src/pages/pages/native-array.tsx create mode 100644 packages/e2e/react-router/v6/src/routes/native-array.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/native-array.tsx create mode 100644 packages/e2e/react/src/routes/native-array.tsx create mode 100644 packages/e2e/remix/app/routes/native-array.tsx create mode 100644 packages/e2e/shared/specs/native-array.cy.ts create mode 100644 packages/e2e/shared/specs/native-array.tsx create mode 100644 packages/e2e/tanstack-router/cypress/e2e/shared/native-array.cy.ts create mode 100644 packages/e2e/tanstack-router/src/routes/native-array.tsx diff --git a/packages/e2e/next/src/app/app/(shared)/native-array/page.tsx b/packages/e2e/next/src/app/app/(shared)/native-array/page.tsx new file mode 100644 index 000000000..f4499a2b7 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/native-array/page.tsx @@ -0,0 +1,10 @@ +import { NativeArray } from 'e2e-shared/specs/native-array' +import { Suspense } from 'react' + +export default function Page() { + return ( + + + + ) +} diff --git a/packages/e2e/next/src/pages/pages/native-array.tsx b/packages/e2e/next/src/pages/pages/native-array.tsx new file mode 100644 index 000000000..77a9969db --- /dev/null +++ b/packages/e2e/next/src/pages/pages/native-array.tsx @@ -0,0 +1,3 @@ +import { NativeArray } from 'e2e-shared/specs/native-array' + +export default NativeArray diff --git a/packages/e2e/react-router/v6/src/react-router.tsx b/packages/e2e/react-router/v6/src/react-router.tsx index 73c781f41..cf9eb288d 100644 --- a/packages/e2e/react-router/v6/src/react-router.tsx +++ b/packages/e2e/react-router/v6/src/react-router.tsx @@ -34,6 +34,7 @@ const router = createBrowserRouter( + diff --git a/packages/e2e/react-router/v6/src/routes/native-array.tsx b/packages/e2e/react-router/v6/src/routes/native-array.tsx new file mode 100644 index 000000000..77a9969db --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/native-array.tsx @@ -0,0 +1,3 @@ +import { NativeArray } from 'e2e-shared/specs/native-array' + +export default NativeArray diff --git a/packages/e2e/react-router/v7/app/routes.ts b/packages/e2e/react-router/v7/app/routes.ts index 46463fed8..82cb75c67 100644 --- a/packages/e2e/react-router/v7/app/routes.ts +++ b/packages/e2e/react-router/v7/app/routes.ts @@ -17,6 +17,7 @@ export default [ route('/linking/useQueryState/other', './routes/linking.useQueryState.other.tsx'), route('/linking/useQueryStates', './routes/linking.useQueryStates.tsx'), route('/linking/useQueryStates/other', './routes/linking.useQueryStates.other.tsx'), + route('/native-array', './routes/native-array.tsx'), route('/pretty-urls', './routes/pretty-urls.tsx'), route('/referential-stability/useQueryState', './routes/referential-stability.useQueryState.tsx'), route('/referential-stability/useQueryStates', './routes/referential-stability.useQueryStates.tsx'), diff --git a/packages/e2e/react-router/v7/app/routes/native-array.tsx b/packages/e2e/react-router/v7/app/routes/native-array.tsx new file mode 100644 index 000000000..77a9969db --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/native-array.tsx @@ -0,0 +1,3 @@ +import { NativeArray } from 'e2e-shared/specs/native-array' + +export default NativeArray diff --git a/packages/e2e/react/src/routes.tsx b/packages/e2e/react/src/routes.tsx index 082abd871..a288a06f6 100644 --- a/packages/e2e/react/src/routes.tsx +++ b/packages/e2e/react/src/routes.tsx @@ -16,6 +16,7 @@ const routes: Record JSX.Element>> = { '/linking/useQueryState/other': lazy(() => import('./routes/linking.useQueryState.other')), '/linking/useQueryStates': lazy(() => import('./routes/linking.useQueryStates')), '/linking/useQueryStates/other': lazy(() => import('./routes/linking.useQueryStates.other')), + '/native-array': lazy(() => import('./routes/native-array')), '/pretty-urls': lazy(() => import('./routes/pretty-urls')), '/referential-stability/useQueryState': lazy(() => import('./routes/referential-stability.useQueryState')), '/referential-stability/useQueryStates': lazy(() => import('./routes/referential-stability.useQueryStates')), diff --git a/packages/e2e/react/src/routes/native-array.tsx b/packages/e2e/react/src/routes/native-array.tsx new file mode 100644 index 000000000..77a9969db --- /dev/null +++ b/packages/e2e/react/src/routes/native-array.tsx @@ -0,0 +1,3 @@ +import { NativeArray } from 'e2e-shared/specs/native-array' + +export default NativeArray diff --git a/packages/e2e/remix/app/routes/native-array.tsx b/packages/e2e/remix/app/routes/native-array.tsx new file mode 100644 index 000000000..77a9969db --- /dev/null +++ b/packages/e2e/remix/app/routes/native-array.tsx @@ -0,0 +1,3 @@ +import { NativeArray } from 'e2e-shared/specs/native-array' + +export default NativeArray diff --git a/packages/e2e/shared/shared.cy.ts b/packages/e2e/shared/shared.cy.ts index e73196164..6eef35c87 100644 --- a/packages/e2e/shared/shared.cy.ts +++ b/packages/e2e/shared/shared.cy.ts @@ -4,6 +4,7 @@ import { testConditionalRendering } from './specs/conditional-rendering.cy' import { testForm } from './specs/form.cy' import { testHashPreservation } from './specs/hash-preservation.cy' import { testJson } from './specs/json.cy' +import { testNativeArray } from './specs/native-array.cy' import { testLifeAndDeath } from './specs/life-and-death.cy' import { testLinking } from './specs/linking.cy' import { testPrettyUrls } from './specs/pretty-urls.cy' @@ -35,6 +36,13 @@ export function runSharedTests( // -- + testNativeArray({ + path: `${pathPrefix}/native-array`, + ...config + }) + + // -- + testConditionalRendering({ path: `${pathPrefix}/conditional-rendering/useQueryState`, hook: 'useQueryState', diff --git a/packages/e2e/shared/specs/native-array.cy.ts b/packages/e2e/shared/specs/native-array.cy.ts new file mode 100644 index 000000000..3d5c6c600 --- /dev/null +++ b/packages/e2e/shared/specs/native-array.cy.ts @@ -0,0 +1,20 @@ +import { createTest } from '../create-test' + +export const testNativeArray = createTest('parseAsNativeArray', ({ path }) => { + it('reads native array from the URL', () => { + cy.visit(path + '?test=1&test=2&test=3') + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('#client-name').should('have.text', '1 - 2 - 3') + cy.get('#add-button').click() + cy.get('#client-name').should('have.text', '1 - 2 - 3 - 4') + }) + it('writes native array to the URL', () => { + cy.visit(path) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('#client-name').should('be.empty') + cy.get('#add-button').click() + cy.location('search').should('eq', '?test=1') + cy.get('#add-button').click() + cy.location('search').should('eq', '?test=1&test=2') + }) +}) diff --git a/packages/e2e/shared/specs/native-array.tsx b/packages/e2e/shared/specs/native-array.tsx new file mode 100644 index 000000000..2867ff694 --- /dev/null +++ b/packages/e2e/shared/specs/native-array.tsx @@ -0,0 +1,24 @@ +'use client' + +import { parseAsInteger, parseAsNativeArrayOf, useQueryState } from 'nuqs' +import { Display } from '../components/display' + +export const parser = parseAsNativeArrayOf(parseAsInteger) + +export function NativeArray() { + const [state, setState] = useQueryState('test', parser) + return ( + <> + + + + + ) +} diff --git a/packages/e2e/tanstack-router/cypress/e2e/shared/native-array.cy.ts b/packages/e2e/tanstack-router/cypress/e2e/shared/native-array.cy.ts new file mode 100644 index 000000000..cceeb09bc --- /dev/null +++ b/packages/e2e/tanstack-router/cypress/e2e/shared/native-array.cy.ts @@ -0,0 +1,3 @@ +import { testNativeArray } from 'e2e-shared/specs/native-array.cy' + +testNativeArray({ path: '/native-array' }) diff --git a/packages/e2e/tanstack-router/src/routes/native-array.tsx b/packages/e2e/tanstack-router/src/routes/native-array.tsx new file mode 100644 index 000000000..b65d90e90 --- /dev/null +++ b/packages/e2e/tanstack-router/src/routes/native-array.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' +import { NativeArray, parser } from 'e2e-shared/specs/native-array' +import { createStandardSchemaV1 } from 'nuqs' + +const validateSearch = createStandardSchemaV1( + { test: parser }, + { partialOutput: true } +) + +export const Route = createFileRoute('/native-array')({ + component: NativeArray, + validateSearch +}) From 6c95153204668a036f036a33154319ea7c66646f Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 13 Sep 2025 21:28:58 +0200 Subject: [PATCH 08/41] fix: return null from parsing if everything is unparsable otherwise, the default is never applied --- packages/nuqs/src/parsers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index dc2363d77..e0cd87149 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -499,9 +499,10 @@ export function parseAsNativeArrayOf( const itemEq = itemParser.eq ?? ((a: ItemType, b: ItemType) => a === b) return createMultiParser({ parse: query => { - return query + const parsed = query .map((item, index) => safeParse(itemParser.parse, item, `[${index}]`)) .filter(value => value !== null && value !== undefined) as ItemType[] + return parsed.length === 0 ? null : parsed }, serialize: values => values.flatMap(value => { From 864e0cd86ba6dc108d247d350c2a4daaebfce85b Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 09:09:29 +0200 Subject: [PATCH 09/41] test: add withDefault([]) to parseAsNativeArrayOf in --- packages/e2e/shared/specs/native-array.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/e2e/shared/specs/native-array.tsx b/packages/e2e/shared/specs/native-array.tsx index 2867ff694..0ec223b91 100644 --- a/packages/e2e/shared/specs/native-array.tsx +++ b/packages/e2e/shared/specs/native-array.tsx @@ -3,7 +3,7 @@ import { parseAsInteger, parseAsNativeArrayOf, useQueryState } from 'nuqs' import { Display } from '../components/display' -export const parser = parseAsNativeArrayOf(parseAsInteger) +export const parser = parseAsNativeArrayOf(parseAsInteger).withDefault([]) export function NativeArray() { const [state, setState] = useQueryState('test', parser) @@ -12,9 +12,7 @@ export function NativeArray() { From c8da7d1a066fc97cf02992f0af860e2015a8671e Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 09:24:04 +0200 Subject: [PATCH 10/41] ref: type guard --- packages/nuqs/src/lib/search-params.ts | 4 +++- packages/nuqs/src/loader.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts index fe6dd5993..5fc4182cf 100644 --- a/packages/nuqs/src/lib/search-params.ts +++ b/packages/nuqs/src/lib/search-params.ts @@ -1,4 +1,6 @@ -export function isEmpty(query: string | Iterable | null): boolean { +export function isEmpty( + query: string | Iterable | null +): query is null | [] { return query === null || (Array.isArray(query) && query.length === 0) } diff --git a/packages/nuqs/src/loader.ts b/packages/nuqs/src/loader.ts index fd7fb484f..30b4beedf 100644 --- a/packages/nuqs/src/loader.ts +++ b/packages/nuqs/src/loader.ts @@ -108,7 +108,7 @@ export function createLoader( let parsedValue try { // we have properly narrowed `query` here, but TS doesn't keep track of that - parsedValue = parser.parse(query as string & readonly string[]) + parsedValue = parser.parse(query as string & Array) } catch (error) { if (strict) { throw new Error( From effb49c582c890f54f36e54a603874718d2c4bde Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 09:32:31 +0200 Subject: [PATCH 11/41] ref: use object.entries over object.keys with an indexed access and a bang (!) --- packages/nuqs/src/useQueryStates.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 6353b6380..a7bd6b56c 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -382,9 +382,8 @@ function parseMap( hasChanged: boolean } { let hasChanged = false - const state = Object.keys(keyMap).reduce((out, stateKey) => { + const state = Object.entries(keyMap).reduce((out, [stateKey, parser]) => { const urlKey = urlKeys?.[stateKey] ?? stateKey - const parser = keyMap[stateKey]! const queuedQuery = queuedQueries[urlKey] const query = queuedQuery === undefined From ff107378c8d1678fb31a4fd92ca1408235d78ec2 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 09:40:29 +0200 Subject: [PATCH 12/41] ref: rename isEmpty to avoid ambiguity --- packages/nuqs/src/lib/search-params.ts | 2 +- packages/nuqs/src/loader.ts | 4 ++-- packages/nuqs/src/useQueryStates.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts index 5fc4182cf..d47605347 100644 --- a/packages/nuqs/src/lib/search-params.ts +++ b/packages/nuqs/src/lib/search-params.ts @@ -1,4 +1,4 @@ -export function isEmpty( +export function isAbsentFromUrl( query: string | Iterable | null ): query is null | [] { return query === null || (Array.isArray(query) && query.length === 0) diff --git a/packages/nuqs/src/loader.ts b/packages/nuqs/src/loader.ts index 30b4beedf..f80f5fe36 100644 --- a/packages/nuqs/src/loader.ts +++ b/packages/nuqs/src/loader.ts @@ -1,6 +1,6 @@ import type { UrlKeys } from './defs' import { type inferParserType, type ParserMap } from './parsers' -import { isEmpty } from './lib/search-params' +import { isAbsentFromUrl } from './lib/search-params' export type LoaderInput = | URL @@ -101,7 +101,7 @@ export function createLoader( parser.type === 'multi' ? searchParams.getAll(urlKey) : searchParams.get(urlKey) - if (isEmpty(query)) { + if (isAbsentFromUrl(query)) { result[key] = parser.defaultValue ?? null continue } diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index a7bd6b56c..c707a76cd 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -15,7 +15,7 @@ import { } from './lib/queues/throttle' import { emitter, type CrossHookSyncPayload } from './lib/sync' import { type Parser } from './parsers' -import { isEmpty } from './lib/search-params' +import { isAbsentFromUrl } from './lib/search-params' import { safeParse } from './lib/safe-parse' type KeyMapValue = Parser & @@ -399,7 +399,7 @@ function parseMap( } // Cache miss hasChanged = true - const value = isEmpty(query) + const value = isAbsentFromUrl(query) ? null : // we have properly narrowed `query` here, but TS doesn't keep track of that safeParse(parser.parse, query as string & Array, urlKey) From e0021b1947c8c4ba3192b2d2ffde768921d8bfd7 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 10:35:04 +0200 Subject: [PATCH 13/41] fix: compareQuery for iterables --- packages/nuqs/src/lib/compare.ts | 33 +++++++++++++++++++++++++++++ packages/nuqs/src/useQueryStates.ts | 15 ++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 packages/nuqs/src/lib/compare.ts diff --git a/packages/nuqs/src/lib/compare.ts b/packages/nuqs/src/lib/compare.ts new file mode 100644 index 000000000..27833aa36 --- /dev/null +++ b/packages/nuqs/src/lib/compare.ts @@ -0,0 +1,33 @@ +export function compareQuery>( + a: T | null, + b: T | null +): boolean { + if (a === b) { + return true // Referentially stable + } + if (a === null || b === null) { + return false + } + // we expect either strings or iterables, not a mix of both + if (typeof a === 'string' || typeof b === 'string') { + return a === b + } + + const iterA = a[Symbol.iterator]() + const iterB = b[Symbol.iterator]() + + while (true) { + const nextA = iterA.next() + const nextB = iterB.next() + + if (nextA.done && nextB.done) { + return true // both ended at the same time + } + if (nextA.done !== nextB.done) { + return false // different lengths + } + if (nextA.value !== nextB.value) { + return false // mismatched value + } + } +} diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index c707a76cd..224a7626b 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -17,6 +17,7 @@ import { emitter, type CrossHookSyncPayload } from './lib/sync' import { type Parser } from './parsers' import { isAbsentFromUrl } from './lib/search-params' import { safeParse } from './lib/safe-parse' +import { compareQuery } from './lib/compare' type KeyMapValue = Parser & Options & { @@ -385,14 +386,18 @@ function parseMap( const state = Object.entries(keyMap).reduce((out, [stateKey, parser]) => { const urlKey = urlKeys?.[stateKey] ?? stateKey const queuedQuery = queuedQueries[urlKey] + const defaultValue = parser.type === 'multi' ? [] : null const query = queuedQuery === undefined - ? parser.type === 'multi' - ? (searchParams?.getAll(urlKey) ?? []) - : (searchParams?.get(urlKey) ?? null) + ? ((parser.type === 'multi' + ? searchParams?.getAll(urlKey) + : searchParams?.get(urlKey)) ?? defaultValue) : queuedQuery - // todo this === comparison likely won't work with arrays - if (cachedQuery && cachedState && (cachedQuery[urlKey] ?? null) === query) { + if ( + cachedQuery && + cachedState && + compareQuery(cachedQuery[urlKey] ?? defaultValue, query) + ) { // Cache hit out[stateKey as keyof KeyMap] = cachedState[stateKey] ?? null return out From cc89e20bb7b22c1f04cf1591fef55ea672bf65f2 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 10:56:48 +0200 Subject: [PATCH 14/41] fix: explicitly set searchParam to empty string if we get an empty iterator into "write" --- packages/nuqs/src/lib/search-params.ts | 3 ++ packages/nuqs/src/useQueryState.test.ts | 52 ++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts index d47605347..db85ff476 100644 --- a/packages/nuqs/src/lib/search-params.ts +++ b/packages/nuqs/src/lib/search-params.ts @@ -16,6 +16,9 @@ export function write( for (const v of serialized) { searchParams.append(key, v) } + if (searchParams.size === 0) { + searchParams.set(key, '') + } } return searchParams } diff --git a/packages/nuqs/src/useQueryState.test.ts b/packages/nuqs/src/useQueryState.test.ts index 7abd059d1..4326cad08 100644 --- a/packages/nuqs/src/useQueryState.test.ts +++ b/packages/nuqs/src/useQueryState.test.ts @@ -5,7 +5,13 @@ import { type OnUrlUpdateFunction } from './adapters/testing' import { debounce } from './lib/queues/rate-limiting' -import { parseAsArrayOf, parseAsJson, parseAsString } from './parsers' +import { + parseAsArrayOf, + parseAsInteger, + parseAsJson, + parseAsNativeArrayOf, + parseAsString +} from './parsers' import { useQueryState } from './useQueryState' describe('useQueryState: referential equality', () => { @@ -386,3 +392,47 @@ describe('useQueryState: adapter defaults', () => { expect(onUrlUpdate.mock.calls[0]![0].queryString).toBe('?test=pass') }) }) + +describe('useQueryState: multi-parsers', () => { + it('should clear the url when defaults are set', async () => { + const onUrlUpdate = vi.fn() + const { result } = renderHook( + () => + useQueryState( + 'test', + parseAsNativeArrayOf(parseAsInteger).withDefault([42]) + ), + { + wrapper: withNuqsTestingAdapter({ + searchParams: '?test=1&test=2&test=3', + onUrlUpdate + }) + } + ) + expect(result.current[0]).toEqual([1, 2, 3]) + await act(() => result.current[1]([42])) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('') + }) + + it('should add an empty param when set to empty array and there is a different default', async () => { + const onUrlUpdate = vi.fn() + const { result } = renderHook( + () => + useQueryState( + 'test', + parseAsNativeArrayOf(parseAsInteger).withDefault([42]) + ), + { + wrapper: withNuqsTestingAdapter({ + searchParams: '?test=1&test=2&test=3', + onUrlUpdate + }) + } + ) + expect(result.current[0]).toEqual([1, 2, 3]) + await act(() => result.current[1]([])) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=') + }) +}) From 27a354a6b0336cb7c93bad8d74ef9b3d030aa74b Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 11:02:39 +0200 Subject: [PATCH 15/41] test: fix wrong parser assumptions parsers don't return the default value, they return null (defaultValue gets applied later) --- packages/nuqs/src/parsers.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts index de16e3266..3adc342bf 100644 --- a/packages/nuqs/src/parsers.test.ts +++ b/packages/nuqs/src/parsers.test.ts @@ -309,12 +309,12 @@ describe('parsers', () => { }) it('parses', () => { const parser = parseAsNativeArrayOf(parseAsInteger) - expect(parser.parse([])).toStrictEqual([]) + expect(parser.parse([])).toStrictEqual(null) expect(parser.parse(['1', '2'])).toStrictEqual([1, 2]) }) - it('defaults to empty array', () => { + it('defaults to null', () => { const parser = parseAsNativeArrayOf(parseAsInteger) - expect(parser.parse(['not', 'a', 'number'])).toStrictEqual([]) + expect(parser.parse(['not', 'a', 'number'])).toStrictEqual(null) }) }) From c38d7804db5225f1e7ccdc32541fdc61613302aa Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 11:30:17 +0200 Subject: [PATCH 16/41] fix: switch to comparing all values in key-isolation this is necessary for multi-parsers to not bail-out wrongly --- packages/nuqs/src/adapters/lib/key-isolation.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/nuqs/src/adapters/lib/key-isolation.ts b/packages/nuqs/src/adapters/lib/key-isolation.ts index 05dda7f25..b6f2a0e44 100644 --- a/packages/nuqs/src/adapters/lib/key-isolation.ts +++ b/packages/nuqs/src/adapters/lib/key-isolation.ts @@ -1,4 +1,5 @@ import { debug } from '../../lib/debug' +import { compareQuery } from '../../lib/compare' export function applyChange( newValue: URLSearchParams, @@ -9,7 +10,9 @@ export function applyChange( const hasChanged = keys.length === 0 ? true - : keys.some(key => oldValue.get(key) !== newValue.get(key)) + : keys.some( + key => !compareQuery(oldValue.getAll(key), newValue.getAll(key)) + ) if (!hasChanged) { debug( '[nuqs `%s`] no change, returning previous', From 6f01b4df6a1587cbe94cf9ddf05bc7fa9b2c29fe Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 14 Sep 2025 16:48:47 +0200 Subject: [PATCH 17/41] fix: defensive check for standard schema --- packages/nuqs/src/parsers.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index e0cd87149..63cf1d39f 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -504,11 +504,14 @@ export function parseAsNativeArrayOf( .filter(value => value !== null && value !== undefined) as ItemType[] return parsed.length === 0 ? null : parsed }, - serialize: values => - values.flatMap(value => { + serialize: values => { + // defensive check because we potentially get a single value passed from a standard schema + const safeValues = Array.isArray(values) ? values : [values] + return safeValues.flatMap(value => { const serialized = itemParser.serialize?.(value) ?? String(value) return typeof serialized === 'string' ? [serialized] : [...serialized] - }), + }) + }, eq(a, b) { if (a === b) { return true // Referentially stable From ef68422b82a195da893c4bcf000c4624fd7aaa8a Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 15 Sep 2025 19:30:23 +0200 Subject: [PATCH 18/41] Update packages/nuqs/src/lib/search-params.ts Co-authored-by: Valerii Sidorenko --- packages/nuqs/src/lib/search-params.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts index db85ff476..3f91133ac 100644 --- a/packages/nuqs/src/lib/search-params.ts +++ b/packages/nuqs/src/lib/search-params.ts @@ -16,7 +16,7 @@ export function write( for (const v of serialized) { searchParams.append(key, v) } - if (searchParams.size === 0) { + if (!searchParams.has(key)) { searchParams.set(key, '') } } From 8e4ebf4266df7bf780c60cd405fa83e927d5975f Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 19 Sep 2025 17:42:05 +0200 Subject: [PATCH 19/41] feat: add .withDefault([]) to parseAsNativeArrayOf it makes a lot more sense than null for this parser --- packages/e2e/shared/specs/native-array.tsx | 2 +- packages/nuqs/src/parsers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e/shared/specs/native-array.tsx b/packages/e2e/shared/specs/native-array.tsx index 0ec223b91..39d22bb37 100644 --- a/packages/e2e/shared/specs/native-array.tsx +++ b/packages/e2e/shared/specs/native-array.tsx @@ -3,7 +3,7 @@ import { parseAsInteger, parseAsNativeArrayOf, useQueryState } from 'nuqs' import { Display } from '../components/display' -export const parser = parseAsNativeArrayOf(parseAsInteger).withDefault([]) +export const parser = parseAsNativeArrayOf(parseAsInteger) export function NativeArray() { const [state, setState] = useQueryState('test', parser) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 63cf1d39f..2bcd0fbda 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -521,7 +521,7 @@ export function parseAsNativeArrayOf( } return a.every((value, index) => itemEq(value, b[index]!)) } - }) + }).withDefault([]) } type inferSingleParserType = Parser extends ParserBuilder< From 0be140592ca733d52c650cfd351872b2d1aab698 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 19 Sep 2025 17:42:31 +0200 Subject: [PATCH 20/41] ref: rename defaultValue to fallbackValue --- packages/nuqs/src/useQueryStates.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 224a7626b..67f3cb6ad 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -386,17 +386,17 @@ function parseMap( const state = Object.entries(keyMap).reduce((out, [stateKey, parser]) => { const urlKey = urlKeys?.[stateKey] ?? stateKey const queuedQuery = queuedQueries[urlKey] - const defaultValue = parser.type === 'multi' ? [] : null + const fallbackValue = parser.type === 'multi' ? [] : null const query = queuedQuery === undefined ? ((parser.type === 'multi' ? searchParams?.getAll(urlKey) - : searchParams?.get(urlKey)) ?? defaultValue) + : searchParams?.get(urlKey)) ?? fallbackValue) : queuedQuery if ( cachedQuery && cachedState && - compareQuery(cachedQuery[urlKey] ?? defaultValue, query) + compareQuery(cachedQuery[urlKey] ?? fallbackValue, query) ) { // Cache hit out[stateKey as keyof KeyMap] = cachedState[stateKey] ?? null From 0411386676e18b9cfb3162ee81434f200b4e2235 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 19 Sep 2025 17:44:20 +0200 Subject: [PATCH 21/41] chore: leave a comment about the special empty value set --- packages/nuqs/src/lib/search-params.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts index 3f91133ac..c34156d9b 100644 --- a/packages/nuqs/src/lib/search-params.ts +++ b/packages/nuqs/src/lib/search-params.ts @@ -16,6 +16,9 @@ export function write( for (const v of serialized) { searchParams.append(key, v) } + // if we get here with an empty iterable, no values were appended + // however, an empty iterable here means we explicitly want to set the key + // because for default values, we don't call write at all if (!searchParams.has(key)) { searchParams.set(key, '') } From f036840f0d46ecf4c522af728c02da04e39743ef Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 19 Sep 2025 18:27:03 +0200 Subject: [PATCH 22/41] fix: types for parseAsNativeArray --- packages/nuqs/src/parsers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 2bcd0fbda..08c03fd80 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -495,7 +495,7 @@ export function parseAsArrayOf( export function parseAsNativeArrayOf( itemParser: SingleParser -): MultiParserBuilder { +): ReturnType['withDefault']> { const itemEq = itemParser.eq ?? ((a: ItemType, b: ItemType) => a === b) return createMultiParser({ parse: query => { From f5bf1b8403df12625cd6922abeaf8199a255b4f3 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 24 Sep 2025 15:13:52 +0200 Subject: [PATCH 23/41] test: compare tests --- packages/nuqs/src/lib/compare.test.ts | 46 +++++++++++++++++++++++++++ packages/nuqs/src/lib/compare.ts | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 packages/nuqs/src/lib/compare.test.ts diff --git a/packages/nuqs/src/lib/compare.test.ts b/packages/nuqs/src/lib/compare.test.ts new file mode 100644 index 000000000..8b11c8893 --- /dev/null +++ b/packages/nuqs/src/lib/compare.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { compareQuery } from './compare' + +describe('compare', () => { + describe('strings', () => { + it('should return true for equal values', () => { + expect(compareQuery('a', 'a')).toBe(true) + }) + it('should return false for different strings', () => { + expect(compareQuery('a', 'b')).toBe(false) + }) + }) + describe('iterables', () => { + it('should return true for equal arrays', () => { + expect(compareQuery(['a', 'b'], ['a', 'b'])).toBe(true) + }) + it('should return true for same array instance', () => { + const arr = ['a', 'b'] + expect(compareQuery(arr, arr)).toBe(true) + }) + it('should return false for different arrays', () => { + expect(compareQuery(['a', 'b'], ['a', 'c'])).toBe(false) + }) + it('should return false for different length arrays', () => { + expect(compareQuery(['a', 'b'], ['a', 'b', 'c'])).toBe(false) + }) + it('should return true for equal sets', () => { + expect(compareQuery(new Set(['a', 'b']), new Set(['a', 'b']))).toBe(true) + }) + it('relies on insertion order for sets', () => { + expect(compareQuery(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(false) + }) + it('should return true for same set instance', () => { + const set = new Set(['a', 'b']) + expect(compareQuery(set, set)).toBe(true) + }) + it('should return false for different sets', () => { + expect(compareQuery(new Set(['a', 'b']), new Set(['a', 'c']))).toBe(false) + }) + it('should return false for different length sets', () => { + expect(compareQuery(new Set(['a', 'b']), new Set(['a', 'b', 'c']))).toBe( + false + ) + }) + }) +}) diff --git a/packages/nuqs/src/lib/compare.ts b/packages/nuqs/src/lib/compare.ts index 27833aa36..f36acc4a8 100644 --- a/packages/nuqs/src/lib/compare.ts +++ b/packages/nuqs/src/lib/compare.ts @@ -10,7 +10,7 @@ export function compareQuery>( } // we expect either strings or iterables, not a mix of both if (typeof a === 'string' || typeof b === 'string') { - return a === b + return false } const iterA = a[Symbol.iterator]() From 6b14b75d2b4044a6bacb92eb60e53ee7f15326b3 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 24 Sep 2025 15:22:36 +0200 Subject: [PATCH 24/41] fix: keep backwards compatibility for Parser / ParserBuilder --- packages/nuqs/src/parsers.ts | 26 ++++++++++++++++++-------- packages/nuqs/src/testing.ts | 2 +- packages/nuqs/src/useQueryState.ts | 8 ++++---- packages/nuqs/src/useQueryStates.ts | 4 ++-- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 08c03fd80..852e1676c 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -38,9 +38,17 @@ export type MultiParser = { eq?: (a: T, b: T) => boolean } -export type Parser = SingleParser | MultiParser +export type GenericParser = SingleParser | MultiParser +export type GenericParserBuilder = + | SingleParserBuilder + | MultiParserBuilder + +/* type aliases for backwards compatibility */ +/** @deprecated use SingleParser instead */ +export type Parser = SingleParser +/** @deprecated use SingleParserBuilder instead */ +export type ParserBuilder = SingleParserBuilder -export type ParserBuilder = SingleParserBuilder | MultiParserBuilder export type SingleParserBuilder = Required> & Options & { /** @@ -524,17 +532,19 @@ export function parseAsNativeArrayOf( }).withDefault([]) } -type inferSingleParserType = Parser extends ParserBuilder< +type inferSingleParserType = Parser extends GenericParserBuilder< infer Value > & { defaultValue: infer Value } ? Value - : Parser extends ParserBuilder + : Parser extends GenericParserBuilder ? Value | null : never -type inferParserRecordType>> = { +type inferParserRecordType< + Map extends Record> +> = { [Key in keyof Map]: inferSingleParserType } & {} @@ -563,13 +573,13 @@ type inferParserRecordType>> = { * ``` */ export type inferParserType = - Input extends ParserBuilder + Input extends GenericParserBuilder ? inferSingleParserType - : Input extends Record> + : Input extends Record> ? inferParserRecordType : never -export type ParserWithOptionalDefault = ParserBuilder & { +export type ParserWithOptionalDefault = GenericParserBuilder & { defaultValue?: T } export type ParserMap = Record> diff --git a/packages/nuqs/src/testing.ts b/packages/nuqs/src/testing.ts index f998cc7e9..ffe136be6 100644 --- a/packages/nuqs/src/testing.ts +++ b/packages/nuqs/src/testing.ts @@ -1,4 +1,4 @@ -import type { ParserBuilder, SingleParserBuilder } from './parsers' +import type { SingleParserBuilder } from './parsers' /** * Test that a parser is bijective (serialize then parse gives back the same value). diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 0da1ede93..e18df1add 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -1,9 +1,9 @@ import { useCallback } from 'react' import type { Options } from './defs' -import type { Parser } from './parsers' +import type { GenericParser } from './parsers' import { useQueryStates } from './useQueryStates' -export type UseQueryStateOptions = Parser & Options +export type UseQueryStateOptions = GenericParser & Options export type UseQueryStateReturn = [ Default extends undefined @@ -90,7 +90,7 @@ export function useQueryState( // Note: Ensure this overload isn't picked when specifying a default // value and spreading a parser for which the default would be invalid. // See https://github.com/47ng/nuqs/pull/1057 - [K in keyof Parser]?: never + [K in keyof GenericParser]?: never } ): UseQueryStateReturn @@ -206,7 +206,7 @@ export function useQueryState( serialize, eq, defaultValue - } as Parser + } as GenericParser }, hookOptions ) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 67f3cb6ad..160983430 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -14,12 +14,12 @@ import { type UpdateQueuePushArgs } from './lib/queues/throttle' import { emitter, type CrossHookSyncPayload } from './lib/sync' -import { type Parser } from './parsers' +import { type GenericParser } from './parsers' import { isAbsentFromUrl } from './lib/search-params' import { safeParse } from './lib/safe-parse' import { compareQuery } from './lib/compare' -type KeyMapValue = Parser & +type KeyMapValue = GenericParser & Options & { defaultValue?: Type } From 8fcd61b95c4fcb68b8578f3f7528d998d350e1a0 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 24 Sep 2025 16:29:10 +0200 Subject: [PATCH 25/41] doc: parseAsNativeArrayOf --- .../docs/content/docs/parsers/built-in.mdx | 35 ++++++++++++++++++- packages/docs/content/docs/parsers/demos.tsx | 29 +++++++++++++++ packages/docs/src/components/querystring.tsx | 2 +- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx index bdca1be99..193b0a444 100644 --- a/packages/docs/content/docs/parsers/built-in.mdx +++ b/packages/docs/content/docs/parsers/built-in.mdx @@ -15,7 +15,8 @@ import { DateISOParserDemo, DatetimeISOParserDemo, DateTimestampParserDemo, - JsonParserDemo + JsonParserDemo, + NativeArrayParserDemo } from '@/content/docs/parsers/demos' Search params are strings by default, but chances are your state is more complex than that. @@ -322,6 +323,38 @@ parseAsJson(userSchema.validateSync) return `null{:ts}` for invalid data. Only **synchronous** validation is supported. +## Native Arrays + + + +If you want to use the native URL format for arrays, repeating the same key multiple times like: + +``` +/products?tag=books&tag=tech&tag=design +``` + +you can now use `MultiParsers` like `parseAsNativeArrayOf` to read and write those values in a fully type-safe way. + +```tsx +import { useQueryState, parseAsNativeArrayOf, parseAsInteger } from 'nuqs' + +export function ProjectsFilter() { + // ?project=123&project=456 ==> [123, 456] + const [projectIds, setProjectIds] = useQueryState( + 'project', + parseAsNativeArrayOf(parseAsInteger) + ) +} +``` + +}> + + + + + Note that `parseAsNativeArrayOf` has a built-in default value of empty array (`.withDefault([])`) so that you don't have to handle `null` cases. + + ## Using parsers server-side For shared code that may be imported in the Next.js app router, you should import diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index 55f9eb392..8fc694b4f 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -27,6 +27,7 @@ import { parseAsIsoDate, parseAsIsoDateTime, parseAsJson, + parseAsNativeArrayOf, parseAsStringLiteral, parseAsTimestamp, useQueryState @@ -465,6 +466,34 @@ export function CustomParserDemo() { ) } +export function NativeArrayParserDemo() { + const [_, setValue] = useQueryState( + 'nativeArray', + parseAsNativeArrayOf(parseAsInteger) + ) + return ( + + + + + + ) +} + type StarButtonProps = Omit, 'value'> & { index: Rating value: Rating | null diff --git a/packages/docs/src/components/querystring.tsx b/packages/docs/src/components/querystring.tsx index 5802d1d55..e532a4bf3 100644 --- a/packages/docs/src/components/querystring.tsx +++ b/packages/docs/src/components/querystring.tsx @@ -63,7 +63,7 @@ function filterQueryKeys(query: string | URLSearchParams, keys?: string[]) { const destination = new URLSearchParams() for (const [key, value] of source.entries()) { if (keys.includes(key)) { - destination.set(key, value) + destination.append(key, value) } } return destination From 976aeb92bf9bd9323418601a7a2f22a0fe5f3f42 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 24 Sep 2025 16:38:01 +0200 Subject: [PATCH 26/41] ref: move away from Iterables towards Arrays --- packages/nuqs/src/lib/compare.ts | 26 ++++++++---------------- packages/nuqs/src/lib/queues/debounce.ts | 5 +++-- packages/nuqs/src/lib/queues/throttle.ts | 8 ++++---- packages/nuqs/src/lib/search-params.ts | 8 ++++---- packages/nuqs/src/lib/sync.ts | 3 ++- packages/nuqs/src/parsers.ts | 2 +- packages/nuqs/src/useQueryStates.ts | 8 ++++---- 7 files changed, 26 insertions(+), 34 deletions(-) diff --git a/packages/nuqs/src/lib/compare.ts b/packages/nuqs/src/lib/compare.ts index f36acc4a8..d3dcf846b 100644 --- a/packages/nuqs/src/lib/compare.ts +++ b/packages/nuqs/src/lib/compare.ts @@ -1,4 +1,6 @@ -export function compareQuery>( +import type { QueryParam } from './search-params' + +export function compareQuery( a: T | null, b: T | null ): boolean { @@ -8,26 +10,14 @@ export function compareQuery>( if (a === null || b === null) { return false } - // we expect either strings or iterables, not a mix of both + // we expect either strings or arrays, not a mix of both if (typeof a === 'string' || typeof b === 'string') { return false } - const iterA = a[Symbol.iterator]() - const iterB = b[Symbol.iterator]() - - while (true) { - const nextA = iterA.next() - const nextB = iterB.next() - - if (nextA.done && nextB.done) { - return true // both ended at the same time - } - if (nextA.done !== nextB.done) { - return false // different lengths - } - if (nextA.value !== nextB.value) { - return false // mismatched value - } + if (a.length !== b.length) { + return false } + + return a.every((value, index) => value === b[index]!) } diff --git a/packages/nuqs/src/lib/queues/debounce.ts b/packages/nuqs/src/lib/queues/debounce.ts index 679283f24..504046d88 100644 --- a/packages/nuqs/src/lib/queues/debounce.ts +++ b/packages/nuqs/src/lib/queues/debounce.ts @@ -10,6 +10,7 @@ import { type UpdateQueuePushArgs } from './throttle' import { useSyncExternalStores } from './useSyncExternalStores' +import type { QueryParam } from '../search-params' export class DebouncedPromiseQueue { callback: (value: ValueType) => Promise @@ -75,7 +76,7 @@ export class DebounceController { useQueuedQueries( keys: string[] - ): Record | null | undefined> { + ): Record { return useSyncExternalStores( keys, (key, callback) => this.queuedQuerySync.on(key, callback), @@ -155,7 +156,7 @@ export class DebounceController { this.queues.clear() } - getQueuedQuery(key: string): Iterable | null | undefined { + getQueuedQuery(key: string): QueryParam | null | undefined { // The debounced queued values are more likely to be up-to-date // than any updates pending in the throttle queue, which comes last // in the update chain. diff --git a/packages/nuqs/src/lib/queues/throttle.ts b/packages/nuqs/src/lib/queues/throttle.ts index 7782a3862..65d94d5fb 100644 --- a/packages/nuqs/src/lib/queues/throttle.ts +++ b/packages/nuqs/src/lib/queues/throttle.ts @@ -6,9 +6,9 @@ import { error } from '../errors' import { timeout } from '../timeout' import { withResolvers, type Resolvers } from '../with-resolvers' import { defaultRateLimit } from './rate-limiting' -import { write } from '../search-params' +import { type QueryParam, write } from '../search-params' -type UpdateMap = Map | null> +type UpdateMap = Map type TransitionSet = Set export type UpdateQueueAdapterContext = Pick< AdapterInterface, @@ -20,7 +20,7 @@ export type UpdateQueueAdapterContext = Pick< export type UpdateQueuePushArgs = { key: string - query: Iterable | null + query: QueryParam | null options: AdapterOptions & Pick } @@ -66,7 +66,7 @@ export class ThrottledQueue { } } - getQueuedQuery(key: string): Iterable | null | undefined { + getQueuedQuery(key: string): QueryParam | null | undefined { return this.updateMap.get(key) } diff --git a/packages/nuqs/src/lib/search-params.ts b/packages/nuqs/src/lib/search-params.ts index c34156d9b..6052b99d8 100644 --- a/packages/nuqs/src/lib/search-params.ts +++ b/packages/nuqs/src/lib/search-params.ts @@ -1,11 +1,11 @@ -export function isAbsentFromUrl( - query: string | Iterable | null -): query is null | [] { +export type QueryParam = string | Array + +export function isAbsentFromUrl(query: QueryParam | null): query is null | [] { return query === null || (Array.isArray(query) && query.length === 0) } export function write( - serialized: Iterable, + serialized: QueryParam, key: string, searchParams: URLSearchParams ): URLSearchParams { diff --git a/packages/nuqs/src/lib/sync.ts b/packages/nuqs/src/lib/sync.ts index 2f08c3b16..77a849cc3 100644 --- a/packages/nuqs/src/lib/sync.ts +++ b/packages/nuqs/src/lib/sync.ts @@ -1,8 +1,9 @@ import { createEmitter, type Emitter } from './emitter' +import type { QueryParam } from './search-params' export type CrossHookSyncPayload = { state: any - query: Iterable | null + query: QueryParam | null } type EventMap = { diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 852e1676c..e614be28b 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -34,7 +34,7 @@ export type SingleParser = { export type MultiParser = { type: 'multi' parse: (value: ReadonlyArray) => T | null - serialize?: (value: T) => Iterable + serialize?: (value: T) => Array eq?: (a: T, b: T) => boolean } diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 160983430..88d469b46 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -15,7 +15,7 @@ import { } from './lib/queues/throttle' import { emitter, type CrossHookSyncPayload } from './lib/sync' import { type GenericParser } from './parsers' -import { isAbsentFromUrl } from './lib/search-params' +import { isAbsentFromUrl, type QueryParam } from './lib/search-params' import { safeParse } from './lib/safe-parse' import { compareQuery } from './lib/compare' @@ -99,7 +99,7 @@ export function useQueryStates( ) const adapter = useAdapter(Object.values(resolvedUrlKeys)) const initialSearchParams = adapter.searchParams - const queryRef = useRef | null>>({}) + const queryRef = useRef>({}) const defaultValues = useMemo( () => Object.fromEntries( @@ -375,8 +375,8 @@ function parseMap( keyMap: KeyMap, urlKeys: Partial>, searchParams: URLSearchParams, - queuedQueries: Record | null | undefined>, - cachedQuery?: Record | null>, + queuedQueries: Record, + cachedQuery?: Record, cachedState?: NullableValues ): { state: NullableValues From 79f4249558d3f83cb8d9da17edbf3354821a0064 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 24 Sep 2025 16:58:15 +0200 Subject: [PATCH 27/41] fix: compare tests --- packages/nuqs/src/lib/compare.test.ts | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/packages/nuqs/src/lib/compare.test.ts b/packages/nuqs/src/lib/compare.test.ts index 8b11c8893..e854bac63 100644 --- a/packages/nuqs/src/lib/compare.test.ts +++ b/packages/nuqs/src/lib/compare.test.ts @@ -10,7 +10,7 @@ describe('compare', () => { expect(compareQuery('a', 'b')).toBe(false) }) }) - describe('iterables', () => { + describe('arrays', () => { it('should return true for equal arrays', () => { expect(compareQuery(['a', 'b'], ['a', 'b'])).toBe(true) }) @@ -24,23 +24,5 @@ describe('compare', () => { it('should return false for different length arrays', () => { expect(compareQuery(['a', 'b'], ['a', 'b', 'c'])).toBe(false) }) - it('should return true for equal sets', () => { - expect(compareQuery(new Set(['a', 'b']), new Set(['a', 'b']))).toBe(true) - }) - it('relies on insertion order for sets', () => { - expect(compareQuery(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(false) - }) - it('should return true for same set instance', () => { - const set = new Set(['a', 'b']) - expect(compareQuery(set, set)).toBe(true) - }) - it('should return false for different sets', () => { - expect(compareQuery(new Set(['a', 'b']), new Set(['a', 'c']))).toBe(false) - }) - it('should return false for different length sets', () => { - expect(compareQuery(new Set(['a', 'b']), new Set(['a', 'b', 'c']))).toBe( - false - ) - }) }) }) From 660af9dd2b5226dbe1fc3192419707ba31483597 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 25 Sep 2025 11:58:05 +0200 Subject: [PATCH 28/41] doc: createMultiParser demo --- packages/docs/content/docs/parsers/demos.tsx | 177 ++++++++++++++++++ .../content/docs/parsers/making-your-own.mdx | 67 ++++++- 2 files changed, 243 insertions(+), 1 deletion(-) diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index 8fc694b4f..2933c40cb 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronUp, Minus, Star } from 'lucide-react' import { ParserBuilder, createParser, + createMultiParser, parseAsBoolean, parseAsFloat, parseAsHex, @@ -27,9 +28,12 @@ import { parseAsIsoDate, parseAsIsoDateTime, parseAsJson, + parseAsArrayOf, parseAsNativeArrayOf, + parseAsString, parseAsStringLiteral, parseAsTimestamp, + SingleParser, useQueryState } from 'nuqs' import React from 'react' @@ -494,6 +498,179 @@ export function NativeArrayParserDemo() { ) } +export function CustomMultiParserDemo() { + + const parseAsFromTo = createParser({ + parse: value => { + const [min = null, max = null] = value.split('~').map(parseAsInteger.parse) + if (min === null) return null + if (max === null) return { eq: min} + return { gte: min, lte: max } + }, + serialize: value => { + return value.eq !== undefined ? String(value.eq) : `${value.gte}~${value.lte}` + } + }) + + const parseAsKeyValue = createParser({ + parse: value => { + const [key, val] = value.split(':') + if (!key || !val) return null + return { key, value: val} + }, + serialize: value => { + return `${value.key}:${value.value}` + } + }) + + const parseAsFilters = (itemParser: SingleParser) => { + return createMultiParser({ + parse: values => { + const keyValue = values.map(parseAsKeyValue.parse).filter(v => v !== null) + + const result = Object.fromEntries( + keyValue.flatMap(({ key, value }) => { + const parsedValue: TItem | null = itemParser.parse(value) + return parsedValue === null ? [] : [[key, parsedValue]] + }) + ) + + return Object.keys(result).length === 0 ? null : result + }, + serialize: values => { + return Object.entries(values).map(([key, value]) => { + if (!itemParser.serialize) return null + return parseAsKeyValue.serialize({ key, value: itemParser.serialize(value) }) + }).filter(v => v !== null) + } + }) + } + + const [filters, setFilters] = useQueryState( + 'filters', + parseAsFilters(parseAsFromTo).withDefault({}) + ) + + return ( + +
+ + { + setFilters(prev => ({...prev, rating: { eq: e.target.valueAsNumber }})) + }} + autoComplete="off" + /> +
+
+ + { + setFilters(prev => ({...prev, price: { lte: prev.price?.lte ?? 0, gte: e.target.valueAsNumber }})) + }} + autoComplete="off" + /> +
+
+ + { + setFilters(prev => ({...prev, price: { gte: prev.price?.gte ?? 0, lte: e.target.valueAsNumber }})) + }} + autoComplete="off" + /> +
+ +
+ ) + + return ( + + { + Object.entries(filters).map(([key, value]) => { + if (value.eq !== undefined) { + return ( +
+ + { + setFilters(prev => ({...prev, [key]: { eq: e.target.valueAsNumber }})) + }} + placeholder="What's your favourite number?" + autoComplete="off" + /> +
+ ) + } + return ( +
+ + { + setFilters(prev => ({...prev, [key]: { eq: e.target.valueAsNumber }})) + }} + placeholder="What's your favourite number?" + autoComplete="off" + /> +
+ ) + }) + } + +
+ ) +} + + type StarButtonProps = Omit, 'value'> & { index: Rating value: Rating | null diff --git a/packages/docs/content/docs/parsers/making-your-own.mdx b/packages/docs/content/docs/parsers/making-your-own.mdx index 9500e18d0..dfe80e5cb 100644 --- a/packages/docs/content/docs/parsers/making-your-own.mdx +++ b/packages/docs/content/docs/parsers/making-your-own.mdx @@ -4,7 +4,8 @@ description: Making your own parsers for custom data types & pretty URLs --- import { - CustomParserDemo + CustomParserDemo, + CustomMultiParserDemo } from '@/content/docs/parsers/demos' You may wish to customise the rendered query string for your data type. @@ -35,6 +36,70 @@ const parseAsStarRating = createParser({ +## Custom Multi Parsers + +`MultiParsers` work similar to `SingleParsers`, except that they operate on Arrays. That means: + +1. `parse` takes an `Array`. It receives all matching values of the key it operates on, and returns the parsed value, or `null{:ts}` if invalid. +2. `serialize` takes the parsed value and returns an `Array`, where each item will be separately added to the URL. + +```tsx +const parseAsFromTo = createParser({ + parse: value => { + const [min = null, max = null] = value.split('~').map(parseAsInteger.parse) + if (min === null) return null + if (max === null) return { eq: min} + return { gte: min, lte: max } + }, + serialize: value => { + return value.eq !== undefined ? String(value.eq) : `${value.gte}~${value.lte}` + } +}) + +const parseAsKeyValue = createParser({ + parse: value => { + const [key, val] = value.split(':') + if (!key || !val) return null + return { key, value: val} + }, + serialize: value => { + return `${value.key}:${value.value}` + } +}) + +const parseAsFilters = (itemParser: SingleParser) => { + return createMultiParser({ + parse: values => { + const keyValue = values.map(parseAsKeyValue.parse).filter(v => v !== null) + + const result = Object.fromEntries( + keyValue.flatMap(({ key, value }) => { + const parsedValue: TItem | null = itemParser.parse(value) + return parsedValue === null ? [] : [[key, parsedValue]] + }) + ) + + return Object.keys(result).length === 0 ? null : result + }, + serialize: values => { + return Object.entries(values).map(([key, value]) => { + if (!itemParser.serialize) return null + return parseAsKeyValue.serialize({ key, value: itemParser.serialize(value) }) + }).filter(v => v !== null) + } + }) +} + +const [filters, setFilters] = useQueryState( + 'filters', + parseAsFilters(parseAsFromTo).withDefault({}) +) +``` + + + + + ## Caveat: lossy serializers If your serializer loses precision or doesn't accurately represent From 0622d048cc3a7e9443db753138fb004d4bcbdcd3 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Thu, 25 Sep 2025 12:22:34 +0200 Subject: [PATCH 29/41] Update packages/docs/content/docs/parsers/built-in.mdx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: François Best --- packages/docs/content/docs/parsers/built-in.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx index 193b0a444..5bef29f6a 100644 --- a/packages/docs/content/docs/parsers/built-in.mdx +++ b/packages/docs/content/docs/parsers/built-in.mdx @@ -325,7 +325,7 @@ parseAsJson(userSchema.validateSync) ## Native Arrays - + If you want to use the native URL format for arrays, repeating the same key multiple times like: From 4bf2ea20c031658fe452506a2b4b48ca76fecca6 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 25 Sep 2025 12:25:08 +0200 Subject: [PATCH 30/41] doc: align clear button --- packages/docs/content/docs/parsers/demos.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index 2933c40cb..2f5201870 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -604,7 +604,7 @@ export function CustomMultiParserDemo() { From 7013a5af4615b03afdf1d00e346788bc91e1517c Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 25 Sep 2025 12:26:20 +0200 Subject: [PATCH 31/41] fix: NaN --- packages/docs/content/docs/parsers/demos.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index 2f5201870..327689ff4 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -580,7 +580,7 @@ export function CustomMultiParserDemo() { className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" value={filters.price?.gte ?? 0} onChange={e => { - setFilters(prev => ({...prev, price: { lte: prev.price?.lte ?? 0, gte: e.target.valueAsNumber }})) + setFilters(prev => ({...prev, price: { lte: prev.price?.lte ?? 0, gte: e.target.value === '' ? 0 : e.target.valueAsNumber }})) }} autoComplete="off" /> @@ -596,7 +596,7 @@ export function CustomMultiParserDemo() { className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" value={filters.price?.lte ?? 0} onChange={e => { - setFilters(prev => ({...prev, price: { gte: prev.price?.gte ?? 0, lte: e.target.valueAsNumber }})) + setFilters(prev => ({...prev, price: { gte: prev.price?.gte ?? 0, lte: e.target.value === '' ? 0 : e.target.valueAsNumber }})) }} autoComplete="off" /> From 9f42bfc5b9fdb6c5822b5266c618ac8607691d6a Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 25 Sep 2025 17:35:35 +0200 Subject: [PATCH 32/41] doc: show values --- packages/docs/content/docs/parsers/demos.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index 327689ff4..4a9e75921 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -471,7 +471,7 @@ export function CustomParserDemo() { } export function NativeArrayParserDemo() { - const [_, setValue] = useQueryState( + const [value, setValue] = useQueryState( 'nativeArray', parseAsNativeArrayOf(parseAsInteger) ) @@ -494,6 +494,9 @@ export function NativeArrayParserDemo() { > Clear +
+ Current value: [{value.join(', ') || ''}] +
) } From b0a22779de90acffb748dcdb877df53b6ce32f87 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 25 Sep 2025 17:38:57 +0200 Subject: [PATCH 33/41] doc: simplify example --- packages/docs/content/docs/parsers/built-in.mdx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx index 5bef29f6a..e9813b4c2 100644 --- a/packages/docs/content/docs/parsers/built-in.mdx +++ b/packages/docs/content/docs/parsers/built-in.mdx @@ -338,13 +338,12 @@ you can now use `MultiParsers` like `parseAsNativeArrayOf` to read and write tho ```tsx import { useQueryState, parseAsNativeArrayOf, parseAsInteger } from 'nuqs' -export function ProjectsFilter() { - // ?project=123&project=456 ==> [123, 456] - const [projectIds, setProjectIds] = useQueryState( - 'project', - parseAsNativeArrayOf(parseAsInteger) - ) -} +const [projectIds, setProjectIds] = useQueryState( + 'project', + parseAsNativeArrayOf(parseAsInteger) +) + +// ?project=123&project=456 ==> [123, 456] ``` }> From fb185ce444b7d01bb5bf8db60a74db4e459bf972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 26 Sep 2025 13:53:51 +0200 Subject: [PATCH 34/41] doc: styles & wording for native arrays section & demo --- .../docs/content/docs/parsers/built-in.mdx | 16 +- packages/docs/content/docs/parsers/demos.tsx | 163 +++++++++++------- packages/docs/src/app/styles/tweaks.css | 7 + packages/docs/src/components/querystring.tsx | 9 +- 4 files changed, 122 insertions(+), 73 deletions(-) diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx index e9813b4c2..7c715450d 100644 --- a/packages/docs/content/docs/parsers/built-in.mdx +++ b/packages/docs/content/docs/parsers/built-in.mdx @@ -329,11 +329,11 @@ parseAsJson(userSchema.validateSync) If you want to use the native URL format for arrays, repeating the same key multiple times like: -``` -/products?tag=books&tag=tech&tag=design -``` +import { Querystring } from '@/src/components/querystring' -you can now use `MultiParsers` like `parseAsNativeArrayOf` to read and write those values in a fully type-safe way. + + +you can now use `MultiParsers{:ts}` like `parseAsNativeArrayOf{:ts}` to read and write those values in a fully type-safe way. ```tsx import { useQueryState, parseAsNativeArrayOf, parseAsInteger } from 'nuqs' @@ -343,15 +343,17 @@ const [projectIds, setProjectIds] = useQueryState( parseAsNativeArrayOf(parseAsInteger) ) -// ?project=123&project=456 ==> [123, 456] +// ?project=123&project=456 → [123, 456] ``` }> - - Note that `parseAsNativeArrayOf` has a built-in default value of empty array (`.withDefault([])`) so that you don't have to handle `null` cases. + + `parseAsNativeArrayOf{:ts}` has a built-in default value of empty array (`.withDefault([]){:ts}`) so that you don't have to handle `null{:ts}` cases. + + Calls to `.withDefault(){:ts}` can be chained, so you can use it to set a custom default. ## Using parsers server-side diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index 4a9e75921..5d4b13498 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -15,11 +15,17 @@ import { } from '@/src/components/ui/pagination' import { Slider } from '@/src/components/ui/slider' import { cn } from '@/src/lib/utils' -import { ChevronDown, ChevronUp, Minus, Star } from 'lucide-react' import { - ParserBuilder, - createParser, + ChevronDown, + ChevronUp, + Dices, + Minus, + Star, + Trash2 +} from 'lucide-react' +import { createMultiParser, + createParser, parseAsBoolean, parseAsFloat, parseAsHex, @@ -28,11 +34,10 @@ import { parseAsIsoDate, parseAsIsoDateTime, parseAsJson, - parseAsArrayOf, parseAsNativeArrayOf, - parseAsString, parseAsStringLiteral, parseAsTimestamp, + ParserBuilder, SingleParser, useQueryState } from 'nuqs' @@ -478,13 +483,18 @@ export function NativeArrayParserDemo() { return ( -
- Current value: [{value.join(', ') || ''}] -
+
) } export function CustomMultiParserDemo() { - const parseAsFromTo = createParser({ parse: value => { - const [min = null, max = null] = value.split('~').map(parseAsInteger.parse) + const [min = null, max = null] = value + .split('~') + .map(parseAsInteger.parse) if (min === null) return null - if (max === null) return { eq: min} + if (max === null) return { eq: min } return { gte: min, lte: max } }, serialize: value => { - return value.eq !== undefined ? String(value.eq) : `${value.gte}~${value.lte}` + return value.eq !== undefined + ? String(value.eq) + : `${value.gte}~${value.lte}` } }) @@ -519,17 +535,21 @@ export function CustomMultiParserDemo() { parse: value => { const [key, val] = value.split(':') if (!key || !val) return null - return { key, value: val} + return { key, value: val } }, serialize: value => { return `${value.key}:${value.value}` } }) - const parseAsFilters = (itemParser: SingleParser) => { + const parseAsFilters = ( + itemParser: SingleParser + ) => { return createMultiParser({ parse: values => { - const keyValue = values.map(parseAsKeyValue.parse).filter(v => v !== null) + const keyValue = values + .map(parseAsKeyValue.parse) + .filter(v => v !== null) const result = Object.fromEntries( keyValue.flatMap(({ key, value }) => { @@ -541,10 +561,15 @@ export function CustomMultiParserDemo() { return Object.keys(result).length === 0 ? null : result }, serialize: values => { - return Object.entries(values).map(([key, value]) => { - if (!itemParser.serialize) return null - return parseAsKeyValue.serialize({ key, value: itemParser.serialize(value) }) - }).filter(v => v !== null) + return Object.entries(values) + .map(([key, value]) => { + if (!itemParser.serialize) return null + return parseAsKeyValue.serialize({ + key, + value: itemParser.serialize(value) + }) + }) + .filter(v => v !== null) } }) } @@ -557,25 +582,24 @@ export function CustomMultiParserDemo() { return (
-
-
-