├── .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 |
12 | -
13 |
25 |
26 | -
27 |
39 |
40 | -
41 |
53 |
54 |
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 |
10 | -
11 |
23 |
24 | -
25 |
46 |
47 | -
48 |
69 |
70 |
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 |
--------------------------------------------------------------------------------