From 9f5de0dd1ae8cf4824600da6083e63e60dbc3e19 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Wed, 13 Nov 2024 14:55:51 +0100 Subject: [PATCH 1/5] test: Add failing test for repro 760 --- packages/e2e/next/cypress/e2e/repro-760.cy.js | 14 ++++++ .../e2e/next/src/app/app/repro-760/page.tsx | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 packages/e2e/next/cypress/e2e/repro-760.cy.js create mode 100644 packages/e2e/next/src/app/app/repro-760/page.tsx diff --git a/packages/e2e/next/cypress/e2e/repro-760.cy.js b/packages/e2e/next/cypress/e2e/repro-760.cy.js new file mode 100644 index 000000000..b5274dead --- /dev/null +++ b/packages/e2e/next/cypress/e2e/repro-760.cy.js @@ -0,0 +1,14 @@ +/// + +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') + }) +}) diff --git a/packages/e2e/next/src/app/app/repro-760/page.tsx b/packages/e2e/next/src/app/app/repro-760/page.tsx new file mode 100644 index 000000000..08382b46a --- /dev/null +++ b/packages/e2e/next/src/app/app/repro-760/page.tsx @@ -0,0 +1,44 @@ +'use client' + +import { parseAsString, useQueryState, useQueryStates } from 'nuqs' +import { Suspense, useState } from 'react' + +export default function Page() { + return ( + + + + + ) +} + +function DynamicUseQueryState() { + const [defaultValue, setDefaultValue] = useState('a') + const [value] = useQueryState('a', parseAsString.withDefault(defaultValue)) + return ( +
+ + {value} +
+ ) +} + +function DynamicUseQueryStates() { + const [defaultValue, setDefaultValue] = useState('b') + const [{ value }] = useQueryStates( + { + value: parseAsString.withDefault(defaultValue) + }, + { urlKeys: { value: 'b' } } + ) + return ( +
+ + {value} +
+ ) +} From 560919dfd54125504e07b2b3214e8d715fc9b8f4 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Wed, 13 Nov 2024 21:12:16 +0100 Subject: [PATCH 2/5] fix: Handle dynamic default values in useQueryStates --- packages/nuqs/src/useQueryStates.ts | 54 +++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index c96f81012..6ece5c74f 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -40,6 +40,7 @@ export type Values = { ? NonNullable> : ReturnType | null } +type NullableValues = Nullable> type UpdaterFn = ( old: Values @@ -80,7 +81,7 @@ export function useQueryStates( urlKeys = defaultUrlKeys }: Partial> = {} ): UseQueryStatesReturn { - type V = Values + type V = NullableValues const stateKeys = Object.keys(keyMap).join(',') const resolvedUrlKeys = useMemo( () => @@ -99,6 +100,17 @@ export function useQueryStates( 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, + [ + Object.values(keyMap) + .map(({ defaultValue }) => defaultValue) + .join(',') + ] + ) const [internalState, setInternalState] = useState(() => { const source = initialSearchParams ?? new URLSearchParams() @@ -137,7 +149,7 @@ export function useQueryStates( } const handlers = Object.keys(keyMap).reduce( (handlers, stateKey) => { - handlers[stateKey as keyof V] = ({ + handlers[stateKey as keyof KeyMap] = ({ state, query }: CrossHookSyncPayload) => { @@ -147,7 +159,7 @@ export function useQueryStates( // 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( @@ -162,7 +174,7 @@ export function useQueryStates( } return handlers }, - {} as Record void> + {} as Record void> ) for (const stateKey of Object.keys(keyMap)) { @@ -183,7 +195,7 @@ export function useQueryStates( (stateUpdater, callOptions = {}) => { const newState: Partial> = typeof stateUpdater === 'function' - ? stateUpdater(stateRef.current) + ? stateUpdater(applyDefaultValues(stateRef.current, defaultValues)) : stateUpdater === null ? (Object.fromEntries( Object.keys(keyMap).map(key => [key, null]) @@ -241,10 +253,16 @@ export function useQueryStates( startTransition, resolvedUrlKeys, updateUrl, - rateLimitFactor + rateLimitFactor, + defaultValues ] ) - return [internalState, update] + + const outputState = useMemo( + () => applyDefaultValues(internalState, defaultValues), + [internalState, defaultValues] + ) + return [outputState, update] } // -- @@ -254,26 +272,34 @@ function parseMap( urlKeys: Partial>, searchParams: URLSearchParams, cachedQuery?: Record, - cachedState?: Values -) { + cachedState?: NullableValues +): NullableValues { 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) + }, {} as NullableValues) +} + +function applyDefaultValues( + state: NullableValues, + defaults: Partial> +) { + return Object.fromEntries( + Object.keys(state).map(key => [key, state[key] ?? defaults[key] ?? null]) + ) as Values } From 809d933940d52bcd4fed3c3680e4b544f17e5a3f Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 14 Nov 2024 12:45:02 +0100 Subject: [PATCH 3/5] test: Add referential equality tests --- packages/nuqs/src/useQueryStates.test.tsx | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 packages/nuqs/src/useQueryStates.test.tsx diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx new file mode 100644 index 000000000..ec592750e --- /dev/null +++ b/packages/nuqs/src/useQueryStates.test.tsx @@ -0,0 +1,80 @@ +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 +) { + return (props: { children: ReactNode }) => ( + + ) +} + +describe('useQueryStates', () => { + const defaults = { + str: 'foo', + obj: { initial: 'state' }, + arr: [ + { + initial: 'state' + } + ] + } + + const hook = () => { + return useQueryStates({ + str: parseAsString.withDefault(defaults.str), + obj: parseAsJson(x => x).withDefault(defaults.obj), + arr: parseAsArrayOf(parseAsJson(x => x)).withDefault(defaults.arr) + }) + } + + 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) + }) +}) From adf115b2a7dc52f5243ffb5f5bcb63e029244224 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 14 Nov 2024 14:05:53 +0100 Subject: [PATCH 4/5] test: Check referential equality when defaults change --- packages/nuqs/src/useQueryStates.test.tsx | 56 +++++++++++++++-------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx index ec592750e..240af08f2 100644 --- a/packages/nuqs/src/useQueryStates.test.tsx +++ b/packages/nuqs/src/useQueryStates.test.tsx @@ -10,31 +10,36 @@ function withSearchParams( searchParams?: string | URLSearchParams | Record ) { return (props: { children: ReactNode }) => ( - + ) } -describe('useQueryStates', () => { - const defaults = { - str: 'foo', - obj: { initial: 'state' }, - arr: [ - { - initial: 'state' - } - ] - } +const defaults = { + str: 'foo', + obj: { initial: 'state' }, + arr: [ + { + initial: 'state' + } + ] +} - const hook = () => { - return useQueryStates({ - str: parseAsString.withDefault(defaults.str), - obj: parseAsJson(x => x).withDefault(defaults.obj), - arr: parseAsArrayOf(parseAsJson(x => x)).withDefault(defaults.arr) - }) - } +const hook = ({ defaultValue } = { defaultValue: defaults.str }) => { + return useQueryStates({ + str: parseAsString.withDefault(defaultValue), + obj: parseAsJson(x => x).withDefault(defaults.obj), + arr: parseAsArrayOf(parseAsJson(x => x)).withDefault(defaults.arr) + }) +} +describe('useQueryStates', () => { it('should have referential equality on default values', () => { - const { result } = renderHook(hook, { wrapper: NuqsTestingAdapter }) + const { result } = renderHook(hook, { + wrapper: NuqsTestingAdapter + }) const [state] = result.current expect(state.str).toBe(defaults.str) expect(state.obj).toBe(defaults.obj) @@ -77,4 +82,17 @@ describe('useQueryStates', () => { 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]) + }) }) From d916767ca68a8ea7b6e32340b8bb3e3f565d588f Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 14 Nov 2024 14:32:47 +0100 Subject: [PATCH 5/5] chore: Prettier --- packages/nuqs/src/useQueryStates.test.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx index 240af08f2..eda6b3623 100644 --- a/packages/nuqs/src/useQueryStates.test.tsx +++ b/packages/nuqs/src/useQueryStates.test.tsx @@ -10,10 +10,7 @@ function withSearchParams( searchParams?: string | URLSearchParams | Record ) { return (props: { children: ReactNode }) => ( - + ) }