├── .codesandbox └── ci.json ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── examples ├── 01_minimal │ ├── index.html │ ├── package.json │ └── src │ │ ├── app.jsx │ │ └── main.jsx ├── 02_typescript │ ├── index.html │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ └── main.tsx │ └── tsconfig.json ├── 03_hash │ ├── index.html │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ └── main.tsx │ └── tsconfig.json ├── 04_react_router │ ├── index.html │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ ├── main.tsx │ │ └── routerHistory.ts │ └── tsconfig.json ├── 05_serch_params │ ├── index.html │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ └── main.tsx │ └── tsconfig.json └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── src ├── atomWithHash.ts ├── atomWithLocation.ts ├── atomWithSearchParams.ts └── index.ts ├── tests ├── atomWithHash.spec.tsx ├── atomWithLocation.spec.tsx ├── atomWithSearchParams.spec.tsx └── vitest-setup.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── tsconfig.json └── vite.config.ts /.codesandbox/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "compile", 3 | "sandboxes": ["new", "react-typescript-react-ts"], 4 | "node": "18" 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: pnpm/action-setup@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 'lts/*' 17 | registry-url: 'https://registry.npmjs.org' 18 | cache: 'pnpm' 19 | - run: pnpm install 20 | - run: pnpm run compile 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 'lts/*' 16 | cache: 'pnpm' 17 | - run: pnpm install 18 | - run: pnpm test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | node_modules 4 | /dist 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /pnpm-lock.yaml 2 | /dist 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.6.1] - 2025-05-28 6 | 7 | ### Changed 8 | 9 | - feat(atomWithSearchParams): add applyLocation function to merge existing searchParams (#50) 10 | 11 | ## [0.6.0] - 2025-04-23 12 | 13 | ### Added 14 | 15 | - feat(atomWithSearchParams) add support for Search Params #41 16 | 17 | ### Changed 18 | 19 | - update to morden setup #47 #44 20 | 21 | ## [0.5.5] - 2024-04-25 22 | 23 | ### Added 24 | 25 | - feat(atomWithLocation): support location.hash #30 26 | - feat(atomWithHash): allow the setHash option to be overridden on a per atom set basis #35 27 | 28 | ### Changed 29 | 30 | - fix(atomWithLocation): replaceState function to use window.history.state instead of null #33 31 | 32 | ## [0.5.4] - 2024-02-27 33 | 34 | ### Changed 35 | 36 | - feat(atomWithHash): initial value from hash #31 37 | 38 | ## [0.5.3] - 2024-02-18 39 | 40 | ### Changed 41 | 42 | - feat(atomWithLocation): override replace for specific navigations #28 43 | 44 | ## [0.5.2] - 2023-10-15 45 | 46 | ### Changed 47 | 48 | - fix(atomWithHash): default safeJSONParse #22 49 | - fix(atomWithHash): replaceState function to use window.history.state instead of null #24 50 | 51 | ## [0.5.1] - 2023-03-11 52 | 53 | ### Changed 54 | 55 | - refactor: atomWithHash #13 56 | 57 | ## [0.5.0] - 2023-03-03 58 | 59 | ### Added 60 | 61 | - feat: mark internal atoms as private 62 | 63 | ## [0.4.0] - 2023-01-31 64 | 65 | ### Added 66 | 67 | - Migrate to Jotai v2 API #1 68 | 69 | ## [0.3.3] - 2023-01-05 70 | 71 | ### Changed 72 | 73 | - fix: atomWithLocation without window #8 74 | 75 | ## [0.3.2] - 2023-01-01 76 | 77 | ### Changed 78 | 79 | - feat(atomWithHash): optimize return value to prevent unnecessary re-renders #6 80 | 81 | ## [0.3.1] - 2022-12-22 82 | 83 | ### Changed 84 | 85 | - fix: atomWithHash without window #5 86 | 87 | ## [0.3.0] - 2022-12-01 88 | 89 | ### Added 90 | 91 | - feat(atom-with-hash): allow optional setHash #4 92 | 93 | ## [0.2.0] - 2022-11-17 94 | 95 | ### Added 96 | 97 | - feat: atomWithHash #2 98 | 99 | ## [0.1.0] - 2022-08-12 100 | 101 | ### Added 102 | 103 | - Initial release 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Daishi Kato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jotai-location 2 | 3 | 👻🔗 4 | 5 | https://jotai.org/docs/integrations/location 6 | 7 | ## Tweets 8 | 9 | - [Initial announcement](https://twitter.com/dai_shi/status/1558093027024875520) 10 | - [v0.2.0 announcement](https://twitter.com/dai_shi/status/1593219435896410114) 11 | - [v0.3.0 announcement](https://twitter.com/dai_shi/status/1598153411605671936) 12 | - [v0.3.2 announcement](https://twitter.com/dai_shi/status/1609704860869623809) 13 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import importPlugin from 'eslint-plugin-import'; 4 | import jsxA11y from 'eslint-plugin-jsx-a11y'; 5 | import react from 'eslint-plugin-react'; 6 | import reactHooks from 'eslint-plugin-react-hooks'; 7 | import reactCompiler from 'eslint-plugin-react-compiler'; 8 | 9 | export default tseslint.config( 10 | { ignores: ['dist/', 'website/'] }, 11 | eslint.configs.recommended, 12 | tseslint.configs.recommended, 13 | importPlugin.flatConfigs.recommended, 14 | jsxA11y.flatConfigs.recommended, 15 | react.configs.flat.recommended, 16 | react.configs.flat['jsx-runtime'], 17 | reactHooks.configs['recommended-latest'], 18 | reactCompiler.configs.recommended, 19 | { 20 | settings: { 21 | 'import/resolver': { typescript: true }, 22 | react: { version: 'detect' }, 23 | }, 24 | rules: { 25 | '@typescript-eslint/no-unused-vars': [ 26 | 'error', 27 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 28 | ], 29 | }, 30 | }, 31 | ); 32 | -------------------------------------------------------------------------------- /examples/01_minimal/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | example 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/01_minimal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "jotai": "latest", 8 | "jotai-location": "latest", 9 | "react": "latest", 10 | "react-dom": "latest" 11 | }, 12 | "devDependencies": { 13 | "vite": "latest" 14 | }, 15 | "scripts": { 16 | "dev": "vite" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/01_minimal/src/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { useAtom } from 'jotai/react'; 4 | import { atomWithLocation } from 'jotai-location'; 5 | 6 | const locationAtom = atomWithLocation(); 7 | 8 | const App = () => { 9 | const [loc, setLoc] = useAtom(locationAtom); 10 | return ( 11 | 55 | ); 56 | }; 57 | 58 | export default App; 59 | -------------------------------------------------------------------------------- /examples/01_minimal/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './app'; 5 | 6 | createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /examples/02_typescript/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | example 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/02_typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "jotai": "latest", 8 | "jotai-location": "latest", 9 | "react": "latest", 10 | "react-dom": "latest" 11 | }, 12 | "devDependencies": { 13 | "@types/react": "latest", 14 | "@types/react-dom": "latest", 15 | "typescript": "latest", 16 | "vite": "latest" 17 | }, 18 | "scripts": { 19 | "dev": "vite" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/02_typescript/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai/react'; 2 | import { atomWithLocation } from 'jotai-location'; 3 | 4 | const locationAtom = atomWithLocation(); 5 | 6 | const App = () => { 7 | const [loc, setLoc] = useAtom(locationAtom); 8 | return ( 9 | 71 | ); 72 | }; 73 | 74 | export default App; 75 | -------------------------------------------------------------------------------- /examples/02_typescript/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './app'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /examples/02_typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "target": "es2018", 6 | "esModuleInterop": true, 7 | "module": "esnext", 8 | "moduleResolution": "bundler", 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "noUncheckedIndexedAccess": true, 12 | "exactOptionalPropertyTypes": true, 13 | "jsx": "react-jsx" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/03_hash/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | example 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/03_hash/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "jotai": "latest", 8 | "jotai-location": "latest", 9 | "react": "latest", 10 | "react-dom": "latest" 11 | }, 12 | "devDependencies": { 13 | "@types/react": "latest", 14 | "@types/react-dom": "latest", 15 | "typescript": "latest", 16 | "vite": "latest" 17 | }, 18 | "scripts": { 19 | "dev": "vite" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/03_hash/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai/react'; 2 | import { atomWithHash } from 'jotai-location'; 3 | 4 | const countAtom = atomWithHash('count', 1); 5 | 6 | const Counter = () => { 7 | const [count, setCount] = useAtom(countAtom); 8 | return ( 9 |
10 |
count {count}
11 | 14 |

