(makeLoggingSpy('b')).withDefault(defaultValue)
+ })
+
+ useEffect(() => {
+ setARefCount(old => old + 1)
+ }, [a])
+ useEffect(() => {
+ setBRefCount(old => old + 1)
+ }, [b])
+
+ return (
+ <>
+
+
+
+
+
+ Refs seen: {aRefCount}
+
+
+
+
+
+
+
+ Refs seen: {bRefCount}
+
+
+
+
+ Link to #
+
+
+ >
+ )
+}
diff --git a/packages/nuqs/src/sync.ts b/packages/nuqs/src/sync.ts
index 685706c4d..fb0a476d7 100644
--- a/packages/nuqs/src/sync.ts
+++ b/packages/nuqs/src/sync.ts
@@ -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()
diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts
index d38e035f5..03e6efb13 100644
--- a/packages/nuqs/src/update-queue.ts
+++ b/packages/nuqs/src/update-queue.ts
@@ -55,6 +55,7 @@ export function enqueueQueryStringUpdate(
options.throttleMs ?? FLUSH_RATE_LIMIT_MS,
Number.isFinite(queueOptions.throttleMs) ? queueOptions.throttleMs : 0
)
+ return serializedOrNull
}
export function getQueuedValue(key: string) {
diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts
index 78a023f2a..d13c5eef3 100644
--- a/packages/nuqs/src/useQueryState.ts
+++ b/packages/nuqs/src/useQueryState.ts
@@ -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,
@@ -225,10 +225,12 @@ export function useQueryState(
const router = useRouter()
// Not reactive, but available on the server and on page load
const initialSearchParams = useSearchParams()
+ const queryRef = React.useRef(null)
const [internalState, setInternalState] = React.useState(() => {
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)
@@ -245,25 +247,33 @@ export function useQueryState(
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)
@@ -288,9 +298,7 @@ export function useQueryState(
) {
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,
@@ -298,6 +306,8 @@ export function useQueryState(
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]
diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts
index 57953d6f8..26fefd221 100644
--- a/packages/nuqs/src/useQueryStates.ts
+++ b/packages/nuqs/src/useQueryStates.ts
@@ -7,7 +7,7 @@ import React from 'react'
import { debug } from './debug'
import type { Nullable, 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,
@@ -73,9 +73,13 @@ export function useQueryStates(
const router = useRouter()
// Not reactive, but available on the server and on page load
const initialSearchParams = useSearchParams()
- const [internalState, setInternalState] = React.useState(() =>
- parseMap(keyMap, initialSearchParams ?? new URLSearchParams())
- )
+ const queryRef = React.useRef>({})
+ const [internalState, setInternalState] = React.useState(() => {
+ const source = initialSearchParams ?? new URLSearchParams()
+ queryRef.current = Object.fromEntries(source.entries())
+ return parseMap(keyMap, source)
+ })
+
const stateRef = React.useRef(internalState)
debug(
'[nuq+ `%s`] render - state: %O, iSP: %s',
@@ -92,25 +96,26 @@ export function useQueryStates(
setInternalState(state)
}
function syncFromURL(search: URLSearchParams) {
- const state = parseMap(keyMap, search)
+ const state = parseMap(keyMap, search, queryRef.current, stateRef.current)
debug('[nuq+ `%s`] syncFromURL %O', keys, state)
updateInternalState(state)
}
const handlers = Object.keys(keyMap).reduce(
(handlers, key) => {
- handlers[key as keyof V] = (value: any) => {
+ handlers[key as keyof V] = ({ state, query }: CrossHookSyncPayload) => {
const { defaultValue } = keyMap[key]!
// Note: cannot mutate in-place, the object ref must change
// for the subsequent setState to pick it up.
stateRef.current = {
...stateRef.current,
- [key as keyof V]: value ?? defaultValue ?? null
+ [key as keyof V]: state ?? defaultValue ?? null
}
+ queryRef.current[key] = query
debug(
'[nuq+ `%s`] Cross-hook key sync %s: %O (default: %O). Resolved: %O',
keys,
key,
- value,
+ state,
defaultValue,
stateRef.current
)
@@ -118,13 +123,13 @@ export function useQueryStates(
}
return handlers
},
- {} as Record
+ {} as Record void>
)
emitter.on(SYNC_EVENT_KEY, syncFromURL)
for (const key of Object.keys(keyMap)) {
debug('[nuq+ `%s`] Subscribing to sync for `%s`', keys, key)
- emitter.on(key, handlers[key])
+ emitter.on(key, handlers[key]!)
}
return () => {
emitter.off(SYNC_EVENT_KEY, syncFromURL)
@@ -161,18 +166,28 @@ export function useQueryStates(
) {
value = null
}
- emitter.emit(key, value)
- enqueueQueryStringUpdate(key, value, parser.serialize ?? String, {
- // Call-level options take precedence over individual parser options
- // which take precedence over global options
- history: callOptions.history ?? parser.history ?? history,
- shallow: callOptions.shallow ?? parser.shallow ?? shallow,
- scroll: callOptions.scroll ?? parser.scroll ?? scroll,
- throttleMs: callOptions.throttleMs ?? parser.throttleMs ?? throttleMs,
- startTransition:
- callOptions.startTransition ??
- parser.startTransition ??
- startTransition
+
+ queryRef.current[key] = enqueueQueryStringUpdate(
+ key,
+ value,
+ parser.serialize ?? String,
+ {
+ // Call-level options take precedence over individual parser options
+ // which take precedence over global options
+ history: callOptions.history ?? parser.history ?? history,
+ shallow: callOptions.shallow ?? parser.shallow ?? shallow,
+ scroll: callOptions.scroll ?? parser.scroll ?? scroll,
+ throttleMs:
+ callOptions.throttleMs ?? parser.throttleMs ?? throttleMs,
+ startTransition:
+ callOptions.startTransition ??
+ parser.startTransition ??
+ startTransition
+ }
+ )
+ emitter.emit(key, {
+ state: value,
+ query: queryRef.current[key] ?? null
})
}
return scheduleFlushToURL(router)
@@ -186,13 +201,19 @@ export function useQueryStates(
function parseMap(
keyMap: KeyMap,
- searchParams: URLSearchParams | ReadonlyURLSearchParams
+ searchParams: URLSearchParams | ReadonlyURLSearchParams,
+ cachedQuery?: Record,
+ cachedState?: Values
) {
return Object.keys(keyMap).reduce((obj, key) => {
const { defaultValue, parse } = keyMap[key]!
const urlQuery = searchParams?.get(key) ?? null
const queueQuery = getQueuedValue(key)
const query = queueQuery ?? urlQuery
+ if (cachedQuery && cachedState && cachedQuery[key] === query) {
+ obj[key as keyof KeyMap] = cachedState[key] ?? defaultValue ?? null
+ return obj
+ }
const value = query === null ? null : safeParse(parse, query, key)
obj[key as keyof KeyMap] = value ?? defaultValue ?? null
return obj