Skip to content
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
6 changes: 4 additions & 2 deletions packages/nuqs/src/adapters/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type UrlUpdateEvent = {

export type OnUrlUpdateFunction = (event: UrlUpdateEvent) => void

type TestingAdapterProps = {
type TestingAdapterProps = Pick<AdapterInterface, 'autoResetQueueOnUpdate'> & {
/**
* An initial value for the search params.
*/
Expand Down Expand Up @@ -79,6 +79,7 @@ function renderInitialSearchParams(

export function NuqsTestingAdapter({
resetUrlUpdateQueueOnMount = true,
autoResetQueueOnUpdate = true,
defaultOptions,
processUrlSearchParams,
rateLimitFactor = 0,
Expand Down Expand Up @@ -129,7 +130,8 @@ export function NuqsTestingAdapter({
searchParams,
updateUrl,
getSearchParamsSnapshot,
rateLimitFactor
rateLimitFactor,
autoResetQueueOnUpdate
})
return createElement(
context.Provider,
Expand Down
6 changes: 6 additions & 0 deletions packages/nuqs/src/lib/queues/throttle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export class ThrottledQueue {
return this.updateMap.get(key)
}

getPendingPromise({
getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation
}: UpdateQueueAdapterContext): Promise<URLSearchParams> {
return this.resolvers?.promise ?? Promise.resolve(getSearchParamsSnapshot())
}

flush(
{
getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation,
Expand Down
49 changes: 48 additions & 1 deletion packages/nuqs/src/useQueryState.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { act, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setTimeout as wait } from 'node:timers/promises'
import React, { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import {
Expand All @@ -10,7 +11,7 @@ import {
withNuqsTestingAdapter,
type OnUrlUpdateFunction
} from './adapters/testing'
import { debounce } from './lib/queues/rate-limiting'
import { debounce, throttle } from './lib/queues/rate-limiting'
import {
parseAsArrayOf,
parseAsInteger,
Expand All @@ -20,6 +21,8 @@ import {
} from './parsers'
import { useQueryState } from './useQueryState'

const waitForNextTick = () => wait(0)

describe('useQueryState: referential equality', () => {
const defaults = {
str: 'foo',
Expand Down Expand Up @@ -349,6 +352,50 @@ describe('useQueryState: update sequencing', () => {
expect(onUrlUpdate).toHaveBeenCalledTimes(1)
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=b')
})

it('does flush when pushing throttled updates', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
const { result } = renderHook(() => useQueryState('test'), {
wrapper: withNuqsTestingAdapter({
onUrlUpdate,
autoResetQueueOnUpdate: false
})
})
let p: Promise<URLSearchParams> | undefined = undefined
await act(async () => {
p = result.current[1]('pass', { limitUrlUpdates: throttle(100) })
await waitForNextTick()
})
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
await expect(p).resolves.toEqual(new URLSearchParams('?test=pass'))
})

it('does not flush when pushing debounced updates', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
const { result } = renderHook(() => useQueryState('test'), {
wrapper: withNuqsTestingAdapter({
onUrlUpdate,
autoResetQueueOnUpdate: false
})
})
// Flush a first time without resetting the queue to keep pending items
// in the global throttle queue.
await act(() => result.current[1]('init'))
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=init')
onUrlUpdate.mockClear()
// Now push a debounced update, which should not flush immediately
let p: Promise<URLSearchParams> | undefined = undefined
await act(async () => {
p = result.current[1]('pass', { limitUrlUpdates: debounce(100) })
await waitForNextTick()
})
expect(onUrlUpdate).not.toHaveBeenCalled()
await expect(p).resolves.toEqual(new URLSearchParams('?test=pass'))
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
})
})

describe('useQueryState: adapter defaults', () => {
Expand Down
65 changes: 62 additions & 3 deletions packages/nuqs/src/useQueryStates.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { act, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setTimeout as wait } from 'node:timers/promises'
import React, {
createElement,
useEffect,
useState,
type ReactNode,
useEffect
type ReactNode
} from 'react'
import { describe, expect, it, vi } from 'vitest'
import {
Expand All @@ -16,7 +17,7 @@ import {
withNuqsTestingAdapter,
type OnUrlUpdateFunction
} from './adapters/testing'
import { debounce } from './lib/queues/rate-limiting'
import { debounce, throttle } from './lib/queues/rate-limiting'
import {
parseAsArrayOf,
parseAsInteger,
Expand All @@ -27,6 +28,8 @@ import {
import { useQueryState } from './useQueryState'
import { useQueryStates } from './useQueryStates'

const waitForNextTick = () => wait(0)

describe('useQueryStates', () => {
it('allows setting a single value', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
Expand Down Expand Up @@ -767,6 +770,62 @@ describe('useQueryStates: update sequencing', () => {
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?b=pass')
expect(onUrlUpdate.mock.calls[1]![0].queryString).toEqual('?a=debounced')
})

it('does flush when pushing throttled updates', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
const { result } = renderHook(
() => useQueryStates({ test: parseAsString }),
{
wrapper: withNuqsTestingAdapter({
onUrlUpdate,
autoResetQueueOnUpdate: false
})
}
)
let p: Promise<URLSearchParams> | undefined = undefined
await act(async () => {
p = result.current[1](
{ test: 'pass' },
{ limitUrlUpdates: throttle(100) }
)
await waitForNextTick()
})
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
await expect(p).resolves.toEqual(new URLSearchParams('?test=pass'))
})

it('does not flush when pushing debounced updates', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
const { result } = renderHook(
() => useQueryStates({ test: parseAsString }),
{
wrapper: withNuqsTestingAdapter({
onUrlUpdate,
autoResetQueueOnUpdate: false
})
}
)
// Flush a first time without resetting the queue to keep pending items
// in the global throttle queue.
await act(() => result.current[1]({ test: 'init' }))
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=init')
onUrlUpdate.mockClear()
// Now push a debounced update, which should not flush immediately
let p: Promise<URLSearchParams> | undefined = undefined
await act(async () => {
p = result.current[1](
{ test: 'pass' },
{ limitUrlUpdates: debounce(100) }
)
await waitForNextTick()
})
expect(onUrlUpdate).not.toHaveBeenCalled()
await expect(p).resolves.toEqual(new URLSearchParams('?test=pass'))
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
})
})

describe('useQueryStates: adapter defaults', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/nuqs/src/useQueryStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
debug('[nuq+ %s `%s`] setState: %O', hookId, stateKeys, newState)
let returnedPromise: Promise<URLSearchParams> | undefined = undefined
let maxDebounceTime = 0
let doFlush = false
const debounceAborts: Array<
(p: Promise<URLSearchParams>) => Promise<URLSearchParams>
> = []
Expand Down Expand Up @@ -352,13 +353,16 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
throttleMs
debounceAborts.push(debounceController.abort(urlKey))
globalThrottleQueue.push(update, timeMs)
doFlush = true
}
}
// We need to flush the throttle queue, but we may have a pending
// debounced update that will resolve afterwards.
const globalPromise = debounceAborts.reduce(
(previous, fn) => fn(previous),
globalThrottleQueue.flush(adapter, processUrlSearchParams)
doFlush
? globalThrottleQueue.flush(adapter, processUrlSearchParams)
: globalThrottleQueue.getPendingPromise(adapter)
)
return returnedPromise ?? globalPromise
},
Expand Down
Loading