Skip to content

Commit 6f22280

Browse files
authored
fix: Ensure referential stability for values (#617)
* test: Making sure it fails beforehand * fix: Ensure referential stablity * ref: Carry state & serialized query across hook sync So that internal refs can be synced together without calling the serializer on each reception site. * doc: Add caveat about multiple parsers on the same key
1 parent 396c5a9 commit 6f22280

File tree

7 files changed

+226
-34
lines changed

7 files changed

+226
-34
lines changed

packages/docs/content/docs/troubleshooting.mdx

+29
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,32 @@ Because the Next.js **pages router** is not available in an SSR context, this
1313
hook will always return `null` (or the default value if supplied) on SSR/SSG.
1414

1515
This limitation doesn't apply to the app router.
16+
17+
## Caveats
18+
19+
### Different parsers on the same key
20+
21+
Hooks are synced together on a per-key bassis, so if you use different parsers
22+
on the same key, the last state update will be propagated to all other hooks
23+
using that key. It can lead to unexpected states like this:
24+
25+
```ts
26+
const [int] = useQueryState('foo', parseAsInteger)
27+
const [float, setFloat] = useQueryState('foo', parseAsFloat)
28+
29+
setFloat(1.234)
30+
31+
// `int` is now 1.234, instead of 1
32+
```
33+
34+
We recommend you abstract a key/parser pair into a dedicated hook to avoid this,
35+
and derive any desired state from the value:
36+
37+
```ts
38+
function useIntFloat() {
39+
const [float, setFloat] = useQueryState('foo', parseAsFloat)
40+
const int = Math.floor(float)
41+
return [{int, float}, setFloat] as const
42+
}
43+
```
44+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/// <reference types="cypress" />
2+
3+
it('Referential equality', () => {
4+
cy.visit('/app/referential-equality')
5+
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
6+
cy.get('#ref-a').should('have.text', '1')
7+
cy.get('#ref-b').should('have.text', '1')
8+
cy.get('#increment-a').click()
9+
cy.get('#ref-a').should('have.text', '2')
10+
cy.get('#ref-b').should('have.text', '1')
11+
cy.get('#increment-b').click()
12+
cy.get('#ref-a').should('have.text', '2')
13+
cy.get('#ref-b').should('have.text', '2')
14+
cy.get('#idempotent-a').click()
15+
cy.get('#ref-a').should('have.text', '2')
16+
cy.get('#ref-b').should('have.text', '2')
17+
cy.get('#idempotent-b').click()
18+
cy.get('#ref-a').should('have.text', '2')
19+
cy.get('#ref-b').should('have.text', '2')
20+
cy.get('#clear-a').click()
21+
cy.get('#ref-a').should('have.text', '3')
22+
cy.get('#ref-b').should('have.text', '2')
23+
cy.get('#clear-b').click()
24+
cy.get('#ref-a').should('have.text', '3')
25+
cy.get('#ref-b').should('have.text', '3')
26+
cy.get('#link').click()
27+
cy.get('#ref-a').should('have.text', '3')
28+
cy.get('#ref-b').should('have.text', '3')
29+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { parseAsJson, useQueryState, useQueryStates } from 'nuqs'
5+
import { Suspense, useEffect, useState } from 'react'
6+
7+
export default function Page() {
8+
return (
9+
<Suspense>
10+
<Client />
11+
</Suspense>
12+
)
13+
}
14+
15+
const defaultValue = { x: 0 }
16+
type Value = typeof defaultValue
17+
18+
function increment(value: Value): Value {
19+
return { x: value.x + 1 }
20+
}
21+
22+
const makeLoggingSpy =
23+
(key: string) =>
24+
(value: unknown): Value => {
25+
console.log(`[%s]: Parser running with value %O`, key, value)
26+
return value as Value
27+
}
28+
29+
function Client() {
30+
const [aRefCount, setARefCount] = useState(0)
31+
const [bRefCount, setBRefCount] = useState(0)
32+
const [a, setA] = useQueryState(
33+
'a',
34+
parseAsJson<Value>(makeLoggingSpy('a')).withDefault(defaultValue)
35+
)
36+
const [{ b }, setB] = useQueryStates({
37+
b: parseAsJson<Value>(makeLoggingSpy('b')).withDefault(defaultValue)
38+
})
39+
40+
useEffect(() => {
41+
setARefCount(old => old + 1)
42+
}, [a])
43+
useEffect(() => {
44+
setBRefCount(old => old + 1)
45+
}, [b])
46+
47+
return (
48+
<>
49+
<div>
50+
<button id="increment-a" onClick={() => setA(increment)}>
51+
Increment A
52+
</button>
53+
<button id="idempotent-a" onClick={() => setA(x => x)}>
54+
Itempotent A
55+
</button>
56+
<button id="clear-a" onClick={() => setA(null)}>
57+
Clear A
58+
</button>
59+
<span>
60+
Refs seen: <span id="ref-a">{aRefCount}</span>
61+
</span>
62+
</div>
63+
<div>
64+
<button
65+
id="increment-b"
66+
onClick={() =>
67+
setB(old => ({
68+
b: increment(old.b)
69+
}))
70+
}
71+
>
72+
Increment B
73+
</button>
74+
<button id="idempotent-b" onClick={() => setB(x => x)}>
75+
Itempotent B
76+
</button>
77+
<button
78+
id="clear-b"
79+
onClick={() =>
80+
setB({
81+
b: null
82+
})
83+
}
84+
>
85+
Clear B
86+
</button>
87+
<span>
88+
Refs seen: <span id="ref-b">{bRefCount}</span>
89+
</span>
90+
</div>
91+
<div>
92+
<Link href="#" id="link">
93+
Link to #
94+
</Link>
95+
</div>
96+
</>
97+
)
98+
}

packages/nuqs/src/sync.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ export type QueryUpdateNotificationArgs = {
1212
search: URLSearchParams
1313
source: QueryUpdateSource
1414
}
15+
export type CrossHookSyncPayload = {
16+
state: any
17+
query: string | null
18+
}
1519

1620
type EventMap = {
1721
[SYNC_EVENT_KEY]: URLSearchParams
1822
[NOTIFY_EVENT_KEY]: QueryUpdateNotificationArgs
19-
[key: string]: any
23+
[key: string]: CrossHookSyncPayload
2024
}
2125

2226
export const emitter = Mitt<EventMap>()

packages/nuqs/src/update-queue.ts

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export function enqueueQueryStringUpdate<Value>(
5555
options.throttleMs ?? FLUSH_RATE_LIMIT_MS,
5656
Number.isFinite(queueOptions.throttleMs) ? queueOptions.throttleMs : 0
5757
)
58+
return serializedOrNull
5859
}
5960

6061
export function getQueuedValue(key: string) {

packages/nuqs/src/useQueryState.ts

+20-10
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from 'react'
33
import { debug } from './debug'
44
import type { Options } from './defs'
55
import type { Parser } from './parsers'
6-
import { SYNC_EVENT_KEY, emitter } from './sync'
6+
import { SYNC_EVENT_KEY, emitter, type CrossHookSyncPayload } from './sync'
77
import {
88
FLUSH_RATE_LIMIT_MS,
99
enqueueQueryStringUpdate,
@@ -225,10 +225,12 @@ export function useQueryState<T = string>(
225225
const router = useRouter()
226226
// Not reactive, but available on the server and on page load
227227
const initialSearchParams = useSearchParams()
228+
const queryRef = React.useRef<string | null>(null)
228229
const [internalState, setInternalState] = React.useState<T | null>(() => {
229230
const queueValue = getQueuedValue(key)
230231
const urlValue = initialSearchParams?.get(key) ?? null
231232
const value = queueValue ?? urlValue
233+
queryRef.current = value
232234
return value === null ? null : safeParse(parse, value, key)
233235
})
234236
const stateRef = React.useRef(internalState)
@@ -245,25 +247,33 @@ export function useQueryState<T = string>(
245247
if (window.next?.version !== '14.0.3') {
246248
return
247249
}
248-
const value = initialSearchParams.get(key) ?? null
249-
const state = value === null ? null : safeParse(parse, value, key)
250+
const query = initialSearchParams.get(key) ?? null
251+
if (query === queryRef.current) {
252+
return
253+
}
254+
const state = query === null ? null : safeParse(parse, query, key)
250255
debug('[nuqs `%s`] syncFromUseSearchParams %O', key, state)
251256
stateRef.current = state
257+
queryRef.current = query
252258
setInternalState(state)
253259
}, [initialSearchParams?.get(key), key])
254260

255261
// Sync all hooks together & with external URL changes
256262
React.useInsertionEffect(() => {
257-
function updateInternalState(state: T | null) {
263+
function updateInternalState({ state, query }: CrossHookSyncPayload) {
258264
debug('[nuqs `%s`] updateInternalState %O', key, state)
259265
stateRef.current = state
266+
queryRef.current = query
260267
setInternalState(state)
261268
}
262269
function syncFromURL(search: URLSearchParams) {
263-
const value = search.get(key) ?? null
264-
const state = value === null ? null : safeParse(parse, value, key)
270+
const query = search.get(key)
271+
if (query === queryRef.current) {
272+
return
273+
}
274+
const state = query === null ? null : safeParse(parse, query, key)
265275
debug('[nuqs `%s`] syncFromURL %O', key, state)
266-
updateInternalState(state)
276+
updateInternalState({ state, query })
267277
}
268278
debug('[nuqs `%s`] subscribing to sync', key)
269279
emitter.on(SYNC_EVENT_KEY, syncFromURL)
@@ -288,16 +298,16 @@ export function useQueryState<T = string>(
288298
) {
289299
newValue = null
290300
}
291-
// Sync all hooks state (including this one)
292-
emitter.emit(key, newValue)
293-
enqueueQueryStringUpdate(key, newValue, serialize, {
301+
queryRef.current = enqueueQueryStringUpdate(key, newValue, serialize, {
294302
// Call-level options take precedence over hook declaration options.
295303
history: options.history ?? history,
296304
shallow: options.shallow ?? shallow,
297305
scroll: options.scroll ?? scroll,
298306
throttleMs: options.throttleMs ?? throttleMs,
299307
startTransition: options.startTransition ?? startTransition
300308
})
309+
// Sync all hooks state (including this one)
310+
emitter.emit(key, { state: newValue, query: queryRef.current })
301311
return scheduleFlushToURL(router)
302312
},
303313
[key, history, shallow, scroll, throttleMs, startTransition]

0 commit comments

Comments
 (0)