From f4817f54e56a1a5253d40d121ea55079344ef28e Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sun, 10 Sep 2023 21:51:35 +0200 Subject: [PATCH] feat: Single app/pages implementation After some tests, it turns out the implementation relying on the next router works in both pages and app directories, with the usual SSR/hydration caveats of the pages router. - Add demos - Export individual parsers and deprecate the queryTypes object - Allow hook-level and setState-level options overrides - Add subscribeToQueryUpdates hook for easier component testing - Add options to the parsers builder pattern --- README.md | 224 +++++--- app.d.ts | 7 - cypress.config.ts | 3 +- next.config.mjs | 10 + package.json | 24 +- pages.d.ts | 7 - src/app/app/useQueryState/page.tsx | 19 +- src/app/app/useQueryStates/page.tsx | 16 +- src/app/demos/batching/page.tsx | 40 ++ src/app/demos/builder-pattern/page.tsx | 34 ++ .../demos/subscribeToQueryUpdates/page.tsx | 37 ++ src/app/layout.tsx | 4 + src/app/page.tsx | 21 +- src/lib/defs.ts | 481 ++++++++++-------- src/lib/index.app.ts | 5 - src/lib/index.pages.ts | 6 - src/lib/index.ts | 5 +- src/lib/pages/pagesRouterDefs.ts | 5 - src/lib/pages/useQueryState.ts | 280 ---------- src/lib/pages/useQueryStates.ts | 146 ------ src/lib/sync.ts | 9 + src/lib/update-queue.ts | 74 ++- src/lib/{app => }/useQueryState.ts | 69 +-- src/lib/{app => }/useQueryStates.ts | 50 +- src/pages/pages/useQueryState/index.tsx | 2 +- src/pages/pages/useQueryStates/index.tsx | 2 +- .../{pages => compat}/useQueryState.test-d.ts | 10 +- .../{app => compat}/useQueryStates.test-d.ts | 2 +- src/tests/{app => }/useQueryState.test-d.ts | 95 +++- .../{pages => }/useQueryStates.test-d.ts | 23 +- 30 files changed, 814 insertions(+), 896 deletions(-) delete mode 100644 app.d.ts create mode 100644 next.config.mjs delete mode 100644 pages.d.ts create mode 100644 src/app/demos/batching/page.tsx create mode 100644 src/app/demos/builder-pattern/page.tsx create mode 100644 src/app/demos/subscribeToQueryUpdates/page.tsx delete mode 100644 src/lib/index.app.ts delete mode 100644 src/lib/index.pages.ts delete mode 100644 src/lib/pages/pagesRouterDefs.ts delete mode 100644 src/lib/pages/useQueryState.ts delete mode 100644 src/lib/pages/useQueryStates.ts rename src/lib/{app => }/useQueryState.ts (79%) rename src/lib/{app => }/useQueryStates.ts (76%) rename src/tests/{pages => compat}/useQueryState.test-d.ts (96%) rename src/tests/{app => compat}/useQueryStates.test-d.ts (95%) rename src/tests/{app => }/useQueryState.test-d.ts (67%) rename src/tests/{pages => }/useQueryStates.test-d.ts (78%) diff --git a/README.md b/README.md index 8fa3d5b8..765527e9 100644 --- a/README.md +++ b/README.md @@ -10,27 +10,25 @@ useQueryState hook for Next.js - Like React.useState, but stored in the URL quer ## Features -- 🧘‍♀️ Simple: the URL is the source of truth. +- 🧘‍♀️ Simple: the URL is the source of truth - 🕰 Replace history or append to use the Back button to navigate state updates -- ⚡️ Built-in converters for common object types (number, float, boolean, Date) +- ⚡️ Built-in parsers for common object types (number, float, boolean, Date, and [more](#parsing)) - ♊️ Linked querystrings with `useQueryStates` -- 🔀 Supports both the app router (in client components only) and pages router +- 🔀 _(beta)_ Supports both the app router (in client components only) and pages router ## Installation ```shell +$ pnpm add next-usequerystate $ yarn add next-usequerystate -or $ npm install next-usequerystate ``` ## Usage -> Note: all code samples assume you're using the pages router. -> -> Jump to the [app router documentation](#app-router). - ```tsx +'use client' // app router: only works in client components + import { useQueryState } from 'next-usequerystate' export default () => { @@ -68,19 +66,29 @@ Example outputs for our hello world example: If your state type is not a string, you must pass a parsing function in the second argument object. -We provide helpers for common and more advanced object types: +We provide parsers for common and more advanced object types: ```ts -import { queryTypes } from 'next-usequerystate' +import { + parseAsString, + parseAsInteger, + parseAsFloat, + parseAsBoolean, + parseAsTimestamp, + parseAsIsoDateTime, + parseAsArrayOf, + parseAsJson, + parseAsStringEnum +} from 'next-usequerystate' useQueryState('tag') // defaults to string -useQueryState('count', queryTypes.integer) -useQueryState('brightness', queryTypes.float) -useQueryState('darkMode', queryTypes.boolean) -useQueryState('after', queryTypes.timestamp) // state is a Date -useQueryState('date', queryTypes.isoDateTime) // state is a Date -useQueryState('array', queryTypes.array(queryTypes.integer)) // state is number[] -useQueryState('json', queryTypes.json()) // state is a Point +useQueryState('count', parseAsInteger) +useQueryState('brightness', parseAsFloat) +useQueryState('darkMode', parseAsBoolean) +useQueryState('after', parseAsTimestamp) // state is a Date +useQueryState('date', parseAsIsoDateTime) // state is a Date +useQueryState('array', parseAsArrayOf(parseAsInteger)) // state is number[] +useQueryState('json', parseAsJson()) // state is a Point // Enums (string-based only) enum Direction { @@ -92,8 +100,7 @@ enum Direction { const [direction, setDirection] = useQueryState( 'direction', - queryTypes - .stringEnum(Object.values(Direction)) // pass a list of allowed values + parseAsStringEnum(Object.values(Direction)) // pass a list of allowed values .withDefault(Direction.up) ) ``` @@ -116,10 +123,10 @@ export default () => { Example: simple counter stored in the URL: ```tsx -import { useQueryState, queryTypes } from 'next-usequerystate' +import { useQueryState, parseAsInteger } from 'next-usequerystate' export default () => { - const [count, setCount] = useQueryState('count', queryTypes.integer) + const [count, setCount] = useQueryState('count', parseAsInteger) return ( <>
count: {count}
@@ -143,10 +150,7 @@ tedious. You can specify a default value to be returned in this case: ```ts -const [count, setCount] = useQueryState( - 'count', - queryTypes.integer.withDefault(0) -) +const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0)) const increment = () => setCount(c => c + 1) // c will never be null const decrement = () => setCount(c => c - 1) // c will never be null @@ -159,7 +163,9 @@ URL. Setting the state to `null` will remove the key in the query string and set the state to the default value. -## History options +## Options + +### History By default, state updates are done by replacing the current history entry with the updated query when state changes. @@ -181,35 +187,114 @@ useQueryState('foo', { history: 'push' }) Any other value for the `history` option will fallback to the default. -## Multiple Queries +You can also override the history mode when calling the state updater function: + +```ts +const [query, setQuery] = useQueryState('q', { history: 'push' }) + +// This overrides the hook declaration setting: +setQuery(null, { history: 'replace' }) +``` + +### Shallow + +By default, query state updates are done in a _client-first_ manner: there are +no network calls to the server. -> Note: If using the app router, you don't need to await the state updates. +This uses the `shallow` option of the Next.js router set to `true`. -Because the Next.js router has asynchronous methods, if you want to do multiple -query updates in one go, you'll have to `await` them, otherwise the latter will -overwrite the updates of the former: +To opt-in to query updates notifying the server (to re-run `getServerSideProps` +in the pages router and re-render Server Components on the pages router), +you can set `shallow` to `false`: + +```ts +const [state, setState] = useQueryState('foo', { shallow: false }) + +// You can also pass the option on calls to setState: +setState('bar', { shallow: false }) +``` + +### Scroll + +The Next.js router scrolls to the top of the page on navigation updates, +which may not be desirable when updating the query string with local state. + +Query state updates won't scroll to the top of the page by default, but you +can opt-in to this behaviour (which was the default up to 1.8.0): + +```ts +const [state, setState] = useQueryState('foo', { scroll: true }) + +// You can also pass the option on calls to setState: +setState('bar', { scroll: true }) +``` + +## Composing parsers, default value & options + +You can use a builder pattern to facilitate specifying all of those things: + +```ts +useQueryState( + 'counter', + parseAsInteger + .withOptions({ + history: 'push', + shallow: false + }) + .withDefault(0) +) +``` + +Note: `withDefault` must always come **after** `withOptions` to ensure proper +type safety (providing a non-nullable state type). + +## Multiple Queries (batching) + +You can call as many state update function as needed in a single event loop +tick, and they will be applied to the URL asynchronously: ```ts const MultipleQueriesDemo = () => { - const [lat, setLat] = useQueryState('lat', queryTypes.float) - const [lng, setLng] = useQueryState('lng', queryTypes.float) - const randomCoordinates = React.useCallback(async () => { - await setLat(Math.random() * 180 - 90) - await setLng(Math.random() * 360 - 180) + const [lat, setLat] = useQueryState('lat', parseAsFloat) + const [lng, setLng] = useQueryState('lng', parseAsFloat) + const randomCoordinates = React.useCallback(() => { + setLat(Math.random() * 180 - 90) + setLng(Math.random() * 360 - 180) }, []) } ``` + + For query keys that should always move together, you can use `useQueryStates` -with an object containing each key's type: +with an object containing each key's type, for a better DX: ```ts -import { useQueryStates, queryTypes } from 'next-usequerystate' +import { useQueryStates, parseAsFloat } from 'next-usequerystate' const [coordinates, setCoordinates] = useQueryStates( { - lat: queryTypes.float.withDefault(45.18), - lng: queryTypes.float.withDefault(5.72) + lat: parseAsFloat.withDefault(45.18), + lng: parseAsFloat.withDefault(5.72) }, { history: 'push' @@ -219,62 +304,41 @@ const [coordinates, setCoordinates] = useQueryStates( const { lat, lng } = coordinates // Set all (or a subset of) the keys in one go: -await setCoordinates({ +const search = await setCoordinates({ lat: Math.random() * 180 - 90, lng: Math.random() * 360 - 180 }) ``` -## Transition Options - -> Note: this feature is only available for the pages router. - -By default, Next.js will scroll to the top of the page when changing things in the URL. - -To prevent this, `router.push()` and `router.replace()` have a third optional -parameter to control transitions, which can be passed on the state setter here: - -```ts -const [name, setName] = useQueryState('name') +## Caveats -setName('Foo', { - scroll: false, - shallow: true // Don't run getStaticProps / getServerSideProps / getInitialProps -}) -``` +Because the Next.js pages router is not available in an SSR context, this +hook will always return `null` (or the default value if supplied) on SSR/SSG. -## App router +This limitation doesn't apply to the app router. -This hook can be used with the app router in Next.js 13+, but -**only in client components**. +### Lossy serialization -Next.js doesn't allow obtaining querystring parameters from server components. +If your serializer loses precision or doesn't accurately represent +the underlying state value, you will lose this precision when +reloading the page or restoring state from the URL (eg: on navigation). -The API is the same for both hooks, but you'll need to change your imports to: +Example: ```ts -import { - useQueryState, - useQueryStates, - queryTypes -} from 'next-usequerystate/app' // <- note the /app here -``` - -In an later major version, the default import will stop pointing to the pages -router implementation and switch to the app router (probably when Next.js -starts marking the pages router as deprecated). - -In order to lock your usage of the hook to the pages router, you can change your -imports to the following: +const geoCoordParser = { + parse: parseFloat, + serialize: v => v.toFixed(4) // Loses precision +} -```ts -import { useQueryState } from 'next-usequerystate/pages' +const [lat, setLat] = useQueryState('lat', geoCoordParser) ``` -## Caveats +Here, setting a latitude of 1.23456789 will render a URL query string +of `lat=1.2345`, while the internal `lat` state will be correctly +set to 1.23456789. -Because the Next.js router is not available in an SSR context, this -hook will always return `null` (or the default value if supplied) on SSR/SSG. +Upon reloading the page, the state will be incorrectly set to 1.2345. ## License diff --git a/app.d.ts b/app.d.ts deleted file mode 100644 index aae85bba..00000000 --- a/app.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file is needed for projects that have `moduleResolution` set to `node` -// in their tsconfig.json to be able to `import {} from 'next-usequerystate/app'`. -// Other module resolutions strategies will look for the `exports` in `package.json`, -// but with `node`, TypeScript will look for a .d.ts file with that name at the -// root of the package. - -export * from './dist/app' diff --git a/cypress.config.ts b/cypress.config.ts index 92d96aa6..85f57aef 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from 'cypress' +import nextConfig from './next.config.mjs' export default defineConfig({ e2e: { - baseUrl: 'http://localhost:3000', + baseUrl: `http://localhost:3000${nextConfig.basePath ?? ''}`, video: false, fixturesFolder: false, supportFile: false, diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 00000000..1ddf4c0f --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,10 @@ +// @ts-check + +/** + * @type {import('next').NextConfig} + */ +const config = { + // basePath: '/basePath' +} + +export default config diff --git a/package.json b/package.json index 24b5da1c..1facba1c 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,7 @@ }, "files": [ "dist/", - "useQueryState.gif", - "app.d.ts", - "pages.d.ts" + "useQueryState.gif" ], "type": "module", "sideEffects": false, @@ -39,30 +37,18 @@ "import": "./dist/index.js", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" - }, - "./app": { - "import": "./dist/app.js", - "require": "./dist/app.cjs", - "types": "./dist/app.d.ts" - }, - "./pages": { - "import": "./dist/pages.js", - "require": "./dist/pages.cjs", - "types": "./dist/pages.d.ts" } }, "tsup": { - "entry": { - "index": "src/lib/index.ts", - "pages": "src/lib/index.pages.ts", - "app": "src/lib/index.app.ts" - } + "entry": [ + "src/lib/index.ts" + ] }, "scripts": { "dev": "run-p dev:*", "dev:build": "tsup --format esm --dts --watch", "dev:next": "next dev", - "build": "tsup --clean --format esm,cjs --dts", + "build": "tsup --clean --dts --format esm,cjs", "test": "run-p test:types test:e2e:ci", "test:types": "tsd", "test:e2e:ci": "run-p --race test:e2e:next:start cypress:run", diff --git a/pages.d.ts b/pages.d.ts deleted file mode 100644 index 80e79f48..00000000 --- a/pages.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file is needed for projects that have `moduleResolution` set to `node` -// in their tsconfig.json to be able to `import {} from 'next-usequerystate/pages'`. -// Other module resolutions strategies will look for the `exports` in `package.json`, -// but with `node`, TypeScript will look for a .d.ts file with that name at the -// root of the package. - -export * from './dist/pages' diff --git a/src/app/app/useQueryState/page.tsx b/src/app/app/useQueryState/page.tsx index db567754..9a317424 100644 --- a/src/app/app/useQueryState/page.tsx +++ b/src/app/app/useQueryState/page.tsx @@ -3,13 +3,20 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import React from 'react' -import { queryTypes, useQueryState } from '../../../../dist/app' +import { + parseAsBoolean, + parseAsFloat, + parseAsInteger, + parseAsString, + useQueryState +} from '../../../../dist' export default function IntegrationPage() { const [numPanes, setNumPanes] = React.useState(1) return (
-

useQueryState

+ ⬅️ Home +

useQueryState integration test