Skip to content

Commit

Permalink
fix: Support for dynamic default values in useQueryStates (#762)
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 authored Nov 14, 2024
1 parent 777627e commit 6ee72ee
Show file tree
Hide file tree
Showing 4 changed files with 193 additions and 14 deletions.
14 changes: 14 additions & 0 deletions packages/e2e/next/cypress/e2e/repro-760.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// <reference types="cypress" />

describe('repro-760', () => {
it('supports dynamic default values', () => {
cy.visit('/app/repro-760')
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
cy.get('#value-a').should('have.text', 'a')
cy.get('#value-b').should('have.text', 'b')
cy.get('#trigger-a').click()
cy.get('#trigger-b').click()
cy.get('#value-a').should('have.text', 'pass')
cy.get('#value-b').should('have.text', 'pass')
})
})
44 changes: 44 additions & 0 deletions packages/e2e/next/src/app/app/repro-760/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use client'

import { parseAsString, useQueryState, useQueryStates } from 'nuqs'
import { Suspense, useState } from 'react'

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

function DynamicUseQueryState() {
const [defaultValue, setDefaultValue] = useState('a')
const [value] = useQueryState('a', parseAsString.withDefault(defaultValue))
return (
<section>
<button id="trigger-a" onClick={() => setDefaultValue('pass')}>
Trigger
</button>
<span id="value-a">{value}</span>
</section>
)
}

function DynamicUseQueryStates() {
const [defaultValue, setDefaultValue] = useState('b')
const [{ value }] = useQueryStates(
{
value: parseAsString.withDefault(defaultValue)
},
{ urlKeys: { value: 'b' } }
)
return (
<section>
<button id="trigger-b" onClick={() => setDefaultValue('pass')}>
Trigger
</button>
<span id="value-b">{value}</span>
</section>
)
}
95 changes: 95 additions & 0 deletions packages/nuqs/src/useQueryStates.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { NuqsTestingAdapter } from './adapters/testing'
import { parseAsArrayOf, parseAsJson, parseAsString } from './parsers'
import { useQueryStates } from './useQueryStates'

function withSearchParams(
searchParams?: string | URLSearchParams | Record<string, string>
) {
return (props: { children: ReactNode }) => (
<NuqsTestingAdapter searchParams={searchParams} {...props} />
)
}

const defaults = {
str: 'foo',
obj: { initial: 'state' },
arr: [
{
initial: 'state'
}
]
}

const hook = ({ defaultValue } = { defaultValue: defaults.str }) => {
return useQueryStates({
str: parseAsString.withDefault(defaultValue),
obj: parseAsJson<any>(x => x).withDefault(defaults.obj),
arr: parseAsArrayOf(parseAsJson<any>(x => x)).withDefault(defaults.arr)
})
}

describe('useQueryStates', () => {
it('should have referential equality on default values', () => {
const { result } = renderHook(hook, {
wrapper: NuqsTestingAdapter
})
const [state] = result.current
expect(state.str).toBe(defaults.str)
expect(state.obj).toBe(defaults.obj)
expect(state.arr).toBe(defaults.arr)
expect(state.arr[0]).toBe(defaults.arr[0])
})

it('should keep referential equality when resetting to defaults', () => {
const { result } = renderHook(hook, {
wrapper: withSearchParams({
str: 'foo',
obj: '{"hello":"world"}',
arr: '{"obj":true},{"arr":true}'
})
})
act(() => {
result.current[1](null)
})
const [state] = result.current
expect(state.str).toBe(defaults.str)
expect(state.obj).toBe(defaults.obj)
expect(state.arr).toBe(defaults.arr)
expect(state.arr[0]).toBe(defaults.arr[0])
})

it('should keep referential equality when unrelated keys change', () => {
const { result } = renderHook(hook, {
wrapper: withSearchParams({
str: 'foo',
obj: '{"hello":"world"}'
// Keep arr as default
})
})
const [{ obj: initialObj, arr: initialArr }] = result.current
act(() => {
result.current[1]({ str: 'bar' })
})
const [{ str, obj, arr }] = result.current
expect(str).toBe('bar')
expect(obj).toBe(initialObj)
expect(arr).toBe(initialArr)
})

it('should keep referential equality when default changes for another key', () => {
const { result, rerender } = renderHook(hook, {
wrapper: withSearchParams()
})
expect(result.current[0].str).toBe('foo')
rerender({ defaultValue: 'b' })
const [state] = result.current
expect(state.str).toBe('b')
expect(state.obj).toBe(defaults.obj)
expect(state.arr).toBe(defaults.arr)
expect(state.arr[0]).toBe(defaults.arr[0])
})
})
54 changes: 40 additions & 14 deletions packages/nuqs/src/useQueryStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type Values<T extends UseQueryStatesKeysMap> = {
? NonNullable<ReturnType<T[K]['parse']>>
: ReturnType<T[K]['parse']> | null
}
type NullableValues<T extends UseQueryStatesKeysMap> = Nullable<Values<T>>

type UpdaterFn<T extends UseQueryStatesKeysMap> = (
old: Values<T>
Expand Down Expand Up @@ -80,7 +81,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
urlKeys = defaultUrlKeys
}: Partial<UseQueryStatesOptions<KeyMap>> = {}
): UseQueryStatesReturn<KeyMap> {
type V = Values<KeyMap>
type V = NullableValues<KeyMap>
const stateKeys = Object.keys(keyMap).join(',')
const resolvedUrlKeys = useMemo(
() =>
Expand All @@ -99,6 +100,17 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
if (Object.keys(queryRef.current).length !== Object.keys(keyMap).length) {
queryRef.current = Object.fromEntries(initialSearchParams?.entries() ?? [])
}
const defaultValues = useMemo(
() =>
Object.fromEntries(
Object.keys(keyMap).map(key => [key, keyMap[key]!.defaultValue ?? null])
) as Values<KeyMap>,
[
Object.values(keyMap)
.map(({ defaultValue }) => defaultValue)
.join(',')
]
)

const [internalState, setInternalState] = useState<V>(() => {
const source = initialSearchParams ?? new URLSearchParams()
Expand Down Expand Up @@ -137,7 +149,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
}
const handlers = Object.keys(keyMap).reduce(
(handlers, stateKey) => {
handlers[stateKey as keyof V] = ({
handlers[stateKey as keyof KeyMap] = ({
state,
query
}: CrossHookSyncPayload) => {
Expand All @@ -147,7 +159,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
// for the subsequent setState to pick it up.
stateRef.current = {
...stateRef.current,
[stateKey as keyof V]: state ?? defaultValue ?? null
[stateKey as keyof KeyMap]: state ?? defaultValue ?? null
}
queryRef.current[urlKey] = query
debug(
Expand All @@ -162,7 +174,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
}
return handlers
},
{} as Record<keyof V, (payload: CrossHookSyncPayload) => void>
{} as Record<keyof KeyMap, (payload: CrossHookSyncPayload) => void>
)

for (const stateKey of Object.keys(keyMap)) {
Expand All @@ -183,7 +195,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
(stateUpdater, callOptions = {}) => {
const newState: Partial<Nullable<KeyMap>> =
typeof stateUpdater === 'function'
? stateUpdater(stateRef.current)
? stateUpdater(applyDefaultValues(stateRef.current, defaultValues))
: stateUpdater === null
? (Object.fromEntries(
Object.keys(keyMap).map(key => [key, null])
Expand Down Expand Up @@ -241,10 +253,16 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
startTransition,
resolvedUrlKeys,
updateUrl,
rateLimitFactor
rateLimitFactor,
defaultValues
]
)
return [internalState, update]

const outputState = useMemo(
() => applyDefaultValues(internalState, defaultValues),
[internalState, defaultValues]
)
return [outputState, update]
}

// --
Expand All @@ -254,26 +272,34 @@ function parseMap<KeyMap extends UseQueryStatesKeysMap>(
urlKeys: Partial<Record<keyof KeyMap, string>>,
searchParams: URLSearchParams,
cachedQuery?: Record<string, string | null>,
cachedState?: Values<KeyMap>
) {
cachedState?: NullableValues<KeyMap>
): NullableValues<KeyMap> {
return Object.keys(keyMap).reduce((obj, stateKey) => {
const urlKey = urlKeys?.[stateKey] ?? stateKey
const { defaultValue, parse } = keyMap[stateKey]!
const { parse } = keyMap[stateKey]!
const queuedQuery = getQueuedValue(urlKey)
const query =
queuedQuery === undefined
? (searchParams?.get(urlKey) ?? null)
: queuedQuery
if (cachedQuery && cachedState && cachedQuery[urlKey] === query) {
obj[stateKey as keyof KeyMap] =
cachedState[stateKey] ?? defaultValue ?? null
obj[stateKey as keyof KeyMap] = cachedState[stateKey] ?? null
return obj
}
const value = query === null ? null : safeParse(parse, query, stateKey)
obj[stateKey as keyof KeyMap] = value ?? defaultValue ?? null
obj[stateKey as keyof KeyMap] = value ?? null
if (cachedQuery) {
cachedQuery[urlKey] = query
}
return obj
}, {} as Values<KeyMap>)
}, {} as NullableValues<KeyMap>)
}

function applyDefaultValues<KeyMap extends UseQueryStatesKeysMap>(
state: NullableValues<KeyMap>,
defaults: Partial<Values<KeyMap>>
) {
return Object.fromEntries(
Object.keys(state).map(key => [key, state[key] ?? defaults[key] ?? null])
) as Values<KeyMap>
}

0 comments on commit 6ee72ee

Please sign in to comment.