import * as React from 'react'; import * as qs from 'query-string'; import { renderHook, cleanup } from '@testing-library/react-hooks'; import { NumberParam, ArrayParam, StringParam, EncodedQuery, NumericArrayParam, DateParam, JsonParam, BooleanParam, withDefault, objectToSearchString, } from 'serialize-query-params'; import { describe, it, vi } from 'vitest'; import { useQueryParams, QueryParamProvider, QueryParamAdapter, QueryParamOptions, } from '../index'; import { calledPushQuery, makeMockAdapter } from './helpers'; // helper to setup tests function setupWrapper(query: EncodedQuery, options?: QueryParamOptions) { const Adapter = makeMockAdapter({ search: objectToSearchString(query) }); const adapter = (Adapter as any).adapter as QueryParamAdapter; const wrapper = ({ children }: any) => ( {children} ); return { wrapper, adapter }; } describe('useQueryParams', () => { afterEach(cleanup); it('default update type (pushIn)', () => { const { wrapper, adapter } = setupWrapper({ foo: '123', bar: 'xxx' }); const { result } = renderHook(() => useQueryParams({ foo: StringParam }), { wrapper, }); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ foo: '123' }); setter({ foo: 'zzz' }); expect(calledPushQuery(adapter, 0)).toEqual({ foo: 'zzz', bar: 'xxx' }); }); it('multiple params', () => { const { wrapper, adapter } = setupWrapper({ foo: '123', bar: 'xxx' }); const { result } = renderHook( () => useQueryParams({ foo: NumberParam, bar: StringParam, baz: ArrayParam }), { wrapper, } ); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ foo: 123, bar: 'xxx' }); setter({ foo: 555, baz: ['a', 'b'] }, 'push'); expect(calledPushQuery(adapter, 0)).toEqual({ foo: '555', baz: ['a', 'b'], }); }); it('passes through unconfigured parameter as a string', () => { const { wrapper, adapter } = setupWrapper({ foo: '123', bar: 'xxx' }); const { result } = renderHook( () => useQueryParams({ foo: NumberParam, bar: StringParam }), { wrapper, } ); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ foo: 123, bar: 'xxx' }); setter({ foo: 555, baz: ['a', 'b'] } as any, 'push'); expect(calledPushQuery(adapter, 0)).toEqual({ foo: '555', baz: 'a,b', // ['a,'b'] as string = "a,b" }); }); it('return persistent value if search not changed', () => { const { wrapper } = setupWrapper({ foo: '123', bar: 'xxx' }); const { result, rerender } = renderHook( () => useQueryParams({ foo: NumberParam, bar: StringParam }), { wrapper, } ); const [decodedQuery1] = result.current; rerender(); const [decodedQuery2] = result.current; expect(decodedQuery1).toBe(decodedQuery2); }); it('does not generate a new setter with each new query value', () => { const { wrapper } = setupWrapper({ foo: '123', bar: 'xxx' }); const { result, rerender } = renderHook( () => useQueryParams({ foo: NumberParam, bar: StringParam }), { wrapper, } ); const [, setter] = result.current; setter({ foo: 999 }, 'push'); rerender(); const [, setter2] = result.current; expect(setter).toBe(setter2); }); it('does not generate a new setter with each new parameter type', () => { const { wrapper } = setupWrapper({ foo: '123' }); const { result, rerender } = renderHook( () => useQueryParams({ foo: { ...NumberParam } }), { wrapper, } ); const [, setter] = result.current; rerender(); const [, setter2] = result.current; expect(setter).toBe(setter2); }); it("doesn't decode more than necessary", () => { const { wrapper } = setupWrapper({ foo: ['1', '2', '3'], bar: ['a', 'b'], }); const { result, rerender } = renderHook( () => useQueryParams({ foo: NumericArrayParam, bar: ArrayParam }), { wrapper, } ); const [decodedValue] = result.current; expect(decodedValue).toEqual({ foo: [1, 2, 3], bar: ['a', 'b'] }); rerender(); const [decodedValue2, setter2] = result.current; expect(decodedValue.foo).toBe(decodedValue2.foo); expect(decodedValue.bar).toBe(decodedValue2.bar); expect(decodedValue).toBe(decodedValue2); setter2({ foo: [4, 5, 6] }, 'replaceIn'); rerender(); const [decodedValue3, setter3] = result.current; expect(decodedValue).not.toBe(decodedValue3); expect(decodedValue3.foo).toEqual([4, 5, 6]); expect(decodedValue3.bar).toBe(decodedValue.bar); setter3({ foo: [4, 5, 6] }, 'pushIn'); rerender(); const [decodedValue4, setter4] = result.current; expect(decodedValue3.foo).toBe(decodedValue4.foo); expect(decodedValue3.bar).toBe(decodedValue4.bar); expect(decodedValue3).toBe(decodedValue4); // if another parameter changes, this one shouldn't be affected setter4({ bar: ['x', 'd'] }); rerender(); const [decodedValue5] = result.current; expect(decodedValue5.foo).toBe(decodedValue3.foo); expect(decodedValue5.bar).toEqual(['x', 'd']); }); it('allows the config to change over time', () => { const { wrapper, adapter } = setupWrapper( { foo: '123', bar: 'xxx' }, { searchStringToObject: qs.parse, objectToSearchString: qs.stringify } ); const { result, rerender } = renderHook( ({ config }) => useQueryParams(config), { wrapper, initialProps: { config: { foo: NumberParam, bar: StringParam, baz: ArrayParam, } as any, }, } ); let decodedQuery = result.current[0]; let setter = result.current[1]; expect(decodedQuery).toEqual({ foo: 123, bar: 'xxx' }); setter({ foo: 555, baz: ['a', 'b'] }, 'push'); expect(calledPushQuery(adapter, 0)).toEqual({ foo: '555', baz: ['a', 'b'], }); rerender({ config: { foo: StringParam, newt: NumberParam, bar: StringParam, baz: ArrayParam, }, }); decodedQuery = result.current[0]; setter = result.current[1]; expect(decodedQuery).toEqual({ foo: '555', baz: ['a', 'b'] }); setter({ foo: '99', baz: null, bar: 'regen', newt: 1000 }); expect(calledPushQuery(adapter, 1)).toEqual({ foo: '99', baz: null, bar: 'regen', newt: '1000', }); }); it('sets distinct params in the same render', () => { const { wrapper } = setupWrapper({ foo: '123', bar: 'xxx' }); const { result, rerender } = renderHook( () => useQueryParams({ foo: NumberParam, bar: StringParam }), { wrapper, } ); const [, setter] = result.current; setter({ foo: 999 }, 'replaceIn'); rerender(); const [decodedQuery] = result.current; expect(decodedQuery).toEqual({ foo: 999, bar: 'xxx' }); }); it('sets distinct params with different hooks in the same render', () => { const { wrapper } = setupWrapper({ foo: '123', bar: 'xxx' }); const { result, rerender } = renderHook( () => [ useQueryParams({ foo: NumberParam }), useQueryParams({ bar: StringParam }), ], { wrapper, } ); const [[, setFoo], [, setBar]] = result.current; setFoo({ foo: 999 }, 'replaceIn'); setBar({ bar: 'yyy' }, 'replaceIn'); rerender(); const [[{ foo }], [{ bar }]] = result.current as any; expect({ foo, bar }).toEqual({ foo: 999, bar: 'yyy' }); }); it('works with functional updates', () => { const { wrapper, adapter } = setupWrapper({ foo: '123', bar: ['a', 'b'], }); const { result, rerender } = renderHook( () => useQueryParams({ foo: NumberParam, bar: ArrayParam }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ foo: 123, bar: ['a', 'b'] }); setter( (latestQuery: any) => ({ foo: latestQuery.foo + 100, }), 'pushIn' ); expect(calledPushQuery(adapter, 0)).toEqual({ foo: '223', bar: ['a', 'b'], }); setter((latestQuery: any) => ({ foo: latestQuery.foo + 110 }), 'pushIn'); expect(calledPushQuery(adapter, 1)).toEqual({ foo: '333', bar: ['a', 'b'], }); // use a stale setter (adapter.location as any).search = '?foo=500'; rerender(); setter((latestQuery: any) => ({ foo: latestQuery.foo + 100 }), 'push'); expect(calledPushQuery(adapter, 2)).toEqual({ foo: '600' }); }); it('works with functional JsonParam updates', () => { const { wrapper, adapter } = setupWrapper({ foo: '{"a":1,"b":"abc"}', bar: 'xxx', }); const { result } = renderHook( () => useQueryParams({ foo: JsonParam, bar: StringParam }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ foo: { a: 1, b: 'abc' }, bar: 'xxx' }); setter( (latestQuery: any) => ({ foo: { ...latestQuery.foo, a: latestQuery.foo.a + 1 }, }), 'pushIn' ); expect(calledPushQuery(adapter, 0)).toEqual({ foo: '{"a":2,"b":"abc"}', bar: 'xxx', }); }); it('properly detects new values when equals is overridden', () => { const { wrapper } = setupWrapper({ foo: '2020-01-01', }); const { result, rerender } = renderHook( () => useQueryParams({ foo: DateParam }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue.foo).toEqual(new Date(2020, 0, 1)); setter({ foo: new Date(2020, 0, 2) }); rerender(); const [decodedValue2, setter2] = result.current; expect(decodedValue2.foo).toEqual(new Date(2020, 0, 2)); // expect(decodedValue).not.toBe(decodedValue3); setter2({ foo: new Date(2020, 0, 2) }); rerender(); const [decodedValue3] = result.current; expect(decodedValue3.foo).toBe(decodedValue2.foo); }); it('allows updating params that werent directly configured', () => { const { wrapper, adapter } = setupWrapper({ known: 'foo', unknown: 'bar', }); const { result } = renderHook( () => useQueryParams({ known: StringParam }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ known: 'foo' }); setter({ unknown: 'zzz', nothing: 'xx' } as any); expect(calledPushQuery(adapter, 0)).toEqual({ known: 'foo', unknown: 'zzz', nothing: 'xx', }); }); it('supports alternative urlName config', () => { const { wrapper, adapter } = setupWrapper({ q: 'my search' }); const { result, rerender } = renderHook( () => useQueryParams({ searchQuery: { ...StringParam, urlName: 'q' } }), { wrapper, } ); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ searchQuery: 'my search' }); setter({ searchQuery: 'yours' }); expect(calledPushQuery(adapter, 0)).toEqual({ q: 'yours', }); rerender(); const [decodedQuery2] = result.current; expect(decodedQuery2).toEqual({ searchQuery: 'yours' }); }); it('supports inherited urlName config', () => { const { wrapper, adapter } = setupWrapper( { q: 'my search' }, { params: { searchQuery: { ...StringParam, urlName: 'q' } } } ); const { result, rerender } = renderHook(() => useQueryParams(), { wrapper, }); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ searchQuery: 'my search' }); setter({ searchQuery: 'yours' }); expect(calledPushQuery(adapter, 0)).toEqual({ q: 'yours', }); rerender(); const [decodedQuery2] = result.current; expect(decodedQuery2).toEqual({ searchQuery: 'yours' }); }); describe('default values', () => { it('replaces undefined with default value withDefault', () => { const { wrapper } = setupWrapper({}); const { result, rerender } = renderHook( () => useQueryParams({ foo: withDefault(StringParam, 'boop') }), { wrapper, } ); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ foo: 'boop' }); setter({ foo: undefined }); rerender(); const [decodedQuery2, setter2] = result.current; expect(decodedQuery2).toEqual({ foo: 'boop' }); setter2({ foo: 'beep' }); rerender(); const [decodedQuery3] = result.current; expect(decodedQuery3).toEqual({ foo: 'beep' }); }); it('replaces undefined with default value', () => { const { wrapper } = setupWrapper({}); const { result, rerender } = renderHook( // note it is still recommended to use withDefault() which gives better type inference () => useQueryParams({ foo: { ...StringParam, default: 'boop' } }), { wrapper, } ); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ foo: 'boop' }); setter({ foo: null }); rerender(); const [decodedQuery2, setter2] = result.current; expect(decodedQuery2).toEqual({ foo: 'boop' }); setter2({ foo: 'beep' }); rerender(); const [decodedQuery3] = result.current; expect(decodedQuery3).toEqual({ foo: 'beep' }); }); it('supports a changing default value', () => { const { wrapper } = setupWrapper({}); const { result, rerender } = renderHook( ({ defaultValue }: { defaultValue: string }) => // note it is still recommended to use withDefault() which gives better type inference useQueryParams({ foo: { ...StringParam, default: defaultValue } }), { wrapper, initialProps: { defaultValue: 'boop', }, } ); const [decodedQuery] = result.current; expect(decodedQuery).toEqual({ foo: 'boop' }); rerender({ defaultValue: 'zing' }); const [decodedQuery2] = result.current; expect(decodedQuery2).toEqual({ foo: 'zing' }); }); }); describe('should call custom paramConfig.decode properly', () => { it('when custom paramConfig decode undefined as non-undefined value, should not call decode function when irrelevant update happens', () => { const { wrapper } = setupWrapper({ bar: '1' }); const customQueryParam = { encode: (str: string | undefined | null) => str, decode: (str: string | (string | null)[] | undefined | null) => { if (str === undefined) { return null; } return str; }, }; const decodeSpy = vi.spyOn(customQueryParam, 'decode'); const { result, rerender } = renderHook( () => useQueryParams({ foo: customQueryParam, bar: StringParam }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ foo: null, bar: '1' }); expect(decodeSpy).toHaveBeenCalledTimes(1); setter({ bar: '2' }); rerender(); expect(decodeSpy).toHaveBeenCalledTimes(1); setter({ bar: '3' }); rerender(); expect(decodeSpy).toHaveBeenCalledTimes(1); }); it('when custom paramConfig decode undefined as undefined, should call decode function when irrelevant update happens', () => { const { wrapper } = setupWrapper({ bar: '1' }); const customQueryParam = { encode: (str: string | undefined | null) => str, decode: (str: string | (string | null)[] | undefined | null) => str, }; const decodeSpy = vi.spyOn(customQueryParam, 'decode'); const { result, rerender } = renderHook( () => useQueryParams({ foo: customQueryParam, bar: StringParam }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ foo: undefined, bar: '1' }); expect(decodeSpy).toHaveBeenCalledTimes(1); setter({ bar: '2' }); rerender(); // twice per call since we useState inside the hook :( expect(decodeSpy).toHaveBeenCalledTimes(2); setter({ bar: '3' }); rerender(); expect(decodeSpy).toHaveBeenCalledTimes(3); }); }); describe('inherited params', () => { it('works with useQueryParams()', () => { const { wrapper } = setupWrapper( { x: '99', y: '123', z: '1' }, { params: { x: NumberParam, z: BooleanParam, }, } ); const { result, rerender } = renderHook(() => useQueryParams(), { wrapper, }); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ x: 99, z: true }); setter({ z: false }); rerender(); const [decodedValue2] = result.current; expect(decodedValue2).toEqual({ x: 99, z: false }); }); it('works with useQueryParams(["x", "z"])', () => { const paramConfig = { x: NumberParam, z: BooleanParam, a: StringParam, }; const { wrapper } = setupWrapper( { x: '99', y: '123', z: '1' }, { params: paramConfig, } ); const { result, rerender } = renderHook( () => useQueryParams>(['x', 'z']), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ x: 99, z: true }); setter({ z: false }); rerender(); const [decodedValue2] = result.current; expect(decodedValue2).toEqual({ x: 99, z: false }); }); it('does not auto include with useQueryParams({ y })', () => { const { wrapper } = setupWrapper( { x: '99', y: '123', z: '1' }, { params: { x: NumberParam, z: BooleanParam, }, } ); const { result, rerender } = renderHook( () => useQueryParams({ y: StringParam }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ y: '123' }); setter({ y: 'X' }); rerender(); const [decodedValue2] = result.current; expect(decodedValue2).toEqual({ y: 'X' }); }); it('works when explicitly included with useQueryParams({ y })', () => { const { wrapper } = setupWrapper( { x: '99', y: '123', z: '1' }, { params: { x: NumberParam, z: BooleanParam, }, } ); const { result, rerender } = renderHook( () => useQueryParams< { y: typeof StringParam; }, { x: typeof NumberParam; y: typeof StringParam; z: typeof BooleanParam; } >({ y: StringParam }, { includeKnownParams: true }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ x: 99, y: '123', z: true }); setter({ z: false, y: 'X' }); rerender(); const [decodedValue2] = result.current; expect(decodedValue2).toEqual({ x: 99, y: 'X', z: false }); }); it('works when explicitly included via string with useQueryParams({ y })', () => { const { wrapper } = setupWrapper( { x: '99', y: '123', z: '1' }, { params: { x: NumberParam, z: BooleanParam, }, } ); const { result, rerender } = renderHook( () => useQueryParams< { y: typeof StringParam; z: 'inherit'; }, { y: typeof StringParam; z: typeof BooleanParam; } >({ y: StringParam, z: 'inherit' }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ y: '123', z: true }); setter({ z: false, y: 'X' }); rerender(); const [decodedValue2] = result.current; expect(decodedValue2).toEqual({ y: 'X', z: false }); }); it('allows updating params that have been configured only in a provider', () => { const { wrapper, adapter } = setupWrapper( { known: 'foo', unknown: 'bar', }, { params: { inherited: BooleanParam, }, } ); const { result } = renderHook( () => useQueryParams({ known: StringParam }), { wrapper, } ); const [decodedValue, setter] = result.current; expect(decodedValue).toEqual({ known: 'foo' }); setter({ unknown: 'zzz', nothing: 'xx', inherited: true } as any); expect(calledPushQuery(adapter, 0)).toEqual({ known: 'foo', unknown: 'zzz', nothing: 'xx', inherited: '1', }); }); }); it('works with includeAllParams', () => { const { wrapper, adapter } = setupWrapper( { known: 'foo', unknown: '99', inherited2: '5', }, { params: { inherited: BooleanParam, inherited2: NumberParam, }, includeAllParams: true, } ); const { result, rerender } = renderHook( () => useQueryParams({ known: StringParam }), { wrapper, } ); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ known: 'foo', unknown: '99', inherited2: 5, }); setter({ unknown: 'zzz', inherited: true } as any); expect(calledPushQuery(adapter, 0)).toEqual({ known: 'foo', unknown: 'zzz', inherited: '1', inherited2: '5', }); rerender(); const [decodedQuery2, setter2] = result.current; expect(decodedQuery2).toEqual({ known: 'foo', unknown: 'zzz', inherited: true, inherited2: 5, }); setter2({ nothing: 'A', unknown: 'ooo' } as any); rerender(); const [decodedQuery3] = result.current; expect(decodedQuery3).toEqual({ known: 'foo', unknown: 'ooo', inherited: true, inherited2: 5, nothing: 'A', }); }); it('works with includeAllParams on useQueryParams()', () => { const queryParamConfig = { inherited: BooleanParam, inherited2: NumberParam, }; const { wrapper, adapter } = setupWrapper( { unknown: '99', inherited2: '5', }, { params: queryParamConfig, includeAllParams: true, } ); const { result, rerender } = renderHook( () => useQueryParams(), { wrapper, } ); const [decodedQuery, setter] = result.current; expect(decodedQuery).toEqual({ unknown: '99', inherited2: 5, }); setter({ unknown: 'zzz', inherited: true } as any); expect(calledPushQuery(adapter, 0)).toEqual({ unknown: 'zzz', inherited: '1', inherited2: '5', }); rerender(); const [decodedQuery2, setter2] = result.current; expect(decodedQuery2).toEqual({ unknown: 'zzz', inherited: true, inherited2: 5, }); setter2({ nothing: 'A', unknown: 'ooo' } as any); rerender(); const [decodedQuery3] = result.current; expect(decodedQuery3).toEqual({ unknown: 'ooo', inherited: true, inherited2: 5, nothing: 'A', }); }); });