diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx index bdca1be99..7c715450d 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,39 @@ 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: + +import { Querystring } from '@/src/components/querystring' + + + +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' + +const [projectIds, setProjectIds] = useQueryState( + 'project', + parseAsNativeArrayOf(parseAsInteger) +) + +// ?project=123&project=456 → [123, 456] +``` + +}> + + + + + `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 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..327d79033 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -15,9 +15,16 @@ 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, + ChevronDown, + ChevronUp, + Dices, + Minus, + Star, + Trash2 +} from 'lucide-react' +import { + createMultiParser, createParser, parseAsBoolean, parseAsFloat, @@ -27,8 +34,11 @@ import { parseAsIsoDate, parseAsIsoDateTime, parseAsJson, + parseAsNativeArrayOf, parseAsStringLiteral, parseAsTimestamp, + ParserBuilder, + SingleParser, useQueryState } from 'nuqs' import React from 'react' @@ -465,6 +475,244 @@ export function CustomParserDemo() { ) } +export function NativeArrayParserDemo() { + const [value, setValue] = useQueryState( + 'nativeArray', + parseAsNativeArrayOf(parseAsInteger) + ) + return ( + + + + + + + ) +} + +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.value === '' ? 0 : e.target.valueAsNumber } + })) + }} + autoComplete="off" + /> +
+
+ + { + setFilters(prev => ({ + ...prev, + price: { + lte: prev.price?.lte ?? 0, + gte: e.target.value === '' ? 0 : e.target.valueAsNumber + } + })) + }} + autoComplete="off" + /> +
+
+ + { + setFilters(prev => ({ + ...prev, + price: { + gte: prev.price?.gte ?? 0, + lte: e.target.value === '' ? 0 : 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..f2bf12f00 100644 --- a/packages/docs/content/docs/parsers/making-your-own.mdx +++ b/packages/docs/content/docs/parsers/making-your-own.mdx @@ -4,20 +4,22 @@ 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. -For this, `nuqs` exposes the `createParser` function to make your own parsers. +For this, `nuqs` exposes the `createParser{:ts}` function to make your own parsers. You pass it two functions: -1. `parse`: a function that takes a string and returns the parsed value, or `null{:ts}` if invalid. -2. `serialize`: a function that takes the parsed value and returns a string. +1. `parse{:ts}`: a function that takes a string and returns the parsed value, or `null{:ts}` if invalid. +2. `serialize{:ts}`: a function that takes the parsed value and returns a string. ```ts import { createParser } from 'nuqs' const parseAsStarRating = createParser({ + // [!code word:parse] parse(queryValue) { const inBetween = queryValue.split('★') const isValid = inBetween.length > 1 && inBetween.every(s => s === '') @@ -25,6 +27,7 @@ const parseAsStarRating = createParser({ const numStars = inBetween.length - 1 return Math.min(5, numStars) }, + // [!code word:serialize] serialize(value) { return Array.from({length: value}, () => '★').join('') } @@ -35,6 +38,119 @@ const parseAsStarRating = createParser({ +## Equality function + +For state types that can't be compared by the `==={:ts}` operator, you'll need to +provide an `eq{:ts}` function as well: + +```ts + +// Eg: TanStack Table sorting state +// /?sort=foo:asc → { id: 'foo', desc: false } +const parseAsSort = createParser({ + parse(query) { + const [key = '', direction = ''] = query.split(':') + const desc = parseAsStringLiteral(['asc', 'desc']).parse(direction) ?? 'asc' + return { + id: key, + desc: desc === 'desc' + } + }, + serialize(value) { + return `${value.id}:${value.desc ? 'desc' : 'asc'}` + }, + // [!code highlight:3] + eq(a, b) { + return a.id === b.id && a.desc === b.desc + } +}) +``` + +This is used for the [`clearOnDefault{:ts}`](/docs/options#clear-on-default) option, +to check if the current value is equal to the default value. + +## Custom Multi Parsers + +The parsers we've seen until now are `SingleParsers{:ts}`: they operate on **the first occurence** of the +key in the URL, and give you a string value to parse when it's available. + +`MultiParsers{:ts}` work similar to `SingleParsers{:ts}`, except that they operate on arrays, to support **key repetition**: + +import { Querystring } from '@/src/components/querystring' + + + +This means: + +1. `parse{:ts}` takes an `Array{:ts}`. It receives all matching values of the key it operates on, and returns the parsed value, or `null{:ts}` if invalid. +2. `serialize{:ts}` takes the parsed value and returns an `Array{:ts}`, where each item will be separately added to the URL. + +You can then compose & reduce this array to form **complex data types**: + + + + + +```tsx +/** + * 100~200 <=> { gte: 100, lte: 200 } + * 150 <=> { eq: 150 } + */ +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}` + } +}) + +/** + * foo:bar <=> { key: 'foo', value: 'bar' } + */ +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 diff --git a/packages/docs/src/app/styles/tweaks.css b/packages/docs/src/app/styles/tweaks.css index 2d43dc2b6..b36835c73 100644 --- a/packages/docs/src/app/styles/tweaks.css +++ b/packages/docs/src/app/styles/tweaks.css @@ -35,6 +35,13 @@ /* Fix x-spacing in inline code spans */ .shiki:not(.not-fumadocs-codeblock *):has(> code .line) { --spacing: 0.5px; + & code { + /* + Reduce vertical padding to allow two inline code blocks + on adjacent lines to avoid overlapping. + */ + padding-block: 1.5px; + } } /* Avoid a flash of black (in dark mode) in the navbar background when scrolling */ diff --git a/packages/docs/src/components/querystring.tsx b/packages/docs/src/components/querystring.tsx index 5802d1d55..e1d65b7b0 100644 --- a/packages/docs/src/components/querystring.tsx +++ b/packages/docs/src/components/querystring.tsx @@ -2,17 +2,24 @@ import { cn } from '@/src/lib/utils' import { Fragment, useMemo } from 'react' export type QuerystringProps = React.ComponentProps<'pre'> & { + path?: string value: string | URLSearchParams keepKeys?: string[] } -export function Querystring({ value, keepKeys, ...props }: QuerystringProps) { +export function Querystring({ + path, + value, + keepKeys, + ...props +}: QuerystringProps) { const search = useMemo( () => filterQueryKeys(value, keepKeys), [value, keepKeys] ) return ( + {path && {path}} {Array.from(search.entries()).map(([key, value], i) => ( @@ -63,7 +70,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 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..39d22bb37 --- /dev/null +++ b/packages/e2e/shared/specs/native-array.tsx @@ -0,0 +1,22 @@ +'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 +}) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 278d96e7b..5f8d0f931 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.5 kB" } ] } 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', 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/compare.test.ts b/packages/nuqs/src/lib/compare.test.ts new file mode 100644 index 000000000..e854bac63 --- /dev/null +++ b/packages/nuqs/src/lib/compare.test.ts @@ -0,0 +1,28 @@ +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('arrays', () => { + 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) + }) + }) +}) diff --git a/packages/nuqs/src/lib/compare.ts b/packages/nuqs/src/lib/compare.ts new file mode 100644 index 000000000..01c39b8da --- /dev/null +++ b/packages/nuqs/src/lib/compare.ts @@ -0,0 +1,23 @@ +import type { Query } from './search-params' + +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 arrays, not a mix of both + if (typeof a === 'string' || typeof b === 'string') { + return false + } + + 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 274b13fe7..dfc80bc95 100644 --- a/packages/nuqs/src/lib/queues/debounce.ts +++ b/packages/nuqs/src/lib/queues/debounce.ts @@ -1,5 +1,6 @@ import { debug } from '../debug' import { createEmitter, type Emitter } from '../emitter' +import type { Query } from '../search-params' import { timeout } from '../timeout' import { withResolvers, type Resolvers } from '../with-resolvers' import { @@ -73,7 +74,7 @@ export class DebounceController { this.throttleQueue = throttleQueue } - useQueuedQueries(keys: string[]): Record { + useQueuedQueries(keys: string[]): Record { return useSyncExternalStores( keys, (key, callback) => this.queuedQuerySync.on(key, callback), @@ -153,7 +154,7 @@ export class DebounceController { this.queues.clear() } - getQueuedQuery(key: string): string | null | undefined { + getQueuedQuery(key: string): Query | 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..905e0e47f 100644 --- a/packages/nuqs/src/lib/queues/throttle.ts +++ b/packages/nuqs/src/lib/queues/throttle.ts @@ -3,11 +3,12 @@ import type { Options } from '../../defs' import { compose } from '../compose' import { debug } from '../debug' import { error } from '../errors' +import { write, type Query } from '../search-params' import { timeout } from '../timeout' import { withResolvers, type Resolvers } from '../with-resolvers' import { defaultRateLimit } from './rate-limiting' -type UpdateMap = Map +type UpdateMap = Map 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: Query | null options: AdapterOptions & Pick } @@ -65,7 +66,7 @@ export class ThrottledQueue { } } - getQueuedQuery(key: string): string | null | undefined { + getQueuedQuery(key: string): Query | 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..330413bbb --- /dev/null +++ b/packages/nuqs/src/lib/search-params.ts @@ -0,0 +1,27 @@ +export type Query = string | Array + +export function isAbsentFromUrl(query: Query | null): query is null | [] { + return query === null || (Array.isArray(query) && query.length === 0) +} + +export function write( + serialized: Query, + 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) + } + // 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, '') + } + } + return searchParams +} diff --git a/packages/nuqs/src/lib/sync.ts b/packages/nuqs/src/lib/sync.ts index c9d751049..40d8fa891 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 { Query } from './search-params' export type CrossHookSyncPayload = { state: any - query: string | null + query: Query | null } type EventMap = { 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/loader.ts b/packages/nuqs/src/loader.ts index 68b569dd0..5b613d17c 100644 --- a/packages/nuqs/src/loader.ts +++ b/packages/nuqs/src/loader.ts @@ -1,4 +1,5 @@ import type { UrlKeys } from './defs' +import { isAbsentFromUrl } from './lib/search-params' import type { inferParserType, ParserMap } from './parsers' export type LoaderInput = @@ -96,14 +97,18 @@ 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) { + const query = + parser.type === 'multi' + ? searchParams.getAll(urlKey) + : searchParams.get(urlKey) + if (isAbsentFromUrl(query)) { result[key] = parser.defaultValue ?? null continue } let parsedValue try { - parsedValue = parser.parse(query) + // we have properly narrowed `query` here, but TS doesn't keep track of that + parsedValue = parser.parse(query as string & Array) } catch (error) { if (strict) { throw new Error( diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts index ad186b36a..9ee8ab14e 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,28 @@ 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(null) + expect(parser.parse(['1', '2'])).toStrictEqual([1, 2]) + }) + it('defaults to null', () => { + const parser = parseAsNativeArrayOf(parseAsInteger) + expect(parser.parse(['not', 'a', 'number'])).toStrictEqual(null) + }) + it('is bijective', () => { + const parser = parseAsNativeArrayOf(parseAsString) + expect(isParserBijective(parser, ['a', 'b'], ['a', 'b'])).toBe(true) + expect(() => isParserBijective(parser, ['1', '2'], ['a', 'b'])).toThrow() + }) + }) + it('parseServerSide with default (#384)', () => { const p = parseAsString.withDefault('default') const searchParams = { diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index c85580615..72f15e122 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,25 @@ 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) => Array + eq?: (a: T, b: T) => boolean +} + +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 SingleParserBuilder = Required> & Options & { /** * Set history type, shallow routing and scroll restoration options @@ -58,9 +77,9 @@ export type ParserBuilder = Required> & * @param defaultValue */ withDefault( - this: ParserBuilder, + this: SingleParserBuilder, defaultValue: NonNullable - ): Omit, 'parseServerSide'> & { + ): Omit, 'parseServerSide'> & { readonly defaultValue: NonNullable /** @@ -103,13 +122,34 @@ 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 + /** + * @deprecated exposed for symmetry with SingleParserBuilder only, + * prefer using loaders instead. + */ + parseServerSide(value: string | string[] | undefined): NonNullable + } + /** + * @deprecated exposed for symmetry with SingleParserBuilder only, + * prefer using loaders instead. + */ + 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 +170,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 +227,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 +240,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 +248,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 +259,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 +267,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 +280,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 +293,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 +311,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 +350,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 +376,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 +407,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 +429,7 @@ export function parseAsNumberLiteral( */ export function parseAsJson( validator: ((value: unknown) => T | null) | StandardSchemaV1 -): ParserBuilder { +): SingleParserBuilder { return createParser({ parse: query => { try { @@ -390,9 +464,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,17 +509,50 @@ export function parseAsArrayOf( }) } -type inferSingleParserType = Parser extends ParserBuilder< +export function parseAsNativeArrayOf( + itemParser: SingleParser +): ReturnType['withDefault']> { + const itemEq = itemParser.eq ?? ((a: ItemType, b: ItemType) => a === b) + return createMultiParser({ + parse: 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 => { + // 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 + } + if (a.length !== b.length) { + return false + } + return a.every((value, index) => itemEq(value, b[index]!)) + } + }).withDefault([]) +} + +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 } & {} @@ -474,13 +581,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/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/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..513a80739 100644 --- a/packages/nuqs/src/testing.ts +++ b/packages/nuqs/src/testing.ts @@ -1,4 +1,20 @@ -import type { ParserBuilder } from './parsers' +import { compareQuery } from './lib/compare' +import type { + GenericParserBuilder, + MultiParserBuilder, + SingleParserBuilder +} from './parsers' + +export function isParserBijective( + parser: SingleParserBuilder, + serialized: string, + input: T +): boolean +export function isParserBijective( + parser: MultiParserBuilder, + serialized: Array, + input: T +): boolean /** * Test that a parser is bijective (serialize then parse gives back the same value). @@ -21,15 +37,26 @@ import type { ParserBuilder } from './parsers' * @returns `true` if the test passes, otherwise it will throw. */ export function isParserBijective( - parser: ParserBuilder, - serialized: string, + parser: GenericParserBuilder, + serialized: string | Array, input: T ): boolean { - // Test either sides of the bijectivitiy - testSerializeThenParse(parser, input) - testParseThenSerialize(parser, serialized) + if (parser.type === 'multi' && Array.isArray(serialized)) { + // Test either sides of the bijectivitiy + testSerializeThenParse(parser, input) + testParseThenSerialize(parser, serialized) + } else if (parser.type !== 'multi' && typeof serialized === 'string') { + // Test either sides of the bijectivitiy + testSerializeThenParse(parser, input) + testParseThenSerialize(parser, serialized) + } else { + // Shouldn't happen with correct overload types, but better be safe and fail the test. + throw new Error( + `[nuqs] isParserBijective: mismatched parser type and serialized value type` + ) + } // Test value equality - if (parser.serialize(input) !== serialized) { + if (!compareQuery(parser.serialize(input), serialized)) { throw new Error( `[nuqs] parser.serialize does not match expected serialized value Expected: '${serialized}' @@ -50,6 +77,15 @@ export function isParserBijective( return true } +export function testSerializeThenParse( + parser: SingleParserBuilder, + input: T +): boolean +export function testSerializeThenParse( + parser: MultiParserBuilder, + input: T +): boolean + /** * Test that a parser is bijective (serialize then parse gives back the same value). * @@ -69,11 +105,16 @@ export function isParserBijective( * @returns `true` if the test passes, otherwise it will throw. */ export function testSerializeThenParse( - parser: ParserBuilder, + parser: GenericParserBuilder, input: T ): boolean { const serialized = parser.serialize(input) - const parsed = parser.parse(serialized) + const parsed = + parser.type == 'multi' && Array.isArray(serialized) + ? parser.parse(serialized) + : parser.type !== 'multi' && typeof serialized === 'string' + ? parser.parse(serialized) + : null if (parsed === null) { throw new Error( `[nuqs] testSerializeThenParse: parsed value is null (when parsing ${serialized} serialized from ${input})` @@ -91,6 +132,15 @@ export function testSerializeThenParse( return true } +export function testParseThenSerialize( + parser: SingleParserBuilder, + input: string +): boolean +export function testParseThenSerialize( + parser: MultiParserBuilder, + input: Array +): boolean + /** * Tests that a parser is bijective (parse then serialize gives back the same query string). * @@ -109,17 +159,22 @@ export function testSerializeThenParse( * @returns `true` if the test passes, otherwise it will throw. */ export function testParseThenSerialize( - parser: ParserBuilder, - input: string + parser: GenericParserBuilder, + input: string | Array ): boolean { - const parsed = parser.parse(input) + const parsed = + parser.type === 'multi' && Array.isArray(input) + ? parser.parse(input) + : parser.type !== 'multi' && typeof input === 'string' + ? parser.parse(input) + : null if (parsed === null) { throw new Error( `[nuqs] testParseThenSerialize: parsed value is null (when parsing ${input})` ) } const serialized = parser.serialize(parsed) - if (serialized !== input) { + if (!compareQuery(serialized, input)) { throw new Error( `[nuqs] parser is not bijective (in testParseThenSerialize) Expected query: '${input}' diff --git a/packages/nuqs/src/useQueryState.test.tsx b/packages/nuqs/src/useQueryState.test.tsx index f156eb895..59e77e110 100644 --- a/packages/nuqs/src/useQueryState.test.tsx +++ b/packages/nuqs/src/useQueryState.test.tsx @@ -11,7 +11,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', () => { @@ -444,3 +450,47 @@ describe('useQueryState: edge cases & repros', () => { ).resolves.toHaveTextContent('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=') + }) +}) diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index d5bc6879d..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 interface UseQueryStateOptions extends 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 @@ -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 GenericParser }, hookOptions ) diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx index 76f879b27..6168a3ee3 100644 --- a/packages/nuqs/src/useQueryStates.test.tsx +++ b/packages/nuqs/src/useQueryStates.test.tsx @@ -16,6 +16,7 @@ import { parseAsArrayOf, parseAsInteger, parseAsJson, + parseAsNativeArrayOf, parseAsString } from './parsers' import { useQueryState } from './useQueryState' @@ -112,6 +113,11 @@ describe('useQueryStates: referential equality', () => { { initial: 'state' } + ], + multi: [ + { + initial: 'state' + } ] } @@ -121,7 +127,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 + ) }) } @@ -134,6 +143,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 () => { @@ -142,7 +152,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}' } }) }) @@ -152,6 +163,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 () => { @@ -183,6 +196,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]) }) }) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 2c44bf687..8d51d0a54 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -5,6 +5,7 @@ import { useAdapterProcessUrlSearchParams } from './adapters/lib/context' import type { Nullable, Options, UrlKeys } from './defs' +import { compareQuery } from './lib/compare' import { debug } from './lib/debug' import { error } from './lib/errors' import { debounceController } from './lib/queues/debounce' @@ -14,10 +15,11 @@ import { type UpdateQueuePushArgs } from './lib/queues/throttle' import { safeParse } from './lib/safe-parse' +import { isAbsentFromUrl, type Query } from './lib/search-params' import { emitter, type CrossHookSyncPayload } from './lib/sync' -import type { Parser } from './parsers' +import { type GenericParser } from './parsers' -type KeyMapValue = Parser & +type KeyMapValue = GenericParser & Options & { defaultValue?: Type } @@ -97,7 +99,7 @@ export function useQueryStates( ) const adapter = useAdapter(Object.values(resolvedUrlKeys)) const initialSearchParams = adapter.searchParams - const queryRef = useRef>({}) + const queryRef = useRef>({}) const defaultValues = useMemo( () => Object.fromEntries( @@ -373,30 +375,40 @@ function parseMap( keyMap: KeyMap, urlKeys: Partial>, searchParams: URLSearchParams, - queuedQueries: Record, - cachedQuery?: Record, + queuedQueries: Record, + cachedQuery?: Record, cachedState?: NullableValues ): { state: NullableValues 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 { parse } = keyMap[stateKey]! const queuedQuery = queuedQueries[urlKey] + const fallbackValue = parser.type === 'multi' ? [] : null const query = queuedQuery === undefined - ? (searchParams?.get(urlKey) ?? null) + ? ((parser.type === 'multi' + ? searchParams?.getAll(urlKey) + : searchParams?.get(urlKey)) ?? fallbackValue) : queuedQuery - if (cachedQuery && cachedState && (cachedQuery[urlKey] ?? null) === query) { + if ( + cachedQuery && + cachedState && + compareQuery(cachedQuery[urlKey] ?? fallbackValue, query) + ) { // Cache hit out[stateKey as keyof KeyMap] = cachedState[stateKey] ?? null return out } // Cache miss hasChanged = true - const value = query === null ? null : safeParse(parse, query, stateKey) + 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) + out[stateKey as keyof KeyMap] = value ?? null if (cachedQuery) { cachedQuery[urlKey] = query