-
Notifications
You must be signed in to change notification settings - Fork 11
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
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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')); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
}; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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