Skip to content
Draft
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
15 changes: 0 additions & 15 deletions packages/e2e/next/cypress/e2e/repros.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
44 changes: 0 additions & 44 deletions packages/e2e/next/src/app/app/repro-760/page.tsx

This file was deleted.

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({
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 {
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} />
</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')
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(',')
]
)

// 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
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]
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