Skip to content

Commit

Permalink
Add atomWithQueryParam
Browse files Browse the repository at this point in the history
  • Loading branch information
fravic committed Mar 27, 2024
1 parent 9aba6f6 commit ec85ff2
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 12 deletions.
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' });
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
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 `⏎`

0 comments on commit ec85ff2

Please sign in to comment.