├── .nvmrc ├── .env ├── src ├── packages │ ├── use-location-state │ │ ├── next.d.ts │ │ ├── next.js │ │ ├── index.d.ts │ │ ├── tsconfig.json │ │ ├── src │ │ │ ├── hooks │ │ │ │ └── useRefLatest.ts │ │ │ ├── use-location-state.ts │ │ │ ├── useQueryState │ │ │ │ ├── test │ │ │ │ │ ├── useTestQueryStringInterface.ts │ │ │ │ │ ├── method-default.test.ts │ │ │ │ │ ├── method-replace.test.ts │ │ │ │ │ ├── method-push.test.ts │ │ │ │ │ ├── useQueryStateObj.test.ts │ │ │ │ │ ├── invalid-values.test.ts │ │ │ │ │ ├── valid-values.test.ts │ │ │ │ │ ├── useQueryState.test.ts │ │ │ │ │ └── useHashQueryState.test.ts │ │ │ │ ├── useQueryState.types.ts │ │ │ │ ├── useHashQueryState.ts │ │ │ │ ├── useQueryState.ts │ │ │ │ ├── useHashQueryStringInterface.ts │ │ │ │ ├── useQueryStateObj.ts │ │ │ │ └── useQueryReducer.ts │ │ │ ├── types │ │ │ │ └── sharedTypes.ts │ │ │ ├── useLocationState │ │ │ │ ├── useLocationState.types.ts │ │ │ │ ├── useLocationState.ts │ │ │ │ ├── useLocationStateInterface.ts │ │ │ │ ├── test │ │ │ │ │ ├── useLocationState.test.ts │ │ │ │ │ ├── invalid-values.test.ts │ │ │ │ │ └── valid-values.test.ts │ │ │ │ └── useLocationReducer.ts │ │ │ ├── next.ts │ │ │ └── test │ │ │ │ └── nextjs.test.tsx │ │ ├── CHANGELOG.md │ │ ├── rollup.config.js │ │ └── package.json │ ├── query-state-core │ │ ├── tsconfig.json │ │ ├── CHANGELOG.md │ │ ├── rollup.config.js │ │ ├── package.json │ │ ├── test │ │ │ ├── stripLeadingHashOrQuestionMark.test.ts │ │ │ ├── parseQueryStateValue.test.ts │ │ │ └── query-state-core.test.ts │ │ └── src │ │ │ └── query-state-core.ts │ └── react-router-use-location-state │ │ ├── tsconfig.json │ │ ├── src │ │ ├── react-router-use-location-state.ts │ │ ├── useQueryState │ │ │ ├── useQueryState.ts │ │ │ ├── useReactRouterQueryStringInterface.ts │ │ │ └── test │ │ │ │ ├── valid-values.test.tsx │ │ │ │ └── batched-reset.test.tsx │ │ └── useLocationState │ │ │ ├── useLocationState.ts │ │ │ ├── useReactRouterLocationStateInterface.ts │ │ │ └── test │ │ │ └── valid-values.test.tsx │ │ ├── rollup.config.js │ │ └── package.json ├── examples │ ├── use-location-state │ │ ├── public │ │ │ ├── favicon.ico │ │ │ ├── manifest.json │ │ │ └── index.html │ │ ├── src │ │ │ ├── components │ │ │ │ ├── Header.tsx │ │ │ │ ├── QueryStateDisplay.tsx │ │ │ │ ├── Nav.tsx │ │ │ │ ├── QueryStateCheckbox.tsx │ │ │ │ └── LocationStateCheckbox.tsx │ │ │ ├── index.tsx │ │ │ ├── App.tsx │ │ │ ├── hooks │ │ │ │ └── usePageComponent.tsx │ │ │ ├── pages │ │ │ │ ├── test │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ ├── QueryStateTest.test.tsx.snap │ │ │ │ │ │ └── LocationStateTest.test.tsx.snap │ │ │ │ │ ├── ArrayDemo.test.tsx │ │ │ │ │ ├── LocationStateTest.test.tsx │ │ │ │ │ └── QueryStateTest.test.tsx │ │ │ │ ├── ArrayDemo.tsx │ │ │ │ ├── QueryStateDemo.tsx │ │ │ │ └── LocationStateDemo.tsx │ │ │ ├── styles │ │ │ │ └── index.scss │ │ │ └── test │ │ │ │ └── App.test.tsx │ │ ├── README.md │ │ ├── tsconfig.json │ │ ├── package.json │ │ └── CHANGELOG.md │ ├── react-router-use-location-state │ │ ├── public │ │ │ ├── favicon.ico │ │ │ ├── manifest.json │ │ │ └── index.html │ │ ├── src │ │ │ ├── components │ │ │ │ ├── Header.tsx │ │ │ │ ├── QueryStateDisplay.tsx │ │ │ │ └── Nav.tsx │ │ │ ├── index.tsx │ │ │ ├── pages │ │ │ │ ├── QueryReducer │ │ │ │ │ ├── QueryReducerTypes.ts │ │ │ │ │ ├── filterReducer.ts │ │ │ │ │ ├── QueryReducer.tsx │ │ │ │ │ └── FilterDisplay.tsx │ │ │ │ ├── test │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ ├── QueryStateTest.test.tsx.snap │ │ │ │ │ │ └── LocationStateTest.test.tsx.snap │ │ │ │ │ ├── ArrayDemo.test.tsx │ │ │ │ │ ├── QueryStateTest.test.tsx │ │ │ │ │ └── LocationStateTest.test.tsx │ │ │ │ ├── ArrayDemo.tsx │ │ │ │ ├── QueryStateDemo.tsx │ │ │ │ └── LocationStateDemo.tsx │ │ │ ├── App.tsx │ │ │ ├── test │ │ │ │ └── App.test.tsx │ │ │ └── styles │ │ │ │ └── index.scss │ │ ├── tsconfig.json │ │ ├── README.md │ │ ├── package.json │ │ └── CHANGELOG.md │ └── nextjs │ │ ├── next-env.d.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ ├── components │ │ ├── NamesList.tsx │ │ └── AddName.tsx │ │ ├── pages │ │ └── index.tsx │ │ └── README.md └── helpers │ └── use-location-state-test-helpers │ ├── package.json │ └── test-helpers.ts ├── .husky └── pre-commit ├── .prettierrc ├── codecov.yml ├── .prettierignore ├── .eslintrc.json ├── .gitignore ├── .github ├── dependabot.yml ├── .kodiak.toml └── workflows │ └── nodejs.yml ├── lerna.json ├── tsconfig.json ├── DEVNOTES.md ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /src/packages/use-location-state/next.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/next' 2 | -------------------------------------------------------------------------------- /src/packages/use-location-state/next.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/next.cjs') 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /src/packages/use-location-state/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export * from 'dist/use-location-state' 3 | -------------------------------------------------------------------------------- /src/examples/use-location-state/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiel/use-location-state/HEAD/src/examples/use-location-state/public/favicon.ico -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | # basic 6 | target: auto 7 | threshold: 1% 8 | base: auto 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | lerna-debug.log 4 | .DS_Store 5 | dist 6 | build 7 | .rpt2_cache 8 | yarn-error.log 9 | react-app-env.d.ts 10 | coverage 11 | .next 12 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiel/use-location-state/HEAD/src/examples/react-router-use-location-state/public/favicon.ico -------------------------------------------------------------------------------- /src/packages/query-state-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "include": [ 4 | "src/**/*" 5 | ], 6 | "compilerOptions": { 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/helpers/use-location-state-test-helpers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-location-state-test-helpers", 3 | "private": true, 4 | "version": "3.1.0", 5 | "description": "", 6 | "author": "", 7 | "license": "MIT" 8 | } 9 | -------------------------------------------------------------------------------- /src/examples/nextjs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/packages/use-location-state/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["**/dist/**", "**/test/**"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "rootDirs": ["./", "../../../"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "react-app", "plugin:prettier/recommended"], 3 | "rules": { 4 | "react/self-closing-comp": ["error", { "component": true, "html": true }], 5 | "react/jsx-boolean-value": "error", 6 | "jsx-a11y/anchor-is-valid": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["**/dist/**", "**/test/**"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "rootDirs": ["./", "../../../"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | lerna-debug.log 4 | .DS_Store 5 | dist 6 | build 7 | .rpt2_cache 8 | yarn-error.log 9 | react-app-env.d.ts 10 | coverage 11 | src/packages/use-location-state/README.md 12 | src/packages/react-router-use-location-state/README.md 13 | /report.*.json 14 | .next/ 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | time: '18:00' 8 | timezone: Europe/Berlin 9 | open-pull-requests-limit: 3 10 | labels: 11 | - 'automerge' 12 | - 'dependencies' 13 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Nav from './Nav' 3 | 4 | interface Props {} 5 | 6 | export default function Header(props: Props) { 7 | return ( 8 |
9 |

useQueryState()

10 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/examples/use-location-state/README.md: -------------------------------------------------------------------------------- 1 | # use-location-state test/demo 2 | 3 | - [Demo (codesandbox.io)](https://codesandbox.io/s/github/xiel/use-location-state/tree/master/src/examples/use-location-state) 4 | - [use-location-state (Github)](https://github.com/xiel/use-location-state/tree/master/src/packages/use-location-state) 5 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Nav from './Nav' 3 | 4 | interface Props {} 5 | 6 | export default function Header(props: Props) { 7 | return ( 8 |
9 |

useQueryState()

