Skip to content
30 changes: 27 additions & 3 deletions packages/nuqs/src/adapters/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createContext,
createElement,
useContext,
useRef,
type Context,
type ProviderProps,
type ReactElement,
Expand Down Expand Up @@ -30,6 +31,10 @@ export const context: Context<AdapterContext> = createContext<AdapterContext>({
})
context.displayName = 'NuqsAdapterContext'

export type DefaultValueStore = Record<string, unknown>
export const defaultValueContext: Context<DefaultValueStore | null> =
createContext<DefaultValueStore | null>(null)

declare global {
interface Window {
__NuqsAdapterContext?: typeof context
Expand Down Expand Up @@ -60,15 +65,34 @@ export type AdapterProvider = (
export function createAdapterProvider(
useAdapter: UseAdapterHook
): AdapterProvider {
return ({ children, defaultOptions, processUrlSearchParams, ...props }) =>
createElement(
return function NuqsAdapterProvider({
Comment thread
franky47 marked this conversation as resolved.
children,
defaultOptions,
processUrlSearchParams,
...props
}) {
const defaultValueStore = useRef({})
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
Comment thread
TkDodo marked this conversation as resolved.
}

export function useAdapter(watchKeys: string[]): AdapterInterface {
Expand Down
9 changes: 7 additions & 2 deletions packages/nuqs/src/adapters/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
)
)
}

Expand Down
37 changes: 36 additions & 1 deletion packages/nuqs/src/useQueryState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
parseAsString
} from './parsers'
import { useQueryState } from './useQueryState'
import { useQueryStates } from './useQueryStates'

describe('useQueryState: referential equality', () => {
const defaults = {
Expand Down Expand Up @@ -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])
Expand All @@ -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 (
<div>
{id} value: {value}
</div>
)
}
const result = render(
<div>
<TestComponent id="first" defaultValue={5} />
<TestComponent id="second" defaultValue={23} />
Comment on lines +155 to +156

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this test fails on main because it renders first with 5 and second with 23. Now, it renders both with 5, which I think (hope) is what we want.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this relates to the repro for issue #760 (dynamic defaults): a change in the default value should be reflected in the output state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think supporting dynamic default value is an intended feature - it feels more like a bug to me as it would result in different values being rendered on screen for the same key.

It also won’t work with the discussed writeDefaults feature.

</div>,
{
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<OnUrlUpdateFunction>()
Expand Down
35 changes: 34 additions & 1 deletion packages/nuqs/src/useQueryStates.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this change in test actually shows the (imo buggy) current behaviour: If a component re-renders with a different defaultValue, why would the state change? Not even uncontrolled inputs in react with a defaultValue work that way...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state internally can be nullable, and the default is nullish-coalesced on top of that, so that made the output respect dynamic defaults (issue #760).

expect(state.obj).toBe(defaults.obj)
expect(state.arr).toBe(defaults.arr)
expect(state.arr[0]).toBe(defaults.arr[0])
Expand Down Expand Up @@ -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 (
<div>
{id} value: {value.a}
</div>
)
}
const result = render(
<div>
<TestComponent id="first" defaultValue={5} />
<TestComponent id="second" defaultValue={23} />
</div>,
{
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<OnUrlUpdateFunction>()
Expand Down
41 changes: 22 additions & 19 deletions packages/nuqs/src/useQueryStates.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -76,6 +78,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
const hookId = useId()
const defaultOptions = useAdapterDefaultOptions()
const processUrlSearchParams = useAdapterProcessUrlSearchParams()
const defaultValueStore = useDefaultValueStore()

const {
history = 'replace',
Expand All @@ -100,17 +103,14 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
const adapter = useAdapter(Object.values(resolvedUrlKeys))
const initialSearchParams = adapter.searchParams
const queryRef = useRef<Record<string, Query | null>>({})
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(',')
]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this useMemo dependency array being gone is a good thing because stringifying doesn’t work well with complex objects and it wouldn’t work at all if we wanted to support default values as a function.

now, it’s no longer needed because the global store is “stable” and we actually only read the defaultValue imperatively, so it doesn’t need to be reactive.

)

// 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)
)
Expand Down Expand Up @@ -191,7 +191,6 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
useEffect(() => {
function updateInternalState(state: V) {
debug('[nuq+ %s `%s`] updateInternalState %O', hookId, stateKeys, state)
stateRef.current = state

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this update was redundant because updateInternalState is only called with stateRef.current, so at this point, that value is already correctly set.

setInternalState(state)
}
const handlers = Object.keys(keyMap).reduce(
Expand All @@ -200,7 +199,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
state,
query
}: CrossHookSyncPayload) => {
const { defaultValue } = keyMap[stateKey]!
const defaultValue = defaultValueStore[stateKey]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: it might be a bit brittle here to only read from the defaultValueStore. We basically expect it to be initialized already at this point, which is true, but if we ever remove values from the store we would need to re-initialize here too.

const urlKey = resolvedUrlKeys[stateKey]!
// Note: cannot mutate in-place, the object ref must change
// for the subsequent setState to pick it up.
Expand Down Expand Up @@ -257,7 +256,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
const newState: Partial<Nullable<KeyMap>> =
typeof stateUpdater === 'function'
? (stateUpdater(
applyDefaultValues(stateRef.current, defaultValues)
applyDefaultValues(stateRef.current, defaultValueStore)
) ?? nullMap)
: (stateUpdater ?? nullMap)
debug('[nuq+ %s `%s`] setState: %O', hookId, stateKeys, newState)
Expand Down Expand Up @@ -358,13 +357,17 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
adapter.getSearchParamsSnapshot,
adapter.rateLimitFactor,
processUrlSearchParams,
defaultValues
defaultValueStore
]
)

const outputState = useMemo(
() => applyDefaultValues(internalState, defaultValues),
[internalState, defaultValues]
() =>
applyDefaultValues(
internalState,
defaultValueStore as Partial<Values<KeyMap>>
),
[internalState, defaultValueStore]
)
return [outputState, update]
}
Expand Down Expand Up @@ -430,7 +433,7 @@ function parseMap<KeyMap extends UseQueryStatesKeysMap>(

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