Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] Add atomWithQueryParam #34

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions __tests__/atomWithQueryParam_spec.tsx
Original file line number Diff line number Diff line change
@@ -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' });
Comment on lines +34 to +43
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like these tests, but feel that they could be a bit more verbose and/or easy to read. I feel tests can help act as a sort of documentation in a way.

What follows is just a quick rewrite as an example, I haven't tested it out locally.

Suggested change
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' });
const { result } = renderHook(() => useAtom(queryParamAtom), {
// the provider scopes the atoms to a store so their values dont persist between tests
wrapper: Provider,
});
const [testValue, setTestValue] = result.current;
act(() => {
setTestValue({ value: 'test value' });
});
expect(testValue).toEqual({ value: 'test value' });

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const [testValue, setTestValue] = result.current;

i would expect this change to break the test actually because RTL updates result.current with the new testValue but that wouldn't rerun the destructuring after the act call.

That's the reason for the unfortunate index access syntax (which Jotai even has in it's docs)

Copy link
Contributor

@scamden scamden Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw we've built a helper wrapper internally that i think makes this a little nicer. I'd love to contribute it back to the community since it cleans up the readability of these tests like you were hoping, but I'm not sure to which library this util would belong tbh

const HOOK_STATE = 0;
const HOOK_SET_STATE = 1;

type RefStableState<V, F extends (...args: Array<any>) => void> = {
  getState: () => V;
  setState: (...args: Parameters<F>) => void;
};

/**
 * When given a ref that contains the standard react state API: [state, setState]
 * gives back a tuple with a getState and setState function instead that are correct as the ref is updated
 * and can be used like
 * ```
 *  const { result} = renderHook(() => useState('something'), {
        wrapper: getProvideUserContext(),
    })
 *  const {getState, setState} = stateFromRef(result);
    expect(getState()).toEqual('something');
 * ```
 */
export function getRefStableState<
  V,
  F extends (...args: Array<any>) => void,
>(ref: { current: [V, F] }): RefStableState<V, F> {
  return {
    getState() {
      return ref.current[HOOK_STATE];
    },
    setState(...args: Parameters<F>) {
      ref.current[HOOK_SET_STATE](...args);
    },
  };
}

/**
 *  When given a React Testing Library hook result whose return value follows a similar pattern to the react state API,
 *  returns a similar looking object but with the result key wrapped with stateFromRef
 *  This is useful when you want to test a hook that returns a state and setState tuple,
 *  but you want to use the getState and setState functions.
 *
 * Like this:
 * ```
 * const { result } = await wrapStateLikeHook(
 *   renderHook(() => useAtom(atomBiQueryFilters), {
 *    wrapper: getProvideUserContext(),
 *   })
 * );
 * await act(async () => {
 *   result.setState('something');
 * });
 * expect(result.getState()).toEqual('something');
 * ```
 */
export async function wrapStateLikeHook<
  R extends [any, (...args: Array<any>) => any],
  P,
>({ result, ...rest }: RenderHookResult<R, P>) {
  // this is null until done suspending
  await waitFor(() => !!result.current[0]);
  const refState = getRefStableState(result);
  return {
    ...rest,
    result: refState,
  };
}

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'));
});
});
13 changes: 1 addition & 12 deletions src/atomWithHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,7 @@ import { atom } from 'jotai/vanilla';
import type { WritableAtom } from 'jotai/vanilla';
import { RESET } from 'jotai/vanilla/utils';

type SetStateActionWithReset<Value> =
| 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<Value>(
key: string,
Expand Down
64 changes: 64 additions & 0 deletions src/atomWithQueryParam.ts
Original file line number Diff line number Diff line change
@@ -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 = <Value>(
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<Value>) => {
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;
};
14 changes: 14 additions & 0 deletions src/utils.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice moving these out to a util file 👌

Please format the file with something like prettier to get rid of the error.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { RESET } from 'jotai/vanilla/utils';

export type SetStateActionWithReset<Value> =
| Value
| typeof RESET
| ((prev: Value) => Value | typeof RESET);

export const safeJSONParse = (initialValue: unknown) => (str: string) => {
try {
return JSON.parse(str);
} catch (e) {
return initialValue;
}
};

Check failure on line 14 in src/utils.ts

View workflow job for this annotation

GitHub Actions / test

Insert `⏎`
Loading