10 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/components/QueryStateDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface Props { 4 | queryState?: Record 5 | } 6 | 7 | export default function QueryStateDisplay({ queryState = {} }: Props) { 8 | return ( 9 |
10 |       {JSON.stringify(queryState, null, ' ')}
11 |     
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/index.tsx: -------------------------------------------------------------------------------- 1 | import '@ungap/url-search-params' 2 | import 'react-app-polyfill/ie11' 3 | import 'react-app-polyfill/stable' 4 | import React from 'react' 5 | import ReactDOM from 'react-dom' 6 | import './styles/index.scss' 7 | import App from './App' 8 | 9 | export function render() { 10 | ReactDOM.render(, document.getElementById('root')) 11 | } 12 | 13 | render() 14 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/src/react-router-use-location-state.ts: -------------------------------------------------------------------------------- 1 | export * from './useQueryState/useQueryState' 2 | export * from './useLocationState/useLocationState' 3 | 4 | export type { 5 | QueryDispatch, 6 | LocationDispatch, 7 | Reducer, 8 | ReducerWithoutAction, 9 | ReducerState, 10 | ReducerAction, 11 | LazyValueFn, 12 | SetStateAction, 13 | } from 'use-location-state' 14 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/components/QueryStateDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface Props { 4 | queryState?: Record 5 | } 6 | 7 | export default function QueryStateDisplay({ queryState = {} }: Props) { 8 | return ( 9 |
10 |       {JSON.stringify(queryState, null, ' ')}
11 |     
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/index.tsx: -------------------------------------------------------------------------------- 1 | import '@ungap/url-search-params' 2 | import 'react-app-polyfill/ie11' 3 | import 'react-app-polyfill/stable' 4 | import React from 'react' 5 | import ReactDOM from 'react-dom' 6 | import './styles/index.scss' 7 | import App from './App' 8 | 9 | export function render() { 10 | ReactDOM.render(, document.getElementById('root')) 11 | } 12 | 13 | render() 14 | -------------------------------------------------------------------------------- /src/examples/use-location-state/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/hooks/useRefLatest.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useRef } from 'react' 2 | 3 | const isSSR = typeof window === 'undefined' || typeof document === 'undefined' 4 | const useIsoLayoutEffect = isSSR ? useEffect : useLayoutEffect 5 | 6 | export function useRefLatest(value: T) { 7 | const ref = useRef(value) 8 | 9 | useIsoLayoutEffect(() => { 10 | ref.current = value 11 | }, [value]) 12 | 13 | return ref 14 | } 15 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/use-location-state.ts: -------------------------------------------------------------------------------- 1 | export * from './useQueryState/useQueryState.types' 2 | export * from './useLocationState/useLocationState.types' 3 | export * from './types/sharedTypes' 4 | export * from './useQueryState/useQueryState' 5 | export * from './useQueryState/useQueryReducer' 6 | export * from './useLocationState/useLocationState' 7 | export * from './useLocationState/useLocationReducer' 8 | export * from './useQueryState/useHashQueryState' 9 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.1.2", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "command": { 6 | "version": { 7 | "allowBranch": "master" 8 | }, 9 | "publish": { 10 | "allowBranch": "master", 11 | "ignoreChanges": ["ignored-file"], 12 | "message": "chore(release): publish" 13 | }, 14 | "bootstrap": { 15 | "ignore": "src/packages/*", 16 | "npmClientArgs": [""] 17 | } 18 | }, 19 | "packages": ["src/packages/*"] 20 | } 21 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/components/Nav.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface Props {} 4 | 5 | export default function Nav(props: Props) { 6 | return ( 7 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-location-state-next-demo", 3 | "private": true, 4 | "version": "3.1.2", 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@types/react": "^17.0.2", 12 | "@types/react-dom": "^17.0.2", 13 | "next": "^12.3.1", 14 | "prettier": "^2.0.5", 15 | "react": "^17.0.2", 16 | "react-dom": "^17.0.2", 17 | "use-location-state": "^3.1.2" 18 | }, 19 | "license": "ISC" 20 | } 21 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/QueryReducer/QueryReducerTypes.ts: -------------------------------------------------------------------------------- 1 | import { QueryDispatch } from 'react-router-use-location-state' 2 | import { ActionTypes, FilterKeys } from './filterReducer' 3 | 4 | export type Filter = FilterLeaf | FilterWithSubFilters 5 | 6 | export type FilterDisplayProps = Filter & { 7 | activeFilters: FilterKeys 8 | filtersDispatch: QueryDispatch 9 | } 10 | 11 | export interface FilterWithSubFilters { 12 | title: string 13 | subFilters: Filter[] 14 | } 15 | 16 | export interface FilterLeaf { 17 | filterKey: string 18 | } 19 | -------------------------------------------------------------------------------- /src/examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/components/Nav.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | 4 | export default function Nav() { 5 | return ( 6 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/useTestQueryStringInterface.ts: -------------------------------------------------------------------------------- 1 | import { QueryStringInterface } from '../useQueryState.types' 2 | import { useRef, useState } from 'react' 3 | 4 | export default function useTestQueryStringInterface(): QueryStringInterface { 5 | const [queryString, setQueryString] = useState('') 6 | const latestQueryString = useRef(queryString) 7 | 8 | return { 9 | getQueryString: () => latestQueryString.current, 10 | setQueryString: (newQueryString) => { 11 | latestQueryString.current = newQueryString 12 | setQueryString(newQueryString) 13 | }, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/components/QueryStateCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { useQueryState } from 'use-location-state' 3 | 4 | interface Props { 5 | name?: string 6 | method?: 'replace' | 'push' 7 | } 8 | 9 | export default memo(function QueryStateCheckbox({ 10 | name = 'active', 11 | method, 12 | }: Props) { 13 | const [active, setActive] = useQueryState(name, false) 14 | 15 | return ( 16 | 24 | ) 25 | }) 26 | -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | # Minimal config. version is the only required field. 2 | version = 1 3 | 4 | [merge] 5 | # If there is a merge conflict, make a comment on the PR and remove the 6 | # automerge label. This option only applies when `merge.require_automerge_label` 7 | # is enabled. 8 | notify_on_conflict = false # default: true 9 | 10 | [update] 11 | # When a pull request's author matches update.ignored_usernames, Kodiak will never update the pull request, unless update.autoupdate_label is applied to the pull request. 12 | # If the user is a bot user, remove the [bot] suffix from their username. So instead of dependabot-preview[bot], use dependabot-preview. 13 | ignored_usernames = ["dependabot"] 14 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/components/LocationStateCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { useLocationState } from 'use-location-state' 3 | 4 | interface Props { 5 | name?: string 6 | method?: 'replace' | 'push' 7 | } 8 | 9 | export default memo(function LocationStateCheckbox({ 10 | name = 'active', 11 | method, 12 | }: Props) { 13 | const [active, setActive] = useLocationState(name, false) 14 | 15 | return ( 16 | 24 | ) 25 | }) 26 | -------------------------------------------------------------------------------- /src/examples/use-location-state/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "target": "es5", 8 | "lib": [ 9 | "dom", 10 | "dom.iterable", 11 | "esnext" 12 | ], 13 | "allowJs": false, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "strict": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "jsx": "react-jsx", 25 | "noFallthroughCasesInSwitch": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "target": "es5", 8 | "lib": [ 9 | "dom", 10 | "dom.iterable", 11 | "esnext" 12 | ], 13 | "allowJs": false, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "strict": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "jsx": "react-jsx", 25 | "noFallthroughCasesInSwitch": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Header from './components/Header' 3 | import usePageComponent from './hooks/usePageComponent' 4 | // demo pages 5 | import QueryStateDemo from './pages/QueryStateDemo' 6 | import LocationStateDemo from './pages/LocationStateDemo' 7 | import ArrayDemo from './pages/ArrayDemo' 8 | 9 | export default function App() { 10 | const PageComponent = usePageComponent({ 11 | componentsForPathname: { 12 | '/': QueryStateDemo, 13 | '/location-state': LocationStateDemo, 14 | '/array-demo': ArrayDemo, 15 | }, 16 | }) 17 | 18 | return ( 19 |
20 |
21 | 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/test/__snapshots__/QueryStateTest.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`QueryStateDemo can set name with button 1`] = ` 4 |
 7 |   {
 8 |  "name": "Felix",
 9 |  "age": 25,
10 |  "active": false
11 | }
12 | 
13 | `; 14 | 15 | exports[`QueryStateDemo can set name with button 2`] = ` 16 |
19 |   {
20 |  "name": "Kim",
21 |  "age": 25,
22 |  "active": false
23 | }
24 | 
25 | `; 26 | 27 | exports[`QueryStateDemo can set name with button 3`] = ` 28 |
31 |   {
32 |  "name": "Sarah",
33 |  "age": 25,
34 |  "active": false
35 | }
36 | 
37 | `; 38 | -------------------------------------------------------------------------------- /src/examples/nextjs/components/NamesList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useQueryState } from 'use-location-state/next' 3 | 4 | export default function NamesList() { 5 | const [names, setNames] = useQueryState('names', []) 6 | 7 | return ( 8 |
    9 | {names.map((name, nameIndex) => ( 10 |
  • 11 | {name}{' '} 12 | 24 |
  • 25 | ))} 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": [ 25 | "src" 26 | ], 27 | "exclude": [ 28 | "**/dist/**", 29 | "**/build/**", 30 | "**/node_modules/**", 31 | "**.test.**" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/packages/query-state-core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [1.0.0](https://github.com/xiel/use-location-state/compare/v0.1.2...v1.0.0) (2019-04-23) 7 | 8 | **Note:** Version bump only for package query-state-core 9 | 10 | 11 | 12 | 13 | 14 | ## [0.1.2](https://github.com/xiel/use-location-state/compare/v0.1.1...v0.1.2) (2019-04-21) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * test ([63fbc41](https://github.com/xiel/use-location-state/commit/63fbc41)) 20 | 21 | 22 | 23 | 24 | 25 | ## [0.1.1](https://github.com/xiel/use-location-state/compare/v0.0.1-alpha.2...v0.1.1) (2019-04-21) 26 | 27 | **Note:** Version bump only for package query-state-core 28 | -------------------------------------------------------------------------------- /src/examples/nextjs/components/AddName.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useQueryState } from 'use-location-state/next' 3 | 4 | export default function AddName() { 5 | const [names, setNames] = useQueryState('names', []) 6 | const [name, nameSet] = useState('') 7 | 8 | return ( 9 |
{ 11 | e.preventDefault() 12 | if (!name) return 13 | nameSet('') 14 | setNames(names.concat(name)) 15 | }} 16 | > 17 | 27 | 28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/hooks/usePageComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface Props { 4 | componentsForPathname: Record 5 | defaultComponent?: React.ComponentType 6 | } 7 | 8 | export default function usePageComponent({ 9 | componentsForPathname, 10 | defaultComponent = () =>

404 Not found

, 11 | }: Props) { 12 | let pathname = window.location.pathname || '/' 13 | let trimmedPathname = '' 14 | 15 | // tolerate (missing) trailing slashes 16 | if (pathname.slice(-1) === '/') { 17 | trimmedPathname = pathname.slice(0, -1) 18 | } else { 19 | trimmedPathname = pathname 20 | pathname = pathname + '/' 21 | } 22 | 23 | return ( 24 | componentsForPathname[pathname] || 25 | componentsForPathname[trimmedPathname] || 26 | defaultComponent 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [18.x] 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: npm install, build, and test 19 | run: | 20 | yarn install --frozen-lockfile 21 | yarn build 22 | yarn test --silent --coverage 23 | yarn lint 24 | curl -Os https://uploader.codecov.io/latest/linux/codecov 25 | chmod +x codecov 26 | ./codecov -t ${{ secrets.CODECOV_GITHUB_TOKEN }} 27 | npx bundlesize 28 | env: 29 | CI: true 30 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/pages/test/__snapshots__/QueryStateTest.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`QueryStateDemo can set name with button 1`] = ` 4 |
 7 |   {
 8 |  "name": "Felix",
 9 |  "age": 25,
10 |  "active": false,
11 |  "date": "2019-01-01T00:00:00.000Z"
12 | }
13 | 
14 | `; 15 | 16 | exports[`QueryStateDemo can set name with button 2`] = ` 17 |
20 |   {
21 |  "name": "Kim",
22 |  "age": 25,
23 |  "active": false,
24 |  "date": "2019-01-01T00:00:00.000Z"
25 | }
26 | 
27 | `; 28 | 29 | exports[`QueryStateDemo can set name with button 3`] = ` 30 |
33 |   {
34 |  "name": "Sarah",
35 |  "age": 25,
36 |  "active": false,
37 |  "date": "2019-01-01T00:00:00.000Z"
38 | }
39 | 
40 | `; 41 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/useQueryState.types.ts: -------------------------------------------------------------------------------- 1 | import { QueryStateMerge } from 'query-state-core' 2 | 3 | export type QueryString = string 4 | 5 | export type SetQueryStateFn = ( 6 | newState: QueryStateMerge, 7 | opts?: SetQueryStringOptions 8 | ) => void 9 | 10 | export interface QueryStringInterface { 11 | getQueryString: () => QueryString 12 | setQueryString: ( 13 | newQueryString: QueryString, 14 | opts: SetQueryStringOptions 15 | ) => void 16 | } 17 | 18 | export interface SetQueryStringOptions { 19 | method?: 'replace' | 'push' 20 | } 21 | 22 | export type QueryStateOpts = { 23 | stripDefaults?: boolean 24 | queryStringInterface?: QueryStringInterface 25 | } 26 | 27 | export type QueryStateOptsSetInterface = { 28 | stripDefaults?: boolean 29 | } 30 | 31 | export type QueryStateType = string | number | boolean | Date | string[] 32 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/README.md: -------------------------------------------------------------------------------- 1 | # react-router-use-location-state test/demo 2 | 3 | [![Edit react-router-use-location-state-example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/xiel/use-location-state/tree/master/src/examples/react-router-use-location-state?fontsize=14&module=%2Fsrc%2Fpages%2FQueryStateDemo.tsx) 4 | 5 | - [use-location-state (Github)](https://github.com/xiel/use-location-state/tree/master/src/packages/use-location-state) 6 | 7 | ````typescript 8 | // option for react-router < 5.0.0 or in case context is not available (anymore) 9 | export function useHistoryQueryState(itemName: string, defaultValue: T, history: H.History) { 10 | const queryStringInterface = useReactRouterQueryStringInterface(history) 11 | return useQueryState(itemName, defaultValue, { 12 | queryStringInterface, 13 | }) 14 | } 15 | ```` 16 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 3 | import Header from './components/Header' 4 | // demo pages 5 | import QueryStateDemo from './pages/QueryStateDemo' 6 | import ArrayDemo from './pages/ArrayDemo' 7 | import LocationStateDemo from './pages/LocationStateDemo' 8 | import QueryReducerDemo from './pages/QueryReducer/QueryReducer' 9 | 10 | export default function App() { 11 | return ( 12 |
13 | 14 |
15 | 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | 21 | 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/examples/use-location-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "01-simple", 3 | "version": "3.1.2", 4 | "private": true, 5 | "devDependencies": { 6 | "@types/react": "^16.8.8", 7 | "@types/react-dom": "^16.8.2", 8 | "add": "^2.0.6", 9 | "react-scripts": "^5.0.0", 10 | "sass": "^1.45.1", 11 | "typescript": "*" 12 | }, 13 | "dependencies": { 14 | "@ungap/url-search-params": "^0.2.2", 15 | "react": "^17.0.2", 16 | "react-app-polyfill": "^1.0.0", 17 | "react-dom": "^17.0.2", 18 | "use-location-state": "^3.1.2" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "echo \"Error: run tests from root\" && exit 1", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": "react-app" 28 | }, 29 | "browserslist": [ 30 | ">0.2%", 31 | "not dead", 32 | "not ie <= 10", 33 | "not op_mini all" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/types/sharedTypes.ts: -------------------------------------------------------------------------------- 1 | // Since action _can_ be undefined, dispatch may be called without any parameters. 2 | // export type DispatchWithoutAction = () => void 3 | // Unlike redux, the actions _can_ be anything 4 | export type Reducer = (prevState: S, action: A) => S 5 | // If useReducer accepts a reducer without action, dispatch may be called without any parameters. 6 | export type ReducerWithoutAction = (prevState: S) => S 7 | // types used to try and prevent the compiler from reducing S 8 | // to a supertype common with the second argument to useReducer() 9 | export type ReducerState> = R extends Reducer< 10 | infer S, 11 | any 12 | > 13 | ? S 14 | : never 15 | 16 | export type ReducerAction> = R extends Reducer< 17 | any, 18 | infer A 19 | > 20 | ? A 21 | : never 22 | 23 | export type LazyValueFn = () => S 24 | export type SetStateAction = S | ((prevState: S) => S) 25 | -------------------------------------------------------------------------------- /DEVNOTES.md: -------------------------------------------------------------------------------- 1 | # DEVNOTES 2 | 3 | ### Release 4 | 5 | ## update version: 6 | 7 | ``` 8 | yarn build; yarn lerna version 9 | ``` 10 | 11 | ## publish a pre-release - dist-tag: next 12 | 13 | create a pre-release version before 14 | 15 | ``` 16 | yarn lerna publish from-package --dist-tag next --ignore-prepublish 17 | ``` 18 | 19 | add dist-tag "latest" to previously published packages under "next": 20 | 21 | ``` 22 | npx lerna exec --no-bail --no-private --no-sort --stream -- '[ -n "$(npm v . dist-tags.next)" ] && npm dist-tag add ${LERNA_PACKAGE_NAME}@$(npm v . dist-tags.next) latest' 23 | ``` 24 | 25 | remove next tag from all packages: 26 | 27 | ``` 28 | npx lerna exec --no-bail --no-private --no-sort --stream -- '[ -n "$(npm v . dist-tags.next)" ] && npm dist-tag rm ${LERNA_PACKAGE_NAME}@$(npm v . dist-tags.next) next' 29 | ``` 30 | 31 | ## publish a stable release - dist-tag: latest 32 | 33 | ``` 34 | yarn lerna publish from-package --dist-tag latest --ignore-prepublish 35 | ``` 36 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/src/useQueryState/useQueryState.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QueryStateOpts, 3 | Reducer, 4 | ReducerState, 5 | useQueryReducer as useQueryReducerImported, 6 | useQueryState as useQueryStateImported, 7 | } from 'use-location-state' 8 | import { useReactRouterQueryStringInterface } from './useReactRouterQueryStringInterface' 9 | 10 | export function useQueryState( 11 | itemName: string, 12 | defaultValue: T, 13 | queryStateOpts?: QueryStateOpts 14 | ) { 15 | return useQueryStateImported(itemName, defaultValue, { 16 | queryStringInterface: useReactRouterQueryStringInterface(), 17 | ...queryStateOpts, 18 | }) 19 | } 20 | 21 | export function useQueryReducer>( 22 | itemName: string, 23 | reducer: R, 24 | initialState: ReducerState, 25 | queryStateOpts?: QueryStateOpts 26 | ) { 27 | return useQueryReducerImported(itemName, reducer, initialState, { 28 | queryStringInterface: useReactRouterQueryStringInterface(), 29 | ...queryStateOpts, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-use-location-state-example", 3 | "version": "3.1.2", 4 | "private": true, 5 | "devDependencies": { 6 | "@types/react": "^16.8.8", 7 | "@types/react-dom": "^16.8.2", 8 | "add": "^2.0.6", 9 | "react-scripts": "^5.0.0", 10 | "sass": "^1.45.1", 11 | "typescript": "*" 12 | }, 13 | "dependencies": { 14 | "@ungap/url-search-params": "^0.2.2", 15 | "react": "^17.0.2", 16 | "react-app-polyfill": "^1.0.0", 17 | "react-dom": "^17.0.2", 18 | "react-router-dom": "^6.0.2", 19 | "react-router-use-location-state": "^3.1.2" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "echo \"Error: run tests from root\" && exit 1", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": "react-app" 29 | }, 30 | "browserslist": [ 31 | ">0.2%", 32 | "not dead", 33 | "not ie <= 10", 34 | "not op_mini all" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/helpers/use-location-state-test-helpers/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { act } from 'react-test-renderer' 2 | 3 | export async function asyncAct(callback: () => any) { 4 | return await act(async () => { 5 | await callback() 6 | }) 7 | } 8 | 9 | // nicer access to the current value and setter function from the result ref 10 | export function unwrapResult(result: { current: [A, ASet] }) { 11 | return { 12 | get value() { 13 | return result.current[0] 14 | }, 15 | setValue: result.current[1], 16 | } 17 | } 18 | 19 | // nicer access to the current value and setter function from the result ref 20 | export function unwrapABResult(result: { 21 | current: { a: [A, ASet]; b: [B, BSet] } 22 | }) { 23 | return { 24 | a: { 25 | get value() { 26 | return result.current['a'][0] 27 | }, 28 | setValue: result.current['a'][1], 29 | }, 30 | b: { 31 | get value() { 32 | return result.current['b'][0] 33 | }, 34 | setValue: result.current['b'][1], 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useLocationState/useLocationState.types.ts: -------------------------------------------------------------------------------- 1 | import { SetStateAction } from '../types/sharedTypes' 2 | 3 | export const LOCATION_STATE_KEY = '__useLocationState' 4 | 5 | export type LocationStateValue = 6 | | string 7 | | number 8 | | boolean 9 | | undefined 10 | | Date 11 | | Array 12 | 13 | export type LocationState = Record 14 | 15 | export type GlobalLocationState = { 16 | [LOCATION_STATE_KEY]: LocationState 17 | [key: string]: unknown 18 | } 19 | 20 | export type LocationStateOpts = { 21 | locationStateInterface?: LocationStateInterface 22 | } 23 | 24 | export interface LocationStateInterface { 25 | getLocationState: () => LocationState 26 | setLocationState: ( 27 | newState: LocationState, 28 | opts: SetLocationStateOptions 29 | ) => void 30 | } 31 | 32 | export interface SetLocationStateOptions { 33 | method?: 'replace' | 'push' 34 | } 35 | 36 | export type SetLocationState = ( 37 | newValue: SetStateAction, 38 | opts?: SetLocationStateOptions 39 | ) => void 40 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/src/useLocationState/useLocationState.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LocationStateOpts, 3 | Reducer, 4 | ReducerState, 5 | SetLocationState, 6 | useLocationState as useLocationStateImported, 7 | useLocationReducer as useLocationReducerImported, 8 | } from 'use-location-state' 9 | import { useReactRouterLocationStateInterface } from './useReactRouterLocationStateInterface' 10 | 11 | export function useLocationState( 12 | itemName: string, 13 | defaultValue: S | (() => S) 14 | ): [S, SetLocationState] { 15 | return useLocationStateImported(itemName, defaultValue, { 16 | locationStateInterface: useReactRouterLocationStateInterface(), 17 | }) 18 | } 19 | 20 | export function useLocationReducer>( 21 | itemName: string, 22 | reducer: R, 23 | initialState: ReducerState, 24 | locationStateOpts?: LocationStateOpts 25 | ) { 26 | return useLocationReducerImported(itemName, reducer, initialState, { 27 | locationStateInterface: useReactRouterLocationStateInterface(), 28 | ...locationStateOpts, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/useHashQueryState.ts: -------------------------------------------------------------------------------- 1 | import { QueryState } from 'query-state-core' 2 | import { 3 | QueryStateOptsSetInterface, 4 | QueryStateType, 5 | } from './useQueryState.types' 6 | import { useHashQueryStringInterface } from './useHashQueryStringInterface' 7 | import { useQueryState } from './useQueryState' 8 | import { useQueryStateObj } from './useQueryStateObj' 9 | 10 | export function useHashQueryStateObj( 11 | defaultQueryState: T, 12 | queryStateOpts: QueryStateOptsSetInterface = {} 13 | ) { 14 | const hashQSI = useHashQueryStringInterface() 15 | return useQueryStateObj(defaultQueryState, { 16 | ...queryStateOpts, 17 | queryStringInterface: hashQSI, 18 | }) 19 | } 20 | 21 | export function useHashQueryState( 22 | itemName: string, 23 | defaultValue: T, 24 | queryStateOpts: QueryStateOptsSetInterface = {} 25 | ) { 26 | const hashQSI = useHashQueryStringInterface() 27 | return useQueryState(itemName, defaultValue, { 28 | ...queryStateOpts, 29 | queryStringInterface: hashQSI, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/QueryReducer/filterReducer.ts: -------------------------------------------------------------------------------- 1 | export type FilterKeys = Readonly 2 | export type ActionFilterKeys = string | FilterKeys 3 | 4 | export interface ClearAction { 5 | type: 'clear' 6 | } 7 | export interface AddAction { 8 | type: 'add' 9 | toAdd: ActionFilterKeys 10 | } 11 | export interface RemoveAction { 12 | type: 'remove' 13 | toRemove: ActionFilterKeys 14 | } 15 | export type ActionTypes = ClearAction | AddAction | RemoveAction 16 | 17 | export const emptyFilters: FilterKeys = Object.freeze([]) 18 | 19 | export function filterReducer( 20 | filterKeys: FilterKeys, 21 | action: ActionTypes 22 | ): FilterKeys { 23 | switch (action.type) { 24 | case 'clear': 25 | return [] 26 | case 'add': 27 | return Array.from( 28 | new Set(filterKeys.concat(action.toAdd).filter((x) => x)) 29 | ) 30 | case 'remove': 31 | return filterKeys.filter((x) => !action.toRemove.includes(x)) 32 | default: 33 | // @ts-expect-error 34 | throw new Error('Unhandled action in filterKeyReducer: ' + action.type) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/packages/query-state-core/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | import pkg from './package.json' 3 | 4 | const typescriptPluginOptions = { 5 | tsconfigOverride: { 6 | compilerOptions: { 7 | declaration: true, 8 | allowJs: false, 9 | isolatedModules: false, 10 | }, 11 | }, 12 | } 13 | 14 | export default [ 15 | // CommonJS (for Node) and ES module (for bundlers) build 16 | { 17 | input: `src/${pkg.name}.ts`, 18 | external: [ 19 | ...Object.keys(pkg.dependencies || {}), 20 | ...Object.keys(pkg.peerDependencies || {}), 21 | ], 22 | plugins: [ 23 | typescript(typescriptPluginOptions), // so Rollup can convert TypeScript to JavaScript 24 | ], 25 | output: [ 26 | { file: pkg.main, format: 'cjs' }, 27 | { file: pkg.module, format: 'es' }, 28 | ], 29 | }, 30 | ] 31 | 32 | // rollup config with typescript was adopted from: 33 | // - https://github.com/rollup/rollup-starter-lib/blob/typescript/rollup.config.js 34 | // - https://hackernoon.com/building-and-publishing-a-module-with-typescript-and-rollup-js-faa778c85396 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Felix Leupold 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | import pkg from './package.json' 3 | 4 | const typescriptPluginOptions = { 5 | tsconfigOverride: { 6 | compilerOptions: { 7 | declaration: true, 8 | allowJs: false, 9 | isolatedModules: false, 10 | }, 11 | }, 12 | } 13 | 14 | export default [ 15 | // CommonJS (for Node) and ES module (for bundlers) build 16 | { 17 | input: `src/${pkg.name}.ts`, 18 | external: [ 19 | ...Object.keys(pkg.dependencies || {}), 20 | ...Object.keys(pkg.peerDependencies || {}), 21 | ], 22 | plugins: [ 23 | typescript(typescriptPluginOptions), // so Rollup can convert TypeScript to JavaScript 24 | ], 25 | output: [ 26 | { file: pkg.main, format: 'cjs' }, 27 | { file: pkg.module, format: 'es' }, 28 | ], 29 | }, 30 | ] 31 | 32 | // rollup config with typescript was adopted from: 33 | // - https://github.com/rollup/rollup-starter-lib/blob/typescript/rollup.config.js 34 | // - https://hackernoon.com/building-and-publishing-a-module-with-typescript-and-rollup-js-faa778c85396 35 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/src/useQueryState/useReactRouterQueryStringInterface.ts: -------------------------------------------------------------------------------- 1 | import { useLocation, useNavigate } from 'react-router' 2 | import { QueryStringInterface } from 'use-location-state' 3 | 4 | // Needed for updates that happen right after each other (sync) as we do not have access to the latest history ref (since react router v6) 5 | let virtualQueryString: null | string = null 6 | 7 | export function useReactRouterQueryStringInterface(): 8 | | QueryStringInterface 9 | | undefined { 10 | const location = useLocation() 11 | const navigate = useNavigate() 12 | 13 | // Use the real one again as soon as location changes and update was incorporated 14 | virtualQueryString = null 15 | 16 | return { 17 | getQueryString: () => { 18 | return typeof virtualQueryString === 'string' 19 | ? virtualQueryString 20 | : location.search 21 | }, 22 | setQueryString: (newQueryString, { method = 'replace' }) => { 23 | navigate(`${location.pathname}?${newQueryString}${location.hash}`, { 24 | replace: method === 'replace', 25 | }) 26 | virtualQueryString = newQueryString 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/useQueryState.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | import { QueryStateOpts } from './useQueryState.types' 3 | import { QueryDispatch, useQueryReducer } from './useQueryReducer' 4 | import { LazyValueFn, Reducer, SetStateAction } from '../types/sharedTypes' 5 | import { useCallback } from 'react' 6 | 7 | export function useQueryState( 8 | itemName: string, 9 | initialState: S | LazyValueFn, 10 | queryStateOpts: QueryStateOpts = {} 11 | ): [S, QueryDispatch>] { 12 | const reducer: Reducer> = useCallback( 13 | (prevState: S, action: SetStateAction) => { 14 | if (action && typeof action === 'function') { 15 | return (action as (prevState: S) => S)(prevState) 16 | } 17 | return action 18 | }, 19 | [] 20 | ) 21 | 22 | if (typeof initialState === 'function') { 23 | return useQueryReducer( 24 | itemName, 25 | reducer, 26 | undefined, 27 | initialState as LazyValueFn, 28 | queryStateOpts 29 | ) 30 | } 31 | return useQueryReducer(itemName, reducer, initialState, queryStateOpts) 32 | } 33 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/pages/test/ArrayDemo.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cleanup, fireEvent, render } from '@testing-library/react' 3 | import ArrayDemo from '../ArrayDemo' 4 | 5 | const location = window.location 6 | 7 | // reset jest mocked hash 8 | beforeAll(() => { 9 | location.hash = '' 10 | }) 11 | 12 | afterEach(() => { 13 | cleanup() 14 | location.hash = '' 15 | }) 16 | 17 | describe('ArrayDemo', () => { 18 | test('ArrayDemo renders without crash/loop', async () => { 19 | expect(render()) 20 | }) 21 | 22 | test('can enable tag using button', async () => { 23 | const { getByLabelText } = render() 24 | expect(location.hash).toEqual('') 25 | 26 | // should put new names into the hash (and age default value comes along) 27 | fireEvent.click(getByLabelText('Tag 1')) 28 | expect(location.hash).toEqual('#tags=tag1') 29 | fireEvent.click(getByLabelText('Tag 2')) 30 | fireEvent.click(getByLabelText('Tag 3')) 31 | expect(location.hash).toEqual('#tags=tag1&tags=tag2&tags=tag3') 32 | fireEvent.click(getByLabelText('Tag 1')) 33 | expect(location.hash).toEqual('#tags=tag2&tags=tag3') 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/method-default.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks' 2 | import { useQueryState } from '../../use-location-state' 3 | import { 4 | asyncAct, 5 | unwrapResult, 6 | } from 'use-location-state-test-helpers/test-helpers' 7 | import { cleanup } from '@testing-library/react' 8 | 9 | // reset jest mocked hash 10 | beforeAll(() => { 11 | window.location.hash = '' 12 | }) 13 | 14 | afterEach(() => { 15 | cleanup() 16 | }) 17 | 18 | describe('method - replace', () => { 19 | it('should not create new history - default/replace', async () => { 20 | const { result, unmount } = renderHook( 21 | ({ itemName, defaultValue }) => useQueryState(itemName, defaultValue), 22 | { 23 | initialProps: { itemName: 'name', defaultValue: 'Sarah' }, 24 | } 25 | ) 26 | const name = unwrapResult(result) 27 | expect(name.value).toEqual('Sarah') 28 | expect(window.history.length).toEqual(1) 29 | 30 | await asyncAct(async () => void name.setValue('Kim')) 31 | 32 | expect(window.location.hash).toEqual('#name=Kim') 33 | expect(name.value).toEqual('Kim') 34 | expect(window.history.length).toEqual(1) 35 | unmount() 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/packages/query-state-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-state-core", 3 | "main": "dist/query-state-core.cjs.js", 4 | "module": "dist/query-state-core.esm.js", 5 | "types": "dist/query-state-core.d.ts", 6 | "description": "thin layer on around URLSearchParams to parse/merge/stringify QueryStates", 7 | "version": "3.1.0", 8 | "author": "Felix Leupold ", 9 | "homepage": "https://github.com/xiel/use-location-state", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "rollup": ">=2.0.0", 13 | "rollup-plugin-typescript2": ">=0.26.0", 14 | "tslib": "*", 15 | "typescript": "*" 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "repository": "git+https://github.com/xiel/use-location-state.git", 21 | "scripts": { 22 | "cleanup": "rm -rf dist && rm -rf .rpt2_cache && rm -rf node_modules/.cache", 23 | "build": "yarn cleanup; rollup -c", 24 | "dev": "rollup -c -w", 25 | "test": "echo \"Error: run tests from root\" && exit 1;" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/xiel/use-location-state/issues" 29 | }, 30 | "publishConfig": { 31 | "registry": "https://registry.npmjs.org" 32 | }, 33 | "gitHead": "0405f8897834eeb7ec5e989e0a5fd16198be15e9" 34 | } 35 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useLocationState/useLocationState.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | import { LocationStateOpts } from './useLocationState.types' 3 | import { LocationDispatch, useLocationReducer } from './useLocationReducer' 4 | import { LazyValueFn, SetStateAction } from '../types/sharedTypes' 5 | 6 | const locationStateOptsDefaults = Object.freeze({}) 7 | 8 | export function useLocationState( 9 | itemName: string, 10 | initialState: S | LazyValueFn, 11 | opts: LocationStateOpts = locationStateOptsDefaults 12 | ): [S, LocationDispatch>] { 13 | if (typeof initialState === 'function') { 14 | return useLocationReducer, any>( 15 | itemName, 16 | stateReducer, 17 | undefined, 18 | initialState as unknown as LazyValueFn, 19 | opts 20 | ) 21 | } 22 | return useLocationReducer>( 23 | itemName, 24 | stateReducer, 25 | initialState as unknown as S, 26 | opts 27 | ) 28 | } 29 | 30 | function stateReducer(prevState: S, action: SetStateAction): S { 31 | if (action && typeof action === 'function') { 32 | return (action as (prevState: S) => S)(prevState) 33 | } 34 | return action 35 | } 36 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/method-replace.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks' 2 | import { useQueryState } from '../../use-location-state' 3 | import { 4 | asyncAct, 5 | unwrapResult, 6 | } from 'use-location-state-test-helpers/test-helpers' 7 | import { cleanup } from '@testing-library/react' 8 | 9 | // reset jest mocked hash 10 | beforeAll(() => { 11 | window.location.hash = '' 12 | }) 13 | 14 | afterEach(() => { 15 | cleanup() 16 | }) 17 | 18 | describe('method - replace', () => { 19 | it('should not create new history when manually passing replace', async () => { 20 | const { result, unmount } = renderHook( 21 | ({ itemName, defaultValue }) => useQueryState(itemName, defaultValue), 22 | { 23 | initialProps: { itemName: 'name', defaultValue: 'Sarah' }, 24 | } 25 | ) 26 | const name = unwrapResult(result) 27 | expect(name.value).toEqual('Sarah') 28 | expect(window.history.length).toEqual(1) 29 | 30 | await asyncAct(async () => void name.setValue('Kim', { method: 'replace' })) 31 | 32 | expect(window.location.hash).toEqual('#name=Kim') 33 | expect(name.value).toEqual('Kim') 34 | expect(window.history.length).toEqual(1) 35 | unmount() 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/method-push.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks' 2 | import { useQueryState } from '../../use-location-state' 3 | import { 4 | asyncAct, 5 | unwrapResult, 6 | } from 'use-location-state-test-helpers/test-helpers' 7 | import { cleanup } from '@testing-library/react' 8 | 9 | // reset jest mocked hash 10 | beforeAll(() => { 11 | window.location.hash = '' 12 | }) 13 | 14 | afterEach(() => { 15 | window.location.hash = '' 16 | cleanup() 17 | }) 18 | 19 | describe('method - push', () => { 20 | it('method push creates new entries in history with each change', async () => { 21 | const { result, unmount } = renderHook( 22 | ({ itemName, defaultValue }) => useQueryState(itemName, defaultValue), 23 | { 24 | initialProps: { itemName: 'name', defaultValue: 'Sarah' }, 25 | } 26 | ) 27 | const name = unwrapResult(result) 28 | expect(name.value).toEqual('Sarah') 29 | expect(window.history.length).toEqual(1) 30 | 31 | await asyncAct(async () => void name.setValue('Kim', { method: 'push' })) 32 | 33 | expect(window.location.hash).toEqual('#name=Kim') 34 | expect(name.value).toEqual('Kim') 35 | 36 | // method push creates new entries in history with each change 37 | expect(window.history.length).toEqual(2) 38 | unmount() 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/test/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { cleanup, render } from '@testing-library/react' 4 | import App from '../App' 5 | 6 | afterEach(() => { 7 | cleanup() 8 | }) 9 | 10 | it('renders App.tsx without crashing', () => { 11 | const div = document.createElement('div') 12 | ReactDOM.render(, div) 13 | ReactDOM.unmountComponentAtNode(div) 14 | }) 15 | 16 | it('renders index.tsx without crashing', async () => { 17 | const div = document.createElement('div') 18 | div.id = 'root' 19 | document.documentElement.appendChild(div) 20 | const { render } = await import('../index') 21 | render() 22 | }) 23 | 24 | describe.each` 25 | pathname | title 26 | ${'/'} | ${'Intro'} 27 | ${'/array-demo'} | ${'Array Demo'} 28 | ${'/array-demo/'} | ${'Array Demo'} 29 | ${'/query-reducer/'} | ${'useQueryReducer Demo'} 30 | `( 31 | 'allows some pathname tolerance @ $pathname expect $title', 32 | ({ pathname, title }) => { 33 | afterAll(() => { 34 | window.history.replaceState(null, '', '/') 35 | }) 36 | 37 | test(`@ ${pathname} should render page with title ${title}`, () => { 38 | window.history.replaceState(null, '', pathname) 39 | const { getByText } = render() 40 | expect(getByText(title)) 41 | }) 42 | } 43 | ) 44 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 5 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | color: white; 9 | background-color: #282c34; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 14 | } 15 | 16 | a { 17 | color: #61dafb; 18 | text-decoration: none; 19 | } 20 | 21 | button { 22 | font: inherit; 23 | margin: 0 .25em; 24 | } 25 | 26 | ul, ol, li { 27 | list-style: none; 28 | margin: 0; 29 | padding: 0; 30 | } 31 | 32 | header { 33 | display: flex; 34 | flex-wrap: wrap; 35 | align-items: center; 36 | justify-content: space-between; 37 | 38 | h1 { 39 | font-weight: 100; 40 | margin-right: 1em; 41 | } 42 | } 43 | 44 | 45 | nav { 46 | //flex: 1; 47 | margin: 1em; 48 | padding: 10px; 49 | background: rgba(#000, .2); 50 | border-radius: 10em; 51 | 52 | ul { 53 | display: flex; 54 | } 55 | 56 | li { 57 | a { 58 | display: block; 59 | margin: .5em .5em; 60 | } 61 | } 62 | } 63 | 64 | .page-wrapper { 65 | padding: 1em; 66 | min-height: 100vh; 67 | max-width: 1024px; 68 | margin: 0 auto; 69 | } 70 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 5 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | color: white; 9 | background-color: #282c34; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 14 | } 15 | 16 | a { 17 | color: #61dafb; 18 | text-decoration: none; 19 | } 20 | 21 | button { 22 | font: inherit; 23 | margin: 0 .25em; 24 | } 25 | 26 | ul, ol, li { 27 | list-style: none; 28 | margin: 0; 29 | padding: 0; 30 | } 31 | 32 | header { 33 | display: flex; 34 | flex-wrap: wrap; 35 | align-items: center; 36 | justify-content: space-between; 37 | 38 | h1 { 39 | font-weight: 100; 40 | margin-right: 1em; 41 | } 42 | } 43 | 44 | 45 | nav { 46 | //flex: 1; 47 | margin: 1em; 48 | padding: 10px; 49 | background: rgba(#000, .2); 50 | border-radius: 10em; 51 | 52 | ul { 53 | display: flex; 54 | } 55 | 56 | li { 57 | a { 58 | display: block; 59 | margin: .5em .5em; 60 | } 61 | } 62 | } 63 | 64 | .page-wrapper { 65 | padding: 1em; 66 | min-height: 100vh; 67 | max-width: 1024px; 68 | margin: 0 auto; 69 | } 70 | -------------------------------------------------------------------------------- /src/packages/use-location-state/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [1.0.0](https://github.com/xiel/use-location-state/compare/v0.1.2...v1.0.0) (2019-04-23) 7 | 8 | 9 | ### Features 10 | 11 | * **use-location-state:** add useLocationHashQueryStringInterface hook ([4d61250](https://github.com/xiel/use-location-state/commit/4d61250)) 12 | * **use-location-state:** add useLocationHashQueryStringInterface hook ([0ca3601](https://github.com/xiel/use-location-state/commit/0ca3601)) 13 | * **useLocationHashQueryState:** provide new exports useLocationHashQueryState and useLocationHashQu ([666aa4a](https://github.com/xiel/use-location-state/commit/666aa4a)), closes [#20](https://github.com/xiel/use-location-state/issues/20) 14 | 15 | 16 | ### BREAKING CHANGES 17 | 18 | * **useLocationHashQueryState:** renamed exports, queryState and setQueryState are now all returned as array tuples 19 | 20 | 21 | 22 | 23 | 24 | ## [0.1.2](https://github.com/xiel/use-location-state/compare/v0.1.1...v0.1.2) (2019-04-21) 25 | 26 | **Note:** Version bump only for package use-location-state 27 | 28 | 29 | 30 | 31 | 32 | ## [0.1.1](https://github.com/xiel/use-location-state/compare/v0.0.1-alpha.2...v0.1.1) (2019-04-21) 33 | 34 | **Note:** Version bump only for package use-location-state 35 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/test/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { cleanup, render } from '@testing-library/react' 4 | import App from '../App' 5 | 6 | afterEach(() => { 7 | cleanup() 8 | }) 9 | 10 | it('renders App.tsx without crashing', () => { 11 | const div = document.createElement('div') 12 | ReactDOM.render(, div) 13 | ReactDOM.unmountComponentAtNode(div) 14 | }) 15 | 16 | it('renders index.tsx without crashing', async () => { 17 | const div = document.createElement('div') 18 | div.id = 'root' 19 | document.documentElement.appendChild(div) 20 | const { render } = await import('../index') 21 | render() 22 | }) 23 | 24 | describe.each` 25 | pathname | title 26 | ${'/'} | ${'Intro'} 27 | ${'/array-demo'} | ${'Array Demo'} 28 | ${'/array-demo/'} | ${'Array Demo'} 29 | ${'/location-state/'} | ${'useLocationState Demo'} 30 | ${'/404/'} | ${'404 Not found'} 31 | `( 32 | 'allows some pathname tolerance @ $pathname expect $title', 33 | ({ pathname, title }) => { 34 | afterAll(() => { 35 | window.history.replaceState(null, '', '/') 36 | }) 37 | 38 | test(`@ ${pathname} should render page with title ${title}`, () => { 39 | window.history.replaceState(null, '', pathname) 40 | const { getByText } = render() 41 | expect(getByText(title)) 42 | }) 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/pages/ArrayDemo.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEventHandler } from 'react' 2 | import { useQueryState } from 'use-location-state' 3 | 4 | const defaultTags: string[] = [] 5 | 6 | export default function ArrayDemo() { 7 | const [tags, setTags] = useQueryState('tags', defaultTags) 8 | 9 | const toggleTag: ChangeEventHandler = (e) => { 10 | const tag = e.target.value 11 | if (tags.includes(e.target.value)) { 12 | setTags(tags.filter((t) => t !== tag)) 13 | } else { 14 | setTags([...tags, tag]) 15 | } 16 | } 17 | 18 | return ( 19 |
20 |

Array Demo

21 |
22 | 31 | 40 | 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/ArrayDemo.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEventHandler } from 'react' 2 | import { useQueryState } from 'react-router-use-location-state' 3 | 4 | const defaultTags: string[] = [] 5 | 6 | export default function ArrayDemo() { 7 | const [tags, setTags] = useQueryState('tags', defaultTags) 8 | 9 | const toggleTag: ChangeEventHandler = (e) => { 10 | const tag = e.target.value 11 | if (tags.includes(e.target.value)) { 12 | setTags(tags.filter((t) => t !== tag)) 13 | } else { 14 | setTags([...tags, tag]) 15 | } 16 | } 17 | 18 | return ( 19 |
20 |

Array Demo

21 |
22 | 31 | 40 | 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-use-location-state", 3 | "main": "dist/react-router-use-location-state.cjs.js", 4 | "module": "dist/react-router-use-location-state.esm.js", 5 | "types": "dist/react-router-use-location-state.d.ts", 6 | "description": "react hook to the browsers location query state", 7 | "peerDependencies": { 8 | "react": "^16.8.0 || ^17.0.2 || ^18.0.0", 9 | "react-router": "^6.0.2" 10 | }, 11 | "dependencies": { 12 | "use-location-state": "^3.1.2" 13 | }, 14 | "version": "3.1.2", 15 | "author": "Felix Leupold ", 16 | "homepage": "https://github.com/xiel/use-location-state", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@types/react": "^17.0.5", 20 | "react": "^17.0.2", 21 | "rollup": ">=2.0.0", 22 | "rollup-plugin-typescript2": ">=0.26.0", 23 | "tslib": "*", 24 | "typescript": "*" 25 | }, 26 | "files": [ 27 | "dist" 28 | ], 29 | "repository": "git+https://github.com/xiel/use-location-state.git", 30 | "scripts": { 31 | "cleanup": "rm -rf dist && rm -rf .rpt2_cache && rm -rf node_modules/.cache", 32 | "build": "yarn cleanup; rollup -c", 33 | "dev": "rollup -c -w", 34 | "test": "echo \"Error: run tests from root\" && exit 1" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/xiel/use-location-state/issues" 38 | }, 39 | "publishConfig": { 40 | "registry": "https://registry.npmjs.org" 41 | }, 42 | "gitHead": "0405f8897834eeb7ec5e989e0a5fd16198be15e9" 43 | } 44 | -------------------------------------------------------------------------------- /src/examples/use-location-state/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [1.0.0](https://github.com/xiel/use-location-state/compare/v0.1.2...v1.0.0) (2019-04-23) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * **package:** update @types/node to version 11.13.7 ([0973706](https://github.com/xiel/use-location-state/commit/0973706)) 12 | * **package:** update react-scripts to version 3.0.0 ([c80d054](https://github.com/xiel/use-location-state/commit/c80d054)) 13 | 14 | 15 | ### Features 16 | 17 | * **use-location-state:** add useLocationHashQueryStringInterface hook ([4d61250](https://github.com/xiel/use-location-state/commit/4d61250)) 18 | * **useLocationHashQueryState:** provide new exports useLocationHashQueryState and useLocationHashQu ([666aa4a](https://github.com/xiel/use-location-state/commit/666aa4a)), closes [#20](https://github.com/xiel/use-location-state/issues/20) 19 | 20 | 21 | ### BREAKING CHANGES 22 | 23 | * **useLocationHashQueryState:** renamed exports, queryState and setQueryState are now all returned as array tuples 24 | 25 | 26 | 27 | 28 | 29 | ## [0.1.2](https://github.com/xiel/use-location-state/compare/v0.1.1...v0.1.2) (2019-04-21) 30 | 31 | **Note:** Version bump only for package 01-simple 32 | 33 | 34 | 35 | 36 | 37 | ## [0.1.1](https://github.com/xiel/use-location-state/compare/v0.0.1-alpha.2...v0.1.1) (2019-04-21) 38 | 39 | **Note:** Version bump only for package 01-simple 40 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [1.0.0](https://github.com/xiel/use-location-state/compare/v0.1.2...v1.0.0) (2019-04-23) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * **package:** update @types/node to version 11.13.7 ([0973706](https://github.com/xiel/use-location-state/commit/0973706)) 12 | * **package:** update react-scripts to version 3.0.0 ([c80d054](https://github.com/xiel/use-location-state/commit/c80d054)) 13 | 14 | 15 | ### Features 16 | 17 | * **use-location-state:** add useLocationHashQueryStringInterface hook ([4d61250](https://github.com/xiel/use-location-state/commit/4d61250)) 18 | * **useLocationHashQueryState:** provide new exports useLocationHashQueryState and useLocationHashQu ([666aa4a](https://github.com/xiel/use-location-state/commit/666aa4a)), closes [#20](https://github.com/xiel/use-location-state/issues/20) 19 | 20 | 21 | ### BREAKING CHANGES 22 | 23 | * **useLocationHashQueryState:** renamed exports, queryState and setQueryState are now all returned as array tuples 24 | 25 | 26 | 27 | 28 | 29 | ## [0.1.2](https://github.com/xiel/use-location-state/compare/v0.1.1...v0.1.2) (2019-04-21) 30 | 31 | **Note:** Version bump only for package 01-simple 32 | 33 | 34 | 35 | 36 | 37 | ## [0.1.1](https://github.com/xiel/use-location-state/compare/v0.0.1-alpha.2...v0.1.1) (2019-04-21) 38 | 39 | **Note:** Version bump only for package 01-simple 40 | -------------------------------------------------------------------------------- /src/examples/nextjs/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import React from 'react' 3 | import NamesList from '../components/NamesList' 4 | import AddName from '../components/AddName' 5 | 6 | import { useQueryState } from 'use-location-state/next' 7 | 8 | // Page must be server rendered otherwise React warns about a hydration mismatch 9 | // You can use your own getServerSideProps function or use this empty one 10 | export { getServerSideProps } from 'use-location-state/next' 11 | 12 | export default function Page() { 13 | const [countA, setCountA] = useQueryState('countA', 0) 14 | const [countB, setCountB] = useQueryState('countB', 0) 15 | 16 | return ( 17 |
18 |

Hello World.

19 | 20 |
21 | 22 | 23 | 24 | 25 |
26 | 27 | 30 | 33 | 41 | 50 | 51 |
52 | 53 | 54 | Reset All (link) 55 | 56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/src/useLocationState/useReactRouterLocationStateInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GlobalLocationState, 3 | LOCATION_STATE_KEY, 4 | LocationStateInterface, 5 | } from 'use-location-state' 6 | import { useLocation, useNavigate } from 'react-router' 7 | 8 | // Needed for updates that happen right after each other (sync) as we do not have access to the latest history ref (since react router v6) 9 | let virtualState: GlobalLocationState | null = null 10 | 11 | export function useReactRouterLocationStateInterface(): 12 | | LocationStateInterface 13 | | undefined { 14 | const location = useLocation() 15 | const navigate = useNavigate() 16 | 17 | // Use the real one again as soon as location changes and update was incorporated 18 | virtualState = null 19 | 20 | return { 21 | getLocationState: () => { 22 | const historyState = 23 | virtualState || (location.state as GlobalLocationState) 24 | return ( 25 | (historyState && 26 | LOCATION_STATE_KEY in historyState && 27 | historyState[LOCATION_STATE_KEY]) || 28 | {} 29 | ) 30 | }, 31 | setLocationState: (nextState, { method = 'replace' }) => { 32 | const historyState = (location.state || {}) as GlobalLocationState 33 | const updatedState = { 34 | ...historyState, 35 | [LOCATION_STATE_KEY]: nextState, 36 | } 37 | 38 | navigate(location, { 39 | state: updatedState, 40 | replace: method === 'replace', 41 | }) 42 | 43 | virtualState = updatedState 44 | }, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/packages/use-location-state/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | import pkg from './package.json' 3 | 4 | const typescriptPluginOptions = { 5 | tsconfigOverride: { 6 | compilerOptions: { 7 | declaration: true, 8 | allowJs: false, 9 | isolatedModules: false, 10 | }, 11 | }, 12 | } 13 | 14 | export default [ 15 | // CommonJS (for Node) and ES module (for bundlers) build 16 | { 17 | input: `src/${pkg.name}.ts`, 18 | external: [ 19 | ...Object.keys(pkg.dependencies || {}), 20 | ...Object.keys(pkg.peerDependencies || {}), 21 | ], 22 | plugins: [ 23 | typescript(typescriptPluginOptions), // so Rollup can convert TypeScript to JavaScript 24 | ], 25 | output: [ 26 | { file: pkg.main, format: 'cjs' }, 27 | { file: pkg.module, format: 'es' }, 28 | ], 29 | }, 30 | { 31 | input: `src/next.ts`, 32 | external: [ 33 | ...Object.keys(pkg.dependencies || {}), 34 | ...Object.keys(pkg.peerDependencies || {}), 35 | 'next/router', 36 | ], 37 | plugins: [ 38 | typescript(typescriptPluginOptions), // so Rollup can convert TypeScript to JavaScript 39 | ], 40 | output: [ 41 | { file: 'dist/next.cjs.js', format: 'cjs' }, 42 | { file: 'dist/next.esm.js', format: 'es' }, 43 | ], 44 | }, 45 | ] 46 | 47 | // rollup config with typescript was adopted from: 48 | // - https://github.com/rollup/rollup-starter-lib/blob/typescript/rollup.config.js 49 | // - https://hackernoon.com/building-and-publishing-a-module-with-typescript-and-rollup-js-faa778c85396 50 | -------------------------------------------------------------------------------- /src/examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | # Hello World example 2 | 3 | This example shows the most basic idea behind Next. We have 2 pages: `pages/index.js` and `pages/about.js`. The former responds to `/` requests and the latter to `/about`. Using `next/link` you can add hyperlinks between them with universal routing capabilities. The `day` directory shows that you can have subdirectories. 4 | 5 | ## Deploy your own 6 | 7 | Deploy the example using [Vercel](https://vercel.com): 8 | 9 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/vercel/next.js/tree/canary/examples/hello-world) 10 | 11 | ## How to use 12 | 13 | ### Using `create-next-app` 14 | 15 | Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: 16 | 17 | ```bash 18 | npm init next-app --example hello-world hello-world-app 19 | # or 20 | yarn create next-app --example hello-world hello-world-app 21 | ``` 22 | 23 | ### Download manually 24 | 25 | Download the example: 26 | 27 | ```bash 28 | curl https://codeload.github.com/vercel/next.js/tar.gz/canary | tar -xz --strip=2 next.js-canary/examples/hello-world 29 | cd hello-world 30 | ``` 31 | 32 | Install it and run: 33 | 34 | ```bash 35 | npm install 36 | npm run dev 37 | # or 38 | yarn 39 | yarn dev 40 | ``` 41 | 42 | Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). 43 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/test/ArrayDemo.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { act, cleanup, fireEvent, render } from '@testing-library/react' 3 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 4 | import ArrayDemo from '../ArrayDemo' 5 | 6 | const location = window.location 7 | 8 | // reset jest mocked hash 9 | beforeAll(() => { 10 | cleanup() 11 | }) 12 | 13 | afterEach(() => { 14 | cleanup() 15 | window.history.replaceState(null, '', '/') 16 | }) 17 | 18 | describe('ArrayDemo', () => { 19 | test('ArrayDemo renders without crash/loop', async () => { 20 | expect( 21 | render( 22 | 23 | 24 | } /> 25 | 26 | 27 | ) 28 | ) 29 | }) 30 | 31 | test('can enable tag using button', async () => { 32 | const { getByLabelText } = render( 33 | 34 | 35 | } /> 36 | 37 | 38 | ) 39 | expect(location.search).toEqual('') 40 | 41 | // should put new names into the hash (and age default value comes along) 42 | act(() => void fireEvent.click(getByLabelText('Tag 1'))) 43 | expect(location.search).toEqual('?tags=tag1') 44 | act(() => void fireEvent.click(getByLabelText('Tag 2'))) 45 | act(() => void fireEvent.click(getByLabelText('Tag 3'))) 46 | expect(location.search).toEqual('?tags=tag1&tags=tag2&tags=tag3') 47 | act(() => void fireEvent.click(getByLabelText('Tag 1'))) 48 | expect(location.search).toEqual('?tags=tag2&tags=tag3') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/examples/use-location-state/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/useHashQueryStringInterface.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react' 2 | import { QueryStringInterface } from './useQueryState.types' 3 | 4 | interface Props { 5 | disabled?: boolean 6 | } 7 | 8 | const hasWindowLocation = 9 | typeof window !== `undefined` && 'location' in window && 'history' in window 10 | 11 | export function useHashQueryStringInterface({ 12 | disabled = false, 13 | }: Props = {}): QueryStringInterface { 14 | const enabled = !disabled && hasWindowLocation 15 | const hashQSI: QueryStringInterface = useMemo( 16 | () => ({ 17 | getQueryString: () => { 18 | if (!enabled) return '' 19 | return window.location.hash 20 | }, 21 | 22 | setQueryString: (newQueryString, { method = 'replace' }) => { 23 | if (!enabled) return 24 | 25 | // use history to update hash using replace / push 26 | window.history[method === 'replace' ? 'replaceState' : 'pushState']( 27 | window.history.state, 28 | '', 29 | '#' + newQueryString 30 | ) 31 | 32 | // manually dispatch a hashchange event (replace state does not trigger this event) 33 | // so all subscribers get notified (old way for IE11) 34 | const customEvent = document.createEvent('CustomEvent') 35 | customEvent.initEvent('hashchange', false, false) 36 | window.dispatchEvent(customEvent) 37 | 38 | setR((r) => r + 1) 39 | }, 40 | }), 41 | [enabled] 42 | ) 43 | // this state is used to trigger re-renders 44 | const [, setR] = useState(0) 45 | 46 | useEffect(() => { 47 | if (!enabled) return 48 | const hashChangeHandler = () => { 49 | setR((r) => r + 1) 50 | } 51 | window.addEventListener('hashchange', hashChangeHandler, false) 52 | return () => 53 | window.removeEventListener('hashchange', hashChangeHandler, false) 54 | }, [enabled]) 55 | 56 | return hashQSI 57 | } 58 | -------------------------------------------------------------------------------- /src/packages/use-location-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-location-state", 3 | "main": "dist/use-location-state.cjs.js", 4 | "module": "dist/use-location-state.esm.js", 5 | "types": "dist/use-location-state.d.ts", 6 | "description": "react hook to the browsers location query state", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/use-location-state.esm.js", 10 | "types": "./dist/use-location-state.d.ts", 11 | "default": "./dist/use-location-state.cjs.js" 12 | }, 13 | "./next": { 14 | "import": "./dist/next.esm.js", 15 | "types": "./dist/next.d.ts", 16 | "default": "./dist/next.cjs.js" 17 | } 18 | }, 19 | "peerDependencies": { 20 | "@types/react": "^16.8.0 || ^17.0.2 || ^18.0.0", 21 | "next": "*", 22 | "react": "^16.8.0 || ^17.0.2 || ^18.0.0" 23 | }, 24 | "dependencies": { 25 | "query-state-core": "^3.1.0" 26 | }, 27 | "version": "3.1.2", 28 | "author": "Felix Leupold ", 29 | "homepage": "https://github.com/xiel/use-location-state", 30 | "license": "MIT", 31 | "devDependencies": { 32 | "@types/react": "^17.0.5", 33 | "next-router-mock": "^0.9.1", 34 | "react": "^17.0.2", 35 | "rollup": ">=2.0.0", 36 | "rollup-plugin-typescript2": ">=0.26.0", 37 | "tslib": "*", 38 | "typescript": "*" 39 | }, 40 | "files": [ 41 | "dist", 42 | "index.d.ts", 43 | "next.d.ts", 44 | "next.js" 45 | ], 46 | "repository": "git+https://github.com/xiel/use-location-state.git", 47 | "scripts": { 48 | "cleanup": "rm -rf dist && rm -rf .rpt2_cache && rm -rf node_modules/.cache", 49 | "build": "yarn cleanup; rollup -c", 50 | "dev": "rollup -c -w", 51 | "test": "echo \"Error: run tests from root\" && exit 1" 52 | }, 53 | "bugs": { 54 | "url": "https://github.com/xiel/use-location-state/issues" 55 | }, 56 | "publishConfig": { 57 | "registry": "https://registry.npmjs.org" 58 | }, 59 | "gitHead": "0405f8897834eeb7ec5e989e0a5fd16198be15e9" 60 | } 61 | -------------------------------------------------------------------------------- /src/packages/query-state-core/test/stripLeadingHashOrQuestionMark.test.ts: -------------------------------------------------------------------------------- 1 | import { stripLeadingHashOrQuestionMark } from '../src/query-state-core' 2 | 3 | describe('stripLeadingHashOrQuestionMark', () => { 4 | it('should strip leading question mark', () => { 5 | expect(stripLeadingHashOrQuestionMark('?qwe=rtz')).toBe('qwe=rtz') 6 | }) 7 | 8 | it('should strip leading hash tag', () => { 9 | expect(stripLeadingHashOrQuestionMark('#hash=bang')).toBe('hash=bang') 10 | }) 11 | 12 | it('should not strip leading hash tag in the middle', () => { 13 | expect(stripLeadingHashOrQuestionMark('hash=b#ng')).toBe('hash=b#ng') 14 | }) 15 | 16 | it('should not strip question mark in the middle', () => { 17 | expect(stripLeadingHashOrQuestionMark('hash=b?ng')).toBe('hash=b?ng') 18 | }) 19 | 20 | it('should not strip hash tag at the end', () => { 21 | expect(stripLeadingHashOrQuestionMark('qwe=rtz#')).toBe('qwe=rtz#') 22 | }) 23 | 24 | it('should not strip question mark at the end', () => { 25 | expect(stripLeadingHashOrQuestionMark('qwe=rtz?')).toBe('qwe=rtz?') 26 | }) 27 | 28 | it('should only strip leading hash tag', () => { 29 | expect(stripLeadingHashOrQuestionMark('#qwe=rtz#abc?=+')).toBe( 30 | 'qwe=rtz#abc?=+' 31 | ) 32 | }) 33 | 34 | it('should only strip leading question mark', () => { 35 | expect(stripLeadingHashOrQuestionMark('?qwe=rtz#abc?=+[1,2,3]')).toBe( 36 | 'qwe=rtz#abc?=+[1,2,3]' 37 | ) 38 | }) 39 | 40 | it('should handle empty strings', () => { 41 | expect(stripLeadingHashOrQuestionMark('')).toBe('') 42 | }) 43 | 44 | it('should handle undefined', () => { 45 | expect(stripLeadingHashOrQuestionMark(undefined)).toBe('') 46 | }) 47 | 48 | it('should empty strings only containing hash mark', () => { 49 | expect(stripLeadingHashOrQuestionMark('#')).toBe('') 50 | }) 51 | 52 | it('should empty strings only containing question mark', () => { 53 | expect(stripLeadingHashOrQuestionMark('?')).toBe('') 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/QueryReducer/QueryReducer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useQueryReducer } from 'react-router-use-location-state' 3 | import { emptyFilters, filterReducer } from './filterReducer' 4 | import { FilterDisplay } from './FilterDisplay' 5 | import { Filter } from './QueryReducerTypes' 6 | 7 | const availableFilters: Filter[] = [ 8 | { 9 | title: 'clothing', 10 | subFilters: [ 11 | { 12 | filterKey: 'shirts', 13 | }, 14 | { 15 | filterKey: 'jackets', 16 | }, 17 | { 18 | filterKey: 'shorts', 19 | }, 20 | ], 21 | }, 22 | { 23 | title: 'shoes', 24 | subFilters: [ 25 | { 26 | filterKey: 'boots', 27 | }, 28 | { 29 | filterKey: 'running', 30 | }, 31 | { 32 | filterKey: 'slippers', 33 | }, 34 | { 35 | filterKey: 'sneakers', 36 | subFilters: [ 37 | { 38 | filterKey: 'converse', 39 | }, 40 | { 41 | filterKey: 'other', 42 | }, 43 | ], 44 | }, 45 | ], 46 | }, 47 | ] 48 | 49 | export default function QueryReducerDemo() { 50 | const [activeFilters, filtersDispatch] = useQueryReducer( 51 | 'filters', 52 | filterReducer, 53 | emptyFilters 54 | ) 55 | 56 | return ( 57 |
58 |

useQueryReducer Demo

59 |
60 | 79 |
80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useLocationState/useLocationStateInterface.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react' 2 | import { 3 | LOCATION_STATE_KEY, 4 | LocationStateInterface, 5 | } from './useLocationState.types' 6 | 7 | interface Props { 8 | disabled?: boolean 9 | } 10 | 11 | const hasWindowHistory = typeof window !== `undefined` && 'history' in window 12 | 13 | export default function useLocationStateInterface({ 14 | disabled = false, 15 | }: Props = {}) { 16 | const enabled = !disabled && hasWindowHistory 17 | 18 | // this state is used to trigger re-renders 19 | const [, setR] = useState(0) 20 | 21 | const locationStateInterface: LocationStateInterface = useMemo( 22 | () => ({ 23 | getLocationState: () => { 24 | if (!enabled) return {} 25 | const historyState = window.history.state 26 | return ( 27 | (historyState && 28 | LOCATION_STATE_KEY in historyState && 29 | historyState[LOCATION_STATE_KEY]) || 30 | {} 31 | ) 32 | }, 33 | setLocationState: (nextState, { method = 'replace' }) => { 34 | if (!enabled) return null 35 | const historyState = window.history.state || {} 36 | const updatedState = { 37 | ...historyState, 38 | [LOCATION_STATE_KEY]: nextState, 39 | } 40 | 41 | // update history state using replace / push 42 | window.history[method === 'replace' ? 'replaceState' : 'pushState']( 43 | updatedState, 44 | '', 45 | '' 46 | ) 47 | 48 | // manually dispatch a hashchange event (replace state does not trigger this event) 49 | // so all subscribers get notified (old way for IE11) 50 | const customEvent = document.createEvent('CustomEvent') 51 | customEvent.initEvent('popstate', false, false) 52 | window.dispatchEvent(customEvent) 53 | }, 54 | }), 55 | [enabled] 56 | ) 57 | 58 | useEffect(() => { 59 | if (!enabled) return 60 | const popstateHandler = () => { 61 | setR((r) => r + 1) 62 | } 63 | window.addEventListener('popstate', popstateHandler, false) 64 | return () => window.removeEventListener('popstate', popstateHandler, false) 65 | }, [enabled]) 66 | 67 | return locationStateInterface 68 | } 69 | -------------------------------------------------------------------------------- /src/packages/query-state-core/test/parseQueryStateValue.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EMPTY_ARRAY_STRING, 3 | parseQueryStateValue, 4 | toQueryStateValue, 5 | } from '../src/query-state-core' 6 | 7 | describe.each` 8 | value | defaultValue | parsedValue 9 | ${EMPTY_ARRAY_STRING} | ${['abc']} | ${[]} 10 | ${EMPTY_ARRAY_STRING} | ${['']} | ${[]} 11 | ${EMPTY_ARRAY_STRING} | ${[]} | ${[]} 12 | ${''} | ${[]} | ${['']} 13 | ${undefined} | ${''} | ${null} 14 | ${'0'} | ${0} | ${0} 15 | ${'0'} | ${100} | ${0} 16 | ${'true'} | ${false} | ${true} 17 | ${'true'} | ${true} | ${true} 18 | ${'false'} | ${false} | ${false} 19 | ${'false'} | ${true} | ${false} 20 | ${'Text'} | ${true} | ${null} 21 | ${'Text, Text'} | ${'Text'} | ${'Text, Text'} 22 | ${'2019-01-01'} | ${new Date()} | ${new Date('2019-01-01')} 23 | ${'xxx'} | ${new Date()} | ${null} 24 | ${() => void 0} | ${'Text'} | ${null} 25 | `( 26 | 'parseQueryStateValue value $value, defaultValue $defaultValue', 27 | ({ value, defaultValue, parsedValue }) => { 28 | test(`should return value transformed into correct type or null (invalid transform)`, () => { 29 | expect(parseQueryStateValue(value, defaultValue)).toEqual(parsedValue) 30 | }) 31 | } 32 | ) 33 | 34 | describe.each` 35 | value | stateValue 36 | ${'Text'} | ${'Text'} 37 | ${10} | ${'10'} 38 | ${NaN} | ${null} 39 | ${true} | ${'true'} 40 | ${false} | ${'false'} 41 | ${[]} | ${[]} 42 | ${['Text']} | ${['Text']} 43 | ${['Just', 'Text']} | ${['Just', 'Text']} 44 | ${Symbol('Any')} | ${null} 45 | ${new Date('2019-01-01')} | ${'2019-01-01T00:00:00.000Z'} 46 | ${new Date('xxx')} | ${null} 47 | `( 48 | 'toQueryStateValue value $value, stateValue $stateValue', 49 | ({ value, stateValue }) => { 50 | test(`should return value transformed into string or array of strings (or null when not possible)`, () => { 51 | expect(toQueryStateValue(value)).toEqual(stateValue) 52 | }) 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/useQueryStateObj.test.ts: -------------------------------------------------------------------------------- 1 | import { cleanup } from '@testing-library/react' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import { useHashQueryStateObj } from '../useHashQueryState' 4 | import { useHashQueryStringInterface } from '../useHashQueryStringInterface' 5 | import { useQueryStateObj } from '../useQueryStateObj' 6 | import { unwrapResult } from 'use-location-state-test-helpers/test-helpers' 7 | 8 | // reset jest mocked hash 9 | beforeAll(() => { 10 | window.location.hash = '' 11 | }) 12 | 13 | afterEach(() => { 14 | window.location.hash = '' 15 | cleanup() 16 | }) 17 | 18 | describe('useQueryStateObj hook', () => { 19 | it('should work with passed HashQueryStringInterface', async () => { 20 | const { result, unmount } = renderHook( 21 | (props) => { 22 | const hashQSI = useHashQueryStringInterface() 23 | return useQueryStateObj(props, { queryStringInterface: hashQSI }) 24 | }, 25 | { 26 | initialProps: { name: 'Sarah' }, 27 | } 28 | ) 29 | 30 | const name = unwrapResult(result) 31 | 32 | expect(name.value).toEqual({ name: 'Sarah' }) 33 | await act(async () => name.setValue({ name: 'Kim' })) 34 | expect(window.location.hash).toEqual('#name=Kim') 35 | expect(name.value).toEqual({ name: 'Kim' }) 36 | await act(async () => name.setValue({ name: 'Sarah' })) 37 | expect(window.location.hash).toEqual('') 38 | expect(name.value).toEqual({ name: 'Sarah' }) 39 | act(() => void unmount()) 40 | }) 41 | }) 42 | 43 | describe('useHashQueryStateObj hook', () => { 44 | it('should work with internal HashQueryStringInterface', async () => { 45 | const { result, unmount } = renderHook( 46 | (props) => useHashQueryStateObj(props), 47 | { 48 | initialProps: { name: 'Sarah' }, 49 | } 50 | ) 51 | 52 | const name = unwrapResult(result) 53 | 54 | expect(name.value).toEqual({ name: 'Sarah' }) 55 | await act(async () => name.setValue({ name: 'Kim' })) 56 | expect(window.location.hash).toEqual('#name=Kim') 57 | expect(name.value).toEqual({ name: 'Kim' }) 58 | await act(async () => name.setValue({ name: 'Sarah' })) 59 | expect(window.location.hash).toEqual('') 60 | expect(name.value).toEqual({ name: 'Sarah' }) 61 | act(() => void unmount()) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "location-state-monorepo", 3 | "private": true, 4 | "workspaces": [ 5 | "src/packages/*", 6 | "src/helpers/*", 7 | "src/examples/**/*" 8 | ], 9 | "devDependencies": { 10 | "@testing-library/react": "^12.1.2", 11 | "@testing-library/react-hooks": "^8.0.0", 12 | "@types/jest": "^29.0.0", 13 | "@types/jsdom": "^21.1.0", 14 | "@types/react-test-renderer": "^18.0.0", 15 | "bundlesize": "^0.18.0", 16 | "codecov": "^3.6.1", 17 | "commitizen": "^4.0.3", 18 | "copyfiles": "2.4.1", 19 | "cz-conventional-changelog": "^3.0.2", 20 | "eslint-config-prettier": "^8.1.0", 21 | "eslint-plugin-prettier": "^4.0.0", 22 | "eslint-plugin-react": "^7.16.0", 23 | "husky": "^8.0.1", 24 | "lerna": "^6.0.0", 25 | "prettier": "^2.0.5", 26 | "pretty-quick": "^3.1.1", 27 | "react": "^17.0.2", 28 | "react-dom": "^17.0.2", 29 | "react-scripts": "^5.0.0", 30 | "react-test-renderer": "^17.0.2", 31 | "rollup": "^2.0.0", 32 | "rollup-plugin-typescript2": "0.34.1", 33 | "tslib": "^2.0.0", 34 | "typescript": "^5.0.2", 35 | "use-location-state-test-helpers": "*" 36 | }, 37 | "scripts": { 38 | "commit": "git-cz", 39 | "start": "yarn dev", 40 | "dev": "yarn build; yarn lerna run dev --parallel", 41 | "build": "yarn copy:readme; yarn lerna run build", 42 | "test": "react-scripts test", 43 | "lint": "prettier --check 'src/**/*.{js,ts,tsx}'", 44 | "prettier": "prettier --write 'src/**/*.{js,ts,tsx}'", 45 | "copy:readme": "npx copyfiles README.md src/packages/use-location-state/; npx copyfiles README.md src/packages/react-router-use-location-state/", 46 | "prepare": "husky install" 47 | }, 48 | "version": "0.0.0-development", 49 | "repository": { 50 | "type": "git", 51 | "url": "https://github.com/xiel/use-location-state.git" 52 | }, 53 | "config": { 54 | "commitizen": { 55 | "path": "node_modules/cz-conventional-changelog" 56 | } 57 | }, 58 | "bundlesize": [ 59 | { 60 | "path": "./src/packages/*/dist/*.js", 61 | "maxSize": "5 kB" 62 | } 63 | ], 64 | "jest": { 65 | "collectCoverageFrom": [ 66 | "src/packages/**/*.{js,jx,tsx,ts}", 67 | "!**/node_modules/**", 68 | "!**/dist/**", 69 | "!**/build/**", 70 | "!**/*.config.{js,jx,tsx,ts}" 71 | ] 72 | }, 73 | "dependencies": {} 74 | } 75 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useLocationState/test/useLocationState.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks' 2 | import { useLocationState } from '../../use-location-state' 3 | import { 4 | asyncAct, 5 | unwrapABResult, 6 | } from 'use-location-state-test-helpers/test-helpers' 7 | 8 | describe('useLocationState', () => { 9 | it('should keep to useLocationState hooks with the same name in sync', async () => { 10 | act(() => window.history.replaceState({}, '', '')) 11 | const { result, unmount } = renderHook(() => { 12 | const a = useLocationState('item', 'Sarah') 13 | const b = useLocationState('item', '') 14 | return { a, b } 15 | }) 16 | 17 | const { a, b } = unwrapABResult(result) 18 | 19 | expect(a.value).toEqual('Sarah') 20 | expect(b.value).toEqual('') 21 | 22 | await asyncAct(async () => void a.setValue('Kim')) 23 | 24 | expect(a.value).toEqual('Kim') 25 | expect(b.value).toEqual('Kim') 26 | 27 | act(() => void unmount()) 28 | }) 29 | 30 | it('should call set function with current value', async () => { 31 | act(() => window.history.replaceState({}, '', '')) 32 | const { result, unmount } = renderHook(() => { 33 | const a = useLocationState('item', 'Kim') 34 | const b = useLocationState('item', '') 35 | return { a, b } 36 | }) 37 | 38 | const { a, b } = unwrapABResult(result) 39 | 40 | expect(a.value).toEqual('Kim') 41 | expect(b.value).toEqual('') 42 | 43 | await asyncAct(async () => 44 | a.setValue((currentValue) => currentValue + 'berly') 45 | ) 46 | 47 | expect(a.value).toEqual('Kimberly') 48 | expect(b.value).toEqual('Kimberly') 49 | 50 | act(() => void unmount()) 51 | }) 52 | 53 | it('should call set function with current value - array', async () => { 54 | act(() => window.history.replaceState({}, '', '')) 55 | const { result, unmount } = renderHook(() => { 56 | const a = useLocationState('arr', [1, 2, 3]) 57 | const b = useLocationState('arr', []) 58 | return { a, b } 59 | }) 60 | 61 | const { a, b } = unwrapABResult(result) 62 | 63 | expect(a.value).toEqual([1, 2, 3]) 64 | expect(b.value).toEqual([]) 65 | 66 | await asyncAct(async () => a.setValue((arr) => arr.concat(4, 5, 6))) 67 | 68 | expect(a.value).toEqual([1, 2, 3, 4, 5, 6]) 69 | expect(b.value).toEqual([1, 2, 3, 4, 5, 6]) 70 | 71 | act(() => void unmount()) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useLocationState/test/invalid-values.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks' 2 | import { useLocationState } from '../../use-location-state' 3 | import { unwrapABResult } from 'use-location-state-test-helpers/test-helpers' 4 | 5 | describe('invalid input defaultValue', () => { 6 | describe.each` 7 | defaultValue 8 | ${Symbol('Test')} 9 | ${() => () => 'function returning a function'} 10 | `('defaultValue $defaultValue', ({ defaultValue }) => { 11 | test(`should throw`, () => { 12 | // reset state 13 | act(() => window.history.replaceState({}, '', '')) 14 | 15 | const replaceState = jest.spyOn(window.history, 'replaceState') 16 | const { result, unmount } = renderHook(() => 17 | useLocationState('anything', defaultValue) 18 | ) 19 | 20 | expect(result.error).toMatchInlineSnapshot( 21 | `[Error: unsupported defaultValue]` 22 | ) 23 | expect(replaceState).toHaveBeenCalledTimes(0) 24 | 25 | // restore mock 26 | replaceState.mockRestore() 27 | act(() => void unmount()) 28 | }) 29 | }) 30 | }) 31 | 32 | describe('invalid value in setter', () => { 33 | describe.each` 34 | invalidValueToSet 35 | ${Symbol('Test')} 36 | ${() => () => 'function returning a function'} 37 | ${() => Symbol('InFunc')} 38 | `('invalidValueToSet $invalidValueToSet', ({ invalidValueToSet }) => { 39 | test(`should throw`, async () => { 40 | // reset state and spy 41 | act(() => window.history.replaceState({}, '', '')) 42 | const warn = jest.spyOn(console, 'warn').mockImplementation() 43 | 44 | const { result, unmount } = renderHook(() => { 45 | const a = useLocationState('itemName', 'valid default value') 46 | const b = useLocationState('itemName', '') 47 | return { a, b } 48 | }) 49 | 50 | const { a, b } = unwrapABResult(result) 51 | 52 | expect(result.error).toBe(undefined) 53 | expect(a.value).toBe('valid default value') 54 | 55 | await act(async () => a.setValue('new value')) 56 | expect(a.value).toBe('new value') 57 | expect(b.value).toBe('new value') 58 | 59 | // calling setter with an invalid value will reset the state to the default value 60 | await act(async () => a.setValue(invalidValueToSet)) 61 | expect(a.value).toBe('valid default value') 62 | expect(b.value).toBe('') 63 | 64 | expect(warn).toHaveBeenCalledTimes(1) 65 | warn.mockRestore() 66 | await act(async () => void unmount()) 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/QueryReducer/FilterDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { FilterKeys } from './filterReducer' 2 | import { Filter, FilterDisplayProps } from './QueryReducerTypes' 3 | import React, { useLayoutEffect, useMemo, useRef } from 'react' 4 | 5 | const emptySubFilters: Filter[] = [] 6 | 7 | function getFilterKeysFlat(filter: Filter): FilterKeys { 8 | return Object.freeze( 9 | ([] as FilterKeys).concat( 10 | 'subFilters' in filter 11 | ? filter.subFilters.flatMap(getFilterKeysFlat) 12 | : filter.filterKey 13 | ) 14 | ) 15 | } 16 | 17 | export function FilterDisplay(props: FilterDisplayProps) { 18 | const { activeFilters, filtersDispatch } = props 19 | const label = 'title' in props ? props.title : props.filterKey 20 | const subFilters = 'subFilters' in props ? props.subFilters : emptySubFilters 21 | const filterKeysInTree = useMemo(() => getFilterKeysFlat(props), [props]) 22 | const activeFilterKeysInTree = useMemo( 23 | () => filterKeysInTree.filter((fK) => activeFilters.includes(fK)), 24 | [activeFilters, filterKeysInTree] 25 | ) 26 | const checkboxRef = useRef(null) 27 | const isActive = !!activeFilterKeysInTree.length 28 | const isIndeterminateActive = 29 | isActive && activeFilterKeysInTree.length !== filterKeysInTree.length 30 | 31 | useLayoutEffect(() => { 32 | if (checkboxRef.current) { 33 | checkboxRef.current.indeterminate = isIndeterminateActive 34 | } 35 | }, [isIndeterminateActive]) 36 | 37 | const changeHandler = () => { 38 | if (isActive) { 39 | filtersDispatch({ 40 | type: 'remove', 41 | toRemove: activeFilterKeysInTree, 42 | }) 43 | } else { 44 | filtersDispatch({ 45 | type: 'add', 46 | toAdd: filterKeysInTree, 47 | }) 48 | } 49 | } 50 | 51 | return ( 52 |
53 | 62 | {subFilters?.length && isActive ? ( 63 |
    64 | {subFilters.map((filter) => ( 65 | 71 | ))} 72 |
73 | ) : null} 74 |
75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useLocationState/test/valid-values.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks' 2 | import { useLocationState } from '../../use-location-state' 3 | import { unwrapABResult } from 'use-location-state-test-helpers/test-helpers' 4 | 5 | describe.each` 6 | defaultValue | newValue 7 | ${'not empty'} | ${''} 8 | ${'not empty'} | ${'still not empty'} 9 | ${''} | ${'not empty anymore'} 10 | ${[]} | ${['new', 'entries']} 11 | ${['']} | ${['new', 'entries']} 12 | ${['same', 'entries']} | ${['same', 'entries']} 13 | ${[]} | ${[]} 14 | ${['multiple', 'strings']} | ${[]} 15 | ${['multiple', 'strings']} | ${['']} 16 | ${['multiple', 'strings']} | ${['just one entry']} 17 | ${new Date()} | ${new Date(0)} 18 | ${0} | ${-50} 19 | ${99} | ${3.14} 20 | ${Infinity} | ${-Infinity} 21 | ${1e23} | ${1e24} 22 | ${true} | ${false} 23 | ${true} | ${true} 24 | ${false} | ${true} 25 | ${false} | ${false} 26 | ${null} | ${true} 27 | ${undefined} | ${true} 28 | ${{ testObj: 1 }} | ${{ testObj: 5 }} 29 | ${[1, 2, 3]} | ${[4, 5, 6]} 30 | `( 31 | 'defaultValue: $defaultValue, newValue: $newValue', 32 | ({ defaultValue = '', newValue }) => { 33 | test(`should return default value and set newValue successfully`, () => { 34 | act(() => window.history.replaceState({}, '', '')) 35 | const { result, unmount } = renderHook(() => { 36 | const a = useLocationState('item', defaultValue) 37 | const b = useLocationState('item', defaultValue) 38 | return { a, b } 39 | }) 40 | 41 | const { a, b } = unwrapABResult(result) 42 | 43 | // default 44 | expect(result.error).toBe(undefined) 45 | expect(window.history.state).toEqual({}) 46 | expect(a.value).toEqual(defaultValue) 47 | expect(b.value).toEqual(defaultValue) 48 | 49 | // new value 50 | act(() => a.setValue(newValue)) 51 | expect(a.value).toEqual(newValue) 52 | expect(b.value).toEqual(newValue) 53 | 54 | // // back to default 55 | act(() => a.setValue(defaultValue)) 56 | expect(a.value).toEqual(defaultValue) 57 | expect(b.value).toEqual(defaultValue) 58 | expect(window.history.state).toEqual({ __useLocationState: {} }) 59 | 60 | act(() => void unmount()) 61 | }) 62 | } 63 | ) 64 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/useQueryStateObj.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 2 | import { 3 | createMergedQuery, 4 | parseQueryState, 5 | QueryState, 6 | QueryStateMerge, 7 | } from 'query-state-core' 8 | import { useHashQueryStringInterface } from './useHashQueryStringInterface' 9 | import { QueryStateOpts, SetQueryStateFn } from './useQueryState.types' 10 | 11 | export function useQueryStateObj( 12 | defaultQueryState: T, 13 | queryStateOpts: QueryStateOpts 14 | ): [QueryState, SetQueryStateFn] { 15 | const { queryStringInterface } = queryStateOpts 16 | const hashQSI = useHashQueryStringInterface( 17 | queryStringInterface && { disabled: true } 18 | ) 19 | const activeQSI = queryStringInterface || hashQSI 20 | const queryString = activeQSI.getQueryString() 21 | const [, setLatestMergedQueryString] = useState() 22 | const queryState = useMemo( 23 | () => ({ 24 | ...defaultQueryState, 25 | ...parseQueryState(queryString), 26 | }), 27 | [defaultQueryState, queryString] 28 | ) 29 | 30 | const ref = useRef({ 31 | defaultQueryState, 32 | queryStateOpts, 33 | activeQSI, 34 | }) 35 | 36 | const setQueryState: SetQueryStateFn = useCallback((newState, opts) => { 37 | const { defaultQueryState, queryStateOpts, activeQSI } = ref.current 38 | const { stripDefaults = true } = queryStateOpts 39 | const stripOverwrite: QueryStateMerge = {} 40 | 41 | // when a params are set to the same value as in the defaults 42 | // we remove them to avoid having two URLs reproducing the same state unless stripDefaults === false 43 | if (stripDefaults) { 44 | Object.entries(newState).forEach(([key]) => { 45 | if (defaultQueryState[key] === newState[key]) { 46 | stripOverwrite[key] = null 47 | } 48 | }) 49 | } 50 | 51 | // retrieve the last value (by re-executing the search getter) 52 | const currentQueryState: QueryState = { 53 | ...defaultQueryState, 54 | ...parseQueryState(activeQSI.getQueryString()), 55 | } 56 | 57 | const mergedQueryString = createMergedQuery( 58 | currentQueryState || {}, 59 | newState, 60 | stripOverwrite 61 | ) 62 | 63 | activeQSI.setQueryString(mergedQueryString, opts || {}) 64 | 65 | // triggers an update (in case the QueryStringInterface misses to do so) 66 | setLatestMergedQueryString(mergedQueryString) 67 | }, []) 68 | 69 | useEffect(() => { 70 | ref.current = { 71 | defaultQueryState, 72 | queryStateOpts, 73 | activeQSI, 74 | } 75 | }) 76 | 77 | return [queryState, setQueryState] 78 | } 79 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/invalid-values.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks' 2 | import { useQueryState } from '../../use-location-state' 3 | import useTestQueryStringInterface from './useTestQueryStringInterface' 4 | import { unwrapResult } from 'use-location-state-test-helpers/test-helpers' 5 | 6 | describe('invalid input defaultValue', () => { 7 | describe.each` 8 | defaultValue 9 | ${NaN} 10 | ${() => void 0} 11 | ${undefined} 12 | ${null} 13 | ${{ object: 1 }} 14 | ${Symbol('Test')} 15 | `('defaultValue $defaultValue', ({ defaultValue }) => { 16 | test(`should throw`, () => { 17 | const orgError = console.error 18 | console.error = jest.fn() 19 | const testQSI = renderHook(() => useTestQueryStringInterface()).result 20 | .current 21 | const { result, unmount } = renderHook(() => 22 | useQueryState('anything', defaultValue, { 23 | queryStringInterface: testQSI, 24 | }) 25 | ) 26 | expect(result.error).toMatchInlineSnapshot( 27 | `[Error: unsupported defaultValue]` 28 | ) 29 | expect(testQSI.getQueryString()).toBe('') 30 | act(() => void unmount()) 31 | console.error = orgError 32 | }) 33 | }) 34 | }) 35 | 36 | describe('invalid value in setter', () => { 37 | describe.each` 38 | invalidValueToSet 39 | ${NaN} 40 | ${() => void 0} 41 | ${undefined} 42 | ${new Date('INVALID DATE')} 43 | ${{ object: 1 }} 44 | ${Symbol('Test')} 45 | `('invalidValueToSet $invalidValueToSet', ({ invalidValueToSet }) => { 46 | test(`should throw`, () => { 47 | const orgWarn = console.warn 48 | console.warn = jest.fn() 49 | const testQSI = renderHook(() => useTestQueryStringInterface()).result 50 | .current 51 | const { result, unmount } = renderHook(() => 52 | useQueryState('itemName', 'valid default value', { 53 | queryStringInterface: testQSI, 54 | }) 55 | ) 56 | const r = unwrapResult(result) 57 | 58 | expect(result.error).toBe(undefined) 59 | expect(r.value).toBe('valid default value') 60 | expect(testQSI.getQueryString()).toBe('') 61 | act(() => r.setValue('new value')) 62 | expect(r.value).toBe('new value') 63 | expect(testQSI.getQueryString()).toBe('itemName=new+value') 64 | 65 | // calling setter with an invalid value will reset the state to the default value 66 | act(() => r.setValue(invalidValueToSet)) 67 | expect(r.value).toBe('valid default value') 68 | expect(testQSI.getQueryString()).toBe('') 69 | expect(console.warn).toHaveBeenCalledTimes(1) 70 | 71 | act(() => void unmount()) 72 | console.warn = orgWarn 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/src/useQueryState/test/valid-values.test.tsx: -------------------------------------------------------------------------------- 1 | import { EMPTY_ARRAY_STRING } from 'query-state-core' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import { BrowserRouter as Router } from 'react-router-dom' 4 | import { useQueryState } from '../useQueryState' 5 | import { unwrapResult } from 'use-location-state-test-helpers/test-helpers' 6 | 7 | describe.each` 8 | defaultValue | newValue | newValueQueryString 9 | ${'not empty'} | ${''} | ${'?item='} 10 | ${'not empty'} | ${'still not empty'} | ${'?item=still+not+empty'} 11 | ${''} | ${'not empty anymore'} | ${'?item=not+empty+anymore'} 12 | ${[]} | ${['new', 'entries']} | ${'?item=new&item=entries'} 13 | ${['']} | ${['new', 'entries']} | ${'?item=new&item=entries'} 14 | ${['multiple', 'strings']} | ${[]} | ${'?item=' + encodeURIComponent(EMPTY_ARRAY_STRING)} 15 | ${['multiple', 'strings']} | ${['']} | ${'?item='} 16 | ${['multiple', 'strings']} | ${['just one entry']} | ${'?item=just+one+entry'} 17 | ${0} | ${-50} | ${'?item=-50'} 18 | ${99} | ${3.14} | ${'?item=3.14'} 19 | ${Infinity} | ${-Infinity} | ${'?item=-Infinity'} 20 | ${1e23} | ${1e24} | ${'?item=' + encodeURIComponent((1e24).toString())} 21 | ${true} | ${false} | ${'?item=false'} 22 | ${true} | ${true} | ${''} 23 | ${false} | ${true} | ${'?item=true'} 24 | ${false} | ${false} | ${''} 25 | `( 26 | 'defaultValue $defaultValue, newValue $newValue', 27 | ({ defaultValue = '', newValue, newValueQueryString }) => { 28 | beforeEach(() => { 29 | window.history.replaceState(null, '', '') 30 | }) 31 | 32 | test(`should return default value and set newValue successfully`, () => { 33 | const { result, unmount } = renderHook( 34 | () => useQueryState('item', defaultValue), 35 | { 36 | wrapper: Router, 37 | } 38 | ) 39 | const r = unwrapResult(result) 40 | // default 41 | expect(result.error).toBe(undefined) 42 | expect(window.location.search).toEqual('') 43 | expect(r.value).toEqual(defaultValue) 44 | // new value 45 | act(() => r.setValue(newValue)) 46 | expect(window.location.search).toEqual(newValueQueryString) 47 | expect(r.value).toEqual(newValue) 48 | // back to default 49 | act(() => r.setValue(defaultValue)) 50 | expect(r.value).toEqual(defaultValue) 51 | expect(window.location.search).toEqual('') 52 | 53 | unmount() 54 | }) 55 | } 56 | ) 57 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/next.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useQueryState as useQueryStateOrg } from './useQueryState/useQueryState' 3 | import { useQueryReducer as useQueryReducerOrg } from './useQueryState/useQueryReducer' 4 | import { Reducer, ReducerState } from './types/sharedTypes' 5 | import { 6 | QueryStateOpts, 7 | QueryStringInterface, 8 | } from './useQueryState/useQueryState.types' 9 | import { GetServerSideProps } from 'next' 10 | export * from './useLocationState/useLocationState' 11 | 12 | // Needed for updates that happen right after each other (sync) as we do not have access to the latest history ref (since react router v6) 13 | let virtualQueryString: null | string = null 14 | let abortUpdateWillBatch: (() => void) | null = null 15 | 16 | const useNextRouterQueryStringInterface = (): QueryStringInterface => { 17 | const router = useRouter() 18 | 19 | // Use the real one again as soon as location changes and update was incorporated 20 | virtualQueryString = null 21 | 22 | return { 23 | getQueryString: () => 24 | typeof virtualQueryString === 'string' 25 | ? virtualQueryString 26 | : router.asPath.split('?')[1], 27 | setQueryString: (newQueryString, { method = 'replace' }) => { 28 | virtualQueryString = newQueryString 29 | 30 | if (abortUpdateWillBatch) { 31 | abortUpdateWillBatch() 32 | abortUpdateWillBatch = null 33 | } 34 | 35 | // Wait a microtask before applying the update, to updates that happen sync after each other are batched into one router update 36 | new Promise((resolve, reject) => { 37 | abortUpdateWillBatch = reject 38 | Promise.resolve().then(resolve) 39 | }) 40 | .then(() => { 41 | router[method](router.pathname + '?' + newQueryString) 42 | }) 43 | .catch(() => { 44 | // Ignore, the update will be batched and merged 45 | }) 46 | }, 47 | } 48 | } 49 | 50 | export const useQueryState: typeof useQueryStateOrg = (key, defaultValue) => { 51 | return useQueryStateOrg(key, defaultValue, { 52 | queryStringInterface: useNextRouterQueryStringInterface(), 53 | }) 54 | } 55 | 56 | export function useQueryReducer>( 57 | itemName: string, 58 | reducer: R, 59 | initialState: ReducerState, 60 | queryStateOpts?: QueryStateOpts 61 | ) { 62 | return useQueryReducerOrg(itemName, reducer, initialState, { 63 | queryStringInterface: useNextRouterQueryStringInterface(), 64 | ...queryStateOpts, 65 | }) 66 | } 67 | 68 | /** 69 | * Empty getServerSideProps to trigger server-side rendering and give router access to query. (static rendered pages may not rely on query) 70 | * This fixes hydration warnings e.g. Warning: Text content did not match. Server: "xzy" Client: "abc" 71 | */ 72 | export const getServerSideProps: GetServerSideProps = async () => { 73 | return { props: {} } 74 | } 75 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/src/useLocationState/test/valid-values.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks' 2 | import { unwrapABResult } from 'use-location-state-test-helpers/test-helpers' 3 | import { useLocationState } from '../useLocationState' 4 | import { BrowserRouter as Router } from 'react-router-dom' 5 | 6 | describe.each` 7 | defaultValue | newValue 8 | ${'not empty'} | ${''} 9 | ${'not empty'} | ${'still not empty'} 10 | ${''} | ${'not empty anymore'} 11 | ${[]} | ${['new', 'entries']} 12 | ${['']} | ${['new', 'entries']} 13 | ${['same', 'entries']} | ${['same', 'entries']} 14 | ${[]} | ${[]} 15 | ${['multiple', 'strings']} | ${[]} 16 | ${['multiple', 'strings']} | ${['']} 17 | ${['multiple', 'strings']} | ${['just one entry']} 18 | ${new Date()} | ${new Date(0)} 19 | ${0} | ${-50} 20 | ${99} | ${3.14} 21 | ${Infinity} | ${-Infinity} 22 | ${1e23} | ${1e24} 23 | ${true} | ${false} 24 | ${true} | ${true} 25 | ${false} | ${true} 26 | ${false} | ${false} 27 | ${null} | ${true} 28 | ${undefined} | ${true} 29 | ${{ testObj: 1 }} | ${{ testObj: 5 }} 30 | ${[1, 2, 3]} | ${[4, 5, 6]} 31 | `( 32 | 'defaultValue: $defaultValue, newValue: $newValue', 33 | ({ defaultValue = '', newValue }) => { 34 | test(`should return default value and set newValue successfully`, async () => { 35 | act(() => window.history.replaceState({}, '', '')) 36 | const warn = jest.spyOn(console, 'warn') 37 | const error = jest.spyOn(console, 'error') 38 | 39 | const { result, unmount } = renderHook( 40 | () => { 41 | const a = useLocationState('item', defaultValue) 42 | const b = useLocationState('item', defaultValue) 43 | return { a, b } 44 | }, 45 | { wrapper: Router } 46 | ) 47 | 48 | const { a, b } = unwrapABResult(result) 49 | 50 | // default 51 | expect(result.error).toBe(undefined) 52 | expect(window.history.state).toEqual({}) 53 | expect(a.value).toEqual(defaultValue) 54 | expect(b.value).toEqual(defaultValue) 55 | 56 | // new value 57 | act(() => a.setValue(newValue)) 58 | expect(a.value).toEqual(newValue) 59 | expect(b.value).toEqual(newValue) 60 | 61 | // // back to default 62 | act(() => a.setValue(defaultValue)) 63 | expect(a.value).toEqual(defaultValue) 64 | expect(b.value).toEqual(defaultValue) 65 | 66 | expect(warn).toHaveBeenCalledTimes(0) 67 | expect(error).toHaveBeenCalledTimes(0) 68 | 69 | warn.mockRestore() 70 | error.mockRestore() 71 | await act(async () => void unmount()) 72 | }) 73 | } 74 | ) 75 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/valid-values.test.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY_ARRAY_STRING } from 'query-state-core' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import { useQueryState } from '../../use-location-state' 4 | import useTestQueryStringInterface from './useTestQueryStringInterface' 5 | import { unwrapResult } from 'use-location-state-test-helpers/test-helpers' 6 | 7 | const enc = encodeURIComponent 8 | 9 | describe.each` 10 | defaultValue | newValue | newValueQueryString 11 | ${'not empty'} | ${''} | ${'item='} 12 | ${'not empty'} | ${'still not empty'} | ${'item=still+not+empty'} 13 | ${''} | ${'not empty anymore'} | ${'item=not+empty+anymore'} 14 | ${[]} | ${['new', 'entries']} | ${'item=new&item=entries'} 15 | ${['']} | ${['new', 'entries']} | ${'item=new&item=entries'} 16 | ${['same', 'entries']} | ${['same', 'entries']} | ${''} 17 | ${[]} | ${[]} | ${''} 18 | ${['multiple', 'strings']} | ${[]} | ${'item=' + enc(EMPTY_ARRAY_STRING)} 19 | ${['multiple', 'strings']} | ${['']} | ${'item='} 20 | ${['multiple', 'strings']} | ${['just one entry']} | ${'item=just+one+entry'} 21 | ${new Date()} | ${new Date(0)} | ${'item=' + enc('1970-01-01T00:00:00.000Z')} 22 | ${0} | ${-50} | ${'item=-50'} 23 | ${99} | ${3.14} | ${'item=3.14'} 24 | ${Infinity} | ${-Infinity} | ${'item=-Infinity'} 25 | ${1e23} | ${1e24} | ${'item=' + enc((1e24).toString())} 26 | ${true} | ${false} | ${'item=false'} 27 | ${true} | ${true} | ${''} 28 | ${false} | ${true} | ${'item=true'} 29 | ${false} | ${false} | ${''} 30 | `( 31 | 'defaultValue $defaultValue, newValue $newValue', 32 | ({ defaultValue = '', newValue, newValueQueryString }) => { 33 | test(`should return default value and set newValue successfully`, () => { 34 | const testQSI = renderHook(() => useTestQueryStringInterface()).result 35 | .current 36 | const { result, unmount } = renderHook(() => 37 | useQueryState('item', defaultValue, { 38 | queryStringInterface: testQSI, 39 | }) 40 | ) 41 | const r = unwrapResult(result) 42 | // default 43 | expect(result.error).toBe(undefined) 44 | expect(testQSI.getQueryString()).toEqual('') 45 | expect(r.value).toEqual(defaultValue) 46 | // new value 47 | act(() => r.setValue(newValue)) 48 | expect(testQSI.getQueryString()).toEqual(newValueQueryString) 49 | expect(r.value).toEqual(newValue) 50 | // back to default 51 | act(() => r.setValue(defaultValue)) 52 | expect(r.value).toEqual(defaultValue) 53 | expect(testQSI.getQueryString()).toEqual('') 54 | 55 | unmount() 56 | }) 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /src/packages/react-router-use-location-state/src/useQueryState/test/batched-reset.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks' 2 | import { render, screen } from '@testing-library/react' 3 | import { BrowserRouter as wrapper } from 'react-router-dom' 4 | import { useNavigate } from 'react-router' 5 | import { useQueryState } from '../useQueryState' 6 | import { unwrapABResult } from 'use-location-state-test-helpers/test-helpers' 7 | import { useEffect } from 'react' 8 | 9 | beforeEach(resetReactRouter) 10 | 11 | describe('Resetting to original value in batch with a following no-op', () => { 12 | test('using clicks', () => { 13 | const onRender = jest.fn() 14 | 15 | function Comp() { 16 | const [name, setName] = useQueryState('name', '') 17 | const [age, setAge] = useQueryState('age', 18) 18 | const state = { age, name } 19 | onRender(state) 20 | return ( 21 | <> 22 | 30 | 38 | 39 | ) 40 | } 41 | 42 | render(, { wrapper }) 43 | 44 | expect(window.location.search).toEqual('') 45 | screen.getByRole('button', { name: 'set' }).click() 46 | 47 | expect(onRender).toHaveBeenCalledTimes(2) 48 | expect(onRender).toHaveBeenLastCalledWith({ 49 | age: 18, 50 | name: 'Ron', 51 | }) 52 | expect(window.location.search).toEqual('?name=Ron') 53 | 54 | screen.getByRole('button', { name: 'reset' }).click() 55 | expect(window.location.search).toEqual('') 56 | }) 57 | 58 | test(`using setter from hook directly`, () => { 59 | const { result, unmount } = renderHook( 60 | () => ({ 61 | a: useQueryState('age', 18), 62 | b: useQueryState('names', [] as string[]), 63 | }), 64 | { wrapper } 65 | ) 66 | 67 | const { a: age, b: names } = unwrapABResult(result) 68 | 69 | // default 70 | expect(result.error).toBe(undefined) 71 | expect(window.location.search).toEqual('') 72 | expect(age.value).toEqual(18) 73 | expect(names.value).toEqual([]) 74 | 75 | // set next values: 76 | act(() => { 77 | age.setValue(31) 78 | names.setValue([]) 79 | }) 80 | 81 | // check new value 82 | expect(result.error).toBe(undefined) 83 | expect(window.location.search).toEqual('?age=31') 84 | expect(age.value).toEqual(31) 85 | expect(names.value).toEqual([]) 86 | 87 | // reset to original values 88 | act(() => { 89 | age.setValue(18) 90 | names.setValue([]) 91 | }) 92 | 93 | // check successful reset 94 | expect(result.error).toBe(undefined) 95 | expect(window.location.search).toEqual('') 96 | expect(age.value).toEqual(18) 97 | expect(names.value).toEqual([]) 98 | 99 | unmount() 100 | }) 101 | }) 102 | 103 | function resetReactRouter() { 104 | renderHook( 105 | () => { 106 | const navigate = useNavigate() 107 | 108 | useEffect(() => { 109 | navigate({ 110 | pathname: '', 111 | search: '', 112 | }) 113 | }, [navigate]) 114 | }, 115 | { wrapper } 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/useQueryState.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks' 2 | import { useQueryState } from '../useQueryState' 3 | import useTestQueryStringInterface from './useTestQueryStringInterface' 4 | import { 5 | asyncAct, 6 | unwrapABResult, 7 | unwrapResult, 8 | } from 'use-location-state-test-helpers/test-helpers' 9 | 10 | describe('useQueryState', () => { 11 | it('should automatically use hashQSI when no queryStringInterface is defined', async () => { 12 | const { result, unmount } = renderHook( 13 | ({ itemName, defaultValue }) => useQueryState(itemName, defaultValue), 14 | { 15 | initialProps: { itemName: 'name', defaultValue: 'Sarah' }, 16 | } 17 | ) 18 | const r = unwrapResult(result) 19 | expect(r.value).toEqual('Sarah') 20 | await asyncAct(async () => void r.setValue('Kim')) 21 | expect(window.location.hash).toEqual('#name=Kim') 22 | expect(r.value).toEqual('Kim') 23 | unmount() 24 | }) 25 | 26 | describe('should enforce types', () => { 27 | test('usage of two queryState hooks with different types on the same item name', () => { 28 | const testQSI = renderHook(() => useTestQueryStringInterface()).result 29 | .current 30 | 31 | // put the clashing hooks into the same render test hook (so they always update together) 32 | const { result, unmount } = renderHook(() => { 33 | const a = useQueryState('clashingItem', 'XL', { 34 | queryStringInterface: testQSI, 35 | }) 36 | const b = useQueryState('clashingItem', 123, { 37 | queryStringInterface: testQSI, 38 | }) 39 | return { a, b } 40 | }) 41 | 42 | // get/set interfaces for the results (ref) 43 | const { a, b } = unwrapABResult(result) 44 | 45 | // expect to get the default values 46 | expect(testQSI.getQueryString()).toBe('') 47 | expect(a.value).toBe('XL') 48 | expect(b.value).toBe(123) 49 | 50 | // types should be enforced if possible ... 51 | act(() => a.setValue('111')) 52 | expect(testQSI.getQueryString()).toBe('clashingItem=111') 53 | expect(a.value).toBe('111') 54 | expect(b.value).toBe(111) 55 | 56 | // ...otherwise default values should be returned again 57 | act(() => a.setValue('Not a Number')) 58 | expect(testQSI.getQueryString()).toBe('clashingItem=Not+a+Number') 59 | expect(a.value).toBe('Not a Number') 60 | expect(b.value).toBe(123) 61 | unmount() 62 | }) 63 | }) 64 | 65 | it('should reset query string when null is passed as value', () => { 66 | const testQSI = renderHook(() => useTestQueryStringInterface()).result 67 | .current 68 | 69 | // put the clashing hooks into the same render test hook (so they always update together) 70 | const { result, unmount } = renderHook(() => 71 | useQueryState('name', 'Sarah', { 72 | queryStringInterface: testQSI, 73 | }) 74 | ) 75 | 76 | const r = unwrapResult(result) 77 | 78 | // expect to get the default values 79 | expect(testQSI.getQueryString()).toBe('') 80 | expect(r.value).toBe('Sarah') 81 | 82 | // set a value different than the default 83 | act(() => void r.setValue('Kim')) 84 | expect(r.value).toBe('Kim') 85 | expect(testQSI.getQueryString()).toBe('name=Kim') 86 | 87 | // when setting the value to null, we expect to get the default value again, and the query string should be reset 88 | act(() => void r.setValue(null)) 89 | expect(r.value).toBe('Sarah') 90 | expect(testQSI.getQueryString()).toBe('') 91 | 92 | unmount() 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/QueryStateDemo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useQueryState } from 'react-router-use-location-state' 3 | import QueryStateDisplay from '../components/QueryStateDisplay' 4 | 5 | export default function QueryStateDemo() { 6 | const [name, setName] = useQueryState('name', 'Sarah') 7 | const [age, setAge] = useQueryState('age', 25) 8 | const [active, setActive] = useQueryState('active', false) 9 | 10 | return ( 11 |
12 |

Intro

13 | 20 |

name

21 |

22 | 25 | 28 | 31 | {name !== 'Sarah' && ( 32 | 35 | )} 36 | 37 | setName(e.target.value)} 42 | /> 43 |

44 |

Age

45 |

46 | 49 | 52 | 55 | 56 | setAge(Number(e.target.value))} 61 | /> 62 |

63 |

name & age

64 |

65 | 74 | 83 | 92 |

93 |
94 |

active

95 | 104 | 113 |
114 |
115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/LocationStateDemo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useLocationState } from 'react-router-use-location-state' 3 | import QueryStateDisplay from '../components/QueryStateDisplay' 4 | 5 | export default function LocationStateDemo() { 6 | const [name, setName] = useLocationState('name', 'Sarah') 7 | const [age, setAge] = useLocationState('age', 25) 8 | const [active, setActive] = useLocationState('active', false) 9 | 10 | return ( 11 |
12 |

Intro

13 |

14 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Aliquid 15 | architecto, atque, corporis debitis esse. 16 |

17 | 24 |

name

25 |

26 | 29 | 32 | 35 | 36 | setName(e.target.value)} 41 | /> 42 |

43 |

Age

44 |

45 | 48 | 51 | 54 | 55 | setAge(Number(e.target.value))} 60 | /> 61 |

62 |

name & age

63 |

64 | 73 | 82 | 91 |

92 |
93 |

active

94 | 103 | 112 |
113 |
114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/pages/QueryStateDemo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useQueryState } from 'use-location-state' 3 | import QueryStateDisplay from '../components/QueryStateDisplay' 4 | import QueryStateCheckbox from '../components/QueryStateCheckbox' 5 | 6 | export default function QueryStateDemo() { 7 | const [name, setName] = useQueryState('name', 'Sarah') 8 | const [age, setAge] = useQueryState('age', 25) 9 | const [date, setDate] = useQueryState('date', new Date('2019-01-01')) 10 | const [active] = useQueryState('active', false) 11 | 12 | return ( 13 |
14 |

Intro

15 | 16 | 24 | 25 |

name

26 |
27 | 28 | setName(e.target.value)} 33 | /> 34 | 37 | 40 | 43 | {name !== 'Sarah' && ( 44 | 47 | )} 48 |
49 | 50 |

Age

51 |
52 | 53 | setAge(Number(e.target.value))} 58 | /> 59 | 62 | 65 | 68 |
69 | 70 |

name & age (at the same time)

71 |
72 | 81 | 90 | 99 |
100 | 101 |

active

102 |
103 | 104 | 105 |
106 | 107 |

Date

108 |
109 | 110 | setDate(new Date(e.target.value))} 115 | /> 116 |
117 |
118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/pages/LocationStateDemo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useLocationState } from 'use-location-state' 3 | import QueryStateDisplay from '../components/QueryStateDisplay' 4 | import LocationStateCheckbox from '../components/LocationStateCheckbox' 5 | 6 | export default function LocationStateDemo() { 7 | const [name, setName] = useLocationState('name', 'Sarah') 8 | const [age, setAge] = useLocationState('age', 25) 9 | const [date, setDate] = useLocationState( 10 | 'date', 11 | new Date('2019-01-01') 12 | ) 13 | const [active] = useLocationState('active', false) 14 | 15 | return ( 16 |
17 |

useLocationState Demo

18 | 19 | 27 | 28 |

name

29 |
30 | 31 | setName(e.target.value)} 36 | /> 37 | 40 | 43 | 46 |
47 | 48 |

Age

49 |
50 | 51 | setAge(Number(e.target.value))} 56 | /> 57 | 60 | 63 | 66 |
67 | 68 |

name & age (at the same time)

69 |
70 | 79 | 88 | 97 |
98 | 99 |

active

100 |
101 | 102 | 103 |
104 | 105 |

Date

106 |
107 | 108 | 113 | setDate( 114 | new Date(e.target.value).toJSON() 115 | ? new Date(e.target.value) 116 | : null 117 | ) 118 | } 119 | /> 120 |
121 |
122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/test/useHashQueryState.test.ts: -------------------------------------------------------------------------------- 1 | import { cleanup } from '@testing-library/react' 2 | import { renderHook } from '@testing-library/react-hooks' 3 | import { useHashQueryState } from '../../use-location-state' 4 | import { 5 | asyncAct, 6 | unwrapABResult, 7 | } from 'use-location-state-test-helpers/test-helpers' 8 | 9 | // reset jest mocked hash 10 | beforeAll(() => { 11 | window.location.hash = '' 12 | }) 13 | 14 | beforeEach(() => { 15 | window.location.hash = '' 16 | cleanup() 17 | }) 18 | 19 | afterAll(() => { 20 | window.location.hash = '' 21 | }) 22 | 23 | describe('useHashQueryState', () => { 24 | it('should work with internal HashQueryStringInterface', async () => { 25 | const { result, unmount } = renderHook( 26 | ({ itemName, defaultValue }) => useHashQueryState(itemName, defaultValue), 27 | { 28 | initialProps: { itemName: 'name', defaultValue: 'Sarah' }, 29 | } 30 | ) 31 | const val = () => result.current[0] 32 | const setVal: typeof result.current[1] = (newValue) => 33 | result.current[1](newValue) 34 | 35 | expect(val()).toEqual('Sarah') 36 | 37 | await asyncAct(async () => setVal('Kim')) 38 | 39 | expect(window.location.hash).toEqual('#name=Kim') 40 | expect(val()).toEqual('Kim') 41 | unmount() 42 | }) 43 | 44 | it('should reset hash when default', async () => { 45 | const { result, unmount } = renderHook( 46 | ({ itemName, defaultValue }) => useHashQueryState(itemName, defaultValue), 47 | { 48 | initialProps: { itemName: 'name', defaultValue: 'Sarah' }, 49 | } 50 | ) 51 | const val = () => result.current[0] 52 | const setVal: typeof result.current[1] = (newValue) => 53 | result.current[1](newValue) 54 | 55 | expect(val()).toEqual('Sarah') 56 | await asyncAct(async () => void setVal('Kim')) 57 | 58 | expect(window.location.hash).toEqual('#name=Kim') 59 | expect(val()).toEqual('Kim') 60 | 61 | await asyncAct(() => void setVal('Sarah')) 62 | 63 | expect(window.location.hash).toEqual('') 64 | expect(val()).toEqual('Sarah') 65 | unmount() 66 | }) 67 | 68 | it('should enforce types with same item name', async () => { 69 | // two hooks use the same itemName -> they should still get the value in their correct type if possible (otherwise their own defaultValue) 70 | const { result, unmount } = renderHook( 71 | ({ itemName, defaultValueNum, defaultValueStr }) => { 72 | // a = num 73 | const a = useHashQueryState(itemName, defaultValueNum) 74 | // b = str 75 | const b = useHashQueryState(itemName, defaultValueStr) 76 | return { a, b } 77 | }, 78 | { 79 | initialProps: { 80 | itemName: 'name', 81 | defaultValueNum: 25, 82 | defaultValueStr: 'Sarah', 83 | }, 84 | } 85 | ) 86 | 87 | const { a: num, b: str } = unwrapABResult(result) 88 | 89 | // initially both should show their defaults 90 | expect(window.location.hash).toEqual('') 91 | expect(str.value).toEqual('Sarah') 92 | expect(num.value).toEqual(25) 93 | 94 | // after setting a string value for same item, 95 | // numerical QS should still return default number, because string "Kim" cannot be transformed into a number 96 | await asyncAct(async () => str.setValue('Kim')) 97 | 98 | expect(window.location.hash).toEqual('#name=Kim') 99 | expect(str.value).toEqual('Kim') 100 | expect(num.value).toEqual(25) 101 | 102 | await asyncAct(async () => str.setValue('Tom')) 103 | 104 | expect(window.location.hash).toEqual('#name=Tom') 105 | expect(str.value).toEqual('Tom') 106 | expect(num.value).toEqual(25) 107 | 108 | // string QS should return number as string, after setting it via numerical setter 109 | await asyncAct(async () => num.setValue(375)) 110 | expect(window.location.hash).toEqual('#name=375') 111 | expect(num.value).toEqual(375) 112 | expect(str.value).toEqual('375') 113 | 114 | unmount() 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /src/packages/query-state-core/src/query-state-core.ts: -------------------------------------------------------------------------------- 1 | export type ValueType = string | string[] | number | boolean | Date 2 | export type QueryStateValue = string | string[] 3 | export type QueryStateResetValue = null | undefined 4 | export type QueryState = Record 5 | export type QueryStateMerge = Record< 6 | string, 7 | QueryStateValue | QueryStateResetValue 8 | > 9 | 10 | export const EMPTY_ARRAY_STRING = '[\u00A0]' 11 | 12 | export function stripLeadingHashOrQuestionMark(s: string = '') { 13 | if (s && (s.indexOf('?') === 0 || s.indexOf('#') === 0)) { 14 | return s.slice(1) 15 | } 16 | return s 17 | } 18 | 19 | export function parseQueryState(queryString: string): QueryState | null { 20 | const queryState: QueryState = {} 21 | const params = new URLSearchParams( 22 | stripLeadingHashOrQuestionMark(queryString) 23 | ) 24 | 25 | params.forEach((value, key) => { 26 | if (key in queryState.constructor.prototype) { 27 | return console.warn( 28 | `parseQueryState | invalid key "${key}" will be ignored` 29 | ) 30 | } 31 | 32 | if (key in queryState) { 33 | const queryStateForKey = queryState[key] 34 | 35 | if (Array.isArray(queryStateForKey)) { 36 | queryStateForKey.push(value) 37 | } else { 38 | queryState[key] = [queryStateForKey, value] 39 | } 40 | } else { 41 | queryState[key] = value 42 | } 43 | }) 44 | 45 | return Object.keys(queryState).length ? queryState : null 46 | } 47 | 48 | export function createMergedQuery(...queryStates: QueryStateMerge[]) { 49 | const mergedQueryStates: QueryStateMerge = Object.assign({}, ...queryStates) 50 | const params = new URLSearchParams() 51 | 52 | Object.entries(mergedQueryStates).forEach(([key, value]) => { 53 | // entries with null or undefined values are removed from the query string 54 | if (value === null || value === undefined) { 55 | return 56 | } 57 | 58 | if (Array.isArray(value)) { 59 | if (value.length) { 60 | value.forEach((v) => { 61 | params.append(key, v || '') 62 | }) 63 | } else { 64 | params.append(key, EMPTY_ARRAY_STRING) 65 | } 66 | } else { 67 | params.append(key, value) 68 | } 69 | }) 70 | 71 | params.sort() 72 | return params.toString() 73 | } 74 | 75 | export function toQueryStateValue( 76 | value: ValueType | unknown 77 | ): QueryStateValue | null { 78 | if (Array.isArray(value)) { 79 | return value.map((v) => v.toString()) 80 | } else if (value || value === '' || value === false || value === 0) { 81 | if (value instanceof Date) { 82 | return value.toJSON() 83 | } 84 | 85 | switch (typeof value) { 86 | case 'string': 87 | case 'number': 88 | case 'boolean': 89 | return value.toString() 90 | default: 91 | break 92 | } 93 | } 94 | return null 95 | } 96 | 97 | export const newStringArray: () => string[] = () => [] 98 | 99 | export function parseQueryStateValue( 100 | value: QueryStateValue, 101 | defaultValue: T 102 | ): T | null { 103 | const defaultValueType = typeof defaultValue 104 | let num: number 105 | 106 | if (Array.isArray(defaultValue)) { 107 | // special case of empty array saved in query string to keep it distinguishable from [''] 108 | if (value === EMPTY_ARRAY_STRING) { 109 | return newStringArray() as T 110 | } 111 | return newStringArray().concat(value) as T 112 | } 113 | 114 | if (typeof value !== 'string' && !Array.isArray(value)) { 115 | return null 116 | } 117 | 118 | if (defaultValue instanceof Date) { 119 | const valueAsDate = new Date(value.toString()) 120 | 121 | if (!isNaN(valueAsDate.valueOf())) { 122 | return valueAsDate as T 123 | } 124 | } 125 | 126 | switch (defaultValueType) { 127 | case 'string': 128 | return value.toString() as T 129 | case 'number': 130 | num = Number(value) 131 | return (num || num === 0 ? num : null) as T 132 | case 'boolean': 133 | if (value === 'true') { 134 | return true as T 135 | } else if (value === 'false') { 136 | return false as T 137 | } 138 | break 139 | default: 140 | } 141 | return null 142 | } 143 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/pages/test/__snapshots__/LocationStateTest.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LocationStateDemo can set active with checkbox - push 1`] = ` 4 |
  7 |   {
  8 |  "name": "Sarah",
  9 |  "age": 25,
 10 |  "active": true,
 11 |  "date": "2019-01-01T00:00:00.000Z"
 12 | }
 13 | 
14 | `; 15 | 16 | exports[`LocationStateDemo can set active with checkbox 1`] = ` 17 |
 20 |   {
 21 |  "name": "Sarah",
 22 |  "age": 25,
 23 |  "active": true,
 24 |  "date": "2019-01-01T00:00:00.000Z"
 25 | }
 26 | 
27 | `; 28 | 29 | exports[`LocationStateDemo can set age with button 1`] = ` 30 |
 33 |   {
 34 |  "name": "Sarah",
 35 |  "age": 30,
 36 |  "active": false,
 37 |  "date": "2019-01-01T00:00:00.000Z"
 38 | }
 39 | 
40 | `; 41 | 42 | exports[`LocationStateDemo can set age with button 2`] = ` 43 |
 46 |   {
 47 |  "name": "Sarah",
 48 |  "age": 45,
 49 |  "active": false,
 50 |  "date": "2019-01-01T00:00:00.000Z"
 51 | }
 52 | 
53 | `; 54 | 55 | exports[`LocationStateDemo can set age with button 3`] = ` 56 |
 59 |   {
 60 |  "name": "Sarah",
 61 |  "age": 25,
 62 |  "active": false,
 63 |  "date": "2019-01-01T00:00:00.000Z"
 64 | }
 65 | 
66 | `; 67 | 68 | exports[`LocationStateDemo can set age with text field 1`] = ` 69 |
 72 |   {
 73 |  "name": "Sarah",
 74 |  "age": 33,
 75 |  "active": false,
 76 |  "date": "2019-01-01T00:00:00.000Z"
 77 | }
 78 | 
79 | `; 80 | 81 | exports[`LocationStateDemo can set date with date field 1`] = ` 82 |
 85 |   {
 86 |  "name": "Sarah",
 87 |  "age": 25,
 88 |  "active": false,
 89 |  "date": "2019-05-01T00:00:00.000Z"
 90 | }
 91 | 
92 | `; 93 | 94 | exports[`LocationStateDemo can set name & age with button 1`] = ` 95 |
 98 |   {
 99 |  "name": "Felix",
100 |  "age": 30,
101 |  "active": false,
102 |  "date": "2019-01-01T00:00:00.000Z"
103 | }
104 | 
105 | `; 106 | 107 | exports[`LocationStateDemo can set name & age with button 2`] = ` 108 |
111 |   {
112 |  "name": "Kim",
113 |  "age": 45,
114 |  "active": false,
115 |  "date": "2019-01-01T00:00:00.000Z"
116 | }
117 | 
118 | `; 119 | 120 | exports[`LocationStateDemo can set name & age with button 3`] = ` 121 |
124 |   {
125 |  "name": "Sarah",
126 |  "age": 25,
127 |  "active": false,
128 |  "date": "2019-01-01T00:00:00.000Z"
129 | }
130 | 
131 | `; 132 | 133 | exports[`LocationStateDemo can set name with button 1`] = ` 134 |
137 |   {
138 |  "name": "Felix",
139 |  "age": 25,
140 |  "active": false,
141 |  "date": "2019-01-01T00:00:00.000Z"
142 | }
143 | 
144 | `; 145 | 146 | exports[`LocationStateDemo can set name with button 2`] = ` 147 |
150 |   {
151 |  "name": "Kim",
152 |  "age": 25,
153 |  "active": false,
154 |  "date": "2019-01-01T00:00:00.000Z"
155 | }
156 | 
157 | `; 158 | 159 | exports[`LocationStateDemo can set name with button 3`] = ` 160 |
163 |   {
164 |  "name": "Sarah",
165 |  "age": 25,
166 |  "active": false,
167 |  "date": "2019-01-01T00:00:00.000Z"
168 | }
169 | 
170 | `; 171 | 172 | exports[`LocationStateDemo can set name with text field 1`] = ` 173 |
176 |   {
177 |  "name": "Mila",
178 |  "age": 25,
179 |  "active": false,
180 |  "date": "2019-01-01T00:00:00.000Z"
181 | }
182 | 
183 | `; 184 | 185 | exports[`LocationStateDemo can set null in date field 1`] = ` 186 |
189 |   {
190 |  "name": "Sarah",
191 |  "age": 25,
192 |  "active": false,
193 |  "date": null
194 | }
195 | 
196 | `; 197 | 198 | exports[`LocationStateDemo can set null in date field 2`] = ` 199 |
202 |   {
203 |  "name": "Sarah",
204 |  "age": 25,
205 |  "active": false,
206 |  "date": null
207 | }
208 | 
209 | `; 210 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/test/__snapshots__/LocationStateTest.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LocationStateDemo can set active with checkbox - push 1`] = ` 4 |
  7 |   {
  8 |  "name": "Sarah",
  9 |  "age": 25,
 10 |  "active": false
 11 | }
 12 | 
13 | `; 14 | 15 | exports[`LocationStateDemo can set active with checkbox 1`] = ` 16 |
 19 |   {
 20 |  "name": "Sarah",
 21 |  "age": 25,
 22 |  "active": false
 23 | }
 24 | 
25 | `; 26 | 27 | exports[`LocationStateDemo can set active with checkbox 2`] = ` 28 |
 31 |   {
 32 |  "name": "Sarah",
 33 |  "age": 25,
 34 |  "active": true
 35 | }
 36 | 
37 | `; 38 | 39 | exports[`LocationStateDemo can set age with button 1`] = ` 40 |
 43 |   {
 44 |  "name": "Sarah",
 45 |  "age": 25,
 46 |  "active": false
 47 | }
 48 | 
49 | `; 50 | 51 | exports[`LocationStateDemo can set age with button 2`] = ` 52 |
 55 |   {
 56 |  "name": "Sarah",
 57 |  "age": 30,
 58 |  "active": false
 59 | }
 60 | 
61 | `; 62 | 63 | exports[`LocationStateDemo can set age with button 3`] = ` 64 |
 67 |   {
 68 |  "name": "Sarah",
 69 |  "age": 45,
 70 |  "active": false
 71 | }
 72 | 
73 | `; 74 | 75 | exports[`LocationStateDemo can set age with button 4`] = ` 76 |
 79 |   {
 80 |  "name": "Sarah",
 81 |  "age": 25,
 82 |  "active": false
 83 | }
 84 | 
85 | `; 86 | 87 | exports[`LocationStateDemo can set age with text field 1`] = ` 88 |
 91 |   {
 92 |  "name": "Sarah",
 93 |  "age": 25,
 94 |  "active": false
 95 | }
 96 | 
97 | `; 98 | 99 | exports[`LocationStateDemo can set age with text field 2`] = ` 100 |
103 |   {
104 |  "name": "Sarah",
105 |  "age": 33,
106 |  "active": false
107 | }
108 | 
109 | `; 110 | 111 | exports[`LocationStateDemo can set name & age with button 1`] = ` 112 |
115 |   {
116 |  "name": "Sarah",
117 |  "age": 25,
118 |  "active": false
119 | }
120 | 
121 | `; 122 | 123 | exports[`LocationStateDemo can set name & age with button 2`] = ` 124 |
127 |   {
128 |  "name": "Felix",
129 |  "age": 30,
130 |  "active": false
131 | }
132 | 
133 | `; 134 | 135 | exports[`LocationStateDemo can set name & age with button 3`] = ` 136 |
139 |   {
140 |  "name": "Kim",
141 |  "age": 45,
142 |  "active": false
143 | }
144 | 
145 | `; 146 | 147 | exports[`LocationStateDemo can set name & age with button 4`] = ` 148 |
151 |   {
152 |  "name": "Sarah",
153 |  "age": 25,
154 |  "active": false
155 | }
156 | 
157 | `; 158 | 159 | exports[`LocationStateDemo can set name with button 1`] = ` 160 |
163 |   {
164 |  "name": "Sarah",
165 |  "age": 25,
166 |  "active": false
167 | }
168 | 
169 | `; 170 | 171 | exports[`LocationStateDemo can set name with button 2`] = ` 172 |
175 |   {
176 |  "name": "Felix",
177 |  "age": 25,
178 |  "active": false
179 | }
180 | 
181 | `; 182 | 183 | exports[`LocationStateDemo can set name with button 3`] = ` 184 |
187 |   {
188 |  "name": "Kim",
189 |  "age": 25,
190 |  "active": false
191 | }
192 | 
193 | `; 194 | 195 | exports[`LocationStateDemo can set name with button 4`] = ` 196 |
199 |   {
200 |  "name": "Sarah",
201 |  "age": 25,
202 |  "active": false
203 | }
204 | 
205 | `; 206 | 207 | exports[`LocationStateDemo can set name with text field 1`] = ` 208 |
211 |   {
212 |  "name": "Sarah",
213 |  "age": 25,
214 |  "active": false
215 | }
216 | 
217 | `; 218 | 219 | exports[`LocationStateDemo can set name with text field 2`] = ` 220 |
223 |   {
224 |  "name": "Mila",
225 |  "age": 25,
226 |  "active": false
227 | }
228 | 
229 | `; 230 | -------------------------------------------------------------------------------- /src/packages/query-state-core/test/query-state-core.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createMergedQuery, 3 | EMPTY_ARRAY_STRING, 4 | parseQueryState, 5 | } from '../src/query-state-core' 6 | 7 | describe('parseQueryState parses', () => { 8 | it('empty string', () => { 9 | expect(parseQueryState('')).toEqual(null) 10 | }) 11 | 12 | it('question mark string', () => { 13 | expect(parseQueryState('?')).toEqual(null) 14 | }) 15 | 16 | it('query string with one parameter', () => { 17 | expect(parseQueryState('a=abc')).toEqual({ a: 'abc' }) 18 | }) 19 | 20 | it('query string with multiple parameters', () => { 21 | expect(parseQueryState('a=abc&d=efg')).toEqual({ a: 'abc', d: 'efg' }) 22 | }) 23 | 24 | it('query string with multiple parameters with the same name as array', () => { 25 | expect(parseQueryState('a=abc&a=efg')).toEqual({ a: ['abc', 'efg'] }) 26 | }) 27 | 28 | it('creates array from query string with multiple value values for same key', () => { 29 | expect(parseQueryState('filters=1&filters=2&filters=3')).toEqual({ 30 | filters: ['1', '2', '3'], 31 | }) 32 | }) 33 | 34 | it('query string with "true" boolean parameter', () => { 35 | expect(parseQueryState('a=true')).toEqual({ a: 'true' }) 36 | }) 37 | 38 | it('query string with two boolean parameters', () => { 39 | expect(parseQueryState('b=false&c=true')).toEqual({ c: 'true', b: 'false' }) 40 | }) 41 | 42 | it('query string with parameter value containing spaces', () => { 43 | expect(parseQueryState('param=with%20space%20between')).toEqual({ 44 | param: 'with space between', 45 | }) 46 | }) 47 | 48 | it('query string with invalid keys should be ignored', () => { 49 | const warnSpy = jest.spyOn(console, 'warn').mockImplementation() 50 | expect(parseQueryState('toString=true&valid=value&__proto__=true')).toEqual( 51 | { valid: 'value' } 52 | ) 53 | expect(warnSpy).toHaveBeenCalledTimes(2) 54 | warnSpy.mockRestore() 55 | }) 56 | }) 57 | 58 | describe('createMergedQuery', () => { 59 | it('overwrites same keys with later value', () => { 60 | expect(createMergedQuery({ a: 'b' })).toEqual('a=b') 61 | }) 62 | 63 | it('overwrites same keys with later value', () => { 64 | expect(createMergedQuery({ a: 'b' }, { a: 'c' })).toEqual('a=c') 65 | }) 66 | 67 | it('stable sort keys', () => { 68 | const abcQueryString = 'a=true&b=true&c=true' 69 | expect( 70 | createMergedQuery({ a: 'true' }, { b: 'true' }, { c: 'true' }) 71 | ).toEqual(abcQueryString) 72 | expect( 73 | createMergedQuery({ c: 'true' }, { a: 'true' }, { b: 'true' }) 74 | ).toEqual(abcQueryString) 75 | expect( 76 | createMergedQuery({ b: 'true' }, { c: 'true' }, { a: 'true' }) 77 | ).toEqual(abcQueryString) 78 | expect(createMergedQuery({ b: 'true', c: 'true', a: 'true' })).toEqual( 79 | abcQueryString 80 | ) 81 | }) 82 | 83 | it('with boolean values', () => { 84 | expect(createMergedQuery({ bool: 'true', bool2: 'false' })).toEqual( 85 | 'bool=true&bool2=false' 86 | ) 87 | }) 88 | 89 | it('with removed null values', () => { 90 | expect(createMergedQuery({ nullable: null })).toEqual('') 91 | }) 92 | 93 | it('with removed undefined values', () => { 94 | expect(createMergedQuery({ undef: undefined })).toEqual('') 95 | }) 96 | 97 | it('with removed entries by overwrite with null', () => { 98 | expect(createMergedQuery({ nullable: 'true' }, { nullable: null })).toEqual( 99 | '' 100 | ) 101 | }) 102 | 103 | it('with boolean overwriting null', () => { 104 | expect(createMergedQuery({ nulled: null }, { nulled: 'false' })).toEqual( 105 | 'nulled=false' 106 | ) 107 | }) 108 | 109 | it('allows empty objects', () => { 110 | expect(createMergedQuery({}, { a: 'c' }, {})).toEqual('a=c') 111 | }) 112 | 113 | it('with removed entries by overwrite with undefined', () => { 114 | expect( 115 | createMergedQuery( 116 | { otherwiseEmpty: 'false', notDefined: 'true' }, 117 | { notDefined: undefined }, 118 | { otherwiseEmpty: 'true' } 119 | ) 120 | ).toEqual('otherwiseEmpty=true') 121 | }) 122 | 123 | it('with array value', () => { 124 | expect(createMergedQuery({ arr: ['1', '2', '3'] })).toEqual( 125 | 'arr=1&arr=2&arr=3' 126 | ) 127 | }) 128 | 129 | it('with array value overwrite (no automatic concat)', () => { 130 | expect(createMergedQuery({ arr: ['1'] }, { arr: ['a', 'b'] })).toEqual( 131 | 'arr=a&arr=b' 132 | ) 133 | }) 134 | 135 | it('with array with empty', () => { 136 | expect(createMergedQuery({ arr: [''] })).toEqual('arr=') 137 | }) 138 | 139 | it('with empty array', () => { 140 | expect(createMergedQuery({ arr: [] })).toEqual( 141 | 'arr=' + encodeURIComponent(EMPTY_ARRAY_STRING) 142 | ) 143 | }) 144 | }) 145 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useLocationState/useLocationReducer.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react' 2 | import useLocationStateInterface from './useLocationStateInterface' 3 | import { 4 | LocationStateOpts, 5 | SetLocationStateOptions, 6 | } from './useLocationState.types' 7 | import { useRefLatest } from '../hooks/useRefLatest' 8 | 9 | const validTypes = ['string', 'number', 'boolean', 'object', 'undefined'] 10 | const locationStateOptsDefaults: LocationStateOpts = Object.freeze({}) 11 | 12 | export type LocationDispatch = ( 13 | value: A, 14 | opts?: SetLocationStateOptions 15 | ) => void 16 | export type LocationReducerFn = ( 17 | state: State, 18 | action: Action 19 | ) => State 20 | 21 | export function useLocationReducer( 22 | itemName: string, 23 | reducer: LocationReducerFn, 24 | initialState: State, 25 | opts?: LocationStateOpts 26 | ): [State, LocationDispatch] 27 | 28 | export function useLocationReducer( 29 | itemName: string, 30 | reducer: LocationReducerFn, 31 | initialArg: InitialArg, 32 | initStateFn: (initialArg: InitialArg) => State, 33 | opts?: LocationStateOpts 34 | ): [State, LocationDispatch] 35 | 36 | export function useLocationReducer( 37 | itemName: string, 38 | reducer: LocationReducerFn, 39 | initialStateOrInitialArg: State | InitialArg, 40 | maybeInitStateFnOrOpts?: 41 | | LocationStateOpts 42 | | ((initialArg: InitialArg) => State), 43 | opts?: LocationStateOpts 44 | ): [State, LocationDispatch] { 45 | const { locationStateInterface } = 46 | opts || 47 | (typeof maybeInitStateFnOrOpts === 'object' && maybeInitStateFnOrOpts) || 48 | locationStateOptsDefaults 49 | 50 | // itemName & defaultValue is not allowed to be changed after init 51 | const [defaultValue] = useState(() => { 52 | return maybeInitStateFnOrOpts && 53 | typeof maybeInitStateFnOrOpts === 'function' 54 | ? maybeInitStateFnOrOpts(initialStateOrInitialArg as InitialArg) 55 | : (initialStateOrInitialArg as State) 56 | }) 57 | 58 | // throw for invalid values like functions 59 | if (!validTypes.includes(typeof defaultValue)) { 60 | throw new Error('unsupported defaultValue') 61 | } 62 | 63 | // item name gets a generated suffix based on defaultValue type, to make accidental clashes less likely 64 | ;[itemName] = useState(() => { 65 | const suffixObscurer = typeof btoa !== 'undefined' ? btoa : (s: string) => s 66 | const suffix = suffixObscurer( 67 | Array.isArray(defaultValue) ? 'array' : typeof defaultValue 68 | ).replace(/=/g, '') 69 | return `${itemName}__${suffix}` 70 | }) 71 | 72 | // the interface to get/set the state 73 | const standardLSI = useLocationStateInterface( 74 | locationStateInterface && { disabled: true } 75 | ) 76 | const activeLSI = locationStateInterface || standardLSI 77 | const ref = useRefLatest({ 78 | activeLSI, 79 | reducer, 80 | }) 81 | 82 | const currentState = activeLSI.getLocationState() 83 | const value = useMemo(() => { 84 | let value = defaultValue 85 | if (itemName in currentState) { 86 | value = currentState[itemName] as any 87 | } 88 | return value 89 | }, [currentState, defaultValue, itemName]) as State 90 | 91 | const resetLocationStateItem = useCallback( 92 | (opts: SetLocationStateOptions) => { 93 | const { activeLSI } = ref.current 94 | const newState = { ...activeLSI.getLocationState() } 95 | delete newState[itemName] 96 | activeLSI.setLocationState(newState, opts) 97 | }, 98 | [itemName, ref] 99 | ) 100 | 101 | const dispatchAction: LocationDispatch = useCallback( 102 | (action, opts = {}) => { 103 | const { 104 | reducer, 105 | activeLSI: { getLocationState, setLocationState }, 106 | } = ref.current 107 | const currentState = getLocationState() 108 | const currentValue: State = 109 | itemName in currentState 110 | ? (currentState[itemName] as any) 111 | : defaultValue 112 | const newValue: State = reducer(currentValue, action) 113 | 114 | if (newValue === defaultValue) { 115 | return resetLocationStateItem(opts) 116 | } 117 | 118 | // warn about invalid new values 119 | if (!validTypes.includes(typeof newValue)) { 120 | console.warn(newValue, 'value is not supported, reset to default') 121 | return resetLocationStateItem(opts) 122 | } 123 | 124 | const stateExtendOverwrite: any = { 125 | [itemName]: newValue, 126 | } 127 | 128 | setLocationState( 129 | { 130 | ...currentState, 131 | ...stateExtendOverwrite, 132 | }, 133 | opts 134 | ) 135 | }, 136 | [defaultValue, itemName, ref, resetLocationStateItem] 137 | ) 138 | 139 | return [value, dispatchAction] 140 | } 141 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/pages/test/LocationStateTest.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { act, cleanup, fireEvent, render } from '@testing-library/react' 3 | import LocationStateDemo from '../LocationStateDemo' 4 | 5 | afterEach(() => { 6 | // Restore all spyOn mocks 7 | jest.restoreAllMocks() 8 | 9 | window.history.replaceState(null, '', '/') 10 | cleanup() 11 | }) 12 | 13 | describe('LocationStateDemo', () => { 14 | test('LocationStateDemo renders without crash/loop', () => { 15 | expect(window.history.state).toBe(null) 16 | expect(render()) 17 | }) 18 | 19 | test('can set name with button', () => { 20 | expect(window.history.state).toBe(null) 21 | const { getByText, getByTestId } = render() 22 | 23 | // should put new names into the hash (and age default value comes along) 24 | act(() => void fireEvent.click(getByText('name: "Felix"'))) 25 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 26 | 27 | act(() => void fireEvent.click(getByText('name: "Kim"'))) 28 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 29 | 30 | // set back to default value, so it should remove name value from hash 31 | act(() => void fireEvent.click(getByText('name: "Sarah" (default value)'))) 32 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 33 | }) 34 | 35 | test('can set name with text field', () => { 36 | expect(window.history.state).toBe(null) 37 | const { getByLabelText, getByTestId } = render() 38 | fireEvent.change(getByLabelText('name:'), { target: { value: 'Mila' } }) 39 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 40 | }) 41 | 42 | test('can set age with button', () => { 43 | expect(window.history.state).toBe(null) 44 | const { getByText, getByTestId } = render() 45 | act(() => void fireEvent.click(getByText('age: 30'))) 46 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 47 | act(() => void fireEvent.click(getByText('age: 45'))) 48 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 49 | act(() => void fireEvent.click(getByText('age: 25'))) 50 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 51 | }) 52 | 53 | test('can set age with text field', () => { 54 | expect(window.history.state).toBe(null) 55 | const { getByLabelText, getByTestId } = render() 56 | fireEvent.change(getByLabelText('age:'), { target: { value: '33' } }) 57 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 58 | }) 59 | 60 | test('can set name & age with button', () => { 61 | expect(window.history.state).toBe(null) 62 | const { getByText, getByTestId } = render() 63 | 64 | act(() => void fireEvent.click(getByText('name: "Felix", age: 30'))) 65 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 66 | act(() => void fireEvent.click(getByText('name: "Kim", age: 45'))) 67 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 68 | // set back to default value 69 | act( 70 | () => void fireEvent.click(getByText('name: "Sarah", age: 25 (default)')) 71 | ) 72 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 73 | }) 74 | 75 | test('can set active with checkbox', () => { 76 | expect(window.history.state).toBe(null) 77 | const { getByLabelText, getByTestId } = render() 78 | act(() => { 79 | fireEvent.click(getByLabelText('active')) 80 | }) 81 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 82 | }) 83 | 84 | test('can set active with checkbox - push', () => { 85 | const replaceState = jest.spyOn(window.history, 'replaceState') 86 | const pushState = jest.spyOn(window.history, 'pushState') 87 | expect(window.history.state).toBe(null) 88 | const { getByLabelText, getByTestId } = render() 89 | expect(replaceState).toBeCalledTimes(0) 90 | expect(pushState).toBeCalledTimes(0) 91 | fireEvent.click(getByLabelText('active (method: push)')) 92 | expect(replaceState).toBeCalledTimes(0) 93 | expect(pushState).toBeCalledTimes(1) 94 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 95 | }) 96 | 97 | test('can set date with date field', () => { 98 | expect(window.history.state).toBe(null) 99 | const { getByLabelText, getByTestId } = render() 100 | act(() => { 101 | fireEvent.change(getByLabelText('date:'), { 102 | target: { value: '2019-05-01' }, 103 | }) 104 | }) 105 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 106 | }) 107 | 108 | test('can set null in date field', () => { 109 | expect(window.history.state).toBe(null) 110 | const { getByLabelText, getByTestId } = render() 111 | act(() => { 112 | fireEvent.change(getByLabelText('date:'), { 113 | target: { value: null }, 114 | }) 115 | }) 116 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 117 | act(() => { 118 | fireEvent.change(getByLabelText('date:'), { 119 | target: { value: '' }, 120 | }) 121 | }) 122 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /src/examples/use-location-state/src/pages/test/QueryStateTest.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { act, cleanup, fireEvent, render } from '@testing-library/react' 3 | import QueryStateDemo from '../QueryStateDemo' 4 | import QueryStateDisplay from '../../components/QueryStateDisplay' 5 | 6 | const location = window.location 7 | 8 | // reset jest mocked hash 9 | beforeAll(() => { 10 | location.hash = '' 11 | }) 12 | 13 | afterEach(() => { 14 | // Restore all spyOn mocks 15 | jest.restoreAllMocks() 16 | 17 | cleanup() 18 | location.hash = '' 19 | }) 20 | 21 | describe('QueryStateDemo', () => { 22 | test('QueryStateDemo renders without crash/loop', () => { 23 | expect(render()) 24 | }) 25 | 26 | test('can set name with button', () => { 27 | const { getByText, getByTestId } = render() 28 | expect(location.hash).toEqual('') 29 | 30 | // should put new names into the hash (and age default value comes along) 31 | act(() => void fireEvent.click(getByText('name: "Felix"'))) 32 | expect(location.hash).toEqual('#name=Felix') 33 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 34 | 35 | act(() => void fireEvent.click(getByText('name: "Kim"'))) 36 | expect(location.hash).toEqual('#name=Kim') 37 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 38 | 39 | // set back to default value, so it should remove name value from hash 40 | act(() => void fireEvent.click(getByText('name: "Sarah" (default value)'))) 41 | expect(location.hash).toEqual('') 42 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 43 | }) 44 | 45 | test('can set name with text field', () => { 46 | const { getByLabelText } = render() 47 | expect(location.hash).toEqual('') 48 | fireEvent.change(getByLabelText('name:'), { target: { value: 'Mila' } }) 49 | expect(location.hash).toEqual('#name=Mila') 50 | }) 51 | 52 | test('can set age with button', () => { 53 | const { getByText } = render() 54 | expect(location.hash).toEqual('') 55 | 56 | act(() => void fireEvent.click(getByText('age: 30'))) 57 | expect(location.hash).toEqual('#age=30') 58 | 59 | act(() => void fireEvent.click(getByText('age: 45'))) 60 | expect(location.hash).toEqual('#age=45') 61 | 62 | act(() => void fireEvent.click(getByText('age: 25'))) 63 | expect(location.hash).toEqual('') 64 | }) 65 | 66 | test('can set age with text field', () => { 67 | const { getByLabelText } = render() 68 | expect(location.hash).toEqual('') 69 | fireEvent.change(getByLabelText('age:'), { target: { value: '33' } }) 70 | expect(location.hash).toEqual('#age=33') 71 | }) 72 | 73 | test('can set name & age with button', () => { 74 | const { getByText } = render() 75 | expect(location.hash).toEqual('') 76 | 77 | act(() => void fireEvent.click(getByText('name: "Felix", age: 30'))) 78 | expect(location.hash).toEqual('#age=30&name=Felix') 79 | 80 | act(() => void fireEvent.click(getByText('name: "Kim", age: 45'))) 81 | expect(location.hash).toEqual('#age=45&name=Kim') 82 | 83 | // set back to default value 84 | act( 85 | () => void fireEvent.click(getByText('name: "Sarah", age: 25 (default)')) 86 | ) 87 | expect(location.hash).toEqual('') 88 | }) 89 | 90 | test('can set active with checkbox', () => { 91 | const { getByLabelText } = render() 92 | expect(location.hash).toEqual('') 93 | 94 | fireEvent.click(getByLabelText('active')) 95 | expect(location.hash).toEqual('#active=true') 96 | }) 97 | 98 | test('can set active with checkbox - push', () => { 99 | const replaceState = jest.spyOn(window.history, 'replaceState') 100 | const pushState = jest.spyOn(window.history, 'pushState') 101 | const { getByLabelText } = render() 102 | expect(location.hash).toEqual('') 103 | expect(replaceState).toBeCalledTimes(0) 104 | expect(pushState).toBeCalledTimes(0) 105 | fireEvent.click(getByLabelText('active (method: push)')) 106 | expect(replaceState).toBeCalledTimes(0) 107 | expect(pushState).toBeCalledTimes(1) 108 | expect(pushState).toHaveBeenCalledWith(undefined, '', '#active=true') 109 | }) 110 | 111 | test('can set date with date field', () => { 112 | const { getByLabelText } = render() 113 | expect(location.hash).toEqual('') 114 | fireEvent.change(getByLabelText('date:'), { 115 | target: { value: '2019-05-01' }, 116 | }) 117 | expect(location.hash).toEqual('#date=2019-05-01T00%3A00%3A00.000Z') 118 | }) 119 | 120 | test('can reset date with date field', () => { 121 | const warnSpy = jest.spyOn(console, 'warn').mockImplementation() 122 | const { getByLabelText } = render() 123 | expect(location.hash).toEqual('') 124 | fireEvent.change(getByLabelText('date:'), { 125 | target: { value: '2019-05-01' }, 126 | }) 127 | expect(location.hash).toEqual('#date=2019-05-01T00%3A00%3A00.000Z') 128 | fireEvent.change(getByLabelText('date:'), { target: { value: null } }) 129 | expect(location.hash).toEqual('') 130 | expect(warnSpy).toHaveBeenCalledTimes(1) 131 | warnSpy.mockRestore() 132 | }) 133 | }) 134 | 135 | test('QueryStateDisplay', () => { 136 | expect(render()) 137 | }) 138 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/test/QueryStateTest.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { act, cleanup, fireEvent, render } from '@testing-library/react' 3 | import QueryStateDemo from '../QueryStateDemo' 4 | import QueryStateDisplay from '../../components/QueryStateDisplay' 5 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 6 | 7 | const location = window.location 8 | 9 | // reset jest mocked hash 10 | beforeAll(() => { 11 | cleanup() 12 | }) 13 | 14 | afterEach(() => { 15 | cleanup() 16 | window.history.replaceState(null, '', '/') 17 | }) 18 | 19 | describe('QueryStateDemo', () => { 20 | test('QueryStateDemo renders without crash/loop', () => { 21 | expect( 22 | render( 23 | 24 | 25 | } /> 26 | 27 | 28 | ) 29 | ) 30 | }) 31 | 32 | test('can set name with button', () => { 33 | const { getByText, getByTestId } = render( 34 | 35 | 36 | } /> 37 | 38 | 39 | ) 40 | expect(location.search).toEqual('') 41 | 42 | // should put new names into the hash (and age default value comes along) 43 | act(() => void fireEvent.click(getByText('name: "Felix"'))) 44 | expect(location.search).toEqual('?name=Felix') 45 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 46 | 47 | act(() => void fireEvent.click(getByText('name: "Kim"'))) 48 | expect(location.search).toEqual('?name=Kim') 49 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 50 | 51 | // set back to default value, so it should remove name value from hash 52 | act(() => void fireEvent.click(getByText('name: "Sarah" (default value)'))) 53 | expect(location.search).toEqual('') 54 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 55 | }) 56 | 57 | test('can set name with text field', () => { 58 | const { getByLabelText } = render( 59 | 60 | 61 | } /> 62 | 63 | 64 | ) 65 | expect(location.search).toEqual('') 66 | act( 67 | () => 68 | void fireEvent.change(getByLabelText('name:'), { 69 | target: { value: 'Mila' }, 70 | }) 71 | ) 72 | expect(location.search).toEqual('?name=Mila') 73 | }) 74 | 75 | test('can set age with button', () => { 76 | const { getByText } = render( 77 | 78 | 79 | } /> 80 | 81 | 82 | ) 83 | expect(location.search).toEqual('') 84 | 85 | act(() => void fireEvent.click(getByText('age: 30'))) 86 | expect(location.search).toEqual('?age=30') 87 | 88 | act(() => void fireEvent.click(getByText('age: 45'))) 89 | expect(location.search).toEqual('?age=45') 90 | 91 | act(() => void fireEvent.click(getByText('age: 25'))) 92 | expect(location.search).toEqual('') 93 | }) 94 | 95 | test('can set age with text field', () => { 96 | const { getByLabelText } = render( 97 | 98 | 99 | } /> 100 | 101 | 102 | ) 103 | expect(location.search).toEqual('') 104 | act( 105 | () => 106 | void fireEvent.change(getByLabelText('age:'), { 107 | target: { value: '33' }, 108 | }) 109 | ) 110 | expect(location.search).toEqual('?age=33') 111 | }) 112 | 113 | test('can set name & age with button', () => { 114 | const { getByText } = render( 115 | 116 | 117 | } /> 118 | 119 | 120 | ) 121 | expect(location.search).toEqual('') 122 | 123 | act(() => void fireEvent.click(getByText('name: "Felix", age: 30'))) 124 | expect(location.search).toEqual('?age=30&name=Felix') 125 | 126 | act(() => void fireEvent.click(getByText('name: "Kim", age: 45'))) 127 | expect(location.search).toEqual('?age=45&name=Kim') 128 | 129 | // set back to default value 130 | act(() => void fireEvent.click(getByText('name: "Sarah", age: 25'))) 131 | expect(location.search).toEqual('') 132 | }) 133 | 134 | test('can set active with checkbox', () => { 135 | const { getByLabelText } = render( 136 | 137 | 138 | } /> 139 | 140 | 141 | ) 142 | expect(location.search).toEqual('') 143 | 144 | fireEvent.click(getByLabelText('active')) 145 | expect(location.search).toEqual('?active=true') 146 | }) 147 | 148 | test('can set active with checkbox - push', () => { 149 | const replaceState = jest.spyOn(window.history, 'replaceState') 150 | const pushState = jest.spyOn(window.history, 'pushState') 151 | const { getByLabelText } = render( 152 | 153 | 154 | } /> 155 | 156 | 157 | ) 158 | expect(location.search).toEqual('') 159 | const replaceStateCalls = replaceState.mock.calls.length 160 | expect(replaceState).toBeCalledTimes(replaceStateCalls) 161 | expect(pushState).toBeCalledTimes(0) 162 | fireEvent.click(getByLabelText('active (method: push)')) 163 | expect(replaceState).toBeCalledTimes(replaceStateCalls) 164 | expect(pushState).toBeCalledTimes(1) 165 | expect(pushState).toHaveBeenCalledWith( 166 | expect.anything(), 167 | expect.anything(), 168 | '/?active=true' 169 | ) 170 | }) 171 | }) 172 | 173 | test('QueryStateDisplay', () => { 174 | expect(render()) 175 | }) 176 | -------------------------------------------------------------------------------- /src/examples/react-router-use-location-state/src/pages/test/LocationStateTest.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { act, cleanup, fireEvent, render } from '@testing-library/react' 3 | import LocationStateDemo from '../LocationStateDemo' 4 | import QueryStateDisplay from '../../components/QueryStateDisplay' 5 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 6 | 7 | // reset jest mocked hash 8 | beforeAll(() => { 9 | cleanup() 10 | }) 11 | 12 | afterEach(() => { 13 | cleanup() 14 | window.history.replaceState(null, '', '/') 15 | }) 16 | 17 | describe('LocationStateDemo', () => { 18 | const replaceState = jest.spyOn(window.history, 'replaceState') 19 | const pushState = jest.spyOn(window.history, 'pushState') 20 | 21 | test('LocationStateDemo renders without crash/loop', () => { 22 | expect( 23 | render( 24 | 25 | 26 | } /> 27 | 28 | 29 | ) 30 | ) 31 | }) 32 | 33 | test('can set name with button', () => { 34 | const { getByText, getByTestId } = render( 35 | 36 | 37 | } /> 38 | 39 | 40 | ) 41 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 42 | 43 | // should put new names into the hash (and age default value comes along) 44 | act(() => void fireEvent.click(getByText('name: "Felix"'))) 45 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 46 | 47 | act(() => void fireEvent.click(getByText('name: "Kim"'))) 48 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 49 | 50 | // set back to default value, so it should remove name value from hash 51 | act(() => void fireEvent.click(getByText('name: "Sarah" (default value)'))) 52 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 53 | }) 54 | 55 | test('can set name with text field', () => { 56 | const { getByLabelText, getByTestId } = render( 57 | 58 | 59 | } /> 60 | 61 | 62 | ) 63 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 64 | act( 65 | () => 66 | void fireEvent.change(getByLabelText('name:'), { 67 | target: { value: 'Mila' }, 68 | }) 69 | ) 70 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 71 | }) 72 | 73 | test('can set age with button', () => { 74 | const { getByText, getByTestId } = render( 75 | 76 | 77 | } /> 78 | 79 | 80 | ) 81 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 82 | 83 | act(() => void fireEvent.click(getByText('age: 30'))) 84 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 85 | 86 | act(() => void fireEvent.click(getByText('age: 45'))) 87 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 88 | 89 | act(() => void fireEvent.click(getByText('age: 25'))) 90 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 91 | }) 92 | 93 | test('can set age with text field', () => { 94 | const { getByLabelText, getByTestId } = render( 95 | 96 | 97 | } /> 98 | 99 | 100 | ) 101 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 102 | act( 103 | () => 104 | void fireEvent.change(getByLabelText('age:'), { 105 | target: { value: '33' }, 106 | }) 107 | ) 108 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 109 | }) 110 | 111 | test('can set name & age with button', () => { 112 | const { getByText, getByTestId } = render( 113 | 114 | 115 | } /> 116 | 117 | 118 | ) 119 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 120 | 121 | act(() => void fireEvent.click(getByText('name: "Felix", age: 30'))) 122 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 123 | 124 | act(() => void fireEvent.click(getByText('name: "Kim", age: 45'))) 125 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 126 | 127 | // set back to default value 128 | act(() => void fireEvent.click(getByText('name: "Sarah", age: 25'))) 129 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 130 | }) 131 | 132 | test('can set active with checkbox', () => { 133 | const { getByLabelText, getByTestId } = render( 134 | 135 | 136 | } /> 137 | 138 | 139 | ) 140 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 141 | 142 | act(() => void fireEvent.click(getByLabelText('active'))) 143 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 144 | }) 145 | 146 | test('can set active with checkbox - push', () => { 147 | const { getByLabelText, getByTestId } = render( 148 | 149 | 150 | } /> 151 | 152 | 153 | ) 154 | expect(getByTestId('pre-query-state')).toMatchSnapshot() 155 | const replaceStateCalls = replaceState.mock.calls.length 156 | expect(replaceState).toBeCalledTimes(replaceStateCalls) 157 | expect(pushState).toBeCalledTimes(0) 158 | 159 | act(() => void fireEvent.click(getByLabelText('active (method: push)'))) 160 | 161 | expect(replaceState).toBeCalledTimes(replaceStateCalls) 162 | expect(pushState).toBeCalledTimes(1) 163 | }) 164 | }) 165 | 166 | test('QueryStateDisplay', () => { 167 | expect(render()) 168 | }) 169 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/useQueryState/useQueryReducer.ts: -------------------------------------------------------------------------------- 1 | import { QueryStateOpts, SetQueryStringOptions } from './useQueryState.types' 2 | import { useCallback, useMemo, useState } from 'react' 3 | import { 4 | createMergedQuery, 5 | parseQueryState, 6 | parseQueryStateValue, 7 | toQueryStateValue, 8 | ValueType, 9 | } from 'query-state-core' 10 | import { useHashQueryStringInterface } from './useHashQueryStringInterface' 11 | import { useRefLatest } from '../hooks/useRefLatest' 12 | import { SetLocationStateOptions } from '../useLocationState/useLocationState.types' 13 | import { Reducer, ReducerAction, ReducerState } from '../types/sharedTypes' 14 | 15 | export type QueryDispatch = ( 16 | value: A | null, 17 | opts?: SetQueryStringOptions 18 | ) => void 19 | 20 | const queryStateOptsDefaults: QueryStateOpts = Object.freeze({}) 21 | 22 | export function useQueryReducer< 23 | R extends Reducer, ReducerAction> 24 | >( 25 | itemName: string, 26 | reducer: R, 27 | initialState: ReducerState, 28 | queryStateOpts?: QueryStateOpts 29 | ): [ReducerState, QueryDispatch>] 30 | 31 | export function useQueryReducer< 32 | R extends Reducer, ReducerAction>, 33 | InitialArg 34 | >( 35 | itemName: string, 36 | reducer: R, 37 | initialArg: InitialArg, 38 | initStateFn: (initialArg: InitialArg) => ReducerState, 39 | queryStateOpts?: QueryStateOpts 40 | ): [ReducerState, QueryDispatch>] 41 | 42 | export function useQueryReducer< 43 | R extends Reducer, ReducerAction>, 44 | InitialArg 45 | >( 46 | itemName: string, 47 | reducer: R, 48 | initialStateOrInitialArg: ReducerState | InitialArg, 49 | initStateFnOrOpts?: 50 | | QueryStateOpts 51 | | ((initialArg: InitialArg) => ReducerState), 52 | queryStateOpts?: QueryStateOpts 53 | ): [ReducerState, QueryDispatch>] { 54 | const mergedQueryStateOpts = Object.assign( 55 | {}, 56 | queryStateOptsDefaults, 57 | queryStateOpts, 58 | typeof initStateFnOrOpts === 'object' ? initStateFnOrOpts : null 59 | ) 60 | const { queryStringInterface } = mergedQueryStateOpts 61 | const hashQSI = useHashQueryStringInterface( 62 | queryStringInterface ? { disabled: true } : undefined 63 | ) 64 | const activeQSI = queryStringInterface || hashQSI 65 | 66 | // itemName & defaultValue is not allowed to be changed after init 67 | const [defaultValue] = useState>(() => 68 | initStateFnOrOpts && typeof initStateFnOrOpts === 'function' 69 | ? initStateFnOrOpts(initialStateOrInitialArg as InitialArg) 70 | : (initialStateOrInitialArg as ReducerState) 71 | ) 72 | 73 | if (!useIsValidQueryStateValue(defaultValue)) { 74 | throw new Error('unsupported defaultValue') 75 | } 76 | 77 | const ref = useRefLatest({ 78 | activeQSI, 79 | defaultValue, 80 | mergedQueryStateOpts, 81 | reducer, 82 | }) 83 | 84 | const resetQueryStateItem = useCallback( 85 | (opts: SetLocationStateOptions) => { 86 | const { activeQSI } = ref.current 87 | const currentState = parseQueryState(activeQSI.getQueryString()) || {} 88 | const newState = { ...currentState, [itemName]: null } 89 | activeQSI.setQueryString(createMergedQuery(newState), opts) 90 | setR((rC) => rC + 1) 91 | }, 92 | [itemName, ref] 93 | ) 94 | 95 | const [, setR] = useState(0) 96 | const dispatch: QueryDispatch> = useCallback( 97 | (action, opts = {}) => { 98 | const { activeQSI, defaultValue, mergedQueryStateOpts, reducer } = 99 | ref.current 100 | const { stripDefaults = true } = mergedQueryStateOpts 101 | const currentState = parseQueryState(activeQSI.getQueryString()) || {} 102 | const currentValue = 103 | itemName in currentState 104 | ? parseQueryStateValue(currentState[itemName], defaultValue) 105 | : defaultValue 106 | 107 | if (action === null) { 108 | return resetQueryStateItem(opts) 109 | } 110 | 111 | const newValue = reducer(currentValue ?? defaultValue, action) 112 | const newQueryStateValue = toQueryStateValue(newValue) 113 | 114 | if (newQueryStateValue === null) { 115 | console.warn( 116 | 'value of ' + 117 | JSON.stringify(newValue) + 118 | ' is not supported. "' + 119 | itemName + 120 | '" will reset to default value of:', 121 | defaultValue 122 | ) 123 | } 124 | 125 | // when a params are set to the same value as in the defaults 126 | // we remove them to avoid having two URLs reproducing the same state unless stripDefaults === false 127 | if (stripDefaults) { 128 | if ( 129 | Array.isArray(defaultValue) && 130 | sameAsJsonString(newValue, defaultValue) 131 | ) { 132 | return resetQueryStateItem(opts) 133 | } else if (newValue === defaultValue) { 134 | return resetQueryStateItem(opts) 135 | } 136 | } 137 | 138 | activeQSI.setQueryString( 139 | createMergedQuery({ 140 | ...currentState, 141 | [itemName]: toQueryStateValue(newValue), 142 | }), 143 | opts 144 | ) 145 | 146 | // force re-render 147 | setR((rC) => rC + 1) 148 | }, 149 | [itemName, ref, resetQueryStateItem] 150 | ) 151 | 152 | const currentState = parseQueryState(activeQSI.getQueryString()) || {} 153 | const currentValue = 154 | (itemName in currentState 155 | ? parseQueryStateValue(currentState[itemName], defaultValue) 156 | : defaultValue) ?? defaultValue 157 | 158 | return [currentValue, dispatch] 159 | } 160 | 161 | function useIsValidQueryStateValue(value: unknown): value is ValueType { 162 | const defaultQueryStateValue = useMemo( 163 | () => toQueryStateValue(value), 164 | [value] 165 | ) 166 | return defaultQueryStateValue !== null 167 | } 168 | 169 | function sameAsJsonString(compareValueA: any, compareValueB: any) { 170 | return JSON.stringify(compareValueA) === JSON.stringify(compareValueB) 171 | } 172 | -------------------------------------------------------------------------------- /src/packages/use-location-state/src/test/nextjs.test.tsx: -------------------------------------------------------------------------------- 1 | import { EMPTY_ARRAY_STRING } from 'query-state-core' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import { getServerSideProps, useQueryReducer, useQueryState } from '../next' 4 | import * as NextExportsInRoot from '../../next' 5 | import { 6 | unwrapABResult, 7 | unwrapResult, 8 | } from 'use-location-state-test-helpers/test-helpers' 9 | import mockRouter from 'next-router-mock' 10 | import { useRouter } from 'next/router' 11 | 12 | import { 13 | render, 14 | screen, 15 | waitFor, 16 | act as actReact, 17 | } from '@testing-library/react' 18 | 19 | jest.mock('next/router', () => require('next-router-mock')) 20 | 21 | beforeEach(() => { 22 | mockRouter.setCurrentUrl('') 23 | }) 24 | 25 | describe.each` 26 | defaultValue | newValue | newValueQueryString 27 | ${'not empty'} | ${''} | ${'?item='} 28 | ${'not empty'} | ${'still not empty'} | ${'?item=' + encodeURIComponent('still not empty')} 29 | ${''} | ${'not empty anymore'} | ${'?item=' + encodeURIComponent('not empty anymore')} 30 | ${[]} | ${['new', 'entries']} | ${'?item=new&item=entries'} 31 | ${['']} | ${['new', 'entries']} | ${'?item=new&item=entries'} 32 | ${['multiple', 'strings']} | ${[]} | ${'?item=' + encodeURIComponent(EMPTY_ARRAY_STRING)} 33 | ${['multiple', 'strings']} | ${['']} | ${'?item='} 34 | ${['multiple', 'strings']} | ${['just one entry']} | ${'?item=' + encodeURIComponent('just one entry')} 35 | ${0} | ${-50} | ${'?item=-50'} 36 | ${99} | ${3.14} | ${'?item=3.14'} 37 | ${Infinity} | ${-Infinity} | ${'?item=-Infinity'} 38 | ${1e23} | ${1e24} | ${'?item=' + encodeURIComponent((1e24).toString())} 39 | ${true} | ${false} | ${'?item=false'} 40 | ${true} | ${true} | ${''} 41 | ${false} | ${true} | ${'?item=true'} 42 | ${false} | ${false} | ${''} 43 | `( 44 | 'defaultValue $defaultValue, newValue $newValue', 45 | ({ defaultValue = '', newValue, newValueQueryString }) => { 46 | let routerAsPath = '' 47 | let reducerState = '' 48 | 49 | test(`should return default value and set newValue successfully`, async () => { 50 | const { result, unmount } = renderHook(() => { 51 | routerAsPath = useRouter().asPath 52 | 53 | reducerState = useQueryReducer( 54 | 'item', 55 | (_, action) => action, 56 | defaultValue 57 | )[0] 58 | 59 | return useQueryState('item', defaultValue) 60 | }) 61 | 62 | const r = unwrapResult(result) 63 | // default 64 | expect(result.error).toBe(undefined) 65 | expect(routerAsPath).toEqual('') 66 | expect(r.value).toEqual(defaultValue) 67 | // new value 68 | await act(async () => r.setValue(newValue)) 69 | expect(routerAsPath).toEqual(newValueQueryString) 70 | expect(r.value).toEqual(newValue) 71 | expect(reducerState).toEqual(newValue) 72 | 73 | // back to default 74 | await act(async () => r.setValue(defaultValue)) 75 | expect(r.value).toEqual(defaultValue) 76 | expect(routerAsPath).toEqual('') 77 | 78 | unmount() 79 | }) 80 | } 81 | ) 82 | 83 | test('getServerSideProps', async () => { 84 | expect(await getServerSideProps({} as any)).toEqual({ 85 | props: {}, 86 | }) 87 | }) 88 | 89 | test('Exports for next in root folder', () => { 90 | expect(Object.keys(NextExportsInRoot)).toEqual([ 91 | 'getServerSideProps', 92 | 'useLocationState', 93 | 'useQueryReducer', 94 | 'useQueryState', 95 | ]) 96 | }) 97 | 98 | describe('Resetting to original value in batch with a following no-op', () => { 99 | test('using clicks', async () => { 100 | const onRender = jest.fn() 101 | let routerAsPath = '' 102 | 103 | function Comp() { 104 | const [name, setName] = useQueryState('name', '') 105 | const [age, setAge] = useQueryState('age', 18) 106 | const state = { age, name } 107 | onRender(state) 108 | routerAsPath = useRouter().asPath 109 | return ( 110 | <> 111 | 119 | 127 | 128 | ) 129 | } 130 | 131 | render() 132 | 133 | expect(routerAsPath).toEqual('') 134 | 135 | await actReact(async () => { 136 | screen.getByRole('button', { name: 'set' }).click() 137 | }) 138 | 139 | expect(onRender).toHaveBeenLastCalledWith({ 140 | age: 18, 141 | name: 'Ron', 142 | }) 143 | 144 | await waitFor(() => { 145 | expect(routerAsPath).toEqual('?name=Ron') 146 | }) 147 | 148 | await actReact(async () => { 149 | screen.getByRole('button', { name: 'reset' }).click() 150 | }) 151 | expect(routerAsPath).toEqual('') 152 | }) 153 | 154 | test(`using setter from hook directly`, async () => { 155 | let routerAsPath = '' 156 | const { result, unmount } = renderHook(() => { 157 | routerAsPath = useRouter().asPath 158 | return { 159 | a: useQueryState('age', 18), 160 | b: useQueryState('names', [] as string[]), 161 | } 162 | }) 163 | 164 | const { a: age, b: names } = unwrapABResult(result) 165 | 166 | // default 167 | expect(result.error).toBe(undefined) 168 | expect(routerAsPath).toEqual('') 169 | expect(age.value).toEqual(18) 170 | expect(names.value).toEqual([]) 171 | 172 | // set next values: 173 | await act(async () => { 174 | age.setValue(31) 175 | names.setValue([]) 176 | }) 177 | 178 | // check new value 179 | expect(result.error).toBe(undefined) 180 | expect(routerAsPath).toEqual('?age=31') 181 | expect(age.value).toEqual(31) 182 | expect(names.value).toEqual([]) 183 | 184 | // reset to original values 185 | await act(async () => { 186 | age.setValue(18) 187 | names.setValue([]) 188 | }) 189 | 190 | // check successful reset 191 | expect(result.error).toBe(undefined) 192 | expect(routerAsPath).toEqual('') 193 | expect(age.value).toEqual(18) 194 | expect(names.value).toEqual([]) 195 | 196 | unmount() 197 | }) 198 | }) 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

