From acdfc7081ba562a92cf3d008eb3c781198be0b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Wed, 22 Jan 2025 23:55:49 +0100 Subject: [PATCH 1/2] fix: Allow useQueryStates' state updater function to return null This would allow conditionally clearing the URL based on the previous values. Can't think of an immediate use-case, but when updating the type testing suite in #867, it felt like a blind spot in the API. --- packages/nuqs/src/tests/useQueryStates.test-d.ts | 4 ++-- packages/nuqs/src/useQueryStates.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/nuqs/src/tests/useQueryStates.test-d.ts b/packages/nuqs/src/tests/useQueryStates.test-d.ts index 46d351ba8..c6baa3209 100644 --- a/packages/nuqs/src/tests/useQueryStates.test-d.ts +++ b/packages/nuqs/src/tests/useQueryStates.test-d.ts @@ -25,7 +25,7 @@ describe('types/useQueryStates', () => { setState({ a: null }) // Clear an individual key setState(null) // Clear all managed keys setState(() => ({ a: null })) - // setState(() => null) // todo: Enable this test in a separate PR + setState(() => null) }) it('allows setting to undefined to leave keys as-is', () => { const [, setState] = useQueryStates(parsers) @@ -53,7 +53,7 @@ describe('types/useQueryStates', () => { return {} }) setState(() => ({ a: null, b: null })) // Still allowed to clear it with null (state retuns to default) - // setState(() => null) // todo: Enable this test in a separate PR + setState(() => null) }) it('supports inline custom parsers', () => { const [state] = useQueryStates({ diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 330958aed..45e4cfa47 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -37,7 +37,7 @@ type NullableValues = Nullable> type UpdaterFn = ( old: Values -) => Partial>> +) => Partial>> | null export type SetValues = ( values: Partial>> | UpdaterFn | null, @@ -191,14 +191,15 @@ export function useQueryStates( const update = useCallback>( (stateUpdater, callOptions = {}) => { + const nullMap = Object.fromEntries( + Object.keys(keyMap).map(key => [key, null]) + ) as Nullable const newState: Partial> = typeof stateUpdater === 'function' - ? stateUpdater(applyDefaultValues(stateRef.current, defaultValues)) - : stateUpdater === null - ? (Object.fromEntries( - Object.keys(keyMap).map(key => [key, null]) - ) as Nullable) - : stateUpdater + ? (stateUpdater( + applyDefaultValues(stateRef.current, defaultValues) + ) ?? nullMap) + : (stateUpdater ?? nullMap) debug('[nuq+ `%s`] setState: %O', stateKeys, newState) for (let [stateKey, value] of Object.entries(newState)) { const parser = keyMap[stateKey] From 80b044f508cbd566e2c6a4715bd2c488e5857d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Thu, 23 Jan 2025 12:09:26 +0100 Subject: [PATCH 2/2] test: Add unit test --- packages/nuqs/src/useQueryStates.test.ts | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/nuqs/src/useQueryStates.test.ts b/packages/nuqs/src/useQueryStates.test.ts index a1b0958ef..18ee3847f 100644 --- a/packages/nuqs/src/useQueryStates.test.ts +++ b/packages/nuqs/src/useQueryStates.test.ts @@ -7,6 +7,89 @@ import { import { parseAsArrayOf, parseAsJson, parseAsString } from './parsers' import { useQueryStates } from './useQueryStates' +describe('useQueryStates', () => { + it('allows setting a single value', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates({ + a: parseAsString, + b: parseAsString + }) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + onUrlUpdate + }) + }) + expect(result.current[0].a).toBeNull() + expect(result.current[0].b).toBeNull() + await act(() => result.current[1]({ a: 'pass' })) + expect(result.current[0].a).toEqual('pass') + expect(result.current[0].b).toBeNull() + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=pass') + }) + + it('allows clearing a single key by setting it to null', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates({ + a: parseAsString, + b: parseAsString + }) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init&b=init', + onUrlUpdate + }) + }) + expect(result.current[0].a).toEqual('init') + expect(result.current[0].b).toEqual('init') + await act(() => result.current[1]({ a: null })) + expect(result.current[0].a).toBeNull() + expect(result.current[0].b).toEqual('init') + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?b=init') + }) + it('allows clearing managed keys by passing null', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates({ + a: parseAsString, + b: parseAsString + }) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init&b=init', + onUrlUpdate + }) + }) + await act(() => result.current[1](null)) + expect(result.current[0].a).toBeNull() + expect(result.current[0].b).toBeNull() + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('') + }) + it('allows clearing managed keys by passing a function that returns null', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates({ + a: parseAsString, + b: parseAsString + }) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init&b=init', + onUrlUpdate + }) + }) + await act(() => result.current[1](() => null)) + expect(result.current[0].a).toBeNull() + expect(result.current[0].b).toBeNull() + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('') + }) +}) + describe('useQueryStates: referential equality', () => { const defaults = { str: 'foo',