See the url hash, change it there

15 |
16 | ); 17 | }; 18 | 19 | const App = () => ; 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /examples/03_hash/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './app'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /examples/03_hash/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "target": "es2018", 6 | "esModuleInterop": true, 7 | "module": "esnext", 8 | "moduleResolution": "bundler", 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "noUncheckedIndexedAccess": true, 12 | "exactOptionalPropertyTypes": true, 13 | "jsx": "react-jsx" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/04_react_router/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | example 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/04_react_router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "jotai": "latest", 8 | "jotai-location": "latest", 9 | "react": "latest", 10 | "react-dom": "latest", 11 | "react-router-dom": "6.8.0" 12 | }, 13 | "devDependencies": { 14 | "@types/react": "latest", 15 | "@types/react-dom": "latest", 16 | "typescript": "latest", 17 | "vite": "latest" 18 | }, 19 | "scripts": { 20 | "dev": "vite" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/04_react_router/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | unstable_HistoryRouter as HistoryRouter, 3 | Routes, 4 | Route, 5 | Link, 6 | } from 'react-router-dom'; 7 | import { atomWithLocation } from 'jotai-location'; 8 | import { useAtomValue } from 'jotai'; 9 | import history from './routerHistory'; 10 | 11 | const Route1 =

Hello

; 12 | const Route2 =

World

