Skip to content

Commit 894e141

Browse files
authored
fix: Allow useQueryStates' state updater function to return null (#871)
1 parent ba5ce36 commit 894e141

File tree

3 files changed

+93
-9
lines changed

3 files changed

+93
-9
lines changed

packages/nuqs/src/tests/useQueryStates.test-d.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('types/useQueryStates', () => {
2525
setState({ a: null }) // Clear an individual key
2626
setState(null) // Clear all managed keys
2727
setState(() => ({ a: null }))
28-
// setState(() => null) // todo: Enable this test in a separate PR
28+
setState(() => null)
2929
})
3030
it('allows setting to undefined to leave keys as-is', () => {
3131
const [, setState] = useQueryStates(parsers)
@@ -53,7 +53,7 @@ describe('types/useQueryStates', () => {
5353
return {}
5454
})
5555
setState(() => ({ a: null, b: null })) // Still allowed to clear it with null (state retuns to default)
56-
// setState(() => null) // todo: Enable this test in a separate PR
56+
setState(() => null)
5757
})
5858
it('supports inline custom parsers', () => {
5959
const [state] = useQueryStates({

packages/nuqs/src/useQueryStates.test.ts

+83
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,89 @@ import {
77
import { parseAsArrayOf, parseAsJson, parseAsString } from './parsers'
88
import { useQueryStates } from './useQueryStates'
99

10+
describe('useQueryStates', () => {
11+
it('allows setting a single value', async () => {
12+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
13+
const useTestHook = () =>
14+
useQueryStates({
15+
a: parseAsString,
16+
b: parseAsString
17+
})
18+
const { result } = renderHook(useTestHook, {
19+
wrapper: withNuqsTestingAdapter({
20+
onUrlUpdate
21+
})
22+
})
23+
expect(result.current[0].a).toBeNull()
24+
expect(result.current[0].b).toBeNull()
25+
await act(() => result.current[1]({ a: 'pass' }))
26+
expect(result.current[0].a).toEqual('pass')
27+
expect(result.current[0].b).toBeNull()
28+
expect(onUrlUpdate).toHaveBeenCalledOnce()
29+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=pass')
30+
})
31+
32+
it('allows clearing a single key by setting it to null', async () => {
33+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
34+
const useTestHook = () =>
35+
useQueryStates({
36+
a: parseAsString,
37+
b: parseAsString
38+
})
39+
const { result } = renderHook(useTestHook, {
40+
wrapper: withNuqsTestingAdapter({
41+
searchParams: '?a=init&b=init',
42+
onUrlUpdate
43+
})
44+
})
45+
expect(result.current[0].a).toEqual('init')
46+
expect(result.current[0].b).toEqual('init')
47+
await act(() => result.current[1]({ a: null }))
48+
expect(result.current[0].a).toBeNull()
49+
expect(result.current[0].b).toEqual('init')
50+
expect(onUrlUpdate).toHaveBeenCalledOnce()
51+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?b=init')
52+
})
53+
it('allows clearing managed keys by passing null', async () => {
54+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
55+
const useTestHook = () =>
56+
useQueryStates({
57+
a: parseAsString,
58+
b: parseAsString
59+
})
60+
const { result } = renderHook(useTestHook, {
61+
wrapper: withNuqsTestingAdapter({
62+
searchParams: '?a=init&b=init',
63+
onUrlUpdate
64+
})
65+
})
66+
await act(() => result.current[1](null))
67+
expect(result.current[0].a).toBeNull()
68+
expect(result.current[0].b).toBeNull()
69+
expect(onUrlUpdate).toHaveBeenCalledOnce()
70+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('')
71+
})
72+
it('allows clearing managed keys by passing a function that returns null', async () => {
73+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
74+
const useTestHook = () =>
75+
useQueryStates({
76+
a: parseAsString,
77+
b: parseAsString
78+
})
79+
const { result } = renderHook(useTestHook, {
80+
wrapper: withNuqsTestingAdapter({
81+
searchParams: '?a=init&b=init',
82+
onUrlUpdate
83+
})
84+
})
85+
await act(() => result.current[1](() => null))
86+
expect(result.current[0].a).toBeNull()
87+
expect(result.current[0].b).toBeNull()
88+
expect(onUrlUpdate).toHaveBeenCalledOnce()
89+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('')
90+
})
91+
})
92+
1093
describe('useQueryStates: referential equality', () => {
1194
const defaults = {
1295
str: 'foo',

packages/nuqs/src/useQueryStates.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ type NullableValues<T extends UseQueryStatesKeysMap> = Nullable<Values<T>>
3737

3838
type UpdaterFn<T extends UseQueryStatesKeysMap> = (
3939
old: Values<T>
40-
) => Partial<Nullable<Values<T>>>
40+
) => Partial<Nullable<Values<T>>> | null
4141

4242
export type SetValues<T extends UseQueryStatesKeysMap> = (
4343
values: Partial<Nullable<Values<T>>> | UpdaterFn<T> | null,
@@ -191,14 +191,15 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
191191

192192
const update = useCallback<SetValues<KeyMap>>(
193193
(stateUpdater, callOptions = {}) => {
194+
const nullMap = Object.fromEntries(
195+
Object.keys(keyMap).map(key => [key, null])
196+
) as Nullable<KeyMap>
194197
const newState: Partial<Nullable<KeyMap>> =
195198
typeof stateUpdater === 'function'
196-
? stateUpdater(applyDefaultValues(stateRef.current, defaultValues))
197-
: stateUpdater === null
198-
? (Object.fromEntries(
199-
Object.keys(keyMap).map(key => [key, null])
200-
) as Nullable<KeyMap>)
201-
: stateUpdater
199+
? (stateUpdater(
200+
applyDefaultValues(stateRef.current, defaultValues)
201+
) ?? nullMap)
202+
: (stateUpdater ?? nullMap)
202203
debug('[nuq+ `%s`] setState: %O', stateKeys, newState)
203204
for (let [stateKey, value] of Object.entries(newState)) {
204205
const parser = keyMap[stateKey]

0 commit comments

Comments
 (0)