diff --git a/__tests__/atomWithQueryParam_spec.tsx b/__tests__/atomWithQueryParam_spec.tsx new file mode 100644 index 0000000..9ecbdbf --- /dev/null +++ b/__tests__/atomWithQueryParam_spec.tsx @@ -0,0 +1,121 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider, useAtom } from 'jotai'; +import { RESET } from 'jotai/utils'; + +import { atomWithQueryParam } from '../src/atomWithQueryParam'; + +let pushStateSpy: jest.SpyInstance; +let replaceStateSpy: jest.SpyInstance; + +beforeEach(() => { + pushStateSpy = jest.spyOn(window.history, 'pushState'); + replaceStateSpy = jest.spyOn(window.history, 'replaceState'); +}); + +afterEach(() => { + pushStateSpy.mockRestore(); + replaceStateSpy.mockRestore(); +}); + +describe('atomWithQueryParam', () => { + it('should return a default value for the atom if the query parameter is not present', () => { + const queryParamAtom = atomWithQueryParam('test', 'default'); + const { result } = renderHook(() => useAtom(queryParamAtom), { + // the provider scopes the atoms to a store so their values dont persist between tests + wrapper: Provider, + }); + expect(result.current[0]).toEqual('default'); + }); + + it('should sync an atom to a query parameter', () => { + const queryParamAtom = atomWithQueryParam('test', { + value: 'default', + }); + const { result } = renderHook(() => useAtom(queryParamAtom), { + // the provider scopes the atoms to a store so their values dont persist between tests + wrapper: Provider, + }); + + act(() => { + result.current[1]({ value: 'test value' }); + }); + + expect(result.current[0]).toEqual({ value: 'test value' }); + expect( + (window.history.pushState as jest.Mock).mock.calls[0][2].toString(), + ).toEqual( + expect.stringContaining('?test=%7B%22value%22%3A%22test+value%22%7D'), + ); + }); + + it('should read an atom from a query parameter', () => { + const queryParamAtom = atomWithQueryParam('test', { + value: 'default', + }); + act(() => { + window.history.pushState( + null, + '', + '?test=%7B%22value%22%3A%22test+value%22%7D', + ); + }); + const { result } = renderHook(() => useAtom(queryParamAtom), { + // the provider scopes the atoms to a store so their values dont persist between tests + wrapper: Provider, + }); + expect(result.current[0]).toEqual({ value: 'test value' }); + }); + + it('should allow passing custom serialization and deserialization functions', () => { + const queryParamAtom = atomWithQueryParam('test', 'default', { + serialize: (val) => val.toUpperCase(), + deserialize: (str) => str.toLowerCase(), + }); + const { result } = renderHook(() => useAtom(queryParamAtom), { + // the provider scopes the atoms to a store so their values dont persist between tests + wrapper: Provider, + }); + + act(() => { + result.current[1]('new value'); + }); + + expect(result.current[0]).toEqual('new value'); + expect( + (window.history.pushState as jest.Mock).mock.calls[0][2].toString(), + ).toEqual(expect.stringContaining('?test=NEW+VALUE')); + }); + + it('should allow resetting the query parameter', () => { + const queryParamAtom = atomWithQueryParam('test', 'default'); + const { result } = renderHook(() => useAtom(queryParamAtom), { + // the provider scopes the atoms to a store so their values dont persist between tests + wrapper: Provider, + }); + act(() => { + result.current[1]('new value'); + }); + expect(result.current[0]).toEqual('new value'); + act(() => { + result.current[1](RESET); + }); + expect(result.current[0]).toEqual('default'); + }); + + it('should allow replacing the search params instead of pushing', () => { + const queryParamAtom = atomWithQueryParam('test', 'default', { + replace: true, + }); + const { result } = renderHook(() => useAtom(queryParamAtom), { + // the provider scopes the atoms to a store so their values dont persist between tests + wrapper: Provider, + }); + act(() => { + result.current[1]('new value'); + }); + expect( + // replaceState instead of pushState + (window.history.replaceState as jest.Mock).mock.calls[0][2].toString(), + ).toEqual(expect.stringContaining('?test=%22new+value%22')); + }); +}); diff --git a/src/atomWithHash.ts b/src/atomWithHash.ts index 76b5d58..822f123 100644 --- a/src/atomWithHash.ts +++ b/src/atomWithHash.ts @@ -2,18 +2,7 @@ import { atom } from 'jotai/vanilla'; import type { WritableAtom } from 'jotai/vanilla'; import { RESET } from 'jotai/vanilla/utils'; -type SetStateActionWithReset = - | Value - | typeof RESET - | ((prev: Value) => Value | typeof RESET); - -const safeJSONParse = (initialValue: unknown) => (str: string) => { - try { - return JSON.parse(str); - } catch (e) { - return initialValue; - } -}; +import { SetStateActionWithReset, safeJSONParse } from './utils'; export function atomWithHash( key: string, diff --git a/src/atomWithQueryParam.ts b/src/atomWithQueryParam.ts new file mode 100644 index 0000000..204608a --- /dev/null +++ b/src/atomWithQueryParam.ts @@ -0,0 +1,64 @@ +import { atom } from 'jotai/vanilla'; +import { RESET } from 'jotai/vanilla/utils'; + +import { atomWithLocation } from './atomWithLocation'; +import { SetStateActionWithReset, safeJSONParse } from './utils'; + +/** + * Creates an atom that syncs its value with a specific query parameter in the URL. + * + * @param key The name of the query parameter. + * @param initialValue The initial value of the atom if the query parameter is not present. + * @param options Additional options for the atom: + * - serialize: A custom function to serialize the atom value to the hash. Defaults to JSON.stringify. + * - deserialize: A custom function to deserialize the hash to the atom value. Defaults to JSON.parse. + * - subscribe: A custom function to subscribe to location change + * - replace: A boolean to indicate to use replaceState instead of pushState. Defaults to false. + */ +export const atomWithQueryParam = ( + key: string, + initialValue: Value, + options?: { + serialize?: (val: Value) => string; + deserialize?: (str: string) => Value; + subscribe?: (callback: () => void) => () => void; + replace?: boolean; + }, +) => { + const locationAtom = atomWithLocation(options); + + const serialize = options?.serialize || JSON.stringify; + const deserialize = + options?.deserialize || + (safeJSONParse(initialValue) as (str: string) => Value); + + const valueAtom = atom((get) => { + const location = get(locationAtom); + const value = location.searchParams?.get(key); + return value == null ? initialValue : deserialize(value); + }); + + // Create a derived atom that focuses on the specific query parameter + const queryParamAtom = atom( + (get) => get(valueAtom), + (get, set, update: SetStateActionWithReset) => { + const nextValue = + typeof update === 'function' + ? (update as (prev: Value) => Value | typeof RESET)(get(valueAtom)) + : update; + const location = get(locationAtom); + const params = new URLSearchParams(location.searchParams); + if (nextValue === RESET) { + params.delete(key); + } else { + params.set(key, serialize(nextValue)); + } + set(locationAtom, { + ...location, + searchParams: params, + }); + }, + ); + + return queryParamAtom; +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..753717f --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,14 @@ +import { RESET } from 'jotai/vanilla/utils'; + +export type SetStateActionWithReset = + | Value + | typeof RESET + | ((prev: Value) => Value | typeof RESET); + +export const safeJSONParse = (initialValue: unknown) => (str: string) => { + try { + return JSON.parse(str); + } catch (e) { + return initialValue; + } +}; \ No newline at end of file