{ useLocationState, useQueryState }

3 |
4 | 5 | [![npm (tag)](https://img.shields.io/npm/v/use-location-state/latest.svg)](https://www.npmjs.com/package/use-location-state) 6 | [![codecov badge](https://img.shields.io/codecov/c/github/xiel/use-location-state/master.svg?color=hotpink)](https://codecov.io/gh/xiel/use-location-state) 7 | ![GitHub top language](https://img.shields.io/github/languages/top/xiel/use-location-state.svg) 8 | [![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lerna.js.org/) 9 | 10 | store and retrieve state into/from the browsers [location history](https://developer.mozilla.org/en-US/docs/Web/API/History) using modern hooks 11 | 12 | ## ✨ Features 13 | 14 | - makes it easy to provide a nice UX to your users, by restoring part of the app state after navigation actions 15 | - makes it easy to share the application in a customizable state 16 | - **`useLocationState(name, defaultValue)`** 17 | - restores the latest value after navigation actions (back/forward), by keeping value in `history.state` 18 | - supported value types: `string | number | boolean | Date | Array | Object` 19 | - handles complex & nested values - all values that can be serialized are supported 20 | - **`useQueryState(name, defaultValue)`** 21 | - restores the latest value from URL (`location.href`) and after navigation actions (back/forward) 22 | - supported value types: `string | number | boolean | Date | string[]` 23 | - handles stringification and parsing from query string of for supported value types 24 | - invalid entries from the query string are discarded and the component will receive the defaultValue instead 25 | 26 | 27 | 28 | ## Installation 29 | 30 | ```bash 31 | yarn add use-location-state 32 | ``` 33 | 34 | Using **`react-router`** or another popular router? For the best experience install one of the [router integrations](#router-integration-optional). 35 | 36 | ## Usage 37 | 38 | `useLocationState()` and `useQueryState()` work similar to the `useState()` [hook](https://reactjs.org/docs/hooks-overview.html#state-hook), as they also return the current value and a update function in a tuple `[currentValue, updateValueFn]`. 39 | 40 | The _important difference_ is that **you must pass a name** before your **default value** for your state. 41 | 42 | ```javascript 43 | const [commentText, setCommentText] = useLocationState('commentText', '') 44 | const [priceMax, setPriceMax] = useQueryState('priceMax', 30) 45 | ``` 46 | 47 | The `defaultValue` works as a fallback and is returned when there is no value in the query or location state for this parameter. 48 | 49 | The `defaultValue` can not be changed after the first render, so that same url always provides the same state. 50 | 51 | ### useLocationState() 52 | 53 | `useLocationState()` is perfect, when you want to store a state that should not be reflected in the URL or in case of a complex data structure like a nested object/array. 54 | 55 | ```javascript 56 | const [commentText, setCommentText] = useLocationState('commentText', '') 57 | ``` 58 | 59 | The name you pass, in this case `'commentText'`, will be used as a key when storing the value. So when you use the same name (and default value) in another component, you will get the same state. 60 | 61 | ```javascript 62 | setCommentText('Wow, this works like a charm!') 63 | ``` 64 | 65 | The updated state will be restored when the pages reloads and after the user navigated to a new page and comes back using a back/forward action. 66 | 67 | ### useQueryState() 68 | 69 | `useQueryState()` is a great, when you want to store information about the current state of you app in the URL. 70 | 71 | ```javascript 72 | const [value, setValue] = useQueryState('itemName', 'default value') 73 | ``` 74 | 75 | The name you pass will be used as a parameter name in the query string, when setting a new value: 76 | 77 | ```javascript 78 | setValue('different value') 79 | ``` 80 | 81 | After calling the update function `setValue()` with a new value, the state will be saved withing the query string of the browser, so that the new state is reproducable after reloads or history navigation (using forward / back button) or by loading the same URL anywhere else. 82 | 83 | ``` 84 | http://localhost:3000/#itemName=different+value 85 | ``` 86 | 87 | useQueryState() uses the browsers `location.hash` property by default. 88 | Check out the router integrations to use `location.search` instead. 89 | 90 | ### Push 91 | 92 | In cases where you want the updated state to be represented as a **new entry in the history** you can pass a options object to the set function, with the method property set to `'push'`. 93 | 94 | ```javascript 95 | setValue('a pushed value', { method: 'push' }) 96 | ``` 97 | 98 | This changes the way this state change is handled when the user navigates. When the user now clicks the Back-Button, this state gets popped and the previous state is restored (instead of eg. navigating away). 99 | 100 | ### Example 101 | 102 | ```javascript 103 | import { useQueryState } from 'use-location-state' 104 | 105 | function MyComponent() { 106 | const [active, setActive] = useQueryState('active', true) 107 | return ( 108 |
109 | 112 | {active &&

Some active content

} 113 |
114 | ) 115 | } 116 | ``` 117 | 118 |
119 | Example in CodeSandbox 120 | 121 | 122 | ### Example with multiple useQueryState hooks in one component 123 | 124 | ```javascript 125 | import { useQueryState } from 'use-location-state' 126 | 127 | function MyComponent() { 128 | const [name, setName] = useQueryState('name', 'Sarah') 129 | const [age, setAge] = useQueryState('age', 25) 130 | const [active, setActive] = useQueryState('active', false) 131 | // ... 132 | } 133 | ``` 134 | 135 | 136 | Example in CodeSandbox 137 | 138 | 139 | ## Router Integration (optional) 140 | 141 | In case you want use [`location.search`](https://developer.mozilla.org/en-US/docs/Web/API/Location/search) (after the question mark in the url) you need to use one of these extended versions of the package. 142 | 143 | We plan to provide clean and easy-to-use integrations for all popular routers. 144 | At the moment we provide integrations for: 145 | 146 | ### Next.js 147 | 148 | Import from `use-location-state/next` to use the router build into Next.js, which enables you to use the query state also during **SSR**. 149 | 150 | ```javascript 151 | import { useQueryState } from 'use-location-state/next' 152 | 153 | export { getServerSideProps } from 'use-location-state/next' // [1] 154 | 155 | export default function Page() { 156 | const [count, setCount] = useQueryState('count', 0) 157 | //... 158 | } 159 | ``` 160 | 161 | [1] Page must be server rendered (SRR), otherwise React warns about a hydration mismatch, when your initial rendering depends on the query state. Export your own `getServerSideProps` function or the provided empty one. 162 | 163 | 164 | Example in CodeSandbox 165 | 166 | 167 | ### react-router (react-router@^6.0.0) 168 | 169 | Install & import the package for react-router 170 | 171 | ```bash 172 | yarn add react-router-use-location-state 173 | ``` 174 | 175 | ```javascript 176 | import { 177 | useLocationState, 178 | useQueryState, 179 | } from 'react-router-use-location-state' 180 | ``` 181 | 182 | Usage works the same as described above, except that the URL will look like this now: 183 | 184 | ``` 185 | http://localhost:3000/?itemName=different+value 186 | ``` 187 | 188 | 189 | Edit react-router-use-location-state-example 190 | 191 | 192 | ### Gatsby & @reach/router 193 | 194 | Gatsby & Reach Router are supported. Gatsby currently always scrolls up on location (state) changes. To keep the scroll position, when you update location state using the update function of `useLocationState`, add these lines to the **gatsby-browser.js** file in gatsby root folder. 195 | 196 | ```javascript 197 | // keeps same scroll pos when history state is pushed/replaced (same location === same position) 198 | // see: https://www.gatsbyjs.org/docs/browser-apis/#shouldUpdateScroll 199 | exports.shouldUpdateScroll = ({ routerProps, getSavedScrollPosition }) => { 200 | const currentPosition = getSavedScrollPosition(routerProps.location) 201 | return currentPosition || true 202 | } 203 | ``` 204 | 205 | ### More routers soon - work in progress 206 | 207 | Your favorite router is missing? Feel free to [suggest a router](https://github.com/xiel/use-location-state/issues). 208 | 209 | ## Compatibility 210 | 211 | Tested in current versions Chrome, Firefox, Safari, Edge, and IE11. This library relies on new, yet stable ECMAScript features, so you might need to include a [polyfill](https://www.npmjs.com/package/react-app-polyfill#polyfilling-other-language-features) if you want to support older browsers like IE11: 212 | 213 | ```javascript 214 | import 'react-app-polyfill/ie11' 215 | import 'react-app-polyfill/stable' 216 | ``` 217 | --------------------------------------------------------------------------------