Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }"
},
{
Expand Down
7 changes: 5 additions & 2 deletions packages/nuqs/src/adapters/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import type { AdapterInterface, UseAdapterHook } from './defs'

export type AdapterProps = {
defaultOptions?: Partial<
Pick<Options, 'shallow' | 'clearOnDefault' | 'scroll' | 'limitUrlUpdates'>
>
Pick<Options, 'shallow' | 'writeDefaults' | 'scroll' | 'limitUrlUpdates'>
> & {
/** @deprecated use `writeDefaults` instead */
clearOnDefault?: Options['clearOnDefault']
}
processUrlSearchParams?: (search: URLSearchParams) => URLSearchParams
}

Expand Down
12 changes: 12 additions & 0 deletions packages/nuqs/src/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: we should add a migration note here that the boolean logic is inverted: clearOnDefault: falsewriteDefaults: true.

*/
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<T> = {
Expand Down
4 changes: 2 additions & 2 deletions packages/nuqs/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type SingleParser<T> = {
/**
* 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
Expand Down Expand Up @@ -66,7 +66,7 @@ export type SingleParserBuilder<T> = Required<SingleParser<T>> &
* 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.
Expand Down
60 changes: 60 additions & 0 deletions packages/nuqs/src/serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
30 changes: 18 additions & 12 deletions packages/nuqs/src/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { write } from './lib/search-params'

type Base = string | URLSearchParams | URL

export type CreateSerializerOptions<Parsers extends ParserMap> = Pick<
Options,
'clearOnDefault'
> & {
urlKeys?: UrlKeys<Parsers>
processUrlSearchParams?: (searchParams: URLSearchParams) => URLSearchParams
}
export type CreateSerializerOptions<Parsers extends ParserMap> = {
/** @deprecated use 'writeDefaults' instead */
clearOnDefault?: Options['clearOnDefault']
} & Pick<Options, 'writeDefaults'> & {
urlKeys?: UrlKeys<Parsers>
processUrlSearchParams?: (searchParams: URLSearchParams) => URLSearchParams
}

type SerializeFunction<
Parsers extends ParserMap,
Expand Down Expand Up @@ -45,11 +45,14 @@ export function createSerializer<
{
clearOnDefault = true,
urlKeys = {},
processUrlSearchParams
processUrlSearchParams,
...options
}: CreateSerializerOptions<Parsers> = {}
): SerializeFunction<Parsers, BaseType, Return> {
type Values = Partial<Nullable<inferParserType<Parsers>>>

const optionWriteDefaults = options.writeDefaults ?? !clearOnDefault

/**
* Generate a query string for the given values.
*/
Expand Down Expand Up @@ -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)
Expand Down
122 changes: 121 additions & 1 deletion packages/nuqs/src/useQueryState.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -187,6 +193,120 @@ describe('useQueryState: clearOnDefault', () => {
})
})

describe('useQueryState: writeDefaults', () => {
it('honors writeDefaults: false by default', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
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<OnUrlUpdateFunction>()
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<OnUrlUpdateFunction>()
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<OnUrlUpdateFunction>()
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<OnUrlUpdateFunction>()
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<OnUrlUpdateFunction>()
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<OnUrlUpdateFunction>()
Expand Down
Loading
Loading