-
Notifications
You must be signed in to change notification settings - Fork 12
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
add support for Search Params #39
base: main
Are you sure you want to change the base?
Changes from all commits
525cf48
54e53e8
50d76bc
a63a2bc
f83ef17
46a8647
2f7e58a
0c85ece
3552972
ff39fc9
9364a21
fe90f72
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
{ | ||
"name": "jotai-location-example", | ||
"version": "0.1.0", | ||
"private": true, | ||
"dependencies": { | ||
"@types/react": "latest", | ||
"@types/react-dom": "latest", | ||
"jotai": "latest", | ||
"jotai-location": "latest", | ||
"react": "latest", | ||
"react-dom": "latest", | ||
"react-scripts": "latest", | ||
"typescript": "latest" | ||
}, | ||
"scripts": { | ||
"start": "react-scripts start", | ||
"build": "react-scripts build", | ||
"test": "react-scripts test", | ||
"eject": "react-scripts eject" | ||
}, | ||
"browserslist": [ | ||
">0.2%", | ||
"not dead", | ||
"not ie <= 11", | ||
"not op_mini all" | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
<html> | ||
<head> | ||
<title>jotai-location example</title> | ||
</head> | ||
<body> | ||
<div id="app"></div> | ||
</body> | ||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { atomWithSearchParams } from 'jotai-location'; | ||
import { useAtom } from 'jotai/react'; | ||
import React from 'react'; | ||
|
||
const pageAtom = atomWithSearchParams('page', 1); | ||
|
||
const Page = () => { | ||
const [page, setPage] = useAtom(pageAtom); | ||
return ( | ||
<div> | ||
<div>Page {page}</div> | ||
<button type="button" onClick={() => setPage((c) => c + 1)}> | ||
+1 | ||
</button> | ||
<p>See the url hash, change it there</p> | ||
</div> | ||
); | ||
}; | ||
|
||
const App = () => <Page />; | ||
|
||
export default App; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import React from 'react'; | ||
import { createRoot } from 'react-dom/client'; | ||
|
||
import App from './App'; | ||
|
||
const ele = document.getElementById('app'); | ||
if (ele) { | ||
createRoot(ele).render(<App />); | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,151 @@ | ||||||
import type { SetStateAction, WritableAtom } from 'jotai/vanilla'; | ||||||
import { atom } from 'jotai/vanilla'; | ||||||
import { atomWithLocation } from './atomWithLocation'; | ||||||
|
||||||
// Create an atom for managing location state, including search parameters. | ||||||
const locationAtom = atomWithLocation(); | ||||||
|
||||||
/** | ||||||
* Creates an atom that manages a single search parameter. | ||||||
* | ||||||
* The atom automatically infers the type of the search parameter based on the | ||||||
* type of `defaultValue`. | ||||||
* | ||||||
* The atom's read function returns the current value of the search parameter. | ||||||
* The atom's write function updates the search parameter in the URL. | ||||||
* | ||||||
* @param key - The key of the search parameter. | ||||||
* @param defaultValue - The default value of the search parameter. | ||||||
* @returns A writable atom that manages the search parameter. | ||||||
*/ | ||||||
export const atomWithSearchParams = <T>( | ||||||
key: string, | ||||||
defaultValue: T, | ||||||
): WritableAtom<T, [SetStateAction<T>], void> => { | ||||||
/** | ||||||
* Resolves the value of a search parameter based on the type of `defaultValue`. | ||||||
* | ||||||
* @param value - The raw value from the URL (could be `null` or `undefined`). | ||||||
* @returns The resolved value matching the type of `defaultValue`. | ||||||
*/ | ||||||
const resolveValue = (value: string | null | undefined): T => { | ||||||
// If the value is null, undefined, or not a string, return the default value. | ||||||
if (value === null || value === undefined || typeof value !== 'string') { | ||||||
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. Just a preference, but let's do this:
Suggested change
|
||||||
return defaultValue; | ||||||
} | ||||||
|
||||||
try { | ||||||
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. Do we need |
||||||
// Determine the type of the default value and parse accordingly. | ||||||
if (typeof defaultValue === 'number') { | ||||||
if (value === '') { | ||||||
console.warn( | ||||||
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. This can happen. So, at least it should be DEV-only warning. |
||||||
`Empty string provided for key "${key}". Falling back to default value.`, | ||||||
); | ||||||
return defaultValue; | ||||||
} | ||||||
|
||||||
const parsed = Number(value); | ||||||
if (!Number.isNaN(parsed)) { | ||||||
return parsed as T; | ||||||
} | ||||||
|
||||||
console.warn(`Expected a number for key "${key}", got "${value}".`); | ||||||
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. Same for this. |
||||||
return defaultValue; | ||||||
} | ||||||
|
||||||
// If the default value is a boolean, check if the value is `true` or `false`. | ||||||
if (typeof defaultValue === 'boolean') { | ||||||
if (value === 'true') return true as T; | ||||||
if (value === 'false') return false as T; | ||||||
|
||||||
console.warn(`Expected a boolean for key "${key}", got "${value}".`); | ||||||
return defaultValue; | ||||||
} | ||||||
|
||||||
if (typeof defaultValue === 'string') { | ||||||
return value as T; | ||||||
} | ||||||
|
||||||
// Handle object types (assume JSON if defaultValue is an object) | ||||||
if (typeof defaultValue === 'object') { | ||||||
const parsed = JSON.parse(value); | ||||||
if (parsed && typeof parsed === 'object') { | ||||||
return parsed as T; | ||||||
} | ||||||
console.warn(`Expected an object for key "${key}", got "${value}".`); | ||||||
return defaultValue; | ||||||
} | ||||||
} catch (err) { | ||||||
console.error(`Failed to resolve value for key "${key}":`, err); | ||||||
return defaultValue; | ||||||
} | ||||||
Comment on lines
+69
to
+81
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. Let's remove handling object. It's not type safe. |
||||||
|
||||||
// Fallback to default value for unsupported types | ||||||
console.warn(`Unsupported defaultValue type for key "${key}".`); | ||||||
return defaultValue; | ||||||
Comment on lines
+83
to
+85
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. Let's throw an error. We shouldn't reach here. |
||||||
}; | ||||||
|
||||||
/** | ||||||
* Converts the value into a string for use in the URL. | ||||||
* | ||||||
* Includes runtime type validation to ensure only compatible types are passed. | ||||||
* | ||||||
* @param value - The value to be serialized. | ||||||
* @returns The stringified value. | ||||||
*/ | ||||||
const parseValue = (value: T): string => { | ||||||
if ( | ||||||
typeof value === 'number' || | ||||||
typeof value === 'boolean' || | ||||||
typeof value === 'string' | ||||||
) { | ||||||
return String(value); | ||||||
} | ||||||
|
||||||
if (typeof value === 'object') { | ||||||
try { | ||||||
return JSON.stringify(value); | ||||||
} catch (err) { | ||||||
console.error(`Failed to stringify object for key "${key}":`, err); | ||||||
throw new Error(`Cannot serialize value for key "${key}".`); | ||||||
} | ||||||
} | ||||||
|
||||||
console.warn(`Unsupported value type for key "${key}":`, typeof value); | ||||||
throw new Error(`Unsupported value type for key "${key}".`); | ||||||
}; | ||||||
|
||||||
return atom<T, [SetStateAction<T>], void>( | ||||||
// Read function: Retrieves the current value of the search parameter. | ||||||
(get) => { | ||||||
const { searchParams } = get(locationAtom); | ||||||
|
||||||
// Resolve the value using the parsing logic. | ||||||
return resolveValue(searchParams?.get(key)); | ||||||
}, | ||||||
// Write function: Updates the search parameter in the URL. | ||||||
(_, set, value) => { | ||||||
set(locationAtom, (prev) => { | ||||||
// Create a new instance of URLSearchParams to avoid mutating the original. | ||||||
const newSearchParams = new URLSearchParams(prev.searchParams); | ||||||
|
||||||
let nextValue; | ||||||
|
||||||
if (typeof value === 'function') { | ||||||
// If the new value is a function, compute it based on the current value. | ||||||
const currentValue = resolveValue(newSearchParams.get(key)); | ||||||
nextValue = (value as (curr: T) => T)(currentValue); | ||||||
} else { | ||||||
// Otherwise, use the provided value directly. | ||||||
nextValue = value; | ||||||
} | ||||||
|
||||||
// Update the search parameter with the computed value. | ||||||
newSearchParams.set(key, parseValue(nextValue)); | ||||||
|
||||||
// Return the updated location state with new search parameters. | ||||||
return { ...prev, searchParams: newSearchParams }; | ||||||
}); | ||||||
}, | ||||||
); | ||||||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export { atomWithLocation } from './atomWithLocation'; | ||
export { atomWithHash } from './atomWithHash'; | ||
export { atomWithLocation } from './atomWithLocation'; | ||
export { atomWithSearchParams } from './atomWithSearchParams'; |
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.
Alright, then limit only to support string, number and boolean.
and simply the code please?
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've tried, but I can't figure out why this strange type is being returned in TypeScript. I haven't yet pinpointed the reason.
Atom:
const pageAtom = atomWithSearchParams("page", 1)
Expected:
const pageAtom: WritableAtom<number, [SetStateAction<number>], void>
Got:
const pageAtom: WritableAtom<1, [SetStateAction<1>], void>
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 think in this case you need to do
atomWithSearchParams<number>("page", 1)
oratomWithSearchParams("page", 1 as number)
. Do you have any other issues with this change?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.
Actually, union type doesn't work. We should overload the function.