Skip to content

Commit 7e8c6a5

Browse files
committed
fix: Equality check for non-primitives in clearOnDefault
1 parent 6d4ff9b commit 7e8c6a5

File tree

7 files changed

+71
-8
lines changed

7 files changed

+71
-8
lines changed

packages/e2e/cypress/e2e/clearOnDefault.cy.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/// <reference types="cypress" />
22

33
it('Clears the URL when setting the default value when `clearOnDefault` is used', () => {
4-
cy.visit('/app/clearOnDefault?a=a&b=b')
4+
cy.visit(
5+
'/app/clearOnDefault?a=a&b=b&array=1,2,3&json-ref={"egg":"spam"}&json-new={"egg":"spam"}'
6+
)
57
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
68
cy.get('button').click()
79
cy.location('search').should('eq', '?a=')

packages/e2e/src/app/app/clearOnDefault/page.tsx

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
'use client'
22

3-
import { useQueryState } from 'nuqs'
3+
import {
4+
parseAsArrayOf,
5+
parseAsInteger,
6+
parseAsJson,
7+
useQueryState
8+
} from 'nuqs'
49
import { Suspense } from 'react'
510

611
export default function Page() {
@@ -11,18 +16,37 @@ export default function Page() {
1116
)
1217
}
1318

19+
const defaultJSON = { foo: 'bar' }
20+
1421
function Client() {
1522
const [, setA] = useQueryState('a')
1623
const [, setB] = useQueryState('b', {
1724
defaultValue: '',
1825
clearOnDefault: true
1926
})
27+
const [, setArray] = useQueryState(
28+
'array',
29+
parseAsArrayOf(parseAsInteger)
30+
.withDefault([])
31+
.withOptions({ clearOnDefault: true })
32+
)
33+
const [, setJsonRef] = useQueryState(
34+
'json-ref',
35+
parseAsJson().withDefault(defaultJSON).withOptions({ clearOnDefault: true })
36+
)
37+
const [, setJsonNew] = useQueryState(
38+
'json-new',
39+
parseAsJson().withDefault(defaultJSON).withOptions({ clearOnDefault: true })
40+
)
2041
return (
2142
<>
2243
<button
2344
onClick={() => {
2445
setA('')
2546
setB('')
47+
setArray([])
48+
setJsonRef(defaultJSON)
49+
setJsonNew({ ...defaultJSON })
2650
}}
2751
>
2852
Clear

packages/nuqs/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
{
9292
"name": "Client (ESM)",
9393
"path": "dist/index.js",
94-
"limit": "4 kB",
94+
"limit": "5 kB",
9595
"ignore": [
9696
"react"
9797
]

packages/nuqs/src/parsers.test.ts

+11
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,14 @@ describe('parsers', () => {
9696
expect(p.parseServerSide(undefined)).toBe('bar')
9797
})
9898
})
99+
100+
describe('parsers/equality', () => {
101+
test('parseAsArrayOf', () => {
102+
const eq = parseAsArrayOf(parseAsString).eq!
103+
expect(eq([], [])).toBe(true)
104+
expect(eq(['foo'], ['foo'])).toBe(true)
105+
expect(eq(['foo', 'bar'], ['foo', 'bar'])).toBe(true)
106+
expect(eq([], ['foo'])).toBe(false)
107+
expect(eq(['foo'], ['bar'])).toBe(false)
108+
})
109+
})

packages/nuqs/src/parsers.ts

+23-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import type { Options } from './defs'
22
import { safeParse } from './utils'
33

4+
type Require<T, Keys extends keyof T> = Pick<Required<T>, Keys> & Omit<T, Keys>
5+
46
export type Parser<T> = {
57
parse: (value: string) => T | null
68
serialize?: (value: T) => string
9+
eq?: (a: T, b: T) => boolean
710
}
811

912
export type ParserBuilder<T> = Required<Parser<T>> &
@@ -70,7 +73,9 @@ export type ParserBuilder<T> = Required<Parser<T>> &
7073
* Wrap a set of parse/serialize functions into a builder pattern parser
7174
* you can pass to one of the hooks, making its default value type safe.
7275
*/
73-
export function createParser<T>(parser: Required<Parser<T>>): ParserBuilder<T> {
76+
export function createParser<T>(
77+
parser: Require<Parser<T>, 'parse' | 'serialize'>
78+
): ParserBuilder<T> {
7479
function parseServerSideNullable(value: string | string[] | undefined) {
7580
if (typeof value === 'undefined') {
7681
return null
@@ -91,6 +96,7 @@ export function createParser<T>(parser: Required<Parser<T>>): ParserBuilder<T> {
9196
}
9297

9398
return {
99+
eq: (a, b) => a === b,
94100
...parser,
95101
parseServerSide: parseServerSideNullable,
96102
withDefault(defaultValue) {
@@ -318,7 +324,11 @@ export function parseAsJson<T>(parser?: (value: unknown) => T) {
318324
return null
319325
}
320326
},
321-
serialize: value => JSON.stringify(value)
327+
serialize: value => JSON.stringify(value),
328+
eq(a, b) {
329+
// Check for referential equality first
330+
return a === b || JSON.stringify(a) === JSON.stringify(b)
331+
}
322332
})
323333
}
324334

@@ -333,6 +343,7 @@ export function parseAsArrayOf<ItemType>(
333343
itemParser: Parser<ItemType>,
334344
separator = ','
335345
) {
346+
const itemEq = itemParser.eq ?? ((a: ItemType, b: ItemType) => a === b)
336347
const encodedSeparator = encodeURIComponent(separator)
337348
// todo: Handle default item values and make return type non-nullable
338349
return createParser({
@@ -361,6 +372,15 @@ export function parseAsArrayOf<ItemType>(
361372
: String(value)
362373
return str.replaceAll(separator, encodedSeparator)
363374
})
364-
.join(separator)
375+
.join(separator),
376+
eq(a, b) {
377+
if (a === b) {
378+
return true // Referentially stable
379+
}
380+
if (a.length !== b.length) {
381+
return false
382+
}
383+
return a.every((value, index) => itemEq(value, b[index]!))
384+
}
365385
})
366386
}

packages/nuqs/src/useQueryState.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export function useQueryState<T = string>(
204204
throttleMs = FLUSH_RATE_LIMIT_MS,
205205
parse = x => x as unknown as T,
206206
serialize = String,
207+
eq = (a, b) => a === b,
207208
defaultValue = undefined,
208209
clearOnDefault = false,
209210
startTransition
@@ -216,6 +217,7 @@ export function useQueryState<T = string>(
216217
throttleMs: FLUSH_RATE_LIMIT_MS,
217218
parse: x => x as unknown as T,
218219
serialize: String,
220+
eq: (a, b) => a === b,
219221
clearOnDefault: false,
220222
defaultValue: undefined
221223
}
@@ -285,7 +287,9 @@ export function useQueryState<T = string>(
285287
: stateUpdater
286288
if (
287289
(options.clearOnDefault || clearOnDefault) &&
288-
newValue === defaultValue
290+
newValue !== null &&
291+
defaultValue !== undefined &&
292+
eq(newValue, defaultValue)
289293
) {
290294
newValue = null
291295
}

packages/nuqs/src/useQueryStates.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,9 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
153153
}
154154
if (
155155
(options.clearOnDefault || clearOnDefault) &&
156-
value === config.defaultValue
156+
value !== null &&
157+
config.defaultValue !== undefined &&
158+
(config.eq ?? ((a, b) => a === b))(value, config.defaultValue)
157159
) {
158160
value = null
159161
}

0 commit comments

Comments
 (0)