├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── constants.ts ├── e2e │ ├── basic.cy.ts │ ├── rerenderCheck.cy.ts │ └── useQueryState │ │ └── dynamicOption.cy.ts ├── fixtures │ └── example.json ├── support │ ├── commands.ts │ └── e2e.ts └── tsconfig.json ├── jest.config.ts ├── jest ├── serializerPresets │ ├── nullableQueryTypes.ts │ └── queryTypes.ts ├── useQueryState.ts └── useQueryStates.ts ├── package.json ├── rollup.config.ts ├── src ├── defs.ts ├── index.ts ├── nullableQueryTypes.ts ├── pages │ ├── _app.tsx │ ├── index.tsx │ └── tests │ │ ├── useQueryState │ │ ├── basic.tsx │ │ ├── dynamicNonFunctionPreset.tsx │ │ ├── dynamicTest.tsx │ │ ├── nonDynamicTest.tsx │ │ ├── rerenderCheck.tsx │ │ └── useEffectMultiplePush.tsx │ │ └── useQueryStates │ │ ├── basic.tsx │ │ ├── rerenderCheck.tsx │ │ └── useEffectMultiplePush.tsx ├── useQueryState.ts ├── useQueryStates.ts └── utils.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - main 7 | 8 | jobs: 9 | ci: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | - run: yarn install 17 | - run: yarn test 18 | - run: yarn e2e 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm registry 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | - run: yarn install 17 | - run: yarn test 18 | - run: yarn e2e 19 | - uses: JS-DevTools/npm-publish@v1 20 | with: 21 | token: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # next.js 5 | /.next/ 6 | next-env.d.ts 7 | 8 | # builds 9 | /dist/ 10 | *.tgz 11 | 12 | # misc 13 | .DS_Store 14 | /.vscode 15 | 16 | # test 17 | /coverage -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ticketplace Corp. 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 | 23 | This software incorporates work covered by the following copyright and 24 | permission notice: 25 | 26 | MIT License 27 | 28 | Copyright (c) 2020 François Best 29 | 30 | Permission is hereby granted, free of charge, to any person obtaining a copy 31 | of this software and associated documentation files (the "Software"), to deal 32 | in the Software without restriction, including without limitation the rights 33 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 34 | copies of the Software, and to permit persons to whom the Software is 35 | furnished to do so, subject to the following conditions: 36 | 37 | The above copyright notice and this permission notice shall be included in all 38 | copies or substantial portions of the Software. 39 | 40 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 41 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 42 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 43 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 44 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 45 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 46 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-query-state 2 | [![NPM](https://img.shields.io/npm/v/next-query-state?color=red)](https://www.npmjs.com/package/next-query-state) 3 | [![Bundle size](https://img.shields.io/bundlephobia/minzip/next-query-state)](https://bundlephobia.com/package/next-query-state) 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-green)](https://github.com/youha-info/next-query-state/blob/main/LICENSE) 5 | 6 | Easy state management of URL query string for Next.js 7 | 8 | Save state in URL to persist state between forward/back navigation, bookmarks, and sharing links. 9 | 10 | ## Features 11 | 12 | ### 🌟 Ease of use 13 | 14 | Persisting state in the query string with `next/router` is such a hassle. Instead of having to use as below, 15 | 16 | ```ts 17 | const router = useRouter(); 18 | const state = parseInt(router.query.state) || 0; 19 | 20 | router.push({ query: { ...router.query, state: 10 } }); 21 | ``` 22 | 23 | You can manage state similar to `React.useState`. 24 | 25 | ```ts 26 | const [state, setState] = useQueryState("state", queryTypes.integer.withDefault(0)); 27 | 28 | setState(10); 29 | ``` 30 | 31 |
32 | 33 | ### 📚 Batched URL updates 34 | 35 | With `next/router` or most other packages, updating query state multiple times inside a render causes the updates to overwrite each other. 36 | 37 | `next-query-state` uses [next-batch-router](https://github.com/youha-info/next-batch-router), so that URL updates are collected during the render phase and batched together in one URL update. 38 | 39 | This allows for updating query states individually just like using `useState` instead of having to group states to be updated together. It also makes URL history cleaner by not creating partially updated histories in case you use `push` to update states. 40 | 41 |
42 | 43 | ❌ This is wrong. It results in `?a=1` or `?b=2` 44 | 45 | ```ts 46 | const router = useRouter(); 47 | router.push({ query: { ...router.query, a: 1 } }); 48 | router.push({ query: { ...router.query, b: 2 } }); 49 | ``` 50 | 51 | ✅ `next-query-states` results in `?a=1&b=2` 52 | 53 | ```ts 54 | const [a, setA] = useQueryState("a", queryTypes); 55 | setA(1); 56 | setB(2); 57 | ``` 58 | 59 | 🟡 Most other solutions require grouping of states, which can cause coupling and timing issues. 60 | 61 | ```ts 62 | const [states, setStates] = useQueryStates({ a: queryTypes.string, b: querytypes.string }); 63 | setStates({ a: 1, b: 2 }); 64 | ``` 65 | 66 |
67 | 68 | ### ⏩ Supports functional updates 69 | 70 | You can do functional updates as if using `useState` 71 | 72 | ```ts 73 | // Functional update also works! 74 | const [state, setState] = useQueryState("state"); 75 | setState((prev) => prev + 10); 76 | ``` 77 | 78 |
79 | 80 | ### 🚀 Static generation friendly 81 | 82 | When Next.js prerenders pages to static HTML at build time, it doesn't have any data about the query string. 83 | Therefore to avoid hydration error, client must behave as if there is no query string in the first render. next-query-state relies on Next.js `router.query` instead of `window.location` so it naturally avoids this problem. 84 | 85 | If you don't want the default value to briefly show on first render, don't render the value or show placeholders when `router.isReady` is false. 86 | 87 | --- 88 | 89 |
90 | 91 | ## Installation 92 | 93 | ```sh 94 | $ yarn add next-query-state 95 | or 96 | $ npm install next-query-state 97 | ``` 98 | 99 |
100 | 101 | Then, set up `` at the top of the component tree, preferably inside pages/\_app.js 102 | 103 | ```js 104 | import { BatchRouterProvider } from "next-query-state"; 105 | // or import { BatchRouterProvider } from "next-batch-router"; 106 | 107 | const MyApp = ({ Component, pageProps }) => ( 108 | 109 | 110 | 111 | ); 112 | ``` 113 | 114 | --- 115 | 116 |
117 | 118 | ## Usage 119 | 120 | `BatchRouterProvider` must be provided as above! 121 | 122 | ```ts 123 | import { useQueryState, queryTypes } from "next-query-state"; 124 | 125 | export default function TestPage() { 126 | // Most basic usage, only designating parameter key 127 | const [basicString, setBasicString] = useQueryState("basicString"); 128 | 129 | // Integer typed parameter with 0 as default value 130 | const [int, setInt] = useQueryState("int", queryTypes.integer.withDefault(0)); 131 | 132 | // Array of enum strings. Adds url history. 133 | const [enumArr, setEnumArr] = useQueryState( 134 | "enumArr", 135 | queryTypes.array(queryTypes.stringEnum(["some", "available", "values"])).withDefault([]), 136 | { history: "push" } 137 | ); 138 | 139 | const clearAll = () => { 140 | setBasicString(null); 141 | setInt(null); 142 | setEnumArr(null); 143 | }; 144 | 145 | return ( 146 |
147 |
basicString: {basicString}
148 | 149 | 150 | 151 |
num: {int}
152 | 153 | 154 |
enumArr: {enumArr.join(" ")}
155 | 156 | 157 |
158 | 159 |
160 |
161 | ); 162 | } 163 | ``` 164 | 165 | --- 166 | 167 |
168 | 169 | ## Background on Types 170 | 171 | Each query parameter has two types. `T` and `WT`, and it's converted from and to types of `next-batch-router`. 172 | 173 | When you use `const [foo, setFoo] = useQueryState("foo")`, `foo` is type `T`, and `setFoo` is a function that takes `WT`. 174 | 175 | `T`: The type of the query parameter. 176 | 177 | - `null` value expresses absense of the query parameter in URL(When using `queryTypes` preset). 178 | 179 | `WT`: The **write type** of query parameter. 180 | 181 | - It is a superset of `T`, and usually includes `null` and `undefined`. 182 | - The type and behavior is determined by the serializer function, as the serializer turns `WT` into `WriteQueryValue` to be passed on to `next-batch-router`. It is fully customizable. 183 | 184 | `NextQueryValue`: Parsed query string data that is provided by `next/router`. `parse` function converts this to `T`. 185 | 186 | - `string | string[] | undefined` 187 | 188 | `WriteQueryValue`: Type that's passed to `next-batch-router` to change the URL. `serialize` function converts `WT` to this. 189 | 190 | - `string | number | boolean | (string | number | boolean)[] | null | undefined` 191 | 192 | - `null` is used to remove from url. `undefined` is ignored and leaves value as is. 193 | - Array creates multiple key=value in the URL with the same key. 194 | For example, if `[1, 2, 3]` is set to `'foo'` parameter, URL becomes like this: `/?foo=1&foo=2&foo=3` 195 | - Other types are serialized to string. 196 | 197 | The types are converted in this direction, forming a loop: `NextQueryValue` -(parser)-> `T` -(your state update logic)-> `WT` -(serializer)-> `WriteQueryValue` -(URL change)-> `NextQueryValue` 198 | 199 |
200 | 201 | ## `useQueryState(key, serializers, options): [T, update]` 202 | 203 | ### Parameters 204 | 205 | `key`: `string` 206 | 207 | - Key to use in the query string. Required. 208 | 209 | `serializers`?: `{ parse?: function, serialize?: function }` 210 | 211 | - Object that consists of `parse` and `serialize` functions that transforms the state from and to the URL string. 212 | - This parameter is fixed on the first hook call, and should not be changed unless `dynamic` option is set to true. 213 | - You won't likely create this object yourself, but use one of the presets in `queryTypes` or `nullableQueryTypes` like `useQueryState("foo", queryTypes.string)`. 214 | See the below section about presets. 215 | 216 | - `parse`?: `(v: NextQueryValue) => T` 217 | - Function that parses `NextQueryValue` to the desired type `T`. 218 | - Default value feature is implemented inside this function. 219 | - `serialize`?: `(v: WT) -> WriteQueryValue` 220 | - Transform `WT` into `WriteQueryValue` type. 221 | 222 | `options`?: `{ history?: "push" | "replace", dynamic?: boolean }` 223 | 224 | - `history`?: `"push" | "replace"` 225 | - Set to "push" to add URL history so you can go back to previous state, and set to "replace" to prevent adding history. 226 | - Default is "replace". 227 | - It can be overridden when updating state eg: `setState(newVal, {history:"push"})` 228 | - `dynamic`?: `boolean` 229 | - `parse` and `serialize` options are fixed on the first hook call, as if putting default value in `useState`. 230 | - This restriction is for memoization of the returned value from the hook. 231 | - You can set `dynamic` to `true` to change `parse` and `serialize` functons at runtime, but referential equality 232 | of the function must be manually managed for the memoization to work. 233 | - Currently, stale updater function returned from the hook uses previously supplied parse and serialize functions, 234 | so you must always use update function freshly returned from the hook. 235 | 236 | ### Returns 237 | 238 | `value`: `T` 239 | 240 | - The state of the query parameter. It's parsed from query string and type converted. 241 | 242 | `update`: `(stateUpdater, options) => Promise` 243 | 244 | - Function to update state of the query parameter. 245 | - Returns a promise that resolves when URL change is finished. 246 | Check [Next.js docs](https://nextjs.org/docs/api-reference/next/router#potential-eslint-errors) if no-floating-promises ESLint error occurs. 247 | 248 | - `stateUpdater`: `WT | (prev: T) => WT` 249 | 250 | - Similar to `React.useState`, the new value to update, or a function that takes previous value and returns a new value. 251 | - When using `queryTypes` preset, `null` removes parameter from URL, and `undefined` is ignored. 252 | 253 | - `options`: `{ history?: "push" | "replace", scroll?: boolean, shallow?: boolean, locale?: string }` 254 | 255 | - `history`?: `"push" | "replace"` 256 | 257 | - Overrides history mode set on the hook. 258 | - Set to "push" to add URL history so you can go back to previous state, and set to "replace" to prevent adding history. 259 | 260 | - `scroll`?: `boolean` 261 | 262 | - Scroll to the top of the page after navigation. 263 | - Defaults to `true`. 264 | - When multiple `push` and `replace` calls are merged, all must have `scroll: false` to not scroll after navigation. 265 | 266 | - `shallow`?: `boolean` 267 | 268 | - Update the path of the current page without rerunning `getStaticProps`, `getServerSideProps` or `getInitialProps`. 269 | - Defaults to `false`. 270 | - When merged, all must have `shallow: true` to do shallow routing. 271 | 272 | - `locale`?: `string` 273 | 274 | - Indicates locale of the new page. When merged, the last one will be applied. 275 | 276 |
277 | 278 | ## Serializers Presets 279 | 280 | ### `queryTypes` 281 | 282 | `null` means no key in url. (`/?`) 283 | 284 | `empty string` means only key and no value in URL (`/?foo=`) 285 | 286 | `undefined` only exists in `WT`, and means 'leave value as is' 287 | 288 | If param exists multiple times(`?foo=1&foo=2`), array serializer reads it as array, and other serializers only read the first one. 289 | 290 | When you use `withDefault()`, since there is a default value, `null` is excluded from `T`. 291 | However, it still exists in `WT` so you can remove key from the URL to set value to default value. 292 | 293 | | Serializers | Type | Extra | Value example | URL example | 294 | | ------------------------------------------ | ----------------------------- | -------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------- | 295 | | string | `string` | | "foo" | ?state=foo | 296 | | integer | `number` | Non-integer value is floored. Can set and read Infinity. | 123 | ?state=123 | 297 | | float | `number` | Can set and read Infinity. | 12.3 | ?state=12.3 | 298 | | boolean | `boolean` | Value must be "true" or "false" but their casing is ignored (ex. "tRUe") | true | ?state=true | 299 | | timestamp | `Date` | | new Date(2022, 9, 1, 12, 30, 0) | ?state=1664627400000 | 300 | | isoDateTime | `Date` | | new Date(2022, 9, 1, 12, 30, 0) | ?state=2022-10-01T03%3A30%3A00.000Z | 301 | | stringEnum(validValues) | `string` (or string literals) | Declare valid values as `["foo"] as const`, or set type via generic for type support. | "foo" | ?state=foo | 302 | | json | `any` | Schema is not validated. | {foo: "bar"} | ?state=%7B%22foo%22%3A%22bar%22%7D | 303 | | array(itemSerializers) | `T[]` | Nested arrays unavailable. | [1,2,3] | ?state=1&state=2&state=3 | 304 | | delimitedArray(itemSerializers, separator) | `T[]` | Separator character inside value will cause bugs, and separator might get URL encoded. | [1,2,3] | ?state=1%2C2%2C3 | 305 | 306 | ### `nullableQueryTypes` 307 | 308 | Experimental preset that allows `null` as a value, not as absense of value. `null` is encoded as `%00`. 309 | 310 | `null` means `null` value (`/?foo=%00`) 311 | 312 | `undefined` means no key in url. (`/?`) 313 | 314 | `empty string` means only key and no value in URL (`/?foo=`) 315 | 316 | When you use `withDefault()`, since there is a default value, `undefined` is excluded from `T`. 317 | However, it still exists in `WT` so you can remove key from the URL to set value to default value. 318 | 319 |
320 | 321 | ## `useQueryStates(keys, options): [Values, update]` 322 | 323 | Synchronise multiple query string arguments at once. 324 | 325 | This is similar to `useQueryState`, but without memoization and is more dynamic. 326 | For most cases, using `useQueryState` is recommended, and `useQueryStates` is intended for below cases. 327 | 328 | 1. The keys are changed at runtime. (Since conditional use of useQueryState is illegal) 329 | 2. New value is determined by other params while doing functional update. 330 | 331 | ### Parameters 332 | 333 | `keys`: `Record` 334 | 335 | - Object that has query string key as key and serializers as value. 336 | - For example: `{foo: queryTypes.string, bar:queryTypes.integer.withDefault(0)}` 337 | - Number of the keys and its types can be changed dynamically. 338 | - Check description of `serializers` parameter of `useQueryStates` for more info. 339 | 340 | `options`: { history?: "push" | "replace" } 341 | 342 | - `history`?: `"push" | "replace"` 343 | - Equal to `useQueryState` 344 | 345 | ### Returns 346 | 347 | `values`: `Values` 348 | 349 | - The state of the query parameters defined in `keys` with their own typs. They are parsed from query string and type converted. 350 | 351 | `update`: `(stateUpdater, options) => Promise` 352 | 353 | - Function to update states of the query parameter. 354 | - Returns a promise that resolves when URL change is finished. 355 | Check [Next.js docs](https://nextjs.org/docs/api-reference/next/router#potential-eslint-errors) if no-floating-promises ESLint error occurs. 356 | 357 | - `stateUpdater`: `WriteValues | (prev: Values) => WriteValues` 358 | 359 | - Similar to `useQueryState` but previous states and new states are objects. 360 | - If a key is not in write object, its value is left as is. 361 | To remove all keys from the URL, you must manually set them to `null` or `undefined` depending on the serializer. 362 | 363 | - `options`: `{ history?: "push" | "replace", scroll?: boolean, shallow?: boolean, locale?: string }` 364 | 365 | - Equal to `useQueryState` 366 | 367 |
368 | 369 | 370 | ## Credits 371 | 372 | This package is based on [next-usequerystate](https://github.com/47ng/next-usequerystate) 373 | with different design choices and implementation. 374 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | } 9 | }) -------------------------------------------------------------------------------- /cypress/constants.ts: -------------------------------------------------------------------------------- 1 | export const TEST_URL = "http://localhost:3051"; 2 | -------------------------------------------------------------------------------- /cypress/e2e/basic.cy.ts: -------------------------------------------------------------------------------- 1 | import { TEST_URL } from "../constants"; 2 | 3 | function testScenarios(functionName: "useQueryState" | "useQueryStates") { 4 | it("Returns default value on first render", () => { 5 | cy.visit(TEST_URL + `/tests/${functionName}/basic?defaultStr=val`); 6 | if (Cypress.env("CYPRESS_STRICT")) 7 | cy.get("#defaultStr").should("have.text", JSON.stringify("default")); 8 | 9 | cy.wait(50); // wait for next frame 10 | cy.get("#defaultStr").should("have.text", JSON.stringify("val")); 11 | }); 12 | 13 | it("Basic string param", () => { 14 | cy.visit(TEST_URL + `/tests/${functionName}/basic`); 15 | cy.wait(50); // wait for next frame 16 | 17 | cy.get("#str").should("have.text", JSON.stringify(null)); 18 | 19 | // typing programatically is incompatible with react. 20 | // Increase delay to prevent keypresses from being ignored 21 | // https://github.com/cypress-io/cypress/issues/536 22 | cy.get("#strInput").type("newVal", { delay: 50 }); 23 | cy.get("#str").should("have.text", JSON.stringify("newVal")); 24 | cy.location("search").should("eq", "?str=newVal"); 25 | }); 26 | 27 | it("Functional update on int", () => { 28 | cy.visit(TEST_URL + `/tests/${functionName}/basic`); 29 | cy.wait(50); // wait for next frame 30 | 31 | cy.get("#int").should("have.text", JSON.stringify(0)); 32 | 33 | cy.get("#functionalAddInt").click(); 34 | cy.get("#int").should("have.text", JSON.stringify(1)); 35 | cy.location("search").should("eq", "?int=1"); 36 | }); 37 | 38 | it("Update query with timeout", () => { 39 | cy.visit(TEST_URL + `/tests/${functionName}/basic`); 40 | cy.wait(50); // wait for next frame 41 | 42 | cy.get("#int").should("have.text", JSON.stringify(0)); 43 | 44 | cy.get("#changeStrWithTimeout").click(); 45 | 46 | cy.wait(50); // wait a bit 47 | cy.get("#str").should("have.text", JSON.stringify(null)); 48 | cy.location("search").should("eq", ""); 49 | 50 | cy.wait(500); // wait for timeout 51 | cy.get("#str").should("have.text", JSON.stringify("timeout")); 52 | cy.location("search").should("eq", "?str=timeout"); 53 | }); 54 | 55 | it("Set multiple states at once", () => { 56 | cy.visit(TEST_URL); 57 | cy.visit(TEST_URL + `/tests/${functionName}/basic`); 58 | cy.wait(50); // wait for next frame 59 | 60 | // click to push multiple state update 61 | cy.get("#multipleSetWithPush").click(); 62 | 63 | cy.get("#str").should("have.text", JSON.stringify("strValue")); 64 | cy.get("#int").should("have.text", JSON.stringify(10)); 65 | cy.location("search").should("eq", "?str=strValue&int=10"); 66 | 67 | // go back to before click 68 | cy.go("back"); 69 | 70 | cy.get("#str").should("have.text", JSON.stringify(null)); 71 | cy.get("#int").should("have.text", JSON.stringify(0)); 72 | cy.location("search").should("eq", ""); 73 | 74 | // go back to index 75 | cy.go("back"); 76 | 77 | cy.location("pathname").should("eq", "/"); 78 | cy.location("search").should("eq", ""); 79 | }); 80 | 81 | it("Set states in multiple components at once", () => { 82 | cy.visit(TEST_URL); 83 | cy.visit(TEST_URL + `/tests/${functionName}/useEffectMultiplePush`); 84 | 85 | if (Cypress.env("CYPRESS_STRICT")) { 86 | cy.location("search").should("eq", ""); 87 | cy.get("#query").should("have.text", JSON.stringify({})); 88 | } 89 | 90 | cy.wait(50); // wait for next frame 91 | 92 | cy.location("search").should("eq", "?f1=1&f2=2&f3=3"); 93 | cy.get("#query").should("have.text", JSON.stringify({ f1: "1", f2: "2", f3: "3" })); 94 | 95 | // go back to before effect 96 | cy.go("back"); 97 | 98 | cy.location("search").should("eq", ""); 99 | cy.get("#query").should("have.text", JSON.stringify({})); 100 | 101 | // go back to index 102 | cy.go("back"); 103 | 104 | cy.location("pathname").should("eq", "/"); 105 | cy.location("search").should("eq", ""); 106 | }); 107 | } 108 | 109 | describe("useQueryState basic", () => testScenarios("useQueryState")); 110 | describe("useQueryStates basic", () => testScenarios("useQueryStates")); 111 | -------------------------------------------------------------------------------- /cypress/e2e/rerenderCheck.cy.ts: -------------------------------------------------------------------------------- 1 | import { TEST_URL } from "../constants"; 2 | 3 | function checkRenderCountByURLChange( 4 | rerenderedCounterText: string, 5 | notRerenderedCounterText: string 6 | ) { 7 | cy.get("#parentCounter").should("have.text", rerenderedCounterText); 8 | cy.get("#childCounter").should("have.text", rerenderedCounterText); 9 | cy.get("#siblingWithRouterAccessCounter").should("have.text", rerenderedCounterText); 10 | 11 | cy.get("#memoizedParentCounter").should("have.text", notRerenderedCounterText); 12 | cy.get("#siblingWithoutRouterAccessCounter").should("have.text", notRerenderedCounterText); 13 | } 14 | 15 | function testScenarios(functionName: "useQueryState" | "useQueryStates") { 16 | it("URL change rerenders whole page, but can be optimized with memo", () => { 17 | cy.visit(TEST_URL + `/tests/${functionName}/rerenderCheck?str=a`); 18 | if (Cypress.env("CYPRESS_STRICT")) checkRenderCountByURLChange("1", "1"); 19 | 20 | cy.wait(50); // wait for next frame 21 | 22 | checkRenderCountByURLChange("2", "1"); 23 | 24 | cy.get("#strInput").type("b"); 25 | checkRenderCountByURLChange("3", "1"); 26 | }); 27 | } 28 | 29 | describe("useQueryState rerender behavior", () => testScenarios("useQueryState")); 30 | describe("useQueryStates rerender behavior", () => testScenarios("useQueryStates")); 31 | -------------------------------------------------------------------------------- /cypress/e2e/useQueryState/dynamicOption.cy.ts: -------------------------------------------------------------------------------- 1 | import { TEST_URL } from "../../constants"; 2 | 3 | describe("useQueryState non dynamic memoization behavior", () => { 4 | it("Value and updater is memoized", () => { 5 | cy.visit( 6 | TEST_URL + `/tests/useQueryState/nonDynamicTest?val=${encodeURIComponent('{"a":2}')}` 7 | ); 8 | if (Cypress.env("CYPRESS_STRICT")) { 9 | cy.get("#value").should("have.text", JSON.stringify({ a: 1 })); 10 | cy.get("#equality").should( 11 | "have.text", 12 | JSON.stringify({ valueEq: true, setValueEq: true }) 13 | ); 14 | } 15 | 16 | cy.wait(50); // wait for next frame. value is changed 17 | cy.get("#value").should("have.text", JSON.stringify({ a: 2 })); 18 | cy.get("#equality").should( 19 | "have.text", 20 | JSON.stringify({ valueEq: false, setValueEq: true }) 21 | ); 22 | 23 | // value is memoized 24 | cy.get("#forceRender").click(); 25 | cy.get("#equality").should( 26 | "have.text", 27 | JSON.stringify({ valueEq: true, setValueEq: true }) 28 | ); 29 | }); 30 | 31 | it("Key can be changed without dynamic", () => { 32 | cy.visit( 33 | TEST_URL + 34 | `/tests/useQueryState/nonDynamicTest?val=${encodeURIComponent( 35 | '{"a":2}' 36 | )}&val2=${encodeURIComponent('{"a":10}')}` 37 | ); 38 | 39 | cy.wait(50); // wait for next frame. value is changed 40 | cy.get("#value").should("have.text", JSON.stringify({ a: 2 })); 41 | 42 | cy.get("#changeKey").click(); 43 | cy.get("#value").should("have.text", JSON.stringify({ a: 10 })); 44 | cy.get("#equality").should( 45 | "have.text", 46 | JSON.stringify({ valueEq: false, setValueEq: false }) 47 | ); 48 | }); 49 | 50 | it("History mode can be changed without dynamic", () => { 51 | // History mode is replace at first 52 | cy.visit(TEST_URL); 53 | cy.visit(TEST_URL + "/tests/useQueryState/nonDynamicTest"); 54 | cy.wait(50); // wait for next frame. 55 | 56 | cy.get("#updateValue").click(); 57 | cy.get("#value").should("have.text", JSON.stringify({ a: 2 })); 58 | 59 | cy.go("back"); 60 | cy.location("pathname").should("eq", "/"); 61 | 62 | // Change history mode to push 63 | cy.visit(TEST_URL); 64 | cy.visit(TEST_URL + "/tests/useQueryState/nonDynamicTest"); 65 | 66 | cy.wait(50); // wait for next frame. 67 | cy.get("#changeHistoryToPush").click(); 68 | 69 | // only setValue function updated 70 | cy.get("#equality").should( 71 | "have.text", 72 | JSON.stringify({ valueEq: true, setValueEq: false }) 73 | ); 74 | 75 | // History mode changed to push 76 | cy.get("#updateValue").click(); 77 | cy.get("#value").should("have.text", JSON.stringify({ a: 2 })); 78 | 79 | cy.go("back"); 80 | cy.get("#value").should("have.text", JSON.stringify({ a: 1 })); 81 | cy.location("pathname").should("eq", "/tests/useQueryState/nonDynamicTest"); 82 | 83 | cy.go("back"); 84 | cy.location("pathname").should("eq", "/"); 85 | }); 86 | }); 87 | 88 | describe("useQueryState dynamic option", () => { 89 | it("Serializers can't be changed if dynamic is false", () => { 90 | cy.visit(TEST_URL + "/tests/useQueryState/dynamicTest?dynamic=false"); 91 | cy.wait(50); // wait for next frame. 92 | 93 | cy.get("#value").should("have.text", JSON.stringify({ a: 1 })); 94 | 95 | // Alter parser by changing default value 96 | cy.get("#incrementDefaultVal").click(); 97 | 98 | // default value change not applied if dynamic: false 99 | cy.get("#value").should("have.text", JSON.stringify({ a: 1 })); 100 | cy.get("#equality").should( 101 | "have.text", 102 | JSON.stringify({ valueEq: true, setValueEq: true }) 103 | ); 104 | }); 105 | 106 | it("Serializers can be changed if dynamic is true", () => { 107 | cy.visit(TEST_URL + "/tests/useQueryState/dynamicTest"); 108 | cy.wait(50); // wait for next frame. 109 | 110 | cy.get("#value").should("have.text", JSON.stringify({ a: 1 })); 111 | 112 | // Alter parser by changing default value 113 | cy.get("#incrementDefaultVal").click(); 114 | 115 | // default value change applied 116 | cy.get("#value").should("have.text", JSON.stringify({ a: 2 })); 117 | cy.get("#equality").should( 118 | "have.text", 119 | JSON.stringify({ valueEq: false, setValueEq: false }) 120 | ); 121 | }); 122 | 123 | it("When using dynamic, memoization does not work if parse, serialize function is not memoized", () => { 124 | cy.visit(TEST_URL + "/tests/useQueryState/dynamicTest"); 125 | cy.wait(50); // wait for next frame. 126 | 127 | // force rerender 128 | cy.get("#forceRender").click(); 129 | 130 | // value and updater not memoized 131 | cy.get("#equality").should( 132 | "have.text", 133 | JSON.stringify({ valueEq: false, setValueEq: false }) 134 | ); 135 | }); 136 | 137 | it("It has no problem with memoization when non-function queryTypes preset is used with dynamic option", () => { 138 | cy.visit(TEST_URL + "/tests/useQueryState/dynamicNonFunctionPreset"); 139 | cy.wait(50); // wait for next frame. 140 | 141 | // force rerender 142 | cy.get("#forceRender").click(); 143 | 144 | // value and updater not memoized 145 | cy.get("#equality").should( 146 | "have.text", 147 | JSON.stringify({ valueEq: true, setValueEq: true }) 148 | ); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "es6", 6 | "dom" 7 | ], 8 | "types": [ 9 | "cypress", 10 | "node" 11 | ] 12 | }, 13 | "include": [ 14 | "**/*.ts" 15 | ] 16 | } -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/cf/p1834fdx1rxbg9x346m0w13h0000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls, instances, contexts and results before every test 17 | // clearMocks: false, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: "coverage", 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: "v8", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "mjs", 82 | // "cjs", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | // preset: undefined, 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: undefined, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: [], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | testEnvironment: "node", 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | testMatch: ["**/jest/**/*.[jt]s?(x)"], 157 | 158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 159 | // testPathIgnorePatterns: [ 160 | // "/node_modules/" 161 | // ], 162 | 163 | // The regexp pattern or array of patterns that Jest uses to detect test files 164 | // testRegex: [], 165 | 166 | // This option allows the use of a custom results processor 167 | // testResultsProcessor: undefined, 168 | 169 | // This option allows use of a custom test runner 170 | // testRunner: "jest-circus/runner", 171 | 172 | // A map from regular expressions to paths to transformers 173 | transform: { 174 | "^.+\\.(ts|tsx)$": "ts-jest", 175 | }, 176 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 177 | // transformIgnorePatterns: [ 178 | // "/node_modules/", 179 | // "\\.pnp\\.[^\\/]+$" 180 | // ], 181 | 182 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 183 | // unmockedModulePathPatterns: undefined, 184 | 185 | // Indicates whether each individual test should be reported during the run 186 | // verbose: undefined, 187 | 188 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 189 | // watchPathIgnorePatterns: [], 190 | 191 | // Whether to use watchman for file crawling 192 | // watchman: true, 193 | }; 194 | -------------------------------------------------------------------------------- /jest/serializerPresets/nullableQueryTypes.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import { nullableQueryTypes } from "../../src/nullableQueryTypes"; 3 | 4 | function generateTests any>( 5 | fn: T, 6 | scenarios: [Parameters[0], ReturnType][] 7 | ) { 8 | for (const [input, output] of scenarios) 9 | test(`${JSON.stringify(input)} -> ${JSON.stringify(output)}`, () => 10 | expect(fn(input)).toEqual(output)); 11 | } 12 | 13 | describe("nullableQueryTypes", () => { 14 | describe("string.parse", () => 15 | generateTests(nullableQueryTypes.string.parse, [ 16 | ["foo", "foo"], 17 | ["", ""], 18 | [["foo", "bar"], "foo"], 19 | ["\0", null], 20 | [["\0", "foo"], null], 21 | [undefined, undefined], 22 | ])); 23 | describe("string.withDefault.parse", () => 24 | generateTests(nullableQueryTypes.string.withDefault("default").parse, [ 25 | ["foo", "foo"], 26 | ["", ""], 27 | [["foo", "bar"], "foo"], 28 | ["\0", null], 29 | [["\0", "foo"], null], 30 | [undefined, "default"], 31 | ])); 32 | describe("string.serialize", () => 33 | generateTests(nullableQueryTypes.string.serialize, [ 34 | ["foo", "foo"], 35 | ["", ""], 36 | [null, "\0"], 37 | [undefined, null], 38 | ])); 39 | 40 | describe("integer.parse", () => 41 | generateTests(nullableQueryTypes.integer.parse, [ 42 | ["123", 123], 43 | ["-123", -123], 44 | ["123.2", 123], 45 | ["123.8", 123], 46 | ["-123.2", -124], 47 | [".123", 0], 48 | [["123", "456"], 123], 49 | [["", "456"], undefined], 50 | ["foo", undefined], 51 | ["NaN", undefined], 52 | ["Infinity", Infinity], 53 | ["-Infinity", -Infinity], 54 | ["\0", null], 55 | [["\0", "foo"], null], 56 | [undefined, undefined], 57 | ])); 58 | describe("integer.withDefault.parse", () => 59 | generateTests(nullableQueryTypes.integer.withDefault(9999).parse, [ 60 | ["123", 123], 61 | ["-123", -123], 62 | ["123.2", 123], 63 | ["123.8", 123], 64 | ["-123.2", -124], 65 | [".123", 0], 66 | [["123", "456"], 123], 67 | [["", "456"], 9999], 68 | ["foo", 9999], 69 | ["NaN", 9999], 70 | ["Infinity", Infinity], 71 | ["-Infinity", -Infinity], 72 | ["\0", null], 73 | [["\0", "foo"], null], 74 | [undefined, 9999], 75 | ])); 76 | describe("integer.serialize", () => 77 | generateTests(nullableQueryTypes.integer.serialize, [ 78 | [123, "123"], 79 | [123.2, "123"], 80 | [123.8, "123"], 81 | [Infinity, "Infinity"], 82 | [NaN, "NaN"], 83 | [null, "\0"], 84 | [undefined, null], 85 | ])); 86 | 87 | describe("float.parse", () => 88 | generateTests(nullableQueryTypes.float.parse, [ 89 | ["123", 123], 90 | ["-123", -123], 91 | ["123.2", 123.2], 92 | [".123", 0.123], 93 | [["123", "456"], 123], 94 | [["", "456"], undefined], 95 | ["foo", undefined], 96 | ["NaN", undefined], 97 | ["Infinity", Infinity], 98 | ["-Infinity", -Infinity], 99 | ["\0", null], 100 | [["\0", "foo"], null], 101 | [undefined, undefined], 102 | ])); 103 | describe("float.withDefault.parse", () => 104 | generateTests(nullableQueryTypes.float.withDefault(99.99).parse, [ 105 | ["123", 123], 106 | ["-123", -123], 107 | ["123.2", 123.2], 108 | [".123", 0.123], 109 | [["123", "456"], 123], 110 | [["", "456"], 99.99], 111 | ["foo", 99.99], 112 | ["NaN", 99.99], 113 | ["Infinity", Infinity], 114 | ["-Infinity", -Infinity], 115 | ["\0", null], 116 | [["\0", "foo"], null], 117 | [undefined, 99.99], 118 | ])); 119 | describe("integer.serialize", () => 120 | generateTests(nullableQueryTypes.float.serialize, [ 121 | [123, "123"], 122 | [123.2, "123.2"], 123 | [Infinity, "Infinity"], 124 | [NaN, "NaN"], 125 | [null, "\0"], 126 | [undefined, null], 127 | ])); 128 | 129 | describe("boolean.parse", () => 130 | generateTests(nullableQueryTypes.boolean.parse, [ 131 | ["true", true], 132 | ["TRUE", true], 133 | ["tRue", true], 134 | ["false", false], 135 | ["FALSE", false], 136 | ["fAlse", false], 137 | ["T", undefined], 138 | ["", undefined], 139 | ["\0", null], 140 | [["\0", "foo"], null], 141 | [undefined, undefined], 142 | ])); 143 | describe("boolean.withDefault.parse", () => 144 | generateTests(nullableQueryTypes.boolean.withDefault(false).parse, [ 145 | ["true", true], 146 | ["TRUE", true], 147 | ["tRue", true], 148 | ["false", false], 149 | ["FALSE", false], 150 | ["fAlse", false], 151 | ["T", false], 152 | ["", false], 153 | ["\0", null], 154 | [["\0", "foo"], null], 155 | [undefined, false], 156 | ])); 157 | describe("boolean.serialize", () => 158 | generateTests(nullableQueryTypes.boolean.serialize, [ 159 | [true, "true"], 160 | [false, "false"], 161 | [null, "\0"], 162 | [undefined, null], 163 | ])); 164 | 165 | describe("timestamp.parse", () => 166 | generateTests(nullableQueryTypes.timestamp.parse, [ 167 | ["1668493407000", new Date(1668493407000)], 168 | ["foo", undefined], 169 | ["Infinity", undefined], 170 | ["\0", null], 171 | [["\0", "foo"], null], 172 | [undefined, undefined], 173 | ])); 174 | describe("timestamp.withDefaultparse", () => 175 | generateTests(nullableQueryTypes.timestamp.withDefault(new Date(1660000000000)).parse, [ 176 | ["1668493407000", new Date(1668493407000)], 177 | ["foo", new Date(1660000000000)], 178 | ["Infinity", new Date(1660000000000)], 179 | ["\0", null], 180 | [["\0", "foo"], null], 181 | [undefined, new Date(1660000000000)], 182 | ])); 183 | describe("timestamp.serialize", () => 184 | generateTests(nullableQueryTypes.timestamp.serialize, [ 185 | [new Date(1668493407000), "1668493407000"], 186 | [new Date(NaN), null], 187 | [null, "\0"], 188 | [undefined, null], 189 | ])); 190 | 191 | describe("isoDateTime.parse", () => 192 | generateTests(nullableQueryTypes.isoDateTime.parse, [ 193 | ["2022-11-15T06:23:27.000Z", new Date(1668493407000)], 194 | ["foo", undefined], 195 | ["Infinity", undefined], 196 | ["\0", null], 197 | [["\0", "foo"], null], 198 | [undefined, undefined], 199 | ])); 200 | describe("isoDateTime.withDefault.parse", () => 201 | generateTests(nullableQueryTypes.isoDateTime.withDefault(new Date(1660000000000)).parse, [ 202 | ["2022-11-15T06:23:27.000Z", new Date(1668493407000)], 203 | ["foo", new Date(1660000000000)], 204 | ["Infinity", new Date(1660000000000)], 205 | ["\0", null], 206 | [["\0", "foo"], null], 207 | [undefined, new Date(1660000000000)], 208 | ])); 209 | describe("isoDateTime.serialize", () => 210 | generateTests(nullableQueryTypes.isoDateTime.serialize, [ 211 | [new Date(1668493407000), "2022-11-15T06:23:27.000Z"], 212 | [new Date(NaN), null], 213 | [null, "\0"], 214 | [undefined, null], 215 | ])); 216 | 217 | describe("stringEnum.parse", () => 218 | generateTests(nullableQueryTypes.stringEnum(["foo", "bar"]).parse, [ 219 | ["foo", "foo"], 220 | ["bar", "bar"], 221 | ["asdf", undefined], 222 | ["", undefined], 223 | ["\0", null], 224 | [["\0", "foo"], null], 225 | [undefined, undefined], 226 | ])); 227 | describe("stringEnum.withDefault.parse", () => 228 | generateTests( 229 | nullableQueryTypes.stringEnum(["foo", "bar", "baz"]).withDefault("baz").parse, 230 | [ 231 | ["foo", "foo"], 232 | ["bar", "bar"], 233 | ["asdf", "baz"], 234 | ["", "baz"], 235 | ["\0", null], 236 | [["\0", "foo"], null], 237 | [undefined, "baz"], 238 | ] 239 | )); 240 | describe("stringEnum.serialize", () => 241 | generateTests(nullableQueryTypes.stringEnum(["foo", "bar"]).serialize, [ 242 | ["foo", "foo"], 243 | ["bar", "bar"], 244 | ["asdf" as any, "asdf"], 245 | [null, "\0"], 246 | [undefined, null], 247 | ])); 248 | 249 | describe("json.parse", () => 250 | generateTests(nullableQueryTypes.json().parse, [ 251 | ['{"foo": 1}', { foo: 1 }], 252 | [['{"foo": 1}', '{"bar": 2}'], { foo: 1 }], 253 | ['{"foo":{"bar":2}}', { foo: { bar: 2 } }], 254 | ["1", 1], 255 | ['"foo"', "foo"], 256 | ["asdf", undefined], 257 | ["", undefined], 258 | ["null", null], 259 | ["\0", undefined], 260 | [undefined, undefined], 261 | ])); 262 | describe("json.withDefault.parse", () => 263 | generateTests(nullableQueryTypes.json().withDefault({ baz: 10 }).parse, [ 264 | ['{"foo": 1}', { foo: 1 }], 265 | [['{"foo": 1}', '{"bar": 2}'], { foo: 1 }], 266 | ['{"foo":{"bar":2}}', { foo: { bar: 2 } }], 267 | ["1", 1], 268 | ['"foo"', "foo"], 269 | ["asdf", { baz: 10 }], 270 | ["", { baz: 10 }], 271 | ["null", null], 272 | ["\0", { baz: 10 }], 273 | [undefined, { baz: 10 }], 274 | ])); 275 | describe("json.serialize", () => 276 | generateTests(nullableQueryTypes.json().serialize, [ 277 | [{ foo: 1 }, '{"foo":1}'], 278 | [{ foo: { bar: 2 } }, '{"foo":{"bar":2}}'], 279 | [1, "1"], 280 | ["foo", '"foo"'], 281 | [null, "null"], 282 | [undefined, null], 283 | ])); 284 | 285 | describe("array(integer).parse", () => 286 | generateTests(nullableQueryTypes.array(nullableQueryTypes.integer).parse, [ 287 | ["1", [1]], 288 | ["\0", [null]], 289 | [ 290 | ["1", "2"], 291 | [1, 2], 292 | ], 293 | [ 294 | ["1", "\0", "\0"], 295 | [1, null, null], 296 | ], 297 | [ 298 | ["1", "x", "3"], 299 | [1, 3], 300 | ], 301 | [["x", "y"], undefined], 302 | ["", undefined], 303 | [undefined, undefined], 304 | ])); 305 | describe("array(integer).withDefault.parse", () => 306 | generateTests( 307 | nullableQueryTypes.array(nullableQueryTypes.integer).withDefault([7, 8, 9]).parse, 308 | [ 309 | ["1", [1]], 310 | ["\0", [null]], 311 | [ 312 | ["1", "2"], 313 | [1, 2], 314 | ], 315 | [ 316 | ["1", "\0", "\0"], 317 | [1, null, null], 318 | ], 319 | [ 320 | ["1", "x", "3"], 321 | [1, 3], 322 | ], 323 | [ 324 | ["x", "y"], 325 | [7, 8, 9], 326 | ], 327 | ["", [7, 8, 9]], 328 | [undefined, [7, 8, 9]], 329 | ] 330 | )); 331 | describe("array(integer).serialize", () => 332 | generateTests(nullableQueryTypes.array(nullableQueryTypes.integer).serialize, [ 333 | [[1], ["1"]], 334 | [ 335 | [1, 2, 3], 336 | ["1", "2", "3"], 337 | ], 338 | [[], []], 339 | [[null], ["\0"]], 340 | [ 341 | [null, 1], 342 | ["\0", "1"], 343 | ], 344 | [undefined, null], 345 | ])); 346 | 347 | describe("delimitedArray(integer).parse", () => 348 | generateTests(nullableQueryTypes.delimitedArray(nullableQueryTypes.integer).parse, [ 349 | ["1", [1]], 350 | ["\0", [null]], 351 | ["1,2", [1, 2]], 352 | ["1,\0,\0", [1, null, null]], 353 | ["1,x,3", [1, 3]], 354 | ["x,y", undefined], 355 | ["", undefined], 356 | [undefined, undefined], 357 | ])); 358 | describe("delimitedArray(integer).withDefault.parse", () => 359 | generateTests( 360 | nullableQueryTypes.delimitedArray(nullableQueryTypes.integer).withDefault([7, 8, 9]) 361 | .parse, 362 | [ 363 | ["1", [1]], 364 | ["\0", [null]], 365 | ["1,2", [1, 2]], 366 | ["1,\0,\0", [1, null, null]], 367 | ["1,x,3", [1, 3]], 368 | ["x,y", [7, 8, 9]], 369 | ["", [7, 8, 9]], 370 | [undefined, [7, 8, 9]], 371 | ] 372 | )); 373 | describe("delimitedArray(integer).serialize", () => 374 | generateTests(nullableQueryTypes.delimitedArray(nullableQueryTypes.integer).serialize, [ 375 | [[1], "1"], 376 | [[1, 2, 3], "1,2,3"], 377 | [[], null], 378 | [[null], "\0"], 379 | [[null, 1], "\0,1"], 380 | [undefined, null], 381 | ])); 382 | }); 383 | -------------------------------------------------------------------------------- /jest/serializerPresets/queryTypes.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import { queryTypes, useQueryState } from "../../src"; 3 | 4 | function generateTests any>( 5 | fn: T, 6 | scenarios: [Parameters[0], ReturnType][] 7 | ) { 8 | for (const [input, output] of scenarios) 9 | test(`${JSON.stringify(input)} -> ${JSON.stringify(output)}`, () => 10 | expect(fn(input)).toEqual(output)); 11 | } 12 | 13 | describe("queryTypes", () => { 14 | describe("string.parse", () => 15 | generateTests(queryTypes.string.parse, [ 16 | ["foo", "foo"], 17 | ["", ""], 18 | [["foo", "bar"], "foo"], 19 | [undefined, null], 20 | ])); 21 | describe("string.withDefault.parse", () => 22 | generateTests(queryTypes.string.withDefault("default").parse, [ 23 | ["foo", "foo"], 24 | ["", ""], 25 | [["foo", "bar"], "foo"], 26 | [undefined, "default"], 27 | ])); 28 | describe("string.serialize", () => 29 | generateTests(queryTypes.string.serialize, [ 30 | ["foo", "foo"], 31 | ["", ""], 32 | [null, null], 33 | [undefined, undefined], 34 | ])); 35 | 36 | describe("integer.parse", () => 37 | generateTests(queryTypes.integer.parse, [ 38 | ["123", 123], 39 | ["-123", -123], 40 | ["123.2", 123], 41 | ["123.8", 123], 42 | ["-123.2", -124], 43 | [".123", 0], 44 | [["123", "456"], 123], 45 | [["", "456"], null], 46 | ["foo", null], 47 | ["NaN", null], 48 | ["Infinity", Infinity], 49 | ["-Infinity", -Infinity], 50 | [undefined, null], 51 | ])); 52 | describe("integer.withDefault.parse", () => 53 | generateTests(queryTypes.integer.withDefault(9999).parse, [ 54 | ["123", 123], 55 | ["-123", -123], 56 | ["123.2", 123], 57 | ["123.8", 123], 58 | ["-123.2", -124], 59 | [".123", 0], 60 | [["123", "456"], 123], 61 | [["", "456"], 9999], 62 | ["foo", 9999], 63 | ["NaN", 9999], 64 | ["Infinity", Infinity], 65 | ["-Infinity", -Infinity], 66 | [undefined, 9999], 67 | ])); 68 | describe("integer.serialize", () => 69 | generateTests(queryTypes.integer.serialize, [ 70 | [123, "123"], 71 | [123.2, "123"], 72 | [123.8, "123"], 73 | [Infinity, "Infinity"], 74 | [NaN, "NaN"], 75 | [null, null], 76 | [undefined, undefined], 77 | ])); 78 | 79 | describe("float.parse", () => 80 | generateTests(queryTypes.float.parse, [ 81 | ["123", 123], 82 | ["-123", -123], 83 | ["123.2", 123.2], 84 | [".123", 0.123], 85 | [["123", "456"], 123], 86 | [["", "456"], null], 87 | ["foo", null], 88 | ["NaN", null], 89 | ["Infinity", Infinity], 90 | ["-Infinity", -Infinity], 91 | [undefined, null], 92 | ])); 93 | describe("float.withDefault.parse", () => 94 | generateTests(queryTypes.float.withDefault(99.99).parse, [ 95 | ["123", 123], 96 | ["-123", -123], 97 | ["123.2", 123.2], 98 | [".123", 0.123], 99 | [["123", "456"], 123], 100 | [["", "456"], 99.99], 101 | ["foo", 99.99], 102 | ["NaN", 99.99], 103 | ["Infinity", Infinity], 104 | ["-Infinity", -Infinity], 105 | [undefined, 99.99], 106 | ])); 107 | describe("integer.serialize", () => 108 | generateTests(queryTypes.float.serialize, [ 109 | [123, "123"], 110 | [123.2, "123.2"], 111 | [Infinity, "Infinity"], 112 | [NaN, "NaN"], 113 | [null, null], 114 | [undefined, undefined], 115 | ])); 116 | 117 | describe("boolean.parse", () => 118 | generateTests(queryTypes.boolean.parse, [ 119 | ["true", true], 120 | ["TRUE", true], 121 | ["tRue", true], 122 | ["false", false], 123 | ["FALSE", false], 124 | ["fAlse", false], 125 | ["T", null], 126 | ["", null], 127 | [undefined, null], 128 | ])); 129 | describe("boolean.withDefault.parse", () => 130 | generateTests(queryTypes.boolean.withDefault(false).parse, [ 131 | ["true", true], 132 | ["TRUE", true], 133 | ["tRue", true], 134 | ["false", false], 135 | ["FALSE", false], 136 | ["fAlse", false], 137 | ["T", false], 138 | ["", false], 139 | [undefined, false], 140 | ])); 141 | describe("boolean.serialize", () => 142 | generateTests(queryTypes.boolean.serialize, [ 143 | [true, "true"], 144 | [false, "false"], 145 | [null, null], 146 | [undefined, undefined], 147 | ])); 148 | 149 | describe("timestamp.parse", () => 150 | generateTests(queryTypes.timestamp.parse, [ 151 | ["1668493407000", new Date(1668493407000)], 152 | ["foo", null], 153 | ["Infinity", null], 154 | [undefined, null], 155 | ])); 156 | describe("timestamp.withDefault.parse", () => 157 | generateTests(queryTypes.timestamp.withDefault(new Date(1660000000000)).parse, [ 158 | ["1668493407000", new Date(1668493407000)], 159 | ["foo", new Date(1660000000000)], 160 | ["Infinity", new Date(1660000000000)], 161 | [undefined, new Date(1660000000000)], 162 | ])); 163 | describe("timestamp.serialize", () => 164 | generateTests(queryTypes.timestamp.serialize, [ 165 | [new Date(1668493407000), "1668493407000"], 166 | [new Date(NaN), null], 167 | [null, null], 168 | [undefined, undefined], 169 | ])); 170 | 171 | describe("isoDateTime.parse", () => 172 | generateTests(queryTypes.isoDateTime.parse, [ 173 | ["2022-11-15T06:23:27.000Z", new Date(1668493407000)], 174 | ["foo", null], 175 | ["Infinity", null], 176 | [undefined, null], 177 | ])); 178 | describe("isoDateTime.withDefault.parse", () => 179 | generateTests(queryTypes.isoDateTime.withDefault(new Date(1660000000000)).parse, [ 180 | ["2022-11-15T06:23:27.000Z", new Date(1668493407000)], 181 | ["foo", new Date(1660000000000)], 182 | ["Infinity", new Date(1660000000000)], 183 | [undefined, new Date(1660000000000)], 184 | ])); 185 | describe("isoDateTime.serialize", () => 186 | generateTests(queryTypes.isoDateTime.serialize, [ 187 | [new Date(1668493407000), "2022-11-15T06:23:27.000Z"], 188 | [new Date(NaN), null], 189 | [null, null], 190 | [undefined, undefined], 191 | ])); 192 | 193 | describe("stringEnum.parse", () => 194 | generateTests(queryTypes.stringEnum(["foo", "bar"]).parse, [ 195 | ["foo", "foo"], 196 | ["bar", "bar"], 197 | ["asdf", null], 198 | ["", null], 199 | [undefined, null], 200 | ])); 201 | describe("stringEnum.withDefault.parse", () => 202 | generateTests(queryTypes.stringEnum(["foo", "bar", "baz"]).withDefault("baz").parse, [ 203 | ["foo", "foo"], 204 | ["bar", "bar"], 205 | ["asdf", "baz"], 206 | ["", "baz"], 207 | [undefined, "baz"], 208 | ])); 209 | describe("stringEnum.serialize", () => 210 | generateTests(queryTypes.stringEnum(["foo", "bar"]).serialize, [ 211 | ["foo", "foo"], 212 | ["bar", "bar"], 213 | ["asdf" as any, "asdf"], 214 | [null, null], 215 | [undefined, undefined], 216 | ])); 217 | 218 | describe("json.parse", () => 219 | generateTests(queryTypes.json().parse, [ 220 | ['{"foo": 1}', { foo: 1 }], 221 | ['{"foo":{"bar":2}}', { foo: { bar: 2 } }], 222 | ["1", 1], 223 | ['"foo"', "foo"], 224 | ["asdf", null], 225 | ["", null], 226 | ["null", null], 227 | [undefined, null], 228 | ])); 229 | describe("json.withDefault.parse", () => 230 | generateTests(queryTypes.json().withDefault({ baz: 10 }).parse, [ 231 | ['{"foo": 1}', { foo: 1 }], 232 | ['{"foo":{"bar":2}}', { foo: { bar: 2 } }], 233 | ["1", 1], 234 | ['"foo"', "foo"], 235 | ["asdf", { baz: 10 }], 236 | ["", { baz: 10 }], 237 | [undefined, { baz: 10 }], 238 | ])); 239 | describe("json.serialize", () => 240 | generateTests(queryTypes.json().serialize, [ 241 | [{ foo: 1 }, '{"foo":1}'], 242 | [{ foo: { bar: 2 } }, '{"foo":{"bar":2}}'], 243 | [1, "1"], 244 | ["foo", '"foo"'], 245 | [null, null], 246 | [undefined, undefined], 247 | ])); 248 | 249 | describe("array(integer).parse", () => 250 | generateTests(queryTypes.array(queryTypes.integer).parse, [ 251 | ["1", [1]], 252 | [ 253 | ["1", "2"], 254 | [1, 2], 255 | ], 256 | [ 257 | ["1", "x", "3"], 258 | [1, 3], 259 | ], 260 | [["x", "y"], null], 261 | ["", null], 262 | [undefined, null], 263 | ])); 264 | describe("array(integer).withDefault.parse", () => 265 | generateTests(queryTypes.array(queryTypes.integer).withDefault([7, 8, 9]).parse, [ 266 | ["1", [1]], 267 | [ 268 | ["1", "2"], 269 | [1, 2], 270 | ], 271 | [ 272 | ["1", "x", "3"], 273 | [1, 3], 274 | ], 275 | [ 276 | ["x", "y"], 277 | [7, 8, 9], 278 | ], 279 | ["", [7, 8, 9]], 280 | [undefined, [7, 8, 9]], 281 | ])); 282 | describe("array(integer).serialize", () => 283 | generateTests(queryTypes.array(queryTypes.integer).serialize, [ 284 | [[1], ["1"]], 285 | [ 286 | [1, 2, 3], 287 | ["1", "2", "3"], 288 | ], 289 | [[], []], 290 | [null, null], 291 | [undefined, undefined], 292 | ])); 293 | 294 | describe("delimitedArray(integer).parse", () => 295 | generateTests(queryTypes.delimitedArray(queryTypes.integer).parse, [ 296 | ["1", [1]], 297 | ["1,2", [1, 2]], 298 | ["1,x,3", [1, 3]], 299 | ["x,y", null], 300 | ["", null], 301 | [undefined, null], 302 | ])); 303 | describe("delimitedArray(integer).withDefault.parse", () => 304 | generateTests(queryTypes.delimitedArray(queryTypes.integer).withDefault([7, 8, 9]).parse, [ 305 | ["1", [1]], 306 | ["1,2", [1, 2]], 307 | ["1,x,3", [1, 3]], 308 | ["x,y", [7, 8, 9]], 309 | ["", [7, 8, 9]], 310 | [undefined, [7, 8, 9]], 311 | ])); 312 | describe("delimitedArray(integer).serialize", () => 313 | generateTests(queryTypes.delimitedArray(queryTypes.integer).serialize, [ 314 | [[1], "1"], 315 | [[1, 2, 3], "1,2,3"], 316 | [[], null], 317 | [null, null], 318 | [undefined, undefined], 319 | ])); 320 | }); 321 | -------------------------------------------------------------------------------- /jest/useQueryState.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import exp from "constants"; 3 | import { queryTypes, useQueryState } from "../src"; 4 | 5 | const mockBatchRouter = { 6 | push: jest.fn(), 7 | replace: jest.fn(), 8 | }; 9 | jest.mock("next-batch-router", () => ({ 10 | useBatchRouter: () => mockBatchRouter, 11 | })); 12 | 13 | const mockQuery = { current: {} }; 14 | jest.mock("next/router", () => ({ 15 | useRouter: () => ({ query: mockQuery.current }), 16 | })); 17 | 18 | jest.mock("react", () => ({ 19 | useMemo: (cb: Function, deps: any[]) => cb(), 20 | useCallback: (cb: Function, deps: any[]) => cb, 21 | })); 22 | 23 | describe("useQueryState", () => { 24 | beforeEach(() => { 25 | mockBatchRouter.push = jest.fn(); 26 | mockBatchRouter.replace = jest.fn(); 27 | mockQuery.current = {}; 28 | }); 29 | 30 | test("basic", () => { 31 | mockQuery.current = { val: "foo" }; 32 | const [val, setVal] = useQueryState("val"); 33 | 34 | expect(val).toBe("foo"); 35 | 36 | setVal("bar"); 37 | 38 | expect(mockBatchRouter.replace).toBeCalledWith({ query: { val: "bar" } }, undefined, {}); 39 | }); 40 | 41 | test("history option override", () => { 42 | const [val, setVal] = useQueryState("val", queryTypes.string, { history: "push" }); 43 | 44 | setVal("foo"); 45 | expect(mockBatchRouter.push).toBeCalledWith({ query: { val: "foo" } }, undefined, {}); 46 | 47 | setVal("foo", { history: "replace" }); 48 | expect(mockBatchRouter.replace).toBeCalledWith({ query: { val: "foo" } }, undefined, {}); 49 | }); 50 | 51 | describe("transition options passed", () => { 52 | for (const options of [ 53 | {}, 54 | { shallow: true }, 55 | { scroll: false }, 56 | { shallow: true, scroll: false }, 57 | ]) 58 | test(JSON.stringify(options), () => { 59 | const [, setVal] = useQueryState("val", queryTypes.string); 60 | setVal("foo", options); 61 | expect(mockBatchRouter.replace).toBeCalledWith( 62 | { query: { val: "foo" } }, 63 | undefined, 64 | options 65 | ); 66 | }); 67 | }); 68 | 69 | describe("serializers integration", () => { 70 | test("parse number", () => { 71 | mockQuery.current = { val: "123" }; 72 | const [val] = useQueryState("val", queryTypes.integer); 73 | expect(val).toBe(123); 74 | }); 75 | 76 | test("parse number array", () => { 77 | mockQuery.current = { val: ["123", "456"] }; 78 | const [val] = useQueryState("val", queryTypes.array(queryTypes.integer)); 79 | expect(val).toEqual([123, 456]); 80 | }); 81 | 82 | test("parse number delimited array", () => { 83 | mockQuery.current = { val: "123_456" }; 84 | const [val] = useQueryState("val", queryTypes.delimitedArray(queryTypes.integer, "_")); 85 | expect(val).toEqual([123, 456]); 86 | }); 87 | 88 | test("serialize payload", () => { 89 | const [, setVal] = useQueryState("val", queryTypes.integer); 90 | setVal(123); 91 | expect(mockBatchRouter.replace).toBeCalledWith( 92 | { query: { val: "123" } }, 93 | undefined, 94 | {} 95 | ); 96 | }); 97 | 98 | test("functional update parse/serialize", () => { 99 | mockQuery.current = { val: "123" }; 100 | const spyParse = jest.spyOn(queryTypes.integer, "parse"); 101 | const spySerialize = jest.spyOn(queryTypes.integer, "serialize"); 102 | const [, setVal] = useQueryState("val", queryTypes.integer); 103 | expect(spyParse).toBeCalledWith("123"); 104 | 105 | setVal((prev) => prev! + 1); 106 | setVal((prev) => prev! + 1); 107 | 108 | let i = 0; 109 | let res = mockBatchRouter.replace.mock.calls[i][0].query({ val: "123" }); 110 | expect(spyParse).toBeCalledWith("123"); 111 | expect(spyParse.mock.results[i + 1].value).toBe(123); 112 | expect(spySerialize).toBeCalledWith(124); 113 | expect(spySerialize.mock.results[i].value).toBe("124"); 114 | expect(res).toEqual({ val: "124" }); 115 | 116 | i++; 117 | res = mockBatchRouter.replace.mock.calls[i][0].query(res); 118 | expect(spyParse).toBeCalledWith("124"); 119 | expect(spyParse.mock.results[i + 1].value).toBe(124); 120 | expect(spySerialize).toBeCalledWith(125); 121 | expect(spySerialize.mock.results[i].value).toBe("125"); 122 | expect(res).toEqual({ val: "125" }); 123 | }); 124 | 125 | test("keep previous value if functional update return value is undefined", () => { 126 | const [, setVal] = useQueryState("val", queryTypes.integer); 127 | setVal(() => undefined); 128 | 129 | let res = mockBatchRouter.replace.mock.calls[0][0].query({ val: "123" }); 130 | expect(res).toEqual({ val: "123" }); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /jest/useQueryStates.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import { queryTypes, useQueryState, useQueryStates } from "../src"; 3 | 4 | const mockBatchRouter = { 5 | push: jest.fn(), 6 | replace: jest.fn(), 7 | }; 8 | jest.mock("next-batch-router", () => ({ 9 | useBatchRouter: () => mockBatchRouter, 10 | })); 11 | 12 | const mockQuery = { current: {} }; 13 | jest.mock("next/router", () => ({ 14 | useRouter: () => ({ query: mockQuery.current }), 15 | })); 16 | 17 | jest.mock("react", () => ({ 18 | useMemo: (cb: Function, deps: any[]) => cb(), 19 | useCallback: (cb: Function, deps: any[]) => cb, 20 | })); 21 | 22 | describe("useQueryStates", () => { 23 | beforeEach(() => { 24 | mockBatchRouter.push = jest.fn(); 25 | mockBatchRouter.replace = jest.fn(); 26 | mockQuery.current = {}; 27 | }); 28 | 29 | test("basic", () => { 30 | mockQuery.current = { val: "foo" }; 31 | const [{ val }, setStates] = useQueryStates({ val: queryTypes.string }); 32 | 33 | expect(val).toBe("foo"); 34 | 35 | setStates({ val: "bar" }); 36 | 37 | expect(mockBatchRouter.replace).toBeCalledWith({ query: { val: "bar" } }, undefined, {}); 38 | }); 39 | 40 | test("history option override", () => { 41 | const [{ val }, setStates] = useQueryStates( 42 | { val: queryTypes.string }, 43 | { history: "push" } 44 | ); 45 | 46 | setStates({ val: "foo" }); 47 | expect(mockBatchRouter.push).toBeCalledWith({ query: { val: "foo" } }, undefined, {}); 48 | 49 | setStates({ val: "foo" }, { history: "replace" }); 50 | expect(mockBatchRouter.replace).toBeCalledWith({ query: { val: "foo" } }, undefined, {}); 51 | }); 52 | 53 | describe("transition options passed", () => { 54 | for (const options of [ 55 | {}, 56 | { shallow: true }, 57 | { scroll: false }, 58 | { shallow: true, scroll: false }, 59 | ]) 60 | test(JSON.stringify(options), () => { 61 | const [, setStates] = useQueryStates({ val: queryTypes.string }); 62 | setStates({ val: "foo" }, options); 63 | expect(mockBatchRouter.replace).toBeCalledWith( 64 | { query: { val: "foo" } }, 65 | undefined, 66 | options 67 | ); 68 | }); 69 | }); 70 | 71 | describe("serializers integration", () => { 72 | test("parse number", () => { 73 | mockQuery.current = { val: "123" }; 74 | const [{ val }] = useQueryStates({ val: queryTypes.integer }); 75 | expect(val).toBe(123); 76 | }); 77 | 78 | test("parse number array", () => { 79 | mockQuery.current = { val: ["123", "456"] }; 80 | const [{ val }] = useQueryStates({ val: queryTypes.array(queryTypes.integer) }); 81 | expect(val).toEqual([123, 456]); 82 | }); 83 | 84 | test("parse number delimited array", () => { 85 | mockQuery.current = { val: "123_456" }; 86 | const [{ val }] = useQueryStates({ 87 | val: queryTypes.delimitedArray(queryTypes.integer, "_"), 88 | }); 89 | expect(val).toEqual([123, 456]); 90 | }); 91 | 92 | test("serialize payload", () => { 93 | const [, setStates] = useQueryStates({ val: queryTypes.integer }); 94 | setStates({ val: 123 }); 95 | expect(mockBatchRouter.replace).toBeCalledWith( 96 | { query: { val: "123" } }, 97 | undefined, 98 | {} 99 | ); 100 | }); 101 | 102 | test("functional update parse/serialize", () => { 103 | mockQuery.current = { val: "123" }; 104 | const spyParse = jest.spyOn(queryTypes.integer, "parse"); 105 | const spySerialize = jest.spyOn(queryTypes.integer, "serialize"); 106 | const [, setVal] = useQueryStates({ val: queryTypes.integer }); 107 | expect(spyParse).toBeCalledWith("123"); 108 | 109 | setVal((prev) => ({ val: prev.val! + 1 })); 110 | setVal((prev) => ({ val: prev.val! + 1 })); 111 | 112 | let i = 0; 113 | let res = mockBatchRouter.replace.mock.calls[i][0].query({ val: "123" }); 114 | expect(spyParse).toBeCalledWith("123"); 115 | expect(spyParse.mock.results[i + 1].value).toBe(123); 116 | expect(spySerialize).toBeCalledWith(124); 117 | expect(spySerialize.mock.results[i].value).toBe("124"); 118 | expect(res).toEqual({ val: "124" }); 119 | 120 | i++; 121 | res = mockBatchRouter.replace.mock.calls[i][0].query(res); 122 | expect(spyParse).toBeCalledWith("124"); 123 | expect(spyParse.mock.results[i + 1].value).toBe(124); 124 | expect(spySerialize).toBeCalledWith(125); 125 | expect(spySerialize.mock.results[i].value).toBe("125"); 126 | expect(res).toEqual({ val: "125" }); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-query-state", 3 | "version": "1.0.0", 4 | "description": "Easy state management of URL query string for Next.js", 5 | "repository": "https://github.com/youha-info/next-query-state", 6 | "keywords": [ 7 | "nextjs", 8 | "query", 9 | "state", 10 | "batch", 11 | "url", 12 | "router", 13 | "react", 14 | "hook" 15 | ], 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/youha-info/next-query-state/issues" 19 | }, 20 | "homepage": "https://github.com/youha-info/next-query-state", 21 | "scripts": { 22 | "clean": "rimraf dist", 23 | "build": "yarn clean && rollup -c", 24 | "prepack": "yarn build && yarn movefiles", 25 | "postpack": "yarn putbackfiles", 26 | "movefiles": "mv ./next-env.d.ts ./next-env.d.ts.temp && mv ./dist/dts/*.d.ts ./", 27 | "putbackfiles": "mv ./*.d.ts ./dist/dts/ && mv ./next-env.d.ts.temp ./next-env.d.ts", 28 | "test": "jest --verbose", 29 | "dev": "conc -n NEXT,CYPRESS 'next -p 3051' 'cypress open'", 30 | "dev:strict": "conc -n NEXT,CYPRESS 'next -p 3051' 'cypress open --env CYPRESS_STRICT=true'", 31 | "e2e": "next build && yarn e2e:run", 32 | "e2e:strict": "next build && yarn e2e:run:strict", 33 | "e2e:run": "conc -n NEXT,CYPRESS --kill-others --success command-CYPRESS 'next start -p 3051' 'yarn cypress:ci'", 34 | "e2e:run:strict": "conc -n NEXT,CYPRESS --kill-others --success command-CYPRESS 'next start -p 3051' 'yarn cypress:ci --env CYPRESS_STRICT=true'", 35 | "cypress:ci": "cypress run --headless --config screenshotOnRunFailure=false,video=false" 36 | }, 37 | "main": "./dist/cjs/index.js", 38 | "module": "./dist/es/index.js", 39 | "types": "./index.d.ts", 40 | "exports": { 41 | ".": { 42 | "import": "./dist/es/index.js", 43 | "default": "./dist/cjs/index.js" 44 | }, 45 | "./nullableQueryTypes": { 46 | "import": "./dist/es/nullableQueryTypes.js", 47 | "default": "./dist/cjs/nullableQueryTypes.js" 48 | } 49 | }, 50 | "sideEffects": false, 51 | "files": [ 52 | "dist", 53 | "*.d.ts" 54 | ], 55 | "dependencies": { 56 | "next-batch-router": "^1.0.0" 57 | }, 58 | "peerDependencies": { 59 | "next": "*" 60 | }, 61 | "devDependencies": { 62 | "@rollup/plugin-commonjs": "^22.0.1", 63 | "@types/jest": "^29.2.2", 64 | "@types/node": "^18.0.6", 65 | "@types/react": "^18.0.15", 66 | "concurrently": "^7.5.0", 67 | "cypress": "^11.0.0", 68 | "jest": "^29.3.1", 69 | "next": "^12.2.2", 70 | "prettier": "^2.7.1", 71 | "react": "^18.2.0", 72 | "react-dom": "^18.2.0", 73 | "rimraf": "^3.0.2", 74 | "rollup": "^2.77.0", 75 | "rollup-plugin-peer-deps-external": "^2.2.4", 76 | "rollup-plugin-typescript2": "^0.32.1", 77 | "ts-jest": "^29.0.3", 78 | "ts-node": "^10.9.1", 79 | "typescript": "^4.7.4" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import typescript from "rollup-plugin-typescript2"; 3 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 4 | 5 | const config = [ 6 | { 7 | input: ["src/index.ts", "src/nullableQueryTypes.ts", "src/utils.ts"], 8 | output: [ 9 | { 10 | sourcemap: true, 11 | dir: "dist/cjs", 12 | format: "cjs", 13 | }, 14 | { 15 | sourcemap: true, 16 | dir: "dist/es", 17 | format: "esm", 18 | }, 19 | ], 20 | plugins: [ 21 | peerDepsExternal(), 22 | typescript({ tsconfig: "./tsconfig.build.json", useTsconfigDeclarationDir: true }), 23 | commonjs({ include: "node_modules/**" }), 24 | ], 25 | }, 26 | ]; 27 | export default config; 28 | -------------------------------------------------------------------------------- /src/defs.ts: -------------------------------------------------------------------------------- 1 | import { BatchRouterTypes } from "next-batch-router"; 2 | import { defaultSerializer, firstParam } from "./utils"; 3 | 4 | export type TransitionOptions = BatchRouterTypes.TransitionOptions; 5 | export type HistoryOptions = "replace" | "push"; 6 | export type UpdateOptions = { history?: HistoryOptions } & TransitionOptions; 7 | export type NextQueryValue = BatchRouterTypes.NextQueryValue; 8 | export type WriteQueryValue = BatchRouterTypes.WriteQueryValue; 9 | 10 | /** 11 | * Parse and serializes between batch router interface and useQueryState interface. 12 | * T may contain null or undefined depending on Serializer. 13 | */ 14 | export type Serializers = { 15 | parse: (value: NextQueryValue) => T; 16 | serialize?: (value: WT) => WriteQueryValue; 17 | }; 18 | 19 | export type SerializersWithDefaultFactory = Required< 20 | Serializers 21 | > & { 22 | withDefault: (defaultValue: T) => Required>; 23 | }; 24 | 25 | export type QueryTypeMap = Readonly<{ 26 | string: SerializersWithDefaultFactory; 27 | integer: SerializersWithDefaultFactory; 28 | float: SerializersWithDefaultFactory; 29 | boolean: SerializersWithDefaultFactory; 30 | 31 | /** 32 | * Querystring encoded as the number of milliseconds since epoch, 33 | * and returned as a Date object. 34 | */ 35 | timestamp: SerializersWithDefaultFactory; 36 | 37 | /** 38 | * Querystring encoded as an ISO-8601 string (UTC), 39 | * and returned as a Date object. 40 | */ 41 | isoDateTime: SerializersWithDefaultFactory; 42 | 43 | /** 44 | * String-based enums provide better type-safety for known sets of values. 45 | * You will need to pass the stringEnum function a list of your enum values 46 | * in order to validate the query string. Anything else will return `null`, 47 | * or your default value if specified. 48 | * 49 | * Example: 50 | * ```ts 51 | * enum Direction { 52 | * up = 'UP', 53 | * down = 'DOWN', 54 | * left = 'LEFT', 55 | * right = 'RIGHT' 56 | * } 57 | * 58 | * const [direction, setDirection] = useQueryState( 59 | * 'direction', 60 | * queryTypes 61 | * .stringEnum(Object.values(Direction)) 62 | * .withDefault(Direction.up) 63 | * ) 64 | * ``` 65 | * 66 | * Note: the query string value will be the value of the enum, not its name 67 | * (example above: `direction=UP`). 68 | * 69 | * @param validValues The values you want to accept 70 | */ 71 | stringEnum( 72 | validValues: Enum[] | readonly Enum[] 73 | ): SerializersWithDefaultFactory; 74 | 75 | /** 76 | * Encode any object shape into the querystring value as JSON. 77 | * Value is URI-encoded by next.js for safety, so it may not look nice in the URL. 78 | * Note: you may want to use `useQueryStates` for finer control over 79 | * multiple related query keys. 80 | */ 81 | json(): SerializersWithDefaultFactory; 82 | 83 | /** 84 | * List of items represented with duplicate keys. 85 | * Items are URI-encoded by next.js for safety, so they may not look nice in the URL. 86 | * 87 | * @param itemSerializers Serializers for each individual item in the array 88 | */ 89 | array( 90 | itemSerializers: Serializers 91 | ): SerializersWithDefaultFactory[]>; 92 | 93 | /** 94 | * A comma-separated list of items. 95 | * Items are URI-encoded by next.js for safety, so they may not look nice in the URL. 96 | * 97 | * @param itemSerializers Serializers for each individual item in the array 98 | * @param separator The character to use to separate items (default ',') 99 | */ 100 | delimitedArray( 101 | itemSerializers: Serializers, 102 | separator?: string 103 | ): SerializersWithDefaultFactory[]>; 104 | }>; 105 | 106 | export const queryTypes: QueryTypeMap = { 107 | string: { 108 | parse: (v) => (v === undefined ? null : firstParam(v)), 109 | serialize: (v) => v, 110 | withDefault(defaultValue) { 111 | return { 112 | parse: (v) => (v === undefined ? defaultValue : firstParam(v)), 113 | serialize:this.serialize 114 | }; 115 | }, 116 | }, 117 | integer: { 118 | parse: parseFlooredFloatOrNull, 119 | serialize: (v) => (v == null ? v : Math.floor(v).toFixed()), 120 | withDefault(defaultValue) { 121 | return { 122 | parse: (v) => parseFlooredFloatOrNull(v) ?? defaultValue, 123 | serialize: this.serialize, 124 | }; 125 | }, 126 | }, 127 | float: { 128 | parse: parseFloatOrNull, 129 | serialize: (v) => (v == null ? v : v.toString()), 130 | withDefault(defaultValue) { 131 | return { 132 | parse: (v) => parseFloatOrNull(v) ?? defaultValue, 133 | serialize: this.serialize, 134 | }; 135 | }, 136 | }, 137 | boolean: { 138 | parse: parseBooleanOrNull, 139 | serialize: (v) => (v == null ? v : v ? "true" : "false"), 140 | withDefault(defaultValue) { 141 | return { 142 | parse: (v) => parseBooleanOrNull(v) ?? defaultValue, 143 | serialize: this.serialize, 144 | }; 145 | }, 146 | }, 147 | timestamp: { 148 | parse: parseTimestampOrNull, 149 | serialize: (v) => (v == null ? v : isNaN(v.valueOf()) ? null : v.valueOf().toString()), 150 | withDefault(defaultValue) { 151 | return { 152 | parse: (v) => parseTimestampOrNull(v) ?? defaultValue, 153 | serialize: this.serialize, 154 | }; 155 | }, 156 | }, 157 | isoDateTime: { 158 | parse: parseIsoDateTimeOrNull, 159 | serialize: (v) => (v == null ? v : isNaN(v.valueOf()) ? null : v.toISOString()), 160 | withDefault(defaultValue) { 161 | return { 162 | parse: (v) => parseIsoDateTimeOrNull(v) ?? defaultValue, 163 | serialize: this.serialize, 164 | }; 165 | }, 166 | }, 167 | stringEnum(validValues: Enum[]) { 168 | const parse = (v: NextQueryValue) => { 169 | if (v !== undefined) { 170 | const asEnum = firstParam(v) as Enum; 171 | if (validValues.includes(asEnum)) return asEnum; 172 | } 173 | return null; 174 | }; 175 | return { 176 | parse, 177 | serialize: (v) => (v == null ? v : v.toString()), 178 | withDefault(defaultValue) { 179 | return { 180 | parse: (v) => parse(v) ?? defaultValue, 181 | serialize: this.serialize, 182 | }; 183 | }, 184 | }; 185 | }, 186 | json() { 187 | const parse = (v: NextQueryValue) => { 188 | if (v === undefined) return null; 189 | try { 190 | return JSON.parse(firstParam(v)) as T; 191 | } catch { 192 | return null; 193 | } 194 | }; 195 | return { 196 | parse, 197 | serialize: (v) => (v == null ? v : JSON.stringify(v)) as string | null | undefined, 198 | withDefault(defaultValue) { 199 | return { 200 | parse: (v) => parse(v) ?? defaultValue, 201 | serialize: this.serialize, 202 | }; 203 | }, 204 | }; 205 | }, 206 | array(itemSerializers) { 207 | const parse = (v: NextQueryValue) => { 208 | if (v === undefined) return null; 209 | type ItemType = ReturnType; 210 | const arr = Array.isArray(v) ? v : [v]; 211 | const parsedValues = arr 212 | .map(itemSerializers.parse) 213 | .filter((x) => x !== null) as Exclude[]; 214 | return parsedValues.length ? parsedValues : null; 215 | }; 216 | return { 217 | parse, 218 | serialize: (v) => { 219 | if (v == null) return v; 220 | return v 221 | .map(itemSerializers.serialize || String) 222 | .filter((v) => v != null) as WriteQueryValue; 223 | }, 224 | withDefault(defaultValue) { 225 | return { 226 | parse: (v) => parse(v) ?? defaultValue, 227 | serialize: this.serialize, 228 | }; 229 | }, 230 | }; 231 | }, 232 | delimitedArray(itemSerializers, separator = ",") { 233 | const parse = (v: NextQueryValue) => { 234 | if (v === undefined) return null; 235 | type ItemType = ReturnType; 236 | const parsedValues = firstParam(v) 237 | .split(separator) 238 | .map(itemSerializers.parse) 239 | .filter((x) => x !== null) as Exclude[]; 240 | return parsedValues.length ? parsedValues : null; 241 | }; 242 | return { 243 | parse, 244 | serialize: (v) => { 245 | if (v == null) return v; 246 | if (v.length === 0) return null; 247 | return v.map(itemSerializers.serialize || String).join(separator); 248 | }, 249 | withDefault(defaultValue) { 250 | return { 251 | parse: (v) => parse(v) ?? defaultValue, 252 | serialize: this.serialize, 253 | }; 254 | }, 255 | }; 256 | }, 257 | }; 258 | 259 | function parseFlooredFloatOrNull(v: string | string[] | undefined) { 260 | if (v === undefined) return null; 261 | const parsed = parseFloat(firstParam(v)); 262 | return isNaN(parsed) ? null : Math.floor(parsed); 263 | } 264 | 265 | function parseFloatOrNull(v: string | string[] | undefined) { 266 | if (v === undefined) return null; 267 | const parsed = parseFloat(firstParam(v)); 268 | return isNaN(parsed) ? null : parsed; 269 | } 270 | 271 | function parseBooleanOrNull(v: string | string[] | undefined) { 272 | if (v === undefined) return null; 273 | const first = firstParam(v); 274 | if (first.toLowerCase() === "true") return true; 275 | if (first.toLowerCase() === "false") return false; 276 | return null; 277 | } 278 | 279 | function parseTimestampOrNull(v: string | string[] | undefined) { 280 | const timestamp = parseFlooredFloatOrNull(v); 281 | if (timestamp === null) return null; 282 | const dt = new Date(timestamp); 283 | return isNaN(dt.valueOf()) ? null : dt; 284 | } 285 | 286 | function parseIsoDateTimeOrNull(v: string | string[] | undefined) { 287 | if (v === undefined) return null; 288 | const dt = new Date(firstParam(v)); 289 | return isNaN(dt.valueOf()) ? null : dt; 290 | } 291 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "next-batch-router" 2 | export * from "./defs"; 3 | export * from "./useQueryState"; 4 | export * from "./useQueryStates"; 5 | -------------------------------------------------------------------------------- /src/nullableQueryTypes.ts: -------------------------------------------------------------------------------- 1 | import { NextQueryValue, Serializers, WriteQueryValue } from "./defs"; 2 | import { firstParam } from "./utils"; 3 | 4 | export type NullableSerializersWithDefaultFactory = Required< 5 | Serializers 6 | > & { 7 | withDefault: (defaultValue: T) => Required>; 8 | }; 9 | 10 | export type NullableQueryTypeMap = Readonly<{ 11 | string: NullableSerializersWithDefaultFactory; 12 | integer: NullableSerializersWithDefaultFactory; 13 | float: NullableSerializersWithDefaultFactory; 14 | boolean: NullableSerializersWithDefaultFactory; 15 | 16 | /** 17 | * Querystring encoded as the number of milliseconds since epoch, 18 | * and returned as a Date object. 19 | */ 20 | timestamp: NullableSerializersWithDefaultFactory; 21 | 22 | /** 23 | * Querystring encoded as an ISO-8601 string (UTC), 24 | * and returned as a Date object. 25 | */ 26 | isoDateTime: NullableSerializersWithDefaultFactory; 27 | 28 | /** 29 | * String-based enums provide better type-safety for known sets of values. 30 | * You will need to pass the stringEnum function a list of your enum values 31 | * in order to validate the query string. Anything else will return `null`, 32 | * or your default value if specified. 33 | * 34 | * Example: 35 | * ```ts 36 | * enum Direction { 37 | * up = 'UP', 38 | * down = 'DOWN', 39 | * left = 'LEFT', 40 | * right = 'RIGHT' 41 | * } 42 | * 43 | * const [direction, setDirection] = useQueryState( 44 | * 'direction', 45 | * queryTypes 46 | * .stringEnum(Object.values(Direction)) 47 | * .withDefault(Direction.up) 48 | * ) 49 | * ``` 50 | * 51 | * Note: the query string value will be the value of the enum, not its name 52 | * (example above: `direction=UP`). 53 | * 54 | * @param validValues The values you want to accept 55 | */ 56 | stringEnum( 57 | validValues: Enum[] | readonly Enum[] 58 | ): NullableSerializersWithDefaultFactory; 59 | 60 | /** 61 | * Encode any object shape into the querystring value as JSON. 62 | * Value is URI-encoded by next.js for safety, so it may not look nice in the URL. 63 | * Note: you may want to use `useQueryStates` for finer control over 64 | * multiple related query keys. 65 | */ 66 | json(): NullableSerializersWithDefaultFactory; 67 | 68 | /** 69 | * List of items represented with duplicate keys. 70 | * Items are URI-encoded by next.js for safety, so they may not look nice in the URL. 71 | * 72 | * @param itemSerializers Serializers for each individual item in the array 73 | */ 74 | array( 75 | itemSerializers: Serializers 76 | ): NullableSerializersWithDefaultFactory[]>; 77 | 78 | /** 79 | * A comma-separated list of items. 80 | * Items are URI-encoded by next.js for safety, so they may not look nice in the URL. 81 | * 82 | * @param itemSerializers Serializers for each individual item in the array 83 | * @param separator The character to use to separate items (default ',' which encodes into '%2C') 84 | */ 85 | delimitedArray( 86 | itemSerializers: Serializers, 87 | separator?: string 88 | ): NullableSerializersWithDefaultFactory[]>; 89 | }>; 90 | 91 | export const nullableQueryTypes: NullableQueryTypeMap = { 92 | string: { 93 | parse: (v) => (firstParam(v) === "\0" ? null : firstParam(v)), 94 | serialize: (v) => (v === undefined ? null : v === null ? "\0" : v), 95 | withDefault(defaultValue) { 96 | return { 97 | parse: (v) => 98 | firstParam(v) === "\0" ? null : v === undefined ? defaultValue : firstParam(v), 99 | serialize: this.serialize, 100 | }; 101 | }, 102 | }, 103 | integer: { 104 | parse: (v) => (firstParam(v) === "\0" ? null : parseFlooredFloatOrUndef(v)), 105 | serialize: (v) => (v === undefined ? null : v === null ? "\0" : Math.floor(v).toFixed()), 106 | withDefault(defaultValue) { 107 | return { 108 | parse: (v) => 109 | firstParam(v) === "\0" ? null : parseFlooredFloatOrUndef(v) ?? defaultValue, 110 | serialize: this.serialize, 111 | }; 112 | }, 113 | }, 114 | float: { 115 | parse: (v) => (firstParam(v) === "\0" ? null : parseFloatOrUndef(v)), 116 | serialize: (v) => (v === undefined ? null : v === null ? "\0" : v.toString()), 117 | withDefault(defaultValue) { 118 | return { 119 | parse: (v) => 120 | firstParam(v) === "\0" ? null : parseFloatOrUndef(v) ?? defaultValue, 121 | serialize: this.serialize, 122 | }; 123 | }, 124 | }, 125 | boolean: { 126 | parse: (v) => (firstParam(v) === "\0" ? null : parseBooleanOrUndef(v)), 127 | serialize: (v) => (v === undefined ? null : v === null ? "\0" : v ? "true" : "false"), 128 | withDefault(defaultValue) { 129 | return { 130 | parse: (v) => 131 | firstParam(v) === "\0" ? null : parseBooleanOrUndef(v) ?? defaultValue, 132 | serialize: this.serialize, 133 | }; 134 | }, 135 | }, 136 | timestamp: { 137 | parse: (v) => (firstParam(v) === "\0" ? null : parseTimestampOrUndef(v)), 138 | serialize: (v) => 139 | v === undefined 140 | ? null 141 | : v === null 142 | ? "\0" 143 | : isNaN(v.valueOf()) 144 | ? null 145 | : v.valueOf().toString(), 146 | withDefault(defaultValue) { 147 | return { 148 | parse: (v) => 149 | firstParam(v) === "\0" ? null : parseTimestampOrUndef(v) ?? defaultValue, 150 | serialize: this.serialize, 151 | }; 152 | }, 153 | }, 154 | isoDateTime: { 155 | parse: (v) => (firstParam(v) === "\0" ? null : parseIsoDateTimeOrUndef(v)), 156 | serialize: (v) => 157 | v === undefined 158 | ? null 159 | : v === null 160 | ? "\0" 161 | : isNaN(v.valueOf()) 162 | ? null 163 | : v.toISOString(), 164 | withDefault(defaultValue) { 165 | return { 166 | parse: (v) => 167 | firstParam(v) === "\0" ? null : parseIsoDateTimeOrUndef(v) ?? defaultValue, 168 | serialize: this.serialize, 169 | }; 170 | }, 171 | }, 172 | stringEnum(validValues: Enum[]) { 173 | const parse = (v: string | string[] | undefined) => { 174 | const val = firstParam(v); 175 | if (val === "\0") return null; 176 | if (val !== undefined) { 177 | const asEnum = firstParam(v) as Enum; 178 | if (validValues.includes(asEnum)) return asEnum; 179 | } 180 | return undefined; 181 | }; 182 | return { 183 | parse, 184 | serialize: (value) => 185 | value === undefined ? null : value === null ? "\0" : value.toString(), 186 | withDefault(defaultValue) { 187 | return { 188 | parse: (v) => { 189 | const value = parse(v); 190 | return value === undefined ? defaultValue : value; 191 | }, 192 | serialize: this.serialize, 193 | }; 194 | }, 195 | }; 196 | }, 197 | json() { 198 | const parse = (v: NextQueryValue) => { 199 | if (v === undefined) return undefined; 200 | try { 201 | // null is represented with string "null" 202 | return JSON.parse(firstParam(v)) as T; 203 | } catch { 204 | return undefined; 205 | } 206 | }; 207 | return { 208 | parse, 209 | serialize: (v) => 210 | // null is represented with string "null" 211 | (v === undefined ? null : JSON.stringify(v)) as string | null | undefined, 212 | withDefault(defaultValue) { 213 | return { 214 | parse: (v) => { 215 | const parsed = parse(v); 216 | return parsed === undefined ? defaultValue : parsed; 217 | }, 218 | serialize: this.serialize, 219 | }; 220 | }, 221 | }; 222 | }, 223 | array(itemSerializers) { 224 | const parse = (v: string | string[] | undefined) => { 225 | if (v === undefined) return undefined; 226 | type ItemType = ReturnType; 227 | const arr = Array.isArray(v) ? v : [v]; 228 | const parsedValues = arr 229 | .map(itemSerializers.parse) 230 | .filter((x) => x !== undefined) as Exclude[]; 231 | return parsedValues.length ? parsedValues : undefined; 232 | }; 233 | return { 234 | parse, 235 | serialize: (v) => { 236 | if (v === undefined) return null; 237 | return v 238 | .map(itemSerializers.serialize || String) 239 | .filter((v) => v != null) as WriteQueryValue; 240 | }, 241 | withDefault(defaultValue) { 242 | return { 243 | parse: (v) => { 244 | const value = parse(v); 245 | return value === undefined ? defaultValue : value; 246 | }, 247 | serialize: this.serialize, 248 | }; 249 | }, 250 | }; 251 | }, 252 | delimitedArray(itemSerializers, separator = ",") { 253 | const parse = (v: string | string[] | undefined) => { 254 | if (v === undefined) return undefined; 255 | type ItemType = ReturnType; 256 | const parsedValues = firstParam(v) 257 | .split(separator) 258 | .map(itemSerializers.parse) 259 | .filter((x) => x !== undefined) as Exclude[]; 260 | return parsedValues.length ? parsedValues : undefined; 261 | }; 262 | return { 263 | parse, 264 | serialize: (v) => { 265 | if (v === undefined || v.length === 0) return null; 266 | return v.map(itemSerializers.serialize || String).join(separator); 267 | }, 268 | withDefault(defaultValue) { 269 | return { 270 | parse: (v) => { 271 | const value = parse(v); 272 | return value === undefined ? defaultValue : value; 273 | }, 274 | serialize: this.serialize, 275 | }; 276 | }, 277 | }; 278 | }, 279 | }; 280 | 281 | function parseFlooredFloatOrUndef(v: string | string[] | undefined) { 282 | if (v === undefined) return undefined; 283 | const parsed = parseFloat(firstParam(v)); 284 | return isNaN(parsed) ? undefined : Math.floor(parsed); 285 | } 286 | 287 | function parseFloatOrUndef(v: string | string[] | undefined) { 288 | if (v === undefined) return undefined; 289 | const parsed = parseFloat(firstParam(v)); 290 | return isNaN(parsed) ? undefined : parsed; 291 | } 292 | 293 | function parseBooleanOrUndef(v: string | string[] | undefined) { 294 | if (v === undefined) return undefined; 295 | const first = firstParam(v); 296 | if (first.toLowerCase() === "true") return true; 297 | if (first.toLowerCase() === "false") return false; 298 | return undefined; 299 | } 300 | 301 | function parseTimestampOrUndef(v: string | string[] | undefined) { 302 | const timestamp = parseFlooredFloatOrUndef(v); 303 | if (timestamp === undefined) return undefined; 304 | const dt = new Date(timestamp); 305 | return isNaN(dt.valueOf()) ? undefined : dt; 306 | } 307 | 308 | function parseIsoDateTimeOrUndef(v: string | string[] | undefined) { 309 | if (v === undefined) return undefined; 310 | const dt = new Date(firstParam(v)); 311 | return isNaN(dt.valueOf()) ? undefined : dt; 312 | } 313 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from "next/app"; 2 | import { BatchRouterProvider } from "next-batch-router" 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | // @ts-ignore 6 | if (Component.noProvider) return ; 7 | 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | export default function DummyPage() { 2 | return "Dummy"; 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryState/basic.tsx: -------------------------------------------------------------------------------- 1 | import { queryTypes } from "src/defs"; 2 | import { useQueryState } from "src/useQueryState"; 3 | 4 | export default function BasicPage() { 5 | const [defaultStr, setDefaultStr] = useQueryState( 6 | "defaultStr", 7 | queryTypes.string.withDefault("default") 8 | ); 9 | const [str, setStr] = useQueryState("str"); 10 | 11 | const [int, setInt] = useQueryState("int", queryTypes.integer.withDefault(0)); 12 | 13 | return ( 14 |
15 |
{JSON.stringify(defaultStr)}
16 | 17 |
{JSON.stringify(str)}
18 | setStr(e.currentTarget.value)} 22 | /> 23 | 24 |
{JSON.stringify(int)}
25 | 26 | {Object.entries<(e: any) => void>({ 27 | functionalAddInt: () => setInt((prev) => prev + 1), 28 | changeStrWithTimeout: () => setTimeout(() => setStr("timeout"), 500), 29 | multipleSetWithPush: () => { 30 | setStr("strValue", { history: "push" }); 31 | setInt(10); 32 | }, 33 | }).map(([k, v]) => ( 34 | 37 | ))} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryState/dynamicNonFunctionPreset.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useRef } from "react"; 2 | import { queryTypes } from "src/defs"; 3 | import { useQueryState } from "src/useQueryState"; 4 | 5 | export default function DynamicNonFunctionPresetTestPage() { 6 | const [value, setValue] = useQueryState("val", queryTypes.string, { dynamic: true }); 7 | 8 | const ref = useRef({ value, setValue }); 9 | const lastRef = ref.current; 10 | ref.current = { value, setValue }; 11 | 12 | const [, forceRender] = useReducer((prev) => prev + 1, 0); 13 | 14 | return ( 15 | <> 16 |
{JSON.stringify(value)}
17 |
18 | {JSON.stringify({ 19 | valueEq: value === lastRef.value, 20 | setValueEq: setValue === lastRef.setValue, 21 | })} 22 |
23 | 24 | {Object.entries<(e: any) => void>({ 25 | forceRender, 26 | }).map(([k, v]) => ( 27 | 30 | ))} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryState/dynamicTest.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useRef, useState } from "react"; 2 | import { queryTypes } from "src/defs"; 3 | import { useQueryState } from "src/useQueryState"; 4 | 5 | export default function DynamicTestPage() { 6 | const [defaultVal, setDefaultVal] = useState(1); 7 | const [dynamic, setDynamic] = useQueryState("dynamic", queryTypes.boolean.withDefault(true)); 8 | 9 | const [value, setValue] = useQueryState( 10 | "val", 11 | queryTypes.json<{ a: number }>().withDefault({ a: defaultVal }), 12 | { dynamic } 13 | ); 14 | 15 | const ref = useRef({ value, setValue }); 16 | const lastRef = ref.current; 17 | ref.current = { value, setValue }; 18 | 19 | const [, forceRender] = useReducer((prev) => prev + 1, 0); 20 | 21 | return ( 22 | <> 23 |
{JSON.stringify(value)}
24 |
25 | {JSON.stringify({ 26 | valueEq: value === lastRef.value, 27 | setValueEq: setValue === lastRef.setValue, 28 | })} 29 |
30 | 31 | {Object.entries<(e: any) => void>({ 32 | forceRender, 33 | updateValue: () => setValue((prev) => ({ a: prev.a + 1 })), 34 | incrementDefaultVal: () => setDefaultVal(defaultVal + 1), 35 | }).map(([k, v]) => ( 36 | 39 | ))} 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryState/nonDynamicTest.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useRef, useState } from "react"; 2 | import { HistoryOptions, queryTypes } from "src/defs"; 3 | import { useQueryState } from "src/useQueryState"; 4 | 5 | export default function NonDynamicTestPage() { 6 | const [key, setKey] = useState("val"); 7 | const [history, setHistory] = useState("replace"); 8 | 9 | const [value, setValue] = useQueryState( 10 | key, 11 | queryTypes.json<{ a: number }>().withDefault({ a: 1 }), 12 | { history } 13 | ); 14 | 15 | const ref = useRef({ value, setValue }); 16 | const lastRef = ref.current; 17 | ref.current = { value, setValue }; 18 | 19 | const [, forceRender] = useReducer((prev) => prev + 1, 0); 20 | 21 | return ( 22 | <> 23 |
{JSON.stringify(value)}
24 |
25 | {JSON.stringify({ 26 | valueEq: value === lastRef.value, 27 | setValueEq: setValue === lastRef.setValue, 28 | })} 29 |
30 | 31 | {Object.entries<(e: any) => void>({ 32 | forceRender, 33 | changeKey: () => setKey("val2"), 34 | changeHistoryToPush: () => setHistory("push"), 35 | updateValue: () => setValue((prev) => ({ a: prev.a + 1 })), 36 | }).map(([k, v]) => ( 37 | 40 | ))} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryState/rerenderCheck.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import React, { useRef } from "react"; 3 | import { queryTypes } from "src/defs"; 4 | import { useQueryState } from "src/useQueryState"; 5 | 6 | export default function RerenderCheckPage() { 7 | const ref = useRef(0); 8 | ref.current++; 9 | return ( 10 | <> 11 |
{ref.current}
12 | 13 | 14 | ); 15 | } 16 | 17 | const MemoizedParent = React.memo(() => { 18 | const ref = useRef(0); 19 | ref.current++; 20 | return ( 21 | <> 22 |
{ref.current}
23 | 24 | 25 | 26 | 27 | ); 28 | }); 29 | 30 | function Child() { 31 | const ref = useRef(0); 32 | ref.current++; 33 | 34 | // const [str, setStr] = useState("") 35 | const [str, setStr] = useQueryState("str", queryTypes.string.withDefault("")); 36 | 37 | return ( 38 | <> 39 |
{ref.current}
40 |
{JSON.stringify(str)}
41 | setStr(e.currentTarget.value, { shallow: true })} 45 | /> 46 | 47 | ); 48 | } 49 | 50 | function SiblingWithoutRouterAccess() { 51 | const ref = useRef(0); 52 | ref.current++; 53 | 54 | return
{ref.current}
; 55 | } 56 | 57 | function SiblingWithRouterAccess() { 58 | const ref = useRef(0); 59 | ref.current++; 60 | 61 | const router = useRouter(); 62 | 63 | return
{ref.current}
; 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryState/useEffectMultiplePush.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useEffect } from "react"; 3 | import { useQueryState } from "src/useQueryState"; 4 | 5 | export default function UseEffectMultiplePushPage() { 6 | const router = useRouter(); 7 | 8 | return ( 9 |
10 |
{JSON.stringify(router.query)}
11 | 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | type InitializingComponentProps = { 20 | name: string; 21 | value: string; 22 | }; 23 | function InitializingComponent({ name, value }: InitializingComponentProps) { 24 | const [val, setVal] = useQueryState(name, undefined, { history: "push" }); 25 | 26 | useEffect(() => { 27 | setVal(value); 28 | }, []); 29 | 30 | return
{name}
; 31 | } 32 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryStates/basic.tsx: -------------------------------------------------------------------------------- 1 | import { queryTypes } from "src/defs"; 2 | import { useQueryStates } from "src/useQueryStates"; 3 | 4 | export default function BasicPage() { 5 | const [{ defaultStr, str, int }, setStates] = useQueryStates({ 6 | defaultStr: queryTypes.string.withDefault("default"), 7 | str: queryTypes.string, 8 | int: queryTypes.integer.withDefault(0), 9 | }); 10 | 11 | return ( 12 |
13 |
{JSON.stringify(defaultStr)}
14 | 15 |
{JSON.stringify(str)}
16 | setStates({ str: e.currentTarget.value })} 20 | /> 21 | 22 |
{JSON.stringify(int)}
23 | 24 | {Object.entries<(e: any) => void>({ 25 | functionalAddInt: () => setStates((prev) => ({ int: prev.int + 1 })), 26 | changeStrWithTimeout: () => setTimeout(() => setStates({ str: "timeout" }), 500), 27 | multipleSetWithPush: () => { 28 | setStates({ str: "strValue" }, { history: "push" }); 29 | setStates({ int: 10 }); 30 | }, 31 | }).map(([k, v]) => ( 32 | 35 | ))} 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryStates/rerenderCheck.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import React, { useRef } from "react"; 3 | import { queryTypes } from "src/defs"; 4 | import { useQueryStates } from "src/useQueryStates"; 5 | 6 | export default function RerenderCheckPage() { 7 | const ref = useRef(0); 8 | ref.current++; 9 | return ( 10 | <> 11 |
{ref.current}
12 | 13 | 14 | ); 15 | } 16 | 17 | const MemoizedParent = React.memo(() => { 18 | const ref = useRef(0); 19 | ref.current++; 20 | return ( 21 | <> 22 |
{ref.current}
23 | 24 | 25 | 26 | 27 | ); 28 | }); 29 | 30 | function Child() { 31 | const ref = useRef(0); 32 | ref.current++; 33 | 34 | // const [str, setStr] = useState("") 35 | const [{ str }, setStates] = useQueryStates({ str: queryTypes.string.withDefault("") }); 36 | 37 | return ( 38 | <> 39 |
{ref.current}
40 |
{JSON.stringify(str)}
41 | setStates({ str: e.currentTarget.value }, { shallow: true })} 45 | /> 46 | 47 | ); 48 | } 49 | 50 | function SiblingWithoutRouterAccess() { 51 | const ref = useRef(0); 52 | ref.current++; 53 | 54 | return
{ref.current}
; 55 | } 56 | 57 | function SiblingWithRouterAccess() { 58 | const ref = useRef(0); 59 | ref.current++; 60 | 61 | const router = useRouter(); 62 | 63 | return
{ref.current}
; 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/tests/useQueryStates/useEffectMultiplePush.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useEffect } from "react"; 3 | import { queryTypes } from "src/defs"; 4 | import { useQueryStates } from "src/useQueryStates"; 5 | 6 | export default function UseEffectMultiplePushPage() { 7 | const router = useRouter(); 8 | 9 | return ( 10 |
11 |
{JSON.stringify(router.query)}
12 | 13 | 14 | 15 | 16 |
17 | ); 18 | } 19 | 20 | type InitializingComponentProps = { 21 | name: string; 22 | value: string; 23 | }; 24 | function InitializingComponent({ name, value }: InitializingComponentProps) { 25 | const [val, setStates] = useQueryStates({ [name]: queryTypes.string }, { history: "push" }); 26 | 27 | useEffect(() => { 28 | setStates({ [name]: value }); 29 | }, []); 30 | 31 | return
{name}
; 32 | } 33 | -------------------------------------------------------------------------------- /src/useQueryState.ts: -------------------------------------------------------------------------------- 1 | import { useBatchRouter } from "next-batch-router"; 2 | import { useRouter } from "next/router"; 3 | import React from "react"; 4 | import { HistoryOptions, NextQueryValue, Serializers, UpdateOptions } from "./defs"; 5 | import { defaultSerializer, firstStringParser } from "./utils"; 6 | 7 | type UseQueryStateOptions = { 8 | /** 9 | * The operation to use on state updates. Defaults to `replace`. 10 | */ 11 | history?: HistoryOptions; 12 | /** 13 | * Sets if parse and serialize functions can be changed in runtime. 14 | * Parse and serialize functions must not be changed unless dynamic is set to true. 15 | * Defaults to false. 16 | * 17 | * If set to true, referential equality of the functions must be managed via useCallback or by other means. 18 | * Value and updater functions are dependent on them, and only the recent updater function must be used. 19 | * Stale updater functions use previously supplied parse and serialize functions, 20 | * which might have different types or different default values. 21 | */ 22 | dynamic?: boolean; 23 | }; 24 | 25 | type UseQueryStateReturn = [ 26 | T, 27 | (value: WT | ((prev: T) => WT), options?: UpdateOptions) => Promise 28 | ]; 29 | 30 | /** 31 | * Hook similar to useState but stores state in the URL query string. 32 | * 33 | * If serializers are not supplied, returns the first string value of the query param with the key. 34 | * Update function set the URL with string | string[] | null. 35 | */ 36 | export function useQueryState( 37 | key: string 38 | ): UseQueryStateReturn; 39 | /** 40 | * Hook similar to useState but stores state in the URL query string. 41 | * 42 | * @param key Key to use in the query string. 43 | * @param serializers Object that consists of `parse` and `serialize` functions that transforms the state from and to the URL string. 44 | * @param options Defines history mode and dynamic serializer option. 45 | */ 46 | export function useQueryState( 47 | key: string, 48 | serializers?: Serializers, 49 | options?: UseQueryStateOptions 50 | ): UseQueryStateReturn; 51 | export function useQueryState( 52 | key: string, 53 | { 54 | parse = firstStringParser as unknown as (v: any) => T, 55 | serialize = defaultSerializer, 56 | }: Partial> & UseQueryStateOptions = {}, 57 | { history = "replace", dynamic = false }: UseQueryStateOptions = {} 58 | ): UseQueryStateReturn { 59 | const router = useRouter(); 60 | const batchRouter = useBatchRouter(); 61 | 62 | const routerValue = router.query[key]; 63 | const value = React.useMemo( 64 | () => parse(routerValue), 65 | // Dependency array must be consistent. 66 | // If dynamic is false, set parse, serialize dependency as null so function instance change doesn't update callback. 67 | [ 68 | Array.isArray(routerValue) ? routerValue.join("|") : routerValue, 69 | ...(dynamic ? [parse, serialize] : [null, null]), 70 | ] 71 | ); 72 | 73 | const update = React.useCallback( 74 | ( 75 | stateUpdater: WT | ((prev: T) => WT), 76 | { history: historyOverride, ...transitionOptions }: UpdateOptions = {} 77 | ) => { 78 | const queryUpdater = isUpdaterFunction(stateUpdater) 79 | ? (prevObj: Record) => { 80 | const newVal = serialize(stateUpdater(parse(prevObj[key]))); 81 | // Manually merge. Keep prev value if new is undefined. 82 | if (newVal !== undefined) return { ...prevObj, [key]: newVal }; 83 | return prevObj; 84 | } 85 | : { [key]: serialize(stateUpdater) }; 86 | 87 | const historyMode = historyOverride || history; 88 | if (historyMode === "push") 89 | return batchRouter.push({ query: queryUpdater }, undefined, transitionOptions); 90 | else return batchRouter.replace({ query: queryUpdater }, undefined, transitionOptions); 91 | }, 92 | // Dependency array must be consistent. 93 | // If dynamic is false, set parse, serialize dependency as null so function instance change doesn't update callback. 94 | [key, history, batchRouter, ...(dynamic ? [parse, serialize] : [null, null])] 95 | ); 96 | return [value, update]; 97 | } 98 | 99 | function isUpdaterFunction(input: WT | ((prev: T) => WT)): input is (prev: T) => WT { 100 | return typeof input === "function"; 101 | } 102 | -------------------------------------------------------------------------------- /src/useQueryStates.ts: -------------------------------------------------------------------------------- 1 | import { useBatchRouter } from "next-batch-router"; 2 | import { useRouter } from "next/router"; 3 | import type { 4 | HistoryOptions, 5 | NextQueryValue, 6 | Serializers, 7 | UpdateOptions, 8 | WriteQueryValue, 9 | } from "./defs"; 10 | import { defaultSerializer } from "./utils"; 11 | 12 | export type UseQueryStatesKeyMap = { 13 | [Key in keyof KeyMap]: Serializers; 14 | }; 15 | 16 | export interface UseQueryStatesOptions { 17 | /** 18 | * The operation to use on state updates. Defaults to `replace`. 19 | */ 20 | history: HistoryOptions; 21 | } 22 | 23 | export type Values = { 24 | [K in keyof T]: ReturnType; 25 | }; 26 | 27 | export type WriteValues = { 28 | [K in keyof T]: T[K]["serialize"] extends Function ? Parameters[0] : any; 29 | }; 30 | 31 | type UpdaterFn = (old: Values) => Partial>; 32 | 33 | export type SetValues = ( 34 | stateUpdater: Partial> | UpdaterFn, 35 | options?: UpdateOptions 36 | ) => void; 37 | 38 | export type UseQueryStatesReturn = [Values, SetValues]; 39 | 40 | /** 41 | * Hook that stores multiple states in the URL query string. 42 | * Can be seen as a group of multiple useQueryString hooks. 43 | * 44 | * WARNING: This function is not optimized. No memoization happens inside. 45 | * This function is intended to be used for cases like below. 46 | * 1. The keys are changed at runtime. (Since conditional use of useQueryState is illegal) 47 | * 2. New value is determined by multiple keys while doing functional update. 48 | * 49 | * @param keys - An object describing the keys to synchronise and how to 50 | * serialise and parse them. 51 | * Use `queryTypes.(string|integer|float)` for quick shorthands. 52 | */ 53 | export function useQueryStates( 54 | keys: KeyMap, 55 | { history = "replace" }: Partial = {} 56 | ): UseQueryStatesReturn { 57 | const router = useRouter(); 58 | const batchRouter = useBatchRouter(); 59 | 60 | // Parse query into values 61 | const values = parseObject(router.query, keys); 62 | 63 | // Update function 64 | const update: SetValues = ( 65 | stateUpdater, 66 | { history: historyOverride, ...transitionOptions } = {} 67 | ) => { 68 | const queryUpdater = isUpdaterFunction(stateUpdater) 69 | ? (prevObj: Record) => { 70 | const prev = parseObject(prevObj, keys); 71 | const updated = stateUpdater(prev); 72 | return { ...prevObj, ...serializeAndRemoveUndefined(updated, keys) }; 73 | } 74 | : serializeAndRemoveUndefined(stateUpdater, keys); 75 | const historyMode = historyOverride || history; 76 | if (historyMode === "push") 77 | return batchRouter.push({ query: queryUpdater }, undefined, transitionOptions); 78 | else return batchRouter.replace({ query: queryUpdater }, undefined, transitionOptions); 79 | }; 80 | 81 | return [values, update]; 82 | } 83 | 84 | function isUpdaterFunction( 85 | input: any 86 | ): input is UpdaterFn { 87 | return typeof input === "function"; 88 | } 89 | 90 | function parseObject( 91 | query: Record, 92 | keys: KeyMap 93 | ) { 94 | type V = Values; 95 | const values: V = {} as V; 96 | for (const [k, v] of Object.entries(keys)) values[k as keyof V] = v.parse(query[k]); 97 | return values; 98 | } 99 | 100 | function serializeAndRemoveUndefined( 101 | vals: Partial>, 102 | keys: KeyMap 103 | ) { 104 | const serialized: Record = {}; 105 | for (const [k, v] of Object.entries(vals)) 106 | if (k in keys) { 107 | const serializedVal = (keys[k].serialize || defaultSerializer)(v); 108 | if (serializedVal !== undefined) serialized[k] = serializedVal; 109 | } 110 | 111 | return serialized; 112 | } 113 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** Next.js router parses query string value as string[] when there are duplicate keys. 2 | * This function is used to take only the first value in that case, 3 | * and ensure the value is string or undefined. 4 | */ 5 | export function firstParam(v: string | string[]): string; 6 | export function firstParam(v: string | string[] | undefined): string | undefined; 7 | export function firstParam(v: string | string[] | undefined): string | undefined { 8 | return Array.isArray(v) ? v[0] : v; 9 | } 10 | 11 | export const firstStringParser = (v: string | string[] | undefined) => 12 | v === undefined ? null : firstParam(v); 13 | 14 | export const defaultSerializer = (x: any | null | undefined) => 15 | x == null ? x : Array.isArray(x) ? x.map(String) : String(x); 16 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "dist", 6 | "./src/pages/*" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "lib": [ 5 | "DOM", 6 | "ES2017" 7 | ], 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "baseUrl": ".", 16 | "outDir": "dist", 17 | "declaration": true, 18 | "declarationDir": "./dist/dts", 19 | "sourceMap": true, 20 | "allowJs": true, 21 | "skipLibCheck": true, 22 | "noEmit": true, 23 | "incremental": true, 24 | "resolveJsonModule": true 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "dist" 29 | ], 30 | "include": [ 31 | "src" 32 | ] 33 | } 34 | --------------------------------------------------------------------------------