Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Ensure referential stability for values #617

Merged
merged 4 commits into from
Sep 2, 2024
Merged
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
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