Skip to content

Commit e8c9720

Browse files
authoredMar 2, 2024
fix: Equality check for non-primitives in clearOnDefault (#504)
* fix: Equality check for non-primitives in clearOnDefault * doc: Add JSDoc for the Parser type
1 parent dea5afa commit e8c9720

File tree

7 files changed

+91
-8
lines changed

7 files changed

+91
-8
lines changed
 

Diff for: ‎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=')

Diff for: ‎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

Diff for: ‎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
]

Diff for: ‎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+
})

Diff for: ‎packages/nuqs/src/parsers.ts

+43-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
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> = {
7+
/**
8+
* Convert a query string value into a state value.
9+
*
10+
* If the string value does not represent a valid state value,
11+
* the parser should return `null`. Throwing an error is also supported.
12+
*/
513
parse: (value: string) => T | null
14+
15+
/**
16+
* Render the state value into a query string value.
17+
*/
618
serialize?: (value: T) => string
19+
20+
/**
21+
* Check if two state values are equal.
22+
*
23+
* This is used when using the `clearOnDefault` value, to compare the default
24+
* value with the set value.
25+
*
26+
* It makes sense to provide this function when the state value is an object
27+
* or an array, as the default referential equality check will not work.
28+
*/
29+
eq?: (a: T, b: T) => boolean
730
}
831

932
export type ParserBuilder<T> = Required<Parser<T>> &
@@ -70,7 +93,9 @@ export type ParserBuilder<T> = Required<Parser<T>> &
7093
* Wrap a set of parse/serialize functions into a builder pattern parser
7194
* you can pass to one of the hooks, making its default value type safe.
7295
*/
73-
export function createParser<T>(parser: Required<Parser<T>>): ParserBuilder<T> {
96+
export function createParser<T>(
97+
parser: Require<Parser<T>, 'parse' | 'serialize'>
98+
): ParserBuilder<T> {
7499
function parseServerSideNullable(value: string | string[] | undefined) {
75100
if (typeof value === 'undefined') {
76101
return null
@@ -91,6 +116,7 @@ export function createParser<T>(parser: Required<Parser<T>>): ParserBuilder<T> {
91116
}
92117

93118
return {
119+
eq: (a, b) => a === b,
94120
...parser,
95121
parseServerSide: parseServerSideNullable,
96122
withDefault(defaultValue) {
@@ -318,7 +344,11 @@ export function parseAsJson<T>(parser?: (value: unknown) => T) {
318344
return null
319345
}
320346
},
321-
serialize: value => JSON.stringify(value)
347+
serialize: value => JSON.stringify(value),
348+
eq(a, b) {
349+
// Check for referential equality first
350+
return a === b || JSON.stringify(a) === JSON.stringify(b)
351+
}
322352
})
323353
}
324354

@@ -333,6 +363,7 @@ export function parseAsArrayOf<ItemType>(
333363
itemParser: Parser<ItemType>,
334364
separator = ','
335365
) {
366+
const itemEq = itemParser.eq ?? ((a: ItemType, b: ItemType) => a === b)
336367
const encodedSeparator = encodeURIComponent(separator)
337368
// todo: Handle default item values and make return type non-nullable
338369
return createParser({
@@ -361,6 +392,15 @@ export function parseAsArrayOf<ItemType>(
361392
: String(value)
362393
return str.replaceAll(separator, encodedSeparator)
363394
})
364-
.join(separator)
395+
.join(separator),
396+
eq(a, b) {
397+
if (a === b) {
398+
return true // Referentially stable
399+
}
400+
if (a.length !== b.length) {
401+
return false
402+
}
403+
return a.every((value, index) => itemEq(value, b[index]!))
404+
}
365405
})
366406
}

Diff for: ‎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
}

Diff for: ‎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)