From df5e89470c5fdfb8f7e04b568aa4e3dbbfc9067e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Sun, 11 Feb 2024 13:30:35 +0100 Subject: [PATCH] feat: Add "clear on default" option (#493) --- packages/docs/content/docs/basic-usage.mdx | 2 +- packages/docs/content/docs/options.mdx | 34 +++++++++++++++---- packages/e2e/cypress/e2e/clearOnDefault.cy.js | 8 +++++ .../e2e/src/app/app/clearOnDefault/page.tsx | 32 +++++++++++++++++ packages/nuqs/src/defs.ts | 9 +++++ packages/nuqs/src/update-queue.ts | 4 ++- packages/nuqs/src/useQueryState.ts | 11 ++++-- packages/nuqs/src/useQueryStates.ts | 9 ++++- 8 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 packages/e2e/cypress/e2e/clearOnDefault.cy.js create mode 100644 packages/e2e/src/app/app/clearOnDefault/page.tsx diff --git a/packages/docs/content/docs/basic-usage.mdx b/packages/docs/content/docs/basic-usage.mdx index 60e60e43..b5083725 100644 --- a/packages/docs/content/docs/basic-usage.mdx +++ b/packages/docs/content/docs/basic-usage.mdx @@ -98,7 +98,7 @@ const clearCount = () => setCount(null) // Remove query from the URL The default value is internal to React, it will **not** be written to the - URL. + URL _unless you set it explicitly_. diff --git a/packages/docs/content/docs/options.mdx b/packages/docs/content/docs/options.mdx index 4201618d..868f975c 100644 --- a/packages/docs/content/docs/options.mdx +++ b/packages/docs/content/docs/options.mdx @@ -36,7 +36,7 @@ Call-level options will override hook level options. By default, state updates are done by replacing the current history entry with the updated query when state changes. -You can see this as a sort of `git squash`, where all state-changing +You can see this as a sort of `git squash{:shell}`, where all state-changing operations are merged into a single history value. You can also opt-in to push a new history item for each state change, @@ -64,11 +64,11 @@ _-- "With great power comes great responsibility."_ By default, query state updates are done in a _client-first_ manner: there are no network calls to the server. -This is equivalent to the `shallow` option of the Next.js router set to `true`. +This is equivalent to the `shallow` option of the Next.js router set to `true{:ts}`. To opt-in to query updates notifying the server (to re-run `getServerSideProps` in the pages router and re-render Server Components on the app router), -you can set `shallow` to `false`: +you can set `shallow` to `false{:ts}`: ```ts /shallow: false/ useQueryState('foo', { shallow: false }) @@ -99,7 +99,7 @@ Safari's rate limits are much higher and require a throttle of 120ms (320ms for versions of Safari). If you want to opt-in to a larger throttle time -- for example to reduce the amount -of requests sent to the server when paired with `shallow: false` -- you can +of requests sent to the server when paired with `shallow: false{:ts}` -- you can specify it under the `throttleMs` option: ```ts /throttleMs: 1000/ @@ -118,19 +118,19 @@ the highest value will be used. Also, values lower than 50ms will be ignored, to avoid rate-limiting issues. [Read more](https://francoisbest.com/posts/2023/storing-react-state-in-the-url-with-nextjs#batching--throttling). -Specifying a `+Infinity` value for `throttleMs` will **disable** updates to the +Specifying a `+Infinity{:ts}` value for `throttleMs` will **disable** updates to the URL or the server, which means `useQueryState` will behave essentially like `React.useState`. ## Transitions -When combined with `shallow: false`, you can use React's `useTransition` hook +When combined with `shallow: false{:ts}`, you can use React's `useTransition` hook to get loading states while the server is re-rendering server components with the updated URL. Pass in the `startTransition` function from `useTransition` to the options -to enable this behaviour _(this will set `shallow: false` automatically for you)_: +to enable this behaviour _(this will set `shallow: false{:ts}` automatically for you)_: ```tsx /startTransition/1,3#2 'use client' @@ -156,3 +156,23 @@ function ClientComponent({ data }) { return
...
} ``` + +## Clear on default + +By default, when the state is set to the default value, the search parameter is +**not** removed from the URL, and is reflected explicitly. This is because +**default values _can_ change**, and the meaning of the URL along with it. + +If you want to remove the search parameter from the URL when it's set to the default +value, you can set `clearOnDefault` to `true{:ts}`: + +```ts /clearOnDefault: true/ +useQueryState('search', { + defaultValue: '', + clearOnDefault: true +}) +``` + + + Clearing the key-value pair from the query string can always be done by setting the state to `null{:ts}`. + diff --git a/packages/e2e/cypress/e2e/clearOnDefault.cy.js b/packages/e2e/cypress/e2e/clearOnDefault.cy.js new file mode 100644 index 00000000..189bfbae --- /dev/null +++ b/packages/e2e/cypress/e2e/clearOnDefault.cy.js @@ -0,0 +1,8 @@ +/// + +it('Clears the URL when setting the default value when `clearOnDefault` is used', () => { + cy.visit('/app/clearOnDefault?a=a&b=b') + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('button').click() + cy.location('search').should('eq', '?a=') +}) diff --git a/packages/e2e/src/app/app/clearOnDefault/page.tsx b/packages/e2e/src/app/app/clearOnDefault/page.tsx new file mode 100644 index 00000000..652ed281 --- /dev/null +++ b/packages/e2e/src/app/app/clearOnDefault/page.tsx @@ -0,0 +1,32 @@ +'use client' + +import { useQueryState } from 'nuqs' +import { Suspense } from 'react' + +export default function Page() { + return ( + + + + ) +} + +function Client() { + const [, setA] = useQueryState('a') + const [, setB] = useQueryState('b', { + defaultValue: '', + clearOnDefault: true + }) + return ( + <> + + + ) +} diff --git a/packages/nuqs/src/defs.ts b/packages/nuqs/src/defs.ts index 13a1ff2c..7e46cb06 100644 --- a/packages/nuqs/src/defs.ts +++ b/packages/nuqs/src/defs.ts @@ -60,6 +60,15 @@ export type Options = { * in the same Options object. */ startTransition?: StartTransition + + /** + * Clear the key-value pair from the URL query string when setting the state + * to the default value. + * + * Defaults to `false` to keep backwards-compatiblity when the default value + * changes (prefer explicit URLs whose meaning don't change). + */ + clearOnDefault?: boolean } export type Nullable = { diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index c16a34a6..d4616e0b 100644 --- a/packages/nuqs/src/update-queue.ts +++ b/packages/nuqs/src/update-queue.ts @@ -10,7 +10,9 @@ export const FLUSH_RATE_LIMIT_MS = getDefaultThrottle() type UpdateMap = Map const updateQueue: UpdateMap = new Map() -const queueOptions: Required> = { +const queueOptions: Required< + Omit +> = { history: 'replace', scroll: false, shallow: true, diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 0a24c576..f5f33626 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -205,6 +205,7 @@ export function useQueryState( parse = x => x as unknown as T, serialize = String, defaultValue = undefined, + clearOnDefault = false, startTransition }: Partial> & { defaultValue?: T @@ -215,6 +216,7 @@ export function useQueryState( throttleMs: FLUSH_RATE_LIMIT_MS, parse: x => x as unknown as T, serialize: String, + clearOnDefault: false, defaultValue: undefined } ) { @@ -278,10 +280,15 @@ export function useQueryState( const update = React.useCallback( (stateUpdater: React.SetStateAction, options: Options = {}) => { - const newValue: T | null = isUpdaterFunction(stateUpdater) + let newValue: T | null = isUpdaterFunction(stateUpdater) ? stateUpdater(stateRef.current ?? defaultValue ?? null) : stateUpdater - + if ( + (options.clearOnDefault || clearOnDefault) && + newValue === defaultValue + ) { + newValue = null + } // Sync all hooks state (including this one) emitter.emit(key, newValue) enqueueQueryStringUpdate(key, newValue, serialize, { diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 19c480ec..cf26d1d1 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -63,6 +63,7 @@ export function useQueryStates( scroll = false, shallow = true, throttleMs = FLUSH_RATE_LIMIT_MS, + clearOnDefault = false, startTransition }: Partial = {} ): UseQueryStatesReturn { @@ -145,11 +146,17 @@ export function useQueryStates( ? stateUpdater(stateRef.current) : stateUpdater debug('[nuq+ `%s`] setState: %O', keys, newState) - for (const [key, value] of Object.entries(newState)) { + for (let [key, value] of Object.entries(newState)) { const config = keyMap[key] if (!config) { continue } + if ( + (options.clearOnDefault || clearOnDefault) && + value === config.defaultValue + ) { + value = null + } emitter.emit(key, value) enqueueQueryStringUpdate(key, value, config.serialize ?? String, { // Call-level options take precedence over hook declaration options.