; 13 | const location = atomWithLocation({ 14 | getLocation: () => ({ 15 | searchParams: new URLSearchParams(history.location.search), 16 | ...history.location, 17 | }), 18 | subscribe: (callback) => history.listen(callback), 19 | }); 20 | 21 | const App = () => { 22 | const loc = useAtomValue(location); 23 | return ( 24 |
33 | {/* @ts-expect-error: https://github.com/remix-run/react-router/issues/9630#issuecomment-1341643731 */} 34 | 35 | current pathname in atomWithLocation: "{loc.pathname}" 36 |
37 | to 1 38 | to 1/123 39 | to 2 40 | to 2/123 41 |
42 | 43 | 44 | 45 | 46 |
47 |
48 | ); 49 | }; 50 | export default App; 51 | -------------------------------------------------------------------------------- /examples/04_react_router/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './app'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /examples/04_react_router/src/routerHistory.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | const routerHistory = createBrowserHistory(); 4 | export default routerHistory; 5 | -------------------------------------------------------------------------------- /examples/04_react_router/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "target": "es2018", 6 | "esModuleInterop": true, 7 | "module": "esnext", 8 | "moduleResolution": "bundler", 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "noUncheckedIndexedAccess": true, 12 | "exactOptionalPropertyTypes": true, 13 | "jsx": "react-jsx" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/05_serch_params/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | example 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/05_serch_params/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "jotai": "latest", 8 | "jotai-location": "latest", 9 | "react": "latest", 10 | "react-dom": "latest" 11 | }, 12 | "devDependencies": { 13 | "@types/react": "latest", 14 | "@types/react-dom": "latest", 15 | "typescript": "latest", 16 | "vite": "latest" 17 | }, 18 | "scripts": { 19 | "dev": "vite" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/05_serch_params/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { atomWithSearchParams } from 'jotai-location'; 2 | import { useAtom } from 'jotai/react'; 3 | 4 | const oneAtom = atomWithSearchParams('one', 0); 5 | const twoAtom = atomWithSearchParams('two', 0); 6 | const threeAtom = atomWithSearchParams('three', 0); 7 | 8 | const Page = () => { 9 | const [one, setOne] = useAtom(oneAtom); 10 | const [two, setTwo] = useAtom(twoAtom); 11 | const [three, setThree] = useAtom(threeAtom); 12 | return ( 13 | <> 14 |
15 | one: {one}, two: {two}, three: {three} 16 |
17 | 27 | 28 | ); 29 | }; 30 | 31 | const App = () => ; 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /examples/05_serch_params/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './app'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /examples/05_serch_params/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "target": "es2018", 6 | "esModuleInterop": true, 7 | "module": "esnext", 8 | "moduleResolution": "bundler", 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "noUncheckedIndexedAccess": true, 12 | "exactOptionalPropertyTypes": true, 13 | "jsx": "react-jsx" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "moduleResolution": "bundler" 6 | }, 7 | "exclude": [] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jotai-location", 3 | "description": "👻🔗", 4 | "version": "0.6.1", 5 | "type": "module", 6 | "author": "Daishi Kato", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/jotaijs/jotai-location.git" 10 | }, 11 | "source": "./src/index.ts", 12 | "main": "./dist/index.js", 13 | "types": "./dist/index.d.ts", 14 | "exports": { 15 | "./package.json": "./package.json", 16 | ".": { 17 | "require": { 18 | "types": "./dist/cjs/index.d.ts", 19 | "default": "./dist/cjs/index.js" 20 | }, 21 | "default": { 22 | "types": "./dist/index.d.ts", 23 | "default": "./dist/index.js" 24 | } 25 | } 26 | }, 27 | "sideEffects": false, 28 | "files": [ 29 | "src", 30 | "dist" 31 | ], 32 | "packageManager": "pnpm@9.4.0", 33 | "scripts": { 34 | "compile": "rm -rf dist && pnpm run '/^compile:.*/'", 35 | "compile:esm": "tsc -p tsconfig.esm.json", 36 | "compile:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", 37 | "test": "pnpm run '/^test:.*/'", 38 | "test:format": "prettier -c .", 39 | "test:lint": "eslint .", 40 | "test:types": "tsc -p . --noEmit", 41 | "test:types:examples": "tsc -p examples --noEmit", 42 | "test:spec": "vitest run", 43 | "examples:01_minimal": "DIR=01_minimal EXT=js vite", 44 | "examples:02_typescript": "DIR=02_typescript EXT=tsx vite", 45 | "examples:03_hash": "DIR=03_hash EXT=tsx vite", 46 | "examples:04_react_router": "DIR=04_react_router EXT=tsx vite", 47 | "examples:05_serch_params": "DIR=05_serch_params EXT=tsx vite" 48 | }, 49 | "keywords": [ 50 | "jotai", 51 | "react", 52 | "location" 53 | ], 54 | "prettier": { 55 | "singleQuote": true 56 | }, 57 | "license": "MIT", 58 | "devDependencies": { 59 | "@eslint/js": "^9.19.0", 60 | "@testing-library/jest-dom": "^6.6.3", 61 | "@testing-library/react": "^16.2.0", 62 | "@testing-library/user-event": "^14.6.1", 63 | "@types/node": "^22.10.10", 64 | "@types/react": "^19.0.8", 65 | "@types/react-dom": "^19.0.3", 66 | "eslint": "^9.19.0", 67 | "eslint-import-resolver-typescript": "^3.7.0", 68 | "eslint-plugin-import": "^2.31.0", 69 | "eslint-plugin-jsx-a11y": "^6.10.2", 70 | "eslint-plugin-react": "^7.37.4", 71 | "eslint-plugin-react-compiler": "19.0.0-beta-decd7b8-20250118", 72 | "history": "5.3.0", 73 | "eslint-plugin-react-hooks": "5.2.0-canary-de1eaa26-20250124", 74 | "happy-dom": "^16.7.2", 75 | "jotai": "^2.11.1", 76 | "jotai-valtio": "link:", 77 | "prettier": "^3.4.2", 78 | "react": "^19.0.0", 79 | "react-dom": "^19.0.0", 80 | "react-error-boundary": "^4.0.12", 81 | "react-router-dom": "6.22.1", 82 | "ts-expect": "^1.3.0", 83 | "typescript": "^5.7.3", 84 | "typescript-eslint": "^8.21.0", 85 | "valtio": "^2.1.2", 86 | "vite": "^6.0.11", 87 | "vite-tsconfig-paths": "^5.1.4", 88 | "vitest": "^3.0.4" 89 | }, 90 | "peerDependencies": { 91 | "jotai": ">=1.11.0" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/atomWithHash.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai/vanilla'; 2 | import type { WritableAtom } from 'jotai/vanilla'; 3 | import { RESET } from 'jotai/vanilla/utils'; 4 | 5 | type SetStateActionWithReset = 6 | | Value 7 | | typeof RESET 8 | | ((prev: Value) => Value | typeof RESET); 9 | 10 | const safeJSONParse = (initialValue: unknown) => (str: string) => { 11 | try { 12 | return JSON.parse(str); 13 | } catch { 14 | return initialValue; 15 | } 16 | }; 17 | 18 | export type SetHashOption = 19 | | 'default' 20 | | 'replaceState' 21 | | ((searchParams: string) => void); 22 | 23 | export type AtomWithHashSetOptions = { 24 | setHash?: SetHashOption; 25 | }; 26 | 27 | export const setHashWithPush = (searchParams: string) => { 28 | const newUrl = `${window.location.pathname}${window.location.search}${searchParams ? `#${searchParams}` : ''}`; 29 | window.history.pushState(window.history.state, '', newUrl); 30 | }; 31 | 32 | export const setHashWithReplace = (searchParams: string): void => { 33 | const newUrl = `${window.location.pathname}${window.location.search}${searchParams ? `#${searchParams}` : ''}`; 34 | window.history.replaceState(window.history.state, '', newUrl); 35 | }; 36 | 37 | function getSetHashFn(setHashOption?: SetHashOption) { 38 | if (setHashOption === 'replaceState') { 39 | return setHashWithReplace; 40 | } 41 | if (typeof setHashOption === 'function') { 42 | return setHashOption; 43 | } 44 | return setHashWithPush; 45 | } 46 | 47 | export function atomWithHash( 48 | key: string, 49 | initialValue: Value, 50 | options?: { 51 | serialize?: (val: Value) => string; 52 | deserialize?: (str: string) => Value; 53 | subscribe?: (callback: () => void) => () => void; 54 | setHash?: SetHashOption; 55 | }, 56 | ): WritableAtom< 57 | Value, 58 | [SetStateActionWithReset, AtomWithHashSetOptions?], 59 | void 60 | > { 61 | const serialize = options?.serialize || JSON.stringify; 62 | 63 | const deserialize = options?.deserialize || safeJSONParse(initialValue); 64 | const subscribe = 65 | options?.subscribe || 66 | ((callback) => { 67 | window.addEventListener('hashchange', callback); 68 | return () => { 69 | window.removeEventListener('hashchange', callback); 70 | }; 71 | }); 72 | 73 | const isLocationAvailable = 74 | typeof window !== 'undefined' && !!window.location; 75 | 76 | const strAtom = atom( 77 | isLocationAvailable 78 | ? new URLSearchParams(window.location.hash.slice(1)).get(key) 79 | : null, 80 | ); 81 | strAtom.onMount = (setAtom) => { 82 | if (!isLocationAvailable) { 83 | return undefined; 84 | } 85 | const callback = () => { 86 | setAtom(new URLSearchParams(window.location.hash.slice(1)).get(key)); 87 | }; 88 | const unsubscribe = subscribe(callback); 89 | callback(); 90 | return unsubscribe; 91 | }; 92 | const valueAtom = atom((get) => { 93 | const str = get(strAtom); 94 | return str === null ? initialValue : deserialize(str); 95 | }); 96 | return atom( 97 | (get) => get(valueAtom), 98 | ( 99 | get, 100 | set, 101 | update: SetStateActionWithReset, 102 | setOptions?: AtomWithHashSetOptions, 103 | ) => { 104 | const nextValue = 105 | typeof update === 'function' 106 | ? (update as (prev: Value) => Value | typeof RESET)(get(valueAtom)) 107 | : update; 108 | const searchParams = new URLSearchParams(window.location.hash.slice(1)); 109 | if (nextValue === RESET) { 110 | set(strAtom, null); 111 | searchParams.delete(key); 112 | } else { 113 | const str = serialize(nextValue); 114 | set(strAtom, str); 115 | searchParams.set(key, str); 116 | } 117 | const setHash = getSetHashFn(setOptions?.setHash ?? options?.setHash); 118 | setHash(searchParams.toString()); 119 | }, 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /src/atomWithLocation.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai/vanilla'; 2 | import type { SetStateAction, WritableAtom } from 'jotai/vanilla'; 3 | 4 | export type Location = { 5 | pathname?: string; 6 | searchParams?: URLSearchParams; 7 | hash?: string; 8 | }; 9 | 10 | const getLocation = (): Location => { 11 | if (typeof window === 'undefined' || !window.location) { 12 | return {}; 13 | } 14 | return { 15 | pathname: window.location.pathname, 16 | searchParams: new URLSearchParams(window.location.search), 17 | hash: window.location.hash, 18 | }; 19 | }; 20 | 21 | const applyLocation = ( 22 | location: Location, 23 | options?: { replace?: boolean }, 24 | ): void => { 25 | const url = new URL(window.location.href); 26 | if ('pathname' in location) { 27 | url.pathname = location.pathname; 28 | } 29 | if ('searchParams' in location) { 30 | url.search = location.searchParams.toString(); 31 | } 32 | if ('hash' in location) { 33 | url.hash = location.hash; 34 | } 35 | if (options?.replace) { 36 | window.history.replaceState(window.history.state, '', url); 37 | } else { 38 | window.history.pushState(null, '', url); 39 | } 40 | }; 41 | 42 | const subscribe = (callback: () => void) => { 43 | window.addEventListener('popstate', callback); 44 | return () => window.removeEventListener('popstate', callback); 45 | }; 46 | 47 | export type Options = { 48 | preloaded?: T; 49 | replace?: boolean; 50 | getLocation?: () => T; 51 | applyLocation?: (location: T, options?: { replace?: boolean }) => void; 52 | subscribe?: (callback: () => void) => () => void; 53 | }; 54 | 55 | type RequiredOptions = Omit, 'getLocation' | 'applyLocation'> & 56 | Required, 'getLocation' | 'applyLocation'>>; 57 | 58 | type AtomOptions = Pick, 'replace'>; 59 | 60 | export function atomWithLocation( 61 | options?: Options, 62 | ): WritableAtom< 63 | Location, 64 | [SetStateAction, AtomOptions?], 65 | void 66 | >; 67 | 68 | export function atomWithLocation( 69 | options: RequiredOptions, 70 | ): WritableAtom, AtomOptions?], void>; 71 | 72 | export function atomWithLocation(options?: Options) { 73 | const getL = 74 | options?.getLocation || 75 | (getLocation as unknown as NonNullable['getLocation']>); 76 | const appL = 77 | options?.applyLocation || 78 | (applyLocation as unknown as NonNullable['applyLocation']>); 79 | const sub = options?.subscribe || subscribe; 80 | const baseAtom = atom(options?.preloaded ?? getL()); 81 | 82 | if (process.env.NODE_ENV !== 'production') { 83 | baseAtom.debugPrivate = true; 84 | } 85 | 86 | baseAtom.onMount = (set) => { 87 | const callback = () => set(getL()); 88 | const unsub = sub(callback); 89 | callback(); 90 | return unsub; 91 | }; 92 | const derivedAtom = atom( 93 | (get) => get(baseAtom), 94 | (get, set, arg: SetStateAction, atomOptions: AtomOptions = {}) => { 95 | set(baseAtom, arg); 96 | appL(get(baseAtom), { ...options, ...atomOptions }); 97 | }, 98 | ); 99 | return derivedAtom; 100 | } 101 | -------------------------------------------------------------------------------- /src/atomWithSearchParams.ts: -------------------------------------------------------------------------------- 1 | import type { SetStateAction, WritableAtom } from 'jotai/vanilla'; 2 | import { atom } from 'jotai/vanilla'; 3 | import { 4 | atomWithLocation, 5 | type Options, 6 | type Location, 7 | } from './atomWithLocation.js'; 8 | 9 | function warning(...data: unknown[]) { 10 | if (process.env.NODE_ENV !== 'production') { 11 | console.warn(...data); 12 | } 13 | } 14 | const applyLocation = ( 15 | location: Location, 16 | options?: { replace?: boolean }, 17 | ): void => { 18 | const url = new URL(window.location.href); 19 | if ('pathname' in location) { 20 | url.pathname = location.pathname; 21 | } 22 | if ('searchParams' in location) { 23 | const existingParams = new URLSearchParams(url.search); 24 | const newParams = location.searchParams; 25 | 26 | for (const [key, value] of newParams.entries()) { 27 | existingParams.set(key, value); 28 | } 29 | url.search = existingParams.toString(); 30 | } 31 | if ('hash' in location) { 32 | url.hash = location.hash; 33 | } 34 | if (options?.replace) { 35 | window.history.replaceState(window.history.state, '', url); 36 | } else { 37 | window.history.pushState(null, '', url); 38 | } 39 | }; 40 | 41 | /** 42 | * Creates an atom that manages a single search parameter. 43 | * 44 | * The atom automatically infers the type of the search parameter based on the 45 | * type of `defaultValue`. 46 | * 47 | * The atom's read function returns the current value of the search parameter. 48 | * The atom's write function updates the search parameter in the URL. 49 | * 50 | * @param key - The key of the search parameter. 51 | * @param defaultValue - The default value of the search parameter. 52 | * @returns A writable atom that manages the search parameter. 53 | */ 54 | export const atomWithSearchParams = ( 55 | key: string, 56 | defaultValue: T, 57 | options?: Options, 58 | ): WritableAtom], void> => { 59 | // Create an atom for managing location state, including search parameters. 60 | const locationAtom = atomWithLocation({ 61 | ...options, 62 | applyLocation: options?.applyLocation ?? applyLocation, 63 | }); 64 | 65 | /** 66 | * Resolves the value of a search parameter based on the type of `defaultValue`. 67 | * 68 | * @param value - The raw value from the URL (could be `null` or `undefined`). 69 | * @returns The resolved value matching the type of `defaultValue`. 70 | */ 71 | const resolveValue = (value: string | null | undefined): T => { 72 | // If the value is null, undefined, or not a string, return the default value. 73 | if (value === null || value === undefined) { 74 | return defaultValue; 75 | } 76 | 77 | // Determine the type of the default value and parse accordingly. 78 | if (typeof defaultValue === 'number') { 79 | if (value === '') { 80 | warning( 81 | `Empty string provided for key "${key}". Falling back to default value.`, 82 | ); 83 | return defaultValue; 84 | } 85 | 86 | const parsed = Number(value); 87 | if (!Number.isNaN(parsed)) { 88 | return parsed as T; 89 | } 90 | 91 | warning(`Expected a number for key "${key}", got "${value}".`); 92 | return defaultValue; 93 | } 94 | 95 | // If the default value is a boolean, check if the value is `true` or `false`. 96 | if (typeof defaultValue === 'boolean') { 97 | if (value === 'true') return true as T; 98 | if (value === 'false') return false as T; 99 | 100 | warning(`Expected a boolean for key "${key}", got "${value}".`); 101 | return defaultValue; 102 | } 103 | 104 | if (typeof defaultValue === 'string') { 105 | return value as T; 106 | } 107 | 108 | // Fallback to default value for unsupported types 109 | warning(`Unsupported defaultValue type for key "${key}".`); 110 | return defaultValue; 111 | }; 112 | 113 | /** 114 | * Converts the value into a string for use in the URL. 115 | * 116 | * Includes runtime type validation to ensure only compatible types are passed. 117 | * 118 | * @param value - The value to be serialized. 119 | * @returns The stringified value. 120 | */ 121 | const parseValue = (value: T): string => { 122 | if ( 123 | typeof value === 'number' || 124 | typeof value === 'boolean' || 125 | typeof value === 'string' 126 | ) { 127 | return String(value); 128 | } 129 | 130 | warning(`Unsupported value type for key "${key}":`, typeof value); 131 | throw new Error(`Unsupported value type for key "${key}".`); 132 | }; 133 | 134 | return atom], void>( 135 | // Read function: Retrieves the current value of the search parameter. 136 | (get) => { 137 | const { searchParams } = get(locationAtom); 138 | 139 | // Resolve the value using the parsing logic. 140 | return resolveValue(searchParams?.get(key)); 141 | }, 142 | // Write function: Updates the search parameter in the URL. 143 | (_, set, value) => { 144 | set(locationAtom, (prev) => { 145 | // Create a new instance of URLSearchParams to avoid mutating the original. 146 | const newSearchParams = new URLSearchParams(prev.searchParams); 147 | 148 | let nextValue: T; 149 | 150 | if (typeof value === 'function') { 151 | // If the new value is a function, compute it based on the current value. 152 | const currentValue = resolveValue(newSearchParams.get(key)); 153 | nextValue = (value as (curr: T) => T)(currentValue); 154 | } else { 155 | // Otherwise, use the provided value directly. 156 | nextValue = value; 157 | } 158 | 159 | // Update the search parameter with the computed value. 160 | newSearchParams.set(key, parseValue(nextValue)); 161 | 162 | // Return the updated location state with new search parameters. 163 | return { ...prev, searchParams: newSearchParams }; 164 | }); 165 | }, 166 | ); 167 | }; 168 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { atomWithLocation } from './atomWithLocation.js'; 2 | export { atomWithHash } from './atomWithHash.js'; 3 | export { atomWithSearchParams } from './atomWithSearchParams.js'; 4 | -------------------------------------------------------------------------------- /tests/atomWithHash.spec.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode, useEffect, useMemo, useState } from 'react'; 2 | import { 3 | cleanup, 4 | fireEvent, 5 | render, 6 | screen, 7 | waitFor, 8 | } from '@testing-library/react'; 9 | import { userEvent } from '@testing-library/user-event'; 10 | import { useAtom } from 'jotai/react'; 11 | import { RESET } from 'jotai/vanilla/utils'; 12 | import { atomWithHash } from '../src/index.js'; 13 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 14 | 15 | function resetWindow() { 16 | window.history.pushState(null, '', '/'); 17 | window.location.search = ''; 18 | window.location.hash = ''; 19 | } 20 | 21 | afterEach(cleanup); 22 | 23 | describe('atomWithHash', () => { 24 | beforeEach(() => { 25 | resetWindow(); 26 | }); 27 | 28 | it('simple count', async () => { 29 | const countAtom = atomWithHash('count', 1); 30 | 31 | const Counter = () => { 32 | const [count, setCount] = useAtom(countAtom); 33 | return ( 34 | <> 35 |
count: {count}
36 | 39 | 42 | 43 | ); 44 | }; 45 | 46 | render( 47 | 48 | 49 | , 50 | ); 51 | 52 | await screen.findByText('count: 1'); 53 | 54 | fireEvent.click(screen.getByText('button')); 55 | await screen.findByText('count: 2'); 56 | expect(window.location.hash).toEqual('#count=2'); 57 | 58 | window.location.hash = 'count=3'; 59 | await screen.findByText('count: 3'); 60 | 61 | fireEvent.click(screen.getByText('reset')); 62 | await screen.findByText('count: 1'); 63 | expect(window.location.hash).toEqual(''); 64 | }); 65 | 66 | it('returning reset from state dispatcher', async () => { 67 | const isVisibleAtom = atomWithHash('isVisible', true); 68 | 69 | const Counter = () => { 70 | const [isVisible, setIsVisible] = useAtom(isVisibleAtom); 71 | return ( 72 | <> 73 | {isVisible &&
visible
} 74 | 77 | 80 | 81 | ); 82 | }; 83 | 84 | render( 85 | 86 | 87 | , 88 | ); 89 | 90 | await screen.findByText('visible'); 91 | 92 | fireEvent.click(screen.getByText('button')); 93 | 94 | await waitFor(() => { 95 | expect(screen.queryByText('visible')).toBeNull(); 96 | }); 97 | 98 | expect(window.location.hash).toEqual('#isVisible=false'); 99 | 100 | fireEvent.click(screen.getByText('button')); 101 | await screen.findByText('visible'); 102 | expect(window.location.hash).toEqual('#isVisible=true'); 103 | 104 | fireEvent.click(screen.getByText('button')); 105 | 106 | fireEvent.click(screen.getByText('reset')); 107 | await screen.findByText('visible'); 108 | expect(window.location.hash).toEqual(''); 109 | }); 110 | 111 | it('keeping current path', async () => { 112 | const countAtom = atomWithHash('count', 1, { setHash: 'replaceState' }); 113 | 114 | const Counter = () => { 115 | const [count, setCount] = useAtom(countAtom); 116 | return ( 117 | <> 118 |
count: {count}
119 | 122 | 123 | ); 124 | }; 125 | 126 | render( 127 | 128 | 129 | , 130 | ); 131 | 132 | window.history.pushState(null, '', '/?q=foo'); 133 | 134 | fireEvent.click(screen.getByText('button')); 135 | await screen.findByText('count: 2'); 136 | await waitFor(() => { 137 | expect(window.location.pathname).toEqual('/'); 138 | expect(window.location.search).toEqual('?q=foo'); 139 | expect(window.location.hash).toEqual('#count=2'); 140 | }); 141 | 142 | window.history.pushState(null, '', '/another'); 143 | await waitFor(() => { 144 | expect(window.location.pathname).toEqual('/another'); 145 | }); 146 | 147 | window.history.back(); 148 | await waitFor(() => { 149 | expect(window.location.pathname).toEqual('/'); 150 | expect(window.location.search).toEqual('?q=foo'); 151 | expect(window.location.hash).toEqual('#count=2'); 152 | }); 153 | window.history.back(); 154 | await waitFor(() => { 155 | expect(window.location.pathname).toEqual('/'); 156 | expect(window.location.search).toEqual(''); 157 | expect(window.location.hash).toEqual(''); 158 | }); 159 | }); 160 | 161 | it('keeping current path only for one set', async () => { 162 | const countAtom = atomWithHash('count', 0); 163 | 164 | const Counter = () => { 165 | const [count, setCount] = useAtom(countAtom); 166 | useEffect(() => { 167 | setCount(1, { setHash: 'replaceState' }); 168 | }, []); 169 | return ( 170 | <> 171 |
count: {count}
172 | 175 | 176 | ); 177 | }; 178 | 179 | window.history.pushState(null, '', '/?q=foo'); 180 | render( 181 | 182 | 183 | , 184 | ); 185 | 186 | await screen.findByText('count: 1'); 187 | await waitFor(() => { 188 | expect(window.location.pathname).toEqual('/'); 189 | expect(window.location.search).toEqual('?q=foo'); 190 | expect(window.location.hash).toEqual('#count=1'); 191 | }); 192 | fireEvent.click(screen.getByText('button')); 193 | await screen.findByText('count: 2'); 194 | expect(window.location.pathname).toEqual('/'); 195 | expect(window.location.search).toEqual('?q=foo'); 196 | expect(window.location.hash).toEqual('#count=2'); 197 | 198 | window.history.pushState(null, '', '/another'); 199 | await waitFor(() => { 200 | expect(window.location.pathname).toEqual('/another'); 201 | }); 202 | 203 | window.history.back(); 204 | await waitFor(() => { 205 | expect(window.location.pathname).toEqual('/'); 206 | expect(window.location.search).toEqual('?q=foo'); 207 | expect(window.location.hash).toEqual('#count=2'); 208 | }); 209 | window.history.back(); 210 | await waitFor(() => { 211 | expect(window.location.pathname).toEqual('/'); 212 | expect(window.location.search).toEqual('?q=foo'); 213 | expect(window.location.hash).toEqual('#count=1'); 214 | }); 215 | window.history.back(); 216 | await waitFor(() => { 217 | expect(window.location.pathname).toEqual('/'); 218 | expect(window.location.search).toEqual(''); 219 | expect(window.location.hash).toEqual(''); 220 | }); 221 | }); 222 | 223 | it('should optimize value to prevent unnecessary re-renders', async () => { 224 | const paramAHashAtom = atomWithHash('paramA', ['paramA']); 225 | const paramBHashAtom = atomWithHash('paramB', ['paramB']); 226 | const ParamInput = ({ 227 | paramAMockFn, 228 | paramBMockFn, 229 | }: { 230 | paramAMockFn: ReturnType; 231 | paramBMockFn: ReturnType; 232 | }) => { 233 | const [paramA, setParamA] = useAtom(paramAHashAtom); 234 | const [paramB, setParamB] = useAtom(paramBHashAtom); 235 | 236 | useMemo(() => paramAMockFn(), [paramA]); 237 | useMemo(() => paramBMockFn(), [paramB]); 238 | 239 | return ( 240 | <> 241 | setParamA([e.target.value])} 244 | aria-label="a" 245 | /> 246 | setParamB([e.target.value])} 249 | aria-label="b" 250 | /> 251 | 252 | ); 253 | }; 254 | 255 | const paramAMockFn = vi.fn(); 256 | const paramBMockFn = vi.fn(); 257 | const user = userEvent.setup(); 258 | 259 | render( 260 | 261 | 262 | , 263 | ); 264 | 265 | await user.type(screen.getByLabelText('a'), '1'); 266 | 267 | // StrictMode in React 18 calls useMemo twice, so we're accounting for 2 extra useMemo calls 268 | await waitFor(() => expect(paramAMockFn).toHaveBeenCalledTimes(4)); 269 | expect(paramBMockFn).toHaveBeenCalledTimes(2); 270 | 271 | await user.type(screen.getByLabelText('b'), '1'); 272 | await waitFor(() => expect(paramBMockFn).toHaveBeenCalledTimes(4)); 273 | }); 274 | 275 | it('sets initial value from hash', async () => { 276 | window.location.hash = '#count=2'; 277 | const countAtom = atomWithHash('count', 0); 278 | 279 | const Counter = () => { 280 | const [count] = useAtom(countAtom); 281 | const [countWasZero, setCountWasZero] = useState(false); 282 | 283 | useEffect(() => { 284 | if (count === 0) { 285 | setCountWasZero(true); 286 | } 287 | }, [count]); 288 | return ( 289 | <> 290 |
count: {count}
291 |
count was zero: {countWasZero.toString()}
292 | 293 | ); 294 | }; 295 | 296 | const { findByText } = render( 297 | 298 | 299 | , 300 | ); 301 | 302 | await findByText('count: 2'); 303 | await findByText('count was zero: false'); 304 | }); 305 | }); 306 | 307 | describe('atomWithHash without window', () => { 308 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 309 | let savedWindow: any; 310 | beforeEach(() => { 311 | savedWindow = global.window; 312 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 313 | delete (global as any).window; 314 | }); 315 | afterEach(() => { 316 | global.window = savedWindow; 317 | }); 318 | 319 | it('define atomWithHash', async () => { 320 | atomWithHash('count', 1); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /tests/atomWithLocation.spec.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render, screen, waitFor } from '@testing-library/react'; 2 | import { userEvent } from '@testing-library/user-event'; 3 | import { useAtom } from 'jotai/react'; 4 | import { StrictMode } from 'react'; 5 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 6 | import { atomWithLocation } from '../src/index.js'; 7 | afterEach(cleanup); 8 | function assertPathNameAndHistoryLength( 9 | expectedPathname: string, 10 | expectedHistoryLength: number, 11 | ) { 12 | expect(window.location.pathname).toEqual(expectedPathname); 13 | expect(window.history.length).toEqual(expectedHistoryLength); 14 | } 15 | 16 | function assertHashAndHistoryLength( 17 | expectedHash: string, 18 | expectedHistoryLength: number, 19 | ) { 20 | expect(window.location.hash).toEqual(expectedHash); 21 | expect(window.history.length).toEqual(expectedHistoryLength); 22 | } 23 | 24 | function assertSearchParamAndHistoryLength( 25 | expectedSearchParams: URLSearchParams, 26 | expectedHistoryLength: number, 27 | ) { 28 | expect(window.location.search).toEqual(`?${expectedSearchParams.toString()}`); 29 | expect(window.history.length).toEqual(expectedHistoryLength); 30 | } 31 | 32 | async function assertStartState( 33 | startTestHistoryLength: number, 34 | assert: 'pathname' | 'hash' | 'searchParams' | 'all', 35 | ) { 36 | if (assert === 'pathname' || assert === 'all') { 37 | await screen.findByText('current pathname in atomWithLocation: /'); 38 | assertPathNameAndHistoryLength('/', startTestHistoryLength); 39 | } 40 | if (assert === 'hash' || assert === 'all') { 41 | await screen.findByText('current hash in atomWithLocation: #'); 42 | assertHashAndHistoryLength('', startTestHistoryLength); 43 | } 44 | if (assert === 'searchParams' || assert === 'all') { 45 | await screen.findByText('current searchParams in atomWithLocation:'); 46 | expect(window.location.search).toEqual(''); 47 | expect(window.history.length).toEqual(startTestHistoryLength); 48 | } 49 | } 50 | 51 | function clickButtonAndAssertTemplate() { 52 | return async function clickButtonAndAssert( 53 | target: `button${number}` | 'back' | 'buttonWithReplace' | 'buttonWithPush', 54 | historyLength: number, 55 | { 56 | targetPathName, 57 | targetHash, 58 | targetSearchParams, 59 | }: { 60 | targetPathName?: string; 61 | targetHash?: string; 62 | targetSearchParams?: string; 63 | } = {}, 64 | assert: 'pathname' | 'hash' | 'search' | 'all' = 'pathname', 65 | ) { 66 | let expectedPathname: string = '/'; 67 | let expectedHash: string = ''; 68 | let expectedSearchParams = new URLSearchParams(); 69 | if (target === 'buttonWithReplace') { 70 | expectedPathname = '/123'; 71 | expectedHash = '#tab=1'; 72 | expectedSearchParams.set('tab', '1'); 73 | } else if (target === 'buttonWithPush') { 74 | expectedPathname = '/234'; 75 | expectedHash = '#tab=2'; 76 | expectedSearchParams.set('tab', '2'); 77 | } else if (target.startsWith('button')) { 78 | expectedPathname = `/${target.slice(-1)}`; 79 | expectedHash = `#tab=${target.slice(-1)}`; 80 | expectedSearchParams.set('tab', target.slice(-1)); 81 | } else if (target === 'back') { 82 | expectedPathname = targetPathName ?? ''; 83 | expectedHash = `#${targetHash ?? ''}`; 84 | expectedSearchParams = new URLSearchParams(); 85 | expectedSearchParams.set('tab', targetSearchParams ?? ''); 86 | } 87 | await userEvent.click(await screen.findByText(target)); 88 | if (assert === 'pathname') { 89 | await waitFor(() => { 90 | screen.findByText( 91 | `current pathname in atomWithLocation: ${expectedPathname}`, 92 | ); 93 | }); 94 | assertPathNameAndHistoryLength(expectedPathname, historyLength); 95 | } 96 | if (assert === 'hash') { 97 | await waitFor(() => { 98 | screen.findByText(`current hash in atomWithLocation: ${expectedHash}`); 99 | }); 100 | assertHashAndHistoryLength( 101 | expectedHash === '#' ? '' : expectedHash, 102 | historyLength, 103 | ); 104 | } 105 | if (assert === 'search') { 106 | await screen.findByText(`${expectedSearchParams.toString()}`); 107 | assertSearchParamAndHistoryLength(expectedSearchParams, historyLength); 108 | } 109 | if (assert === 'all') { 110 | await screen.findByText( 111 | `current pathname in atomWithLocation: ${expectedPathname}`, 112 | ); 113 | assertPathNameAndHistoryLength(expectedPathname, historyLength); 114 | await screen.findByText( 115 | `current hash in atomWithLocation: ${expectedHash}`, 116 | ); 117 | assertHashAndHistoryLength(expectedHash, historyLength); 118 | await screen.findByText( 119 | `current searchParams in atomWithLocation: ${expectedSearchParams}`, 120 | ); 121 | assertSearchParamAndHistoryLength(expectedSearchParams, historyLength); 122 | } 123 | }; 124 | } 125 | 126 | const defaultLocation = { 127 | pathname: '/', 128 | search: '', 129 | hash: '', 130 | state: null, 131 | }; 132 | 133 | describe('atomWithLocation, pathName', () => { 134 | beforeEach(() => { 135 | resetWindow(); 136 | }); 137 | 138 | it('can replace state', async () => { 139 | const locationAtom = atomWithLocation({ replace: true }); 140 | 141 | const Navigation = () => { 142 | const [location, setLocation] = useAtom(locationAtom); 143 | return ( 144 | <> 145 |
current pathname in atomWithLocation: {location.pathname}
146 | 149 | 152 | 162 | 168 | 169 | ); 170 | }; 171 | 172 | render( 173 | 174 | 175 | , 176 | ); 177 | 178 | const clickButtonAndAssert = clickButtonAndAssertTemplate(); 179 | const startHistoryLength = window.history.length; 180 | await assertStartState(startHistoryLength, 'pathname'); 181 | 182 | await clickButtonAndAssert('button1', startHistoryLength); 183 | await clickButtonAndAssert('button2', startHistoryLength); 184 | 185 | await userEvent.click(await screen.findByText('reset-button')); 186 | }); 187 | 188 | it('can push state', async () => { 189 | const locationAtom = atomWithLocation({ replace: false }); 190 | 191 | const Navigation = () => { 192 | const [location, setLocation] = useAtom(locationAtom); 193 | return ( 194 | <> 195 |
current pathname in atomWithLocation: {location.pathname}
196 | 199 | 202 | 212 | 218 | 219 | ); 220 | }; 221 | 222 | render( 223 | 224 | 225 | , 226 | ); 227 | 228 | const clickButtonAndAssert = clickButtonAndAssertTemplate(); 229 | const startHistoryLength = window.history.length; 230 | await assertStartState(startHistoryLength, 'pathname'); 231 | 232 | await clickButtonAndAssert('button1', startHistoryLength + 1); 233 | await clickButtonAndAssert('button2', startHistoryLength + 2); 234 | await clickButtonAndAssert('back', startHistoryLength + 2, { 235 | targetPathName: '/1', 236 | }); 237 | 238 | await userEvent.click(await screen.findByText('reset-button')); 239 | }); 240 | 241 | it('can override atomOptions, from replace=false to replace=true', async () => { 242 | const locationAtom = atomWithLocation({ replace: false }); 243 | 244 | const Navigation = () => { 245 | const [location, setLocation] = useAtom(locationAtom); 246 | return ( 247 | <> 248 |
current pathname in atomWithLocation: {location.pathname}
249 | 252 | 258 | 271 | 277 | 278 | ); 279 | }; 280 | 281 | render( 282 | 283 | 284 | , 285 | ); 286 | 287 | const clickButtonAndAssert = clickButtonAndAssertTemplate(); 288 | const startHistoryLength = window.history.length; 289 | 290 | await assertStartState(startHistoryLength, 'pathname'); 291 | 292 | await clickButtonAndAssert('buttonWithPush', startHistoryLength + 1); 293 | await clickButtonAndAssert('buttonWithReplace', startHistoryLength + 1); 294 | await clickButtonAndAssert('back', startHistoryLength + 1, { 295 | targetPathName: '/', 296 | }); 297 | 298 | // This click overwrites the history entry we 299 | // went back from. The history length remains the same. 300 | await clickButtonAndAssert('buttonWithPush', startHistoryLength + 1); 301 | 302 | // The second click adds a new history entry, which now increments the history length. 303 | await clickButtonAndAssert('buttonWithPush', startHistoryLength + 2); 304 | 305 | await userEvent.click(await screen.findByText('reset-button')); 306 | }); 307 | 308 | it('can override atomOptions, from replace=true to replace=false', async () => { 309 | const locationAtom = atomWithLocation({ replace: true }); 310 | 311 | const Navigation = () => { 312 | const [location, setLocation] = useAtom(locationAtom); 313 | return ( 314 | <> 315 |
current pathname in atomWithLocation: {location.pathname}
316 | 319 | 325 | 338 | 344 | 345 | ); 346 | }; 347 | 348 | render( 349 | 350 | 351 | , 352 | ); 353 | 354 | const clickButtonAndAssert = clickButtonAndAssertTemplate(); 355 | const startTestHistoryLength = window.history.length; 356 | await assertStartState(startTestHistoryLength, 'pathname'); 357 | 358 | await clickButtonAndAssert('buttonWithReplace', startTestHistoryLength); 359 | await clickButtonAndAssert('buttonWithPush', startTestHistoryLength + 1); 360 | await clickButtonAndAssert('back', startTestHistoryLength + 1, { 361 | targetPathName: '/123', 362 | }); 363 | await clickButtonAndAssert('buttonWithReplace', startTestHistoryLength + 1); 364 | await clickButtonAndAssert('buttonWithPush', startTestHistoryLength + 1); 365 | await clickButtonAndAssert('buttonWithPush', startTestHistoryLength + 2); 366 | 367 | await userEvent.click(await screen.findByText('reset-button')); 368 | }); 369 | }); 370 | 371 | describe('atomWithLocation, hash', () => { 372 | beforeEach(() => { 373 | resetWindow(); 374 | }); 375 | 376 | it('can push state with hash', async () => { 377 | const locationAtom = atomWithLocation({ replace: false }); 378 | 379 | const Navigation = () => { 380 | const [location, setLocation] = useAtom(locationAtom); 381 | return ( 382 | <> 383 |
current hash in atomWithLocation: #{location.hash}
384 | 387 | 390 | 400 | 406 | 407 | ); 408 | }; 409 | 410 | render( 411 | 412 | 413 | , 414 | ); 415 | 416 | const clickButtonAndAssert = clickButtonAndAssertTemplate(); 417 | const startHistoryLength = window.history.length; 418 | await assertStartState(startHistoryLength, 'hash'); 419 | 420 | await clickButtonAndAssert( 421 | 'button1', 422 | startHistoryLength + 1, 423 | undefined, 424 | 'hash', 425 | ); 426 | await clickButtonAndAssert( 427 | 'button2', 428 | startHistoryLength + 2, 429 | undefined, 430 | 'hash', 431 | ); 432 | 433 | await userEvent.click(await screen.findByText('reset-button')); 434 | }); 435 | 436 | it('can replace state with hash', async () => { 437 | const locationAtom = atomWithLocation({ replace: true }); 438 | 439 | const Navigation = () => { 440 | const [location, setLocation] = useAtom(locationAtom); 441 | return ( 442 | <> 443 |
current hash in atomWithLocation: #{location.hash}
444 | 447 | 450 | 460 | 466 | 467 | ); 468 | }; 469 | 470 | render( 471 | 472 | 473 | , 474 | ); 475 | 476 | const clickButtonAndAssert = clickButtonAndAssertTemplate(); 477 | const startHistoryLength = window.history.length; 478 | await assertStartState(startHistoryLength, 'hash'); 479 | 480 | await clickButtonAndAssert( 481 | 'button1', 482 | startHistoryLength, 483 | undefined, 484 | 'hash', 485 | ); 486 | await clickButtonAndAssert( 487 | 'button2', 488 | startHistoryLength, 489 | undefined, 490 | 'hash', 491 | ); 492 | 493 | await clickButtonAndAssert( 494 | 'back', 495 | startHistoryLength, 496 | { targetHash: '' }, 497 | 'hash', 498 | ); 499 | 500 | await userEvent.click(await screen.findByText('reset-button')); 501 | }); 502 | }); 503 | 504 | function resetWindow() { 505 | window.history.pushState(null, '', '/'); 506 | window.location.search = ''; 507 | window.location.hash = ''; 508 | } 509 | 510 | describe('atomWithLocation, searchParams', () => { 511 | beforeEach(() => { 512 | resetWindow(); 513 | }); 514 | it('can push state with searchParams', async () => { 515 | const locationAtom = atomWithLocation({ replace: false }); 516 | 517 | const Navigation = () => { 518 | const [location, setLocation] = useAtom(locationAtom); 519 | const tab1Params = new URLSearchParams(); 520 | tab1Params.set('tab', '1'); 521 | const tab2Params = new URLSearchParams(); 522 | tab2Params.set('tab', '2'); 523 | 524 | return ( 525 | <> 526 |
527 | current searchParams in atomWithLocation: 528 |
{location.searchParams?.toString()}
529 |
530 | 533 | 539 | 545 | 551 | 552 | ); 553 | }; 554 | 555 | render( 556 | 557 | 558 | , 559 | ); 560 | 561 | const clickButtonAndAssert = clickButtonAndAssertTemplate(); 562 | const startHistoryLength = window.history.length; 563 | await assertStartState(startHistoryLength, 'searchParams'); 564 | await clickButtonAndAssert( 565 | 'button1', 566 | startHistoryLength + 1, 567 | undefined, 568 | 'search', 569 | ); 570 | await clickButtonAndAssert( 571 | 'button2', 572 | startHistoryLength + 2, 573 | undefined, 574 | 'search', 575 | ); 576 | 577 | await userEvent.click(await screen.findByText('reset-button')); 578 | }); 579 | 580 | it('can replace state with searchParams', async () => { 581 | const locationAtom = atomWithLocation({ replace: true }); 582 | 583 | const Navigation = () => { 584 | const [location, setLocation] = useAtom(locationAtom); 585 | const tab1Params = new URLSearchParams(); 586 | tab1Params.set('tab', '1'); 587 | const tab2Params = new URLSearchParams(); 588 | tab2Params.set('tab', '2'); 589 | 590 | return ( 591 | <> 592 |
current searchParams in atomWithLocation:
593 |
{location.searchParams?.toString()}
594 | 597 | 603 | 609 | 615 | 616 | ); 617 | }; 618 | 619 | render( 620 | 621 | 622 | , 623 | ); 624 | 625 | const clickButtonAndAssert = clickButtonAndAssertTemplate(); 626 | const startHistoryLength = window.history.length; 627 | await assertStartState(startHistoryLength, 'searchParams'); 628 | 629 | await clickButtonAndAssert( 630 | 'button1', 631 | startHistoryLength, 632 | undefined, 633 | 'search', 634 | ); 635 | await clickButtonAndAssert( 636 | 'button2', 637 | startHistoryLength, 638 | undefined, 639 | 'search', 640 | ); 641 | 642 | await clickButtonAndAssert( 643 | 'back', 644 | startHistoryLength, 645 | { targetSearchParams: '2' }, 646 | 'search', 647 | ); 648 | 649 | await userEvent.click(await screen.findByText('reset-button')); 650 | }); 651 | }); 652 | 653 | describe('atomWithLocation without window', () => { 654 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 655 | let savedWindow: any; 656 | beforeEach(() => { 657 | savedWindow = global.window; 658 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 659 | delete (global as any).window; 660 | }); 661 | afterEach(() => { 662 | global.window = savedWindow; 663 | }); 664 | 665 | it('define atomWithLocation', async () => { 666 | atomWithLocation(); 667 | }); 668 | }); 669 | -------------------------------------------------------------------------------- /tests/atomWithSearchParams.spec.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render, screen } from '@testing-library/react'; 2 | import { userEvent } from '@testing-library/user-event'; 3 | import { useAtom } from 'jotai/react'; 4 | import { StrictMode } from 'react'; 5 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 6 | import { atomWithSearchParams } from '../src/index.js'; 7 | 8 | afterEach(cleanup); 9 | 10 | function resetWindow() { 11 | window.history.pushState(null, '', '/'); 12 | window.location.search = ''; 13 | } 14 | 15 | describe('atomWithSearchParams', () => { 16 | beforeEach(() => { 17 | resetWindow(); 18 | }); 19 | 20 | it('handles different value types', async () => { 21 | const stringAtom = atomWithSearchParams('string', 'default'); 22 | const numberAtom = atomWithSearchParams('number', 0); 23 | const booleanAtom = atomWithSearchParams('boolean', false); 24 | 25 | const Navigation = () => { 26 | const [stringValue, setStringValue] = useAtom(stringAtom); 27 | const [numberValue, setNumberValue] = useAtom(numberAtom); 28 | const [booleanValue, setBooleanValue] = useAtom(booleanAtom); 29 | 30 | return ( 31 | <> 32 |
current searchParam in atomWithSearchParams: {stringValue}
33 |
current searchParam in atomWithSearchParams: {numberValue}
34 |
35 | current searchParam in atomWithSearchParams:{' '} 36 | {booleanValue.toString()} 37 |
38 | 41 | 44 | 47 | 48 | ); 49 | }; 50 | 51 | render( 52 | 53 | 54 | , 55 | ); 56 | 57 | await userEvent.click(await screen.findByText('setString')); 58 | await screen.findByText( 59 | 'current searchParam in atomWithSearchParams: test', 60 | ); 61 | expect(window.location.search).toContain('string=test'); 62 | 63 | await userEvent.click(await screen.findByText('setNumber')); 64 | await screen.findByText('current searchParam in atomWithSearchParams: 42'); 65 | expect(window.location.search).toContain('number=42'); 66 | 67 | await userEvent.click(await screen.findByText('setBoolean')); 68 | await screen.findByText( 69 | 'current searchParam in atomWithSearchParams: true', 70 | ); 71 | expect(window.location.search).toContain('boolean=true'); 72 | }); 73 | 74 | it('handles function updates', async () => { 75 | const searchParamAtom = atomWithSearchParams('count', 0); 76 | 77 | const Navigation = () => { 78 | const [value, setValue] = useAtom(searchParamAtom); 79 | return ( 80 | <> 81 |
current searchParam in atomWithSearchParams: {value}
82 | 85 | 86 | ); 87 | }; 88 | 89 | render( 90 | 91 | 92 | , 93 | ); 94 | 95 | await userEvent.click(await screen.findByText('increment')); 96 | await screen.findByText('current searchParam in atomWithSearchParams: 1'); 97 | expect(window.location.search).toContain('count=1'); 98 | 99 | await userEvent.click(await screen.findByText('increment')); 100 | await screen.findByText('current searchParam in atomWithSearchParams: 2'); 101 | expect(window.location.search).toContain('count=2'); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /tests/vitest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "verbatimModuleSyntax": false, 6 | "declaration": true, 7 | "outDir": "./dist/cjs" 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "./dist" 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es2018", 5 | "esModuleInterop": true, 6 | "module": "nodenext", 7 | "skipLibCheck": true, 8 | "allowJs": true, 9 | "verbatimModuleSyntax": true, 10 | "noUncheckedIndexedAccess": true, 11 | "exactOptionalPropertyTypes": true, 12 | "jsx": "react-jsx", 13 | "baseUrl": ".", 14 | "paths": { 15 | "jotai-location": ["./src/index.ts"] 16 | } 17 | }, 18 | "exclude": ["dist", "examples"] 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { resolve } from 'node:path'; 4 | import { defineConfig } from 'vite'; 5 | import tsconfigPaths from 'vite-tsconfig-paths'; 6 | 7 | const { DIR, PORT = '8080' } = process.env; 8 | 9 | export default defineConfig(({ mode }) => { 10 | if (mode === 'test') { 11 | return { 12 | test: { 13 | environment: 'happy-dom', 14 | setupFiles: ['./tests/vitest-setup.ts'], 15 | }, 16 | plugins: [tsconfigPaths()], 17 | }; 18 | } 19 | if (!DIR) { 20 | throw new Error('DIR environment variable is required'); 21 | } 22 | return { 23 | root: resolve('examples', DIR), 24 | server: { port: Number(PORT) }, 25 | plugins: [tsconfigPaths({ root: resolve('.') })], 26 | }; 27 | }); 28 | --------------------------------------------------------------------------------