diff --git a/packages/e2e/next/cypress/e2e/repros.cy.ts b/packages/e2e/next/cypress/e2e/repros.cy.ts index 4f0e63eeb..b42af05a0 100644 --- a/packages/e2e/next/cypress/e2e/repros.cy.ts +++ b/packages/e2e/next/cypress/e2e/repros.cy.ts @@ -113,21 +113,6 @@ describe('repro-758', () => { // -- -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') - }) -}) - -// -- - describe('repro-774', () => { it('updates internal state on navigation', () => { cy.visit('/app/repro-774') diff --git a/packages/e2e/next/src/app/app/repro-760/page.tsx b/packages/e2e/next/src/app/app/repro-760/page.tsx deleted file mode 100644 index 08382b46a..000000000 --- a/packages/e2e/next/src/app/app/repro-760/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'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} -
- ) -} diff --git a/packages/nuqs/src/adapters/lib/context.ts b/packages/nuqs/src/adapters/lib/context.ts index f53e851e6..4a9347aba 100644 --- a/packages/nuqs/src/adapters/lib/context.ts +++ b/packages/nuqs/src/adapters/lib/context.ts @@ -2,6 +2,7 @@ import { createContext, createElement, useContext, + useRef, type Context, type ProviderProps, type ReactElement, @@ -30,6 +31,10 @@ export const context: Context = createContext({ }) context.displayName = 'NuqsAdapterContext' +export type DefaultValueStore = Record +export const defaultValueContext: Context = + createContext(null) + declare global { interface Window { __NuqsAdapterContext?: typeof context @@ -60,15 +65,34 @@ export type AdapterProvider = ( export function createAdapterProvider( useAdapter: UseAdapterHook ): AdapterProvider { - return ({ children, defaultOptions, processUrlSearchParams, ...props }) => - createElement( + return function NuqsAdapterProvider({ + children, + defaultOptions, + processUrlSearchParams, + ...props + }) { + const defaultValueStore = useRef(useContext(defaultValueContext) ?? {}) + return createElement( context.Provider, { ...props, value: { useAdapter, defaultOptions, processUrlSearchParams } }, - children + createElement( + defaultValueContext.Provider, + { value: defaultValueStore.current }, + children + ) ) + } +} + +export function useDefaultValueStore(): DefaultValueStore { + const context = useContext(defaultValueContext) + if (!context) { + throw new Error('[nuqs] No DefaultValueContext found') + } + return context } export function useAdapter(watchKeys: string[]): AdapterInterface { diff --git a/packages/nuqs/src/adapters/testing.ts b/packages/nuqs/src/adapters/testing.ts index 240a1f081..df9e797e6 100644 --- a/packages/nuqs/src/adapters/testing.ts +++ b/packages/nuqs/src/adapters/testing.ts @@ -9,7 +9,7 @@ import { } from 'react' import { resetQueues } from '../lib/queues/reset' import { renderQueryString } from './custom' -import { context, type AdapterProps } from './lib/context' +import { context, type AdapterProps, defaultValueContext } from './lib/context' import type { AdapterInterface, AdapterOptions } from './lib/defs' export type UrlUpdateEvent = { @@ -131,10 +131,15 @@ export function NuqsTestingAdapter({ getSearchParamsSnapshot, rateLimitFactor }) + const defaultValueStore = useRef({}) return createElement( context.Provider, { value: { useAdapter, defaultOptions, processUrlSearchParams } }, - children + createElement( + defaultValueContext.Provider, + { value: defaultValueStore.current }, + children + ) ) } diff --git a/packages/nuqs/src/useQueryState.test.tsx b/packages/nuqs/src/useQueryState.test.tsx index 59e77e110..775c73819 100644 --- a/packages/nuqs/src/useQueryState.test.tsx +++ b/packages/nuqs/src/useQueryState.test.tsx @@ -19,6 +19,7 @@ import { parseAsString } from './parsers' import { useQueryState } from './useQueryState' +import { useQueryStates } from './useQueryStates' describe('useQueryState: referential equality', () => { const defaults = { @@ -109,7 +110,7 @@ describe('useQueryState: referential equality', () => { expect(result.current.str[0]).toBe('foo') rerender({ defaultValue: 'b' }) const { str, obj, arr } = result.current - expect(str[0]).toBe('b') + expect(str[0]).toBe('foo') expect(obj[0]).toBe(defaults.obj) expect(arr[0]).toBe(defaults.arr) expect(arr[0][0]).toBe(defaults.arr[0]) @@ -129,6 +130,40 @@ describe('useQueryState: referential equality', () => { }) }) +describe('useQueryState: defaultValue', () => { + it('should read the same default value for multiple usages of the same parser', () => { + function TestComponent({ + id, + defaultValue + }: { + id: string + defaultValue: number + }) { + const [value] = useQueryState( + 'a', + parseAsInteger.withDefault(defaultValue) + ) + + return ( +
+ {id} value: {value} +
+ ) + } + const result = render( +
+ + +
, + { + wrapper: withNuqsTestingAdapter() + } + ) + expect(result.getByText('first value: 5')).toBeInTheDocument() + expect(result.getByText('second value: 5')).toBeInTheDocument() + }) +}) + describe('useQueryState: clearOnDefault', () => { it('honors clearOnDefault: true by default', async () => { const onUrlUpdate = vi.fn() diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx index 6168a3ee3..091e771e9 100644 --- a/packages/nuqs/src/useQueryStates.test.tsx +++ b/packages/nuqs/src/useQueryStates.test.tsx @@ -192,7 +192,7 @@ describe('useQueryStates: referential equality', () => { expect(result.current[0].str).toBe('foo') rerender({ defaultValue: 'b' }) const [state] = result.current - expect(state.str).toBe('b') + expect(state.str).toBe('foo') expect(state.obj).toBe(defaults.obj) expect(state.arr).toBe(defaults.arr) expect(state.arr[0]).toBe(defaults.arr[0]) @@ -266,6 +266,39 @@ describe('useQueryStates: urlKeys remapping', () => { }) }) +describe('useQueryStates: defaultValue', () => { + it('should read the same default value for multiple usages of the same parser', () => { + function TestComponent({ + id, + defaultValue + }: { + id: string + defaultValue: number + }) { + const [value] = useQueryStates({ + a: parseAsInteger.withDefault(defaultValue) + }) + + return ( +
+ {id} value: {value.a} +
+ ) + } + const result = render( +
+ + +
, + { + wrapper: withNuqsTestingAdapter() + } + ) + expect(result.getByText('first value: 5')).toBeInTheDocument() + expect(result.getByText('second value: 5')).toBeInTheDocument() + }) +}) + describe('useQueryStates: clearOnDefault', () => { it('honors clearOnDefault: true by default', async () => { const onUrlUpdate = vi.fn() diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 8d51d0a54..8b84cce00 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { + type DefaultValueStore, useAdapter, useAdapterDefaultOptions, - useAdapterProcessUrlSearchParams + useAdapterProcessUrlSearchParams, + useDefaultValueStore } from './adapters/lib/context' import type { Nullable, Options, UrlKeys } from './defs' import { compareQuery } from './lib/compare' @@ -76,6 +78,7 @@ export function useQueryStates( const hookId = useId() const defaultOptions = useAdapterDefaultOptions() const processUrlSearchParams = useAdapterProcessUrlSearchParams() + const defaultValueStore = useDefaultValueStore() const { history = 'replace', @@ -100,17 +103,14 @@ export function useQueryStates( const adapter = useAdapter(Object.values(resolvedUrlKeys)) const initialSearchParams = adapter.searchParams const queryRef = useRef>({}) - const defaultValues = useMemo( - () => - Object.fromEntries( - Object.keys(keyMap).map(key => [key, keyMap[key]!.defaultValue ?? null]) - ) as Values, - [ - Object.values(keyMap) - .map(({ defaultValue }) => defaultValue) - .join(',') - ] - ) + + // lazily initialize defaultValues in store + for (const [stateKey, { defaultValue }] of Object.entries(keyMap)) { + if (!(stateKey in defaultValueStore)) { + defaultValueStore[stateKey] = defaultValue ?? null + } + } + const queuedQueries = debounceController.useQueuedQueries( Object.values(resolvedUrlKeys) ) @@ -191,7 +191,6 @@ export function useQueryStates( useEffect(() => { function updateInternalState(state: V) { debug('[nuq+ %s `%s`] updateInternalState %O', hookId, stateKeys, state) - stateRef.current = state setInternalState(state) } const handlers = Object.keys(keyMap).reduce( @@ -200,7 +199,7 @@ export function useQueryStates( state, query }: CrossHookSyncPayload) => { - const { defaultValue } = keyMap[stateKey]! + const defaultValue = defaultValueStore[stateKey] const urlKey = resolvedUrlKeys[stateKey]! // Note: cannot mutate in-place, the object ref must change // for the subsequent setState to pick it up. @@ -257,7 +256,7 @@ export function useQueryStates( const newState: Partial> = typeof stateUpdater === 'function' ? (stateUpdater( - applyDefaultValues(stateRef.current, defaultValues) + applyDefaultValues(stateRef.current, defaultValueStore) ) ?? nullMap) : (stateUpdater ?? nullMap) debug('[nuq+ %s `%s`] setState: %O', hookId, stateKeys, newState) @@ -358,13 +357,17 @@ export function useQueryStates( adapter.getSearchParamsSnapshot, adapter.rateLimitFactor, processUrlSearchParams, - defaultValues + defaultValueStore ] ) const outputState = useMemo( - () => applyDefaultValues(internalState, defaultValues), - [internalState, defaultValues] + () => + applyDefaultValues( + internalState, + defaultValueStore as Partial> + ), + [internalState, defaultValueStore] ) return [outputState, update] } @@ -430,7 +433,7 @@ function parseMap( function applyDefaultValues( state: NullableValues, - defaults: Partial> + defaults: DefaultValueStore ) { return Object.fromEntries( Object.keys(state).map(key => [key, state[key] ?? defaults[key] ?? null])