Skip to content

Commit 642cba1

Browse files
authored
fix: don't flush on debounced state updates (#1172)
1 parent 82d8fc9 commit 642cba1

File tree

5 files changed

+125
-7
lines changed

5 files changed

+125
-7
lines changed

packages/nuqs/src/adapters/testing.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export type UrlUpdateEvent = {
2020

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

23-
type TestingAdapterProps = {
23+
type TestingAdapterProps = Pick<AdapterInterface, 'autoResetQueueOnUpdate'> & {
2424
/**
2525
* An initial value for the search params.
2626
*/
@@ -79,6 +79,7 @@ function renderInitialSearchParams(
7979

8080
export function NuqsTestingAdapter({
8181
resetUrlUpdateQueueOnMount = true,
82+
autoResetQueueOnUpdate = true,
8283
defaultOptions,
8384
processUrlSearchParams,
8485
rateLimitFactor = 0,
@@ -129,7 +130,8 @@ export function NuqsTestingAdapter({
129130
searchParams,
130131
updateUrl,
131132
getSearchParamsSnapshot,
132-
rateLimitFactor
133+
rateLimitFactor,
134+
autoResetQueueOnUpdate
133135
})
134136
return createElement(
135137
context.Provider,

packages/nuqs/src/lib/queues/throttle.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ export class ThrottledQueue {
7575
return this.updateMap.get(key)
7676
}
7777

78+
getPendingPromise({
79+
getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation
80+
}: UpdateQueueAdapterContext): Promise<URLSearchParams> {
81+
return this.resolvers?.promise ?? Promise.resolve(getSearchParamsSnapshot())
82+
}
83+
7884
flush(
7985
{
8086
getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation,

packages/nuqs/src/useQueryState.test.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { act, render, renderHook, screen } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
3+
import { setTimeout as wait } from 'node:timers/promises'
34
import React, { useState } from 'react'
45
import { describe, expect, it, vi } from 'vitest'
56
import {
@@ -10,7 +11,7 @@ import {
1011
withNuqsTestingAdapter,
1112
type OnUrlUpdateFunction
1213
} from './adapters/testing'
13-
import { debounce } from './lib/queues/rate-limiting'
14+
import { debounce, throttle } from './lib/queues/rate-limiting'
1415
import {
1516
parseAsArrayOf,
1617
parseAsInteger,
@@ -20,6 +21,8 @@ import {
2021
} from './parsers'
2122
import { useQueryState } from './useQueryState'
2223

24+
const waitForNextTick = () => wait(0)
25+
2326
describe('useQueryState: referential equality', () => {
2427
const defaults = {
2528
str: 'foo',
@@ -349,6 +352,50 @@ describe('useQueryState: update sequencing', () => {
349352
expect(onUrlUpdate).toHaveBeenCalledTimes(1)
350353
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=b')
351354
})
355+
356+
it('does flush when pushing throttled updates', async () => {
357+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
358+
const { result } = renderHook(() => useQueryState('test'), {
359+
wrapper: withNuqsTestingAdapter({
360+
onUrlUpdate,
361+
autoResetQueueOnUpdate: false
362+
})
363+
})
364+
let p: Promise<URLSearchParams> | undefined = undefined
365+
await act(async () => {
366+
p = result.current[1]('pass', { limitUrlUpdates: throttle(100) })
367+
await waitForNextTick()
368+
})
369+
expect(onUrlUpdate).toHaveBeenCalledOnce()
370+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
371+
await expect(p).resolves.toEqual(new URLSearchParams('?test=pass'))
372+
})
373+
374+
it('does not flush when pushing debounced updates', async () => {
375+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
376+
const { result } = renderHook(() => useQueryState('test'), {
377+
wrapper: withNuqsTestingAdapter({
378+
onUrlUpdate,
379+
autoResetQueueOnUpdate: false
380+
})
381+
})
382+
// Flush a first time without resetting the queue to keep pending items
383+
// in the global throttle queue.
384+
await act(() => result.current[1]('init'))
385+
expect(onUrlUpdate).toHaveBeenCalledOnce()
386+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=init')
387+
onUrlUpdate.mockClear()
388+
// Now push a debounced update, which should not flush immediately
389+
let p: Promise<URLSearchParams> | undefined = undefined
390+
await act(async () => {
391+
p = result.current[1]('pass', { limitUrlUpdates: debounce(100) })
392+
await waitForNextTick()
393+
})
394+
expect(onUrlUpdate).not.toHaveBeenCalled()
395+
await expect(p).resolves.toEqual(new URLSearchParams('?test=pass'))
396+
expect(onUrlUpdate).toHaveBeenCalledOnce()
397+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
398+
})
352399
})
353400

354401
describe('useQueryState: adapter defaults', () => {

packages/nuqs/src/useQueryStates.test.tsx

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { act, render, renderHook, screen } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
3+
import { setTimeout as wait } from 'node:timers/promises'
34
import React, {
45
createElement,
6+
useEffect,
57
useState,
6-
type ReactNode,
7-
useEffect
8+
type ReactNode
89
} from 'react'
910
import { describe, expect, it, vi } from 'vitest'
1011
import {
@@ -16,7 +17,7 @@ import {
1617
withNuqsTestingAdapter,
1718
type OnUrlUpdateFunction
1819
} from './adapters/testing'
19-
import { debounce } from './lib/queues/rate-limiting'
20+
import { debounce, throttle } from './lib/queues/rate-limiting'
2021
import {
2122
parseAsArrayOf,
2223
parseAsInteger,
@@ -27,6 +28,8 @@ import {
2728
import { useQueryState } from './useQueryState'
2829
import { useQueryStates } from './useQueryStates'
2930

31+
const waitForNextTick = () => wait(0)
32+
3033
describe('useQueryStates', () => {
3134
it('allows setting a single value', async () => {
3235
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
@@ -767,6 +770,62 @@ describe('useQueryStates: update sequencing', () => {
767770
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?b=pass')
768771
expect(onUrlUpdate.mock.calls[1]![0].queryString).toEqual('?a=debounced')
769772
})
773+
774+
it('does flush when pushing throttled updates', async () => {
775+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
776+
const { result } = renderHook(
777+
() => useQueryStates({ test: parseAsString }),
778+
{
779+
wrapper: withNuqsTestingAdapter({
780+
onUrlUpdate,
781+
autoResetQueueOnUpdate: false
782+
})
783+
}
784+
)
785+
let p: Promise<URLSearchParams> | undefined = undefined
786+
await act(async () => {
787+
p = result.current[1](
788+
{ test: 'pass' },
789+
{ limitUrlUpdates: throttle(100) }
790+
)
791+
await waitForNextTick()
792+
})
793+
expect(onUrlUpdate).toHaveBeenCalledOnce()
794+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
795+
await expect(p).resolves.toEqual(new URLSearchParams('?test=pass'))
796+
})
797+
798+
it('does not flush when pushing debounced updates', async () => {
799+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
800+
const { result } = renderHook(
801+
() => useQueryStates({ test: parseAsString }),
802+
{
803+
wrapper: withNuqsTestingAdapter({
804+
onUrlUpdate,
805+
autoResetQueueOnUpdate: false
806+
})
807+
}
808+
)
809+
// Flush a first time without resetting the queue to keep pending items
810+
// in the global throttle queue.
811+
await act(() => result.current[1]({ test: 'init' }))
812+
expect(onUrlUpdate).toHaveBeenCalledOnce()
813+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=init')
814+
onUrlUpdate.mockClear()
815+
// Now push a debounced update, which should not flush immediately
816+
let p: Promise<URLSearchParams> | undefined = undefined
817+
await act(async () => {
818+
p = result.current[1](
819+
{ test: 'pass' },
820+
{ limitUrlUpdates: debounce(100) }
821+
)
822+
await waitForNextTick()
823+
})
824+
expect(onUrlUpdate).not.toHaveBeenCalled()
825+
await expect(p).resolves.toEqual(new URLSearchParams('?test=pass'))
826+
expect(onUrlUpdate).toHaveBeenCalledOnce()
827+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
828+
})
770829
})
771830

772831
describe('useQueryStates: adapter defaults', () => {

packages/nuqs/src/useQueryStates.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
281281
debug('[nuq+ %s `%s`] setState: %O', hookId, stateKeys, newState)
282282
let returnedPromise: Promise<URLSearchParams> | undefined = undefined
283283
let maxDebounceTime = 0
284+
let doFlush = false
284285
const debounceAborts: Array<
285286
(p: Promise<URLSearchParams>) => Promise<URLSearchParams>
286287
> = []
@@ -352,13 +353,16 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
352353
throttleMs
353354
debounceAborts.push(debounceController.abort(urlKey))
354355
globalThrottleQueue.push(update, timeMs)
356+
doFlush = true
355357
}
356358
}
357359
// We need to flush the throttle queue, but we may have a pending
358360
// debounced update that will resolve afterwards.
359361
const globalPromise = debounceAborts.reduce(
360362
(previous, fn) => fn(previous),
361-
globalThrottleQueue.flush(adapter, processUrlSearchParams)
363+
doFlush
364+
? globalThrottleQueue.flush(adapter, processUrlSearchParams)
365+
: globalThrottleQueue.getPendingPromise(adapter)
362366
)
363367
return returnedPromise ?? globalPromise
364368
},

0 commit comments

Comments
 (0)