Skip to content

Commit

Permalink
fix: Ensure referential stability for values (#617)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
franky47 authored Sep 2, 2024
1 parent 396c5a9 commit 6f22280
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 34 deletions.
29 changes: 29 additions & 0 deletions packages/docs/content/docs/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,32 @@ Because the Next.js **pages router** is not available in an SSR context, this
hook will always return `null` (or the default value if supplied) on SSR/SSG.

This limitation doesn't apply to the app router.

## Caveats

### Different parsers on the same key

Hooks are synced together on a per-key bassis, so if you use different parsers
on the same key, the last state update will be propagated to all other hooks
using that key. It can lead to unexpected states like this:

```ts
const [int] = useQueryState('foo', parseAsInteger)
const [float, setFloat] = useQueryState('foo', parseAsFloat)

setFloat(1.234)

// `int` is now 1.234, instead of 1
```

We recommend you abstract a key/parser pair into a dedicated hook to avoid this,
and derive any desired state from the value:

```ts
function useIntFloat() {
const [float, setFloat] = useQueryState('foo', parseAsFloat)
const int = Math.floor(float)
return [{int, float}, setFloat] as const
}
```

29 changes: 29 additions & 0 deletions packages/e2e/cypress/e2e/referential-equality.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/// <reference types="cypress" />

it('Referential equality', () => {
cy.visit('/app/referential-equality')
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
cy.get('#ref-a').should('have.text', '1')
cy.get('#ref-b').should('have.text', '1')
cy.get('#increment-a').click()
cy.get('#ref-a').should('have.text', '2')
cy.get('#ref-b').should('have.text', '1')
cy.get('#increment-b').click()
cy.get('#ref-a').should('have.text', '2')
cy.get('#ref-b').should('have.text', '2')
cy.get('#idempotent-a').click()
cy.get('#ref-a').should('have.text', '2')
cy.get('#ref-b').should('have.text', '2')
cy.get('#idempotent-b').click()
cy.get('#ref-a').should('have.text', '2')
cy.get('#ref-b').should('have.text', '2')
cy.get('#clear-a').click()
cy.get('#ref-a').should('have.text', '3')
cy.get('#ref-b').should('have.text', '2')
cy.get('#clear-b').click()
cy.get('#ref-a').should('have.text', '3')
cy.get('#ref-b').should('have.text', '3')
cy.get('#link').click()
cy.get('#ref-a').should('have.text', '3')
cy.get('#ref-b').should('have.text', '3')
})
98 changes: 98 additions & 0 deletions packages/e2e/src/app/app/referential-equality/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client'

import Link from 'next/link'
import { parseAsJson, useQueryState, useQueryStates } from 'nuqs'
import { Suspense, useEffect, useState } from 'react'

export default function Page() {
return (
<Suspense>
<Client />
</Suspense>
)
}

const defaultValue = { x: 0 }
type Value = typeof defaultValue

function increment(value: Value): Value {
return { x: value.x + 1 }
}

const makeLoggingSpy =
(key: string) =>
(value: unknown): Value => {
console.log(`[%s]: Parser running with value %O`, key, value)
return value as Value
}

function Client() {
const [aRefCount, setARefCount] = useState(0)
const [bRefCount, setBRefCount] = useState(0)
const [a, setA] = useQueryState(
'a',
parseAsJson<Value>(makeLoggingSpy('a')).withDefault(defaultValue)
)
const [{ b }, setB] = useQueryStates({
b: parseAsJson<Value>(makeLoggingSpy('b')).withDefault(defaultValue)
})

useEffect(() => {
setARefCount(old => old + 1)
}, [a])
useEffect(() => {
setBRefCount(old => old + 1)
}, [b])

return (
<>
<div>
<button id="increment-a" onClick={() => setA(increment)}>
Increment A
</button>
<button id="idempotent-a" onClick={() => setA(x => x)}>
Itempotent A
</button>
<button id="clear-a" onClick={() => setA(null)}>
Clear A
</button>
<span>
Refs seen: <span id="ref-a">{aRefCount}</span>
</span>
</div>
<div>
<button
id="increment-b"
onClick={() =>
setB(old => ({
b: increment(old.b)
}))
}
>
Increment B
</button>
<button id="idempotent-b" onClick={() => setB(x => x)}>
Itempotent B
</button>
<button
id="clear-b"
onClick={() =>
setB({
b: null
})
}
>
Clear B
</button>
<span>
Refs seen: <span id="ref-b">{bRefCount}</span>
</span>
</div>
<div>
<Link href="#" id="link">
Link to #
</Link>
</div>
</>
)
}
6 changes: 5 additions & 1 deletion packages/nuqs/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ export type QueryUpdateNotificationArgs = {
search: URLSearchParams
source: QueryUpdateSource
}
export type CrossHookSyncPayload = {
state: any
query: string | null
}

type EventMap = {
[SYNC_EVENT_KEY]: URLSearchParams
[NOTIFY_EVENT_KEY]: QueryUpdateNotificationArgs
[key: string]: any
[key: string]: CrossHookSyncPayload
}

export const emitter = Mitt<EventMap>()
Expand Down
1 change: 1 addition & 0 deletions packages/nuqs/src/update-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function enqueueQueryStringUpdate<Value>(
options.throttleMs ?? FLUSH_RATE_LIMIT_MS,
Number.isFinite(queueOptions.throttleMs) ? queueOptions.throttleMs : 0
)
return serializedOrNull
}

export function getQueuedValue(key: string) {
Expand Down
30 changes: 20 additions & 10 deletions packages/nuqs/src/useQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react'
import { debug } from './debug'
import type { Options } from './defs'
import type { Parser } from './parsers'
import { SYNC_EVENT_KEY, emitter } from './sync'
import { SYNC_EVENT_KEY, emitter, type CrossHookSyncPayload } from './sync'
import {
FLUSH_RATE_LIMIT_MS,
enqueueQueryStringUpdate,
Expand Down Expand Up @@ -225,10 +225,12 @@ export function useQueryState<T = string>(
const router = useRouter()
// Not reactive, but available on the server and on page load
const initialSearchParams = useSearchParams()
const queryRef = React.useRef<string | null>(null)
const [internalState, setInternalState] = React.useState<T | null>(() => {
const queueValue = getQueuedValue(key)
const urlValue = initialSearchParams?.get(key) ?? null
const value = queueValue ?? urlValue
queryRef.current = value
return value === null ? null : safeParse(parse, value, key)
})
const stateRef = React.useRef(internalState)
Expand All @@ -245,25 +247,33 @@ export function useQueryState<T = string>(
if (window.next?.version !== '14.0.3') {
return
}
const value = initialSearchParams.get(key) ?? null
const state = value === null ? null : safeParse(parse, value, key)
const query = initialSearchParams.get(key) ?? null
if (query === queryRef.current) {
return
}
const state = query === null ? null : safeParse(parse, query, key)
debug('[nuqs `%s`] syncFromUseSearchParams %O', key, state)
stateRef.current = state
queryRef.current = query
setInternalState(state)
}, [initialSearchParams?.get(key), key])

// Sync all hooks together & with external URL changes
React.useInsertionEffect(() => {
function updateInternalState(state: T | null) {
function updateInternalState({ state, query }: CrossHookSyncPayload) {
debug('[nuqs `%s`] updateInternalState %O', key, state)
stateRef.current = state
queryRef.current = query
setInternalState(state)
}
function syncFromURL(search: URLSearchParams) {
const value = search.get(key) ?? null
const state = value === null ? null : safeParse(parse, value, key)
const query = search.get(key)
if (query === queryRef.current) {
return
}
const state = query === null ? null : safeParse(parse, query, key)
debug('[nuqs `%s`] syncFromURL %O', key, state)
updateInternalState(state)
updateInternalState({ state, query })
}
debug('[nuqs `%s`] subscribing to sync', key)
emitter.on(SYNC_EVENT_KEY, syncFromURL)
Expand All @@ -288,16 +298,16 @@ export function useQueryState<T = string>(
) {
newValue = null
}
// Sync all hooks state (including this one)
emitter.emit(key, newValue)
enqueueQueryStringUpdate(key, newValue, serialize, {
queryRef.current = enqueueQueryStringUpdate(key, newValue, serialize, {
// Call-level options take precedence over hook declaration options.
history: options.history ?? history,
shallow: options.shallow ?? shallow,
scroll: options.scroll ?? scroll,
throttleMs: options.throttleMs ?? throttleMs,
startTransition: options.startTransition ?? startTransition
})
// Sync all hooks state (including this one)
emitter.emit(key, { state: newValue, query: queryRef.current })
return scheduleFlushToURL(router)
},
[key, history, shallow, scroll, throttleMs, startTransition]
Expand Down
Loading

0 comments on commit 6f22280

Please sign in to comment.