From b23a045064dd55ec8a48fb798670c9bfb9189497 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sat, 10 Feb 2024 09:10:01 +0100 Subject: [PATCH 1/4] feat: Add "clear on default" option --- packages/nuqs/src/defs.ts | 2 ++ packages/nuqs/src/update-queue.ts | 4 +++- packages/nuqs/src/useQueryState.ts | 11 +++++++++-- packages/nuqs/src/useQueryStates.ts | 9 ++++++++- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/nuqs/src/defs.ts b/packages/nuqs/src/defs.ts index 13a1ff2c9..65c12df03 100644 --- a/packages/nuqs/src/defs.ts +++ b/packages/nuqs/src/defs.ts @@ -60,6 +60,8 @@ export type Options = { * in the same Options object. */ startTransition?: StartTransition + + clearOnDefault?: boolean } export type Nullable = { diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index c16a34a64..d4616e0bf 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 0a24c5761..f5f336266 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 19c480ecb..cf26d1d12 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. From feace3ed22244bd7d253710fe0fea2a0d2a34036 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sat, 10 Feb 2024 09:19:50 +0100 Subject: [PATCH 2/4] test: Add E2E test for clearOnDefault --- packages/e2e/cypress/e2e/clearOnDefault.cy.js | 8 +++++ .../e2e/src/app/app/clearOnDefault/page.tsx | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+) 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/e2e/cypress/e2e/clearOnDefault.cy.js b/packages/e2e/cypress/e2e/clearOnDefault.cy.js new file mode 100644 index 000000000..189bfbaef --- /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 000000000..652ed2810 --- /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 ( + <> + + + ) +} From bad415bd290152cdb15d2268febc835ce136c18b Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sat, 10 Feb 2024 09:29:27 +0100 Subject: [PATCH 3/4] doc: Add clear on default docs --- packages/docs/content/docs/basic-usage.mdx | 2 +- packages/docs/content/docs/options.mdx | 34 +++++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/docs/content/docs/basic-usage.mdx b/packages/docs/content/docs/basic-usage.mdx index 60e60e433..b50837253 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 4201618d8..868f975c8 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}`. + From e0fb8a5dd9ad70cfd9f0019d78bf0fa60d98a216 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sun, 11 Feb 2024 11:18:16 +0100 Subject: [PATCH 4/4] doc: Add JSDoc for new option --- packages/nuqs/src/defs.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/nuqs/src/defs.ts b/packages/nuqs/src/defs.ts index 65c12df03..7e46cb06b 100644 --- a/packages/nuqs/src/defs.ts +++ b/packages/nuqs/src/defs.ts @@ -61,6 +61,13 @@ export type Options = { */ 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 }