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: Support for dynamic default values in useQueryStates #762

Merged
merged 5 commits into from
Nov 14, 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
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>
}
Loading