diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 5f8d0f931..91f56ba21 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -196,12 +196,12 @@ { "name": "Client", "path": "dist/index.js", - "limit": "6 kB" + "limit": "6.06 kB" }, { "name": "Client (minimal tree-shaken)", "path": "dist/index.js", - "limit": "4.5 kB", + "limit": "4.51 kB", "import": "{ useQueryStates, parseAsInteger }" }, { diff --git a/packages/nuqs/src/adapters/lib/context.ts b/packages/nuqs/src/adapters/lib/context.ts index f53e851e6..a56be161f 100644 --- a/packages/nuqs/src/adapters/lib/context.ts +++ b/packages/nuqs/src/adapters/lib/context.ts @@ -14,8 +14,11 @@ import type { AdapterInterface, UseAdapterHook } from './defs' export type AdapterProps = { defaultOptions?: Partial< - Pick - > + Pick + > & { + /** @deprecated use `writeDefaults` instead */ + clearOnDefault?: Options['clearOnDefault'] + } processUrlSearchParams?: (search: URLSearchParams) => URLSearchParams } diff --git a/packages/nuqs/src/defs.ts b/packages/nuqs/src/defs.ts index 80b4c204a..fda69b2a7 100644 --- a/packages/nuqs/src/defs.ts +++ b/packages/nuqs/src/defs.ts @@ -85,8 +85,20 @@ export type Options = { * * Set it to `false` to keep backwards-compatiblity when the default value * changes (prefer explicit URLs whose meaning don't change). + * + * @deprecated use `writeDefaults` instead */ clearOnDefault?: boolean + + /** + * Indicates whether the key-value pair should be written to the URL query string + * when setting the state to the default value. + * + * Defaults to `false` to keep URLs clean. + * + * Set it to `true` to always write default values to the URL query string. + */ + writeDefaults?: boolean } export type Nullable = { diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 72f15e122..6114b004a 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -22,7 +22,7 @@ export type SingleParser = { /** * Check if two state values are equal. * - * This is used when using the `clearOnDefault` value, to compare the default + * This is used when using the `writeDefaults` value, to compare the default * value with the set value. * * It makes sense to provide this function when the state value is an object @@ -66,7 +66,7 @@ export type SingleParserBuilder = Required> & * of `null`. * * Setting the state to the default value¹ will clear the query string key - * from the URL, unless `clearOnDefault` is set to `false`. + * from the URL, unless `writeDefaults` is set to `true`. * * Setting the state to `null` will always clear the query string key * from the URL, and return the default value. diff --git a/packages/nuqs/src/serializer.test.ts b/packages/nuqs/src/serializer.test.ts index 2b5b82e4d..8261bf7ce 100644 --- a/packages/nuqs/src/serializer.test.ts +++ b/packages/nuqs/src/serializer.test.ts @@ -179,6 +179,66 @@ describe('serializer', () => { }) expect(result).toBe('?str=') }) + it('keeps value when setting the default value when `writeDefaults: true`', () => { + const options: Options = { writeDefaults: true } + const serialize = createSerializer({ + int: parseAsInteger.withOptions(options).withDefault(0), + str: parseAsString.withOptions(options).withDefault(''), + bool: parseAsBoolean.withOptions(options).withDefault(false), + arr: parseAsArrayOf(parseAsString).withOptions(options).withDefault([]), + json: parseAsJson(x => x) + .withOptions(options) + .withDefault({ foo: 'bar' }) + }) + const result = serialize({ + int: 0, + str: '', + bool: false, + arr: [], + json: { foo: 'bar' } + }) + expect(result).toBe( + '?int=0&str=&bool=false&arr=&json={%22foo%22:%22bar%22}' + ) + }) + it('writeDefaults takes precedence over clearOnDefault when both are provided', () => { + const options: Options = { writeDefaults: true, clearOnDefault: true } + const serialize = createSerializer({ + int: parseAsInteger.withOptions(options).withDefault(0), + str: parseAsString.withOptions(options).withDefault(''), + bool: parseAsBoolean.withOptions(options).withDefault(false), + arr: parseAsArrayOf(parseAsString).withOptions(options).withDefault([]), + json: parseAsJson(x => x) + .withOptions(options) + .withDefault({ foo: 'bar' }) + }) + const result = serialize({ + int: 0, + str: '', + bool: false, + arr: [], + json: { foo: 'bar' } + }) + expect(result).toBe( + '?int=0&str=&bool=false&arr=&json={%22foo%22:%22bar%22}' + ) + }) + it('gives precedence to parser writeDefaults over global writeDefaults', () => { + const serialize = createSerializer( + { + int: parseAsInteger + .withDefault(0) + .withOptions({ writeDefaults: false }), + str: parseAsString.withDefault('') + }, + { writeDefaults: true } + ) + const result = serialize({ + int: 0, + str: '' + }) + expect(result).toBe('?str=') + }) it('supports urlKeys', () => { const serialize = createSerializer(parsers, { urlKeys: { diff --git a/packages/nuqs/src/serializer.ts b/packages/nuqs/src/serializer.ts index 3e9817497..4d3718ff3 100644 --- a/packages/nuqs/src/serializer.ts +++ b/packages/nuqs/src/serializer.ts @@ -5,13 +5,13 @@ import { write } from './lib/search-params' type Base = string | URLSearchParams | URL -export type CreateSerializerOptions = Pick< - Options, - 'clearOnDefault' -> & { - urlKeys?: UrlKeys - processUrlSearchParams?: (searchParams: URLSearchParams) => URLSearchParams -} +export type CreateSerializerOptions = { + /** @deprecated use 'writeDefaults' instead */ + clearOnDefault?: Options['clearOnDefault'] +} & Pick & { + urlKeys?: UrlKeys + processUrlSearchParams?: (searchParams: URLSearchParams) => URLSearchParams + } type SerializeFunction< Parsers extends ParserMap, @@ -45,11 +45,14 @@ export function createSerializer< { clearOnDefault = true, urlKeys = {}, - processUrlSearchParams + processUrlSearchParams, + ...options }: CreateSerializerOptions = {} ): SerializeFunction { type Values = Partial>> + const optionWriteDefaults = options.writeDefaults ?? !clearOnDefault + /** * Generate a query string for the given values. */ @@ -92,10 +95,13 @@ export function createSerializer< parser.defaultValue !== undefined && (parser.eq ?? ((a, b) => a === b))(value, parser.defaultValue) - if ( - value === null || - ((parser.clearOnDefault ?? clearOnDefault ?? true) && isMatchingDefault) - ) { + const writeDefaults = + parser.writeDefaults ?? + (parser.clearOnDefault === undefined + ? optionWriteDefaults + : !parser.clearOnDefault) + + if (value === null || (!writeDefaults && isMatchingDefault)) { search.delete(urlKey) } else { const serialized = parser.serialize(value) diff --git a/packages/nuqs/src/useQueryState.test.tsx b/packages/nuqs/src/useQueryState.test.tsx index 59e77e110..246a68f10 100644 --- a/packages/nuqs/src/useQueryState.test.tsx +++ b/packages/nuqs/src/useQueryState.test.tsx @@ -1,4 +1,10 @@ -import { act, render, renderHook, screen } from '@testing-library/react' +import { + act, + render, + renderHook, + screen, + waitFor +} from '@testing-library/react' import userEvent from '@testing-library/user-event' import React, { useState } from 'react' import { describe, expect, it, vi } from 'vitest' @@ -187,6 +193,120 @@ describe('useQueryState: clearOnDefault', () => { }) }) +describe('useQueryState: writeDefaults', () => { + it('honors writeDefaults: false by default', async () => { + const onUrlUpdate = vi.fn() + const { result } = renderHook( + () => useQueryState('test', parseAsString.withDefault('default')), + { + wrapper: withNuqsTestingAdapter({ + searchParams: '?test=init', + onUrlUpdate + }) + } + ) + await act(() => result.current[1]('default')) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('') + }) + + it('writeDefaults: true auto-updates URL', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryState( + 'a', + parseAsString.withDefault('default').withOptions({ + writeDefaults: true + }) + ) + renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + onUrlUpdate + }) + }) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalledOnce()) + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=default') + }) + + it('supports writeDefaults: true (hook level)', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryState( + 'a', + parseAsString.withDefault('default').withOptions({ + writeDefaults: true + }) + ) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init', + onUrlUpdate + }) + }) + await act(() => result.current[1]('default')) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=default') + }) + + it('supports writeDefaults: true (defaultOptions on Provider level)', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryState('a', parseAsString.withDefault('default')) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init', + defaultOptions: { + writeDefaults: true + }, + onUrlUpdate + }) + }) + await act(() => result.current[1]('default')) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=default') + }) + + it('supports writeDefaults: true (call level)', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryState( + 'a', + parseAsString.withDefault('default').withOptions({ + writeDefaults: false + }) + ) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init', + onUrlUpdate + }) + }) + await act(() => result.current[1]('default', { writeDefaults: true })) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=default') + }) + + it('forces clearOnDefault: false when set to true', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryState( + 'a', + parseAsString.withDefault('default').withOptions({ + clearOnDefault: true, + writeDefaults: true + }) + ) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + onUrlUpdate + }) + }) + await act(() => result.current[1]('default')) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=default') + }) +}) + describe('useQueryState: update sequencing', () => { it('should combine updates for a single key made in the same event loop tick', async () => { const onUrlUpdate = vi.fn() diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx index c0bbcde43..f05684f00 100644 --- a/packages/nuqs/src/useQueryStates.test.tsx +++ b/packages/nuqs/src/useQueryStates.test.tsx @@ -1,4 +1,10 @@ -import { act, render, renderHook, screen } from '@testing-library/react' +import { + act, + render, + renderHook, + screen, + waitFor +} from '@testing-library/react' import userEvent from '@testing-library/user-event' import React, { createElement, @@ -409,6 +415,126 @@ describe('useQueryStates: clearOnDefault', () => { }) }) +describe('useQueryStates: writeDefaults', () => { + it('honors writeDefaults: false by default', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates({ + test: parseAsString.withDefault('default') + }) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?test=init', + onUrlUpdate + }) + }) + await act(() => result.current[1]({ test: 'default' })) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('') + }) + + it('supports writeDefaults: true (parser level)', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates({ + a: parseAsString.withDefault('default').withOptions({ + writeDefaults: true + }), + b: parseAsString.withDefault('default') + }) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init&b=init', + onUrlUpdate + }) + }) + await act(() => result.current[1]({ a: 'default', b: 'default' })) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=default') + }) + + it('supports writeDefaults: true (hook level)', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates( + { + a: parseAsString.withDefault('default'), + b: parseAsString.withDefault('default').withOptions({ + writeDefaults: false // overrides hook options + }) + }, + { + writeDefaults: true + } + ) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init&b=init', + onUrlUpdate + }) + }) + await act(() => result.current[1]({ a: 'default', b: 'default' })) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=default') + }) + + it('supports writeDefault: true (call level)', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates( + { + a: parseAsString.withDefault('default'), + b: parseAsString.withDefault('default').withOptions({ + writeDefaults: false // overrides hook options + }) + }, + { + writeDefaults: true + } + ) + const { result } = renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + searchParams: '?a=init&b=init', + onUrlUpdate + }) + }) + await act(() => + result.current[1]( + { a: 'default', b: 'default' }, + { + writeDefaults: false + } + ) + ) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('') + }) + + it('writeDefaults: true auto-updates URL', async () => { + const onUrlUpdate = vi.fn() + const useTestHook = () => + useQueryStates( + { + a: parseAsString.withDefault('default'), + b: parseAsString.withDefault('default').withOptions({ + writeDefaults: false // overrides hook options + }) + }, + { + writeDefaults: true + } + ) + renderHook(useTestHook, { + wrapper: withNuqsTestingAdapter({ + onUrlUpdate + }) + }) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalledOnce()) + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?a=default') + }) +}) + describe('useQueryStates: dynamic keys', () => { it('supports dynamic keys', () => { const useTestHook = (keys: [string, string] = ['a', 'b']) => diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 7734f7b02..d925eb0e6 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -83,11 +83,13 @@ export function useQueryStates( shallow = defaultOptions?.shallow ?? true, throttleMs = defaultRateLimit.timeMs, limitUrlUpdates = defaultOptions?.limitUrlUpdates, - clearOnDefault = defaultOptions?.clearOnDefault ?? true, startTransition, urlKeys = defaultUrlKeys as UrlKeys } = options + const optionWriteDefaults = + options.writeDefaults ?? defaultOptions?.writeDefaults ?? false + type V = NullableValues const stateKeys = Object.keys(keyMap).join(',') const resolvedUrlKeys = useMemo( @@ -290,10 +292,26 @@ export function useQueryStates( if (!parser) { continue } + + let clearOnDefault = + callOptions.clearOnDefault ?? + parser.clearOnDefault ?? + options.clearOnDefault ?? + defaultOptions?.clearOnDefault ?? + true + + const writeDefaults = + callOptions.writeDefaults ?? + parser.writeDefaults ?? + optionWriteDefaults + + if (writeDefaults) { + clearOnDefault = false + } + if ( - (callOptions.clearOnDefault ?? - parser.clearOnDefault ?? - clearOnDefault) && + clearOnDefault && + !writeDefaults && value !== null && parser.defaultValue !== undefined && (parser.eq ?? ((a, b) => a === b))(value, parser.defaultValue) @@ -380,6 +398,17 @@ export function useQueryStates( ] ) + const anyParserWriteDefaults = Object.values(keyMap).some( + v => v.writeDefaults + ) + + // effect to write defaults to the url on mount + useEffect(() => { + if (optionWriteDefaults || anyParserWriteDefaults) { + void update(s => s, { history: 'replace' }) + } + }, [update, optionWriteDefaults, anyParserWriteDefaults]) + const outputState = useMemo( () => applyDefaultValues(internalState, defaultValues), [internalState, defaultValues]