Skip to content

Commit c026e23

Browse files
committed
feat: Auto-abort debounced updates when pushing a non-debounced one
1 parent 02526cd commit c026e23

File tree

6 files changed

+156
-2
lines changed

6 files changed

+156
-2
lines changed

packages/nuqs/src/lib/queues/debounce.test.ts

+30
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,34 @@ describe('debounce: DebounceController', () => {
202202
const controller = new DebounceController(throttleQueue)
203203
expect(controller.getQueuedQuery('key')).toEqual('value')
204204
})
205+
it('aborts an update and chains the Promise onto another one that overrides it', async () => {
206+
vi.useFakeTimers()
207+
const fakeAdapter: UpdateQueueAdapterContext = {
208+
updateUrl: vi.fn<UpdateUrlFunction>(),
209+
getSearchParamsSnapshot() {
210+
return new URLSearchParams()
211+
}
212+
}
213+
const controller = new DebounceController()
214+
const debouncedPromise = controller.push(
215+
{
216+
key: 'key',
217+
query: 'value',
218+
options: {}
219+
},
220+
100,
221+
fakeAdapter
222+
)
223+
const attach = controller.abort('key')
224+
expect(attach).toBeInstanceOf(Function)
225+
vi.runAllTimers()
226+
const resolvedPromise = Promise.resolve(
227+
new URLSearchParams('?key=override')
228+
)
229+
const attachedPromise = attach(resolvedPromise)
230+
expect(attachedPromise).toBe(resolvedPromise) // Referential equality
231+
await expect(debouncedPromise).resolves.toEqual(
232+
new URLSearchParams('?key=override')
233+
)
234+
})
205235
})

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

+26
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,32 @@ export class DebounceController {
8989
return queue.push(update, timeMs)
9090
}
9191

92+
abort(
93+
key: string
94+
): (promise: Promise<URLSearchParams>) => Promise<URLSearchParams> {
95+
const queue = this.queues.get(key)
96+
if (!queue) {
97+
return passThrough => passThrough
98+
}
99+
debug(
100+
'[nuqs queue] Aborting debounced queue %s=%s',
101+
key,
102+
queue.queuedValue?.query
103+
)
104+
this.queues.delete(key)
105+
queue.controller.abort() // Don't run to completion
106+
return function attachAbortedDebouncedResolvers(
107+
promise: Promise<URLSearchParams>
108+
) {
109+
promise.then(
110+
value => queue.resolvers.resolve(value),
111+
error => queue.resolvers.reject(error)
112+
)
113+
// Don't chain: keep reference equality
114+
return promise
115+
}
116+
}
117+
92118
getQueuedQuery(key: string) {
93119
// The debounced queued values are more likely to be up-to-date
94120
// than any updates pending in the throttle queue, which comes last

packages/nuqs/src/useQueryState.test.ts

+23
Original file line numberDiff line numberDiff line change
@@ -301,4 +301,27 @@ describe('useQueryState: update sequencing', () => {
301301
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?b=b')
302302
expect(onUrlUpdate.mock.calls[1]![0].queryString).toEqual('?a=a')
303303
})
304+
it('aborts a debounced update when pushing a throttled one', async () => {
305+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
306+
const { result } = renderHook(() => useQueryState('test'), {
307+
wrapper: withNuqsTestingAdapter({
308+
onUrlUpdate,
309+
rateLimitFactor: 1
310+
})
311+
})
312+
let p1: Promise<URLSearchParams> | undefined = undefined
313+
let p2: Promise<URLSearchParams> | undefined = undefined
314+
await act(async () => {
315+
p1 = result.current[1]('a', { limitUrlUpdates: debounce(100) })
316+
p2 = result.current[1]('b')
317+
return Promise.allSettled([p1, p2])
318+
})
319+
expect(p1).toBeInstanceOf(Promise)
320+
expect(p2).toBeInstanceOf(Promise)
321+
expect(p1).not.toBe(p2)
322+
await expect(p1).resolves.toEqual(new URLSearchParams('?test=b'))
323+
await expect(p2).resolves.toEqual(new URLSearchParams('?test=b'))
324+
expect(onUrlUpdate).toHaveBeenCalledTimes(1)
325+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=b')
326+
})
304327
})

packages/nuqs/src/useQueryState.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,9 @@ export function useQueryState<T = string>(
311311
limitUrlUpdates?.timeMs ??
312312
options.throttleMs ??
313313
throttleMs
314+
const handleAbortedDebounce = debounceController.abort(key)
314315
globalThrottleQueue.push(update, timeMs)
315-
return globalThrottleQueue.flush(adapter)
316+
return handleAbortedDebounce(globalThrottleQueue.flush(adapter))
316317
}
317318
},
318319
[

packages/nuqs/src/useQueryStates.test.ts

+67
Original file line numberDiff line numberDiff line change
@@ -615,4 +615,71 @@ describe('useQueryStates: update sequencing', () => {
615615
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?b=b')
616616
expect(onUrlUpdate.mock.calls[1]![0].queryString).toEqual('?a=a')
617617
})
618+
619+
it('aborts a debounced update when pushing a throttled one', async () => {
620+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
621+
const { result } = renderHook(
622+
() =>
623+
useQueryStates({
624+
test: parseAsString
625+
}),
626+
{
627+
wrapper: withNuqsTestingAdapter({
628+
onUrlUpdate,
629+
rateLimitFactor: 1
630+
})
631+
}
632+
)
633+
let p1: Promise<URLSearchParams> | undefined = undefined
634+
let p2: Promise<URLSearchParams> | undefined = undefined
635+
await act(async () => {
636+
p1 = result.current[1](
637+
{ test: 'init' },
638+
{ limitUrlUpdates: debounce(100) }
639+
)
640+
p2 = result.current[1]({ test: 'pass' })
641+
return Promise.allSettled([p1, p2])
642+
})
643+
expect(p1).toBeInstanceOf(Promise)
644+
expect(p2).toBeInstanceOf(Promise)
645+
expect(p1).not.toBe(p2)
646+
// Note: our mock adapter does not save search params, so there is no merge
647+
await expect(p1).resolves.toEqual(new URLSearchParams('?test=pass'))
648+
await expect(p2).resolves.toEqual(new URLSearchParams('?test=pass'))
649+
expect(onUrlUpdate).toHaveBeenCalledTimes(1)
650+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?test=pass')
651+
})
652+
653+
it('does not abort when pushing another key', async () => {
654+
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
655+
const { result } = renderHook(
656+
() =>
657+
useQueryStates({
658+
a: parseAsString.withOptions({ limitUrlUpdates: debounce(100) }),
659+
b: parseAsString
660+
}),
661+
{
662+
wrapper: withNuqsTestingAdapter({
663+
onUrlUpdate,
664+
rateLimitFactor: 1
665+
})
666+
}
667+
)
668+
let p1: Promise<URLSearchParams> | undefined = undefined
669+
let p2: Promise<URLSearchParams> | undefined = undefined
670+
await act(async () => {
671+
p1 = result.current[1]({ a: 'debounced' })
672+
p2 = result.current[1]({ b: 'pass' })
673+
return Promise.allSettled([p1, p2])
674+
})
675+
expect(p1).toBeInstanceOf(Promise)
676+
expect(p2).toBeInstanceOf(Promise)
677+
expect(p1).not.toBe(p2)
678+
// Note: our mock adapter does not save search params, so there is no merge
679+
await expect(p1).resolves.toEqual(new URLSearchParams('?a=debounced'))
680+
await expect(p2).resolves.toEqual(new URLSearchParams('?b=pass'))
681+
expect(onUrlUpdate).toHaveBeenCalledTimes(2)
682+
expect(onUrlUpdate.mock.calls[0]![0].queryString).toEqual('?b=pass')
683+
expect(onUrlUpdate.mock.calls[1]![0].queryString).toEqual('?a=debounced')
684+
})
618685
})

packages/nuqs/src/useQueryStates.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
218218
debug('[nuq+ `%s`] setState: %O', stateKeys, newState)
219219
let returnedPromise: Promise<URLSearchParams> | undefined = undefined
220220
let maxDebounceTime = 0
221+
const debounceAborts: Array<
222+
(p: Promise<URLSearchParams>) => Promise<URLSearchParams>
223+
> = []
221224
for (let [stateKey, value] of Object.entries(newState)) {
222225
const parser = keyMap[stateKey]
223226
const urlKey = resolvedUrlKeys[stateKey]!
@@ -281,12 +284,16 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
281284
callOptions.throttleMs ??
282285
parser.throttleMs ??
283286
throttleMs
287+
debounceAborts.push(debounceController.abort(urlKey))
284288
globalThrottleQueue.push(update, timeMs)
285289
}
286290
}
287291
// We need to flush the throttle queue, but we may have a pending
288292
// debounced update that will resolve afterwards.
289-
const globalPromise = globalThrottleQueue.flush(adapter)
293+
const globalPromise = debounceAborts.reduce(
294+
(previous, fn) => fn(previous),
295+
globalThrottleQueue.flush(adapter)
296+
)
290297
return returnedPromise ?? globalPromise
291298
},
292299
[

0 commit comments

Comments
 (0)