Skip to content

Commit 466ad87

Browse files
committed
fix: Clear queued value after use
1 parent 5553fb9 commit 466ad87

File tree

4 files changed

+191
-23
lines changed

4 files changed

+191
-23
lines changed

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

+154-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import { setTimeout } from 'node:timers/promises'
12
import { describe, expect, it, vi } from 'vitest'
2-
import { DebouncedPromiseQueue } from './debounce'
3+
import type { UpdateUrlFunction } from '../../adapters/lib/defs'
4+
import { DebounceController, DebouncedPromiseQueue } from './debounce'
5+
import { ThrottledQueue, type UpdateQueueAdapterContext } from './throttle'
36

4-
describe('queues: DebouncedPromiseQueue', () => {
5-
it('creates a queue for a given key', () => {
7+
async function passThrough<T>(value: T): Promise<T> {
8+
return value
9+
}
10+
11+
describe('debounce: DebouncedPromiseQueue', () => {
12+
it('calls the callback after the timer expired', () => {
613
vi.useFakeTimers()
714
const spy = vi.fn().mockResolvedValue('output')
815
const queue = new DebouncedPromiseQueue(spy)
@@ -22,12 +29,12 @@ describe('queues: DebouncedPromiseQueue', () => {
2229
})
2330
it('returns a stable promise to the next time the callback is called', async () => {
2431
vi.useFakeTimers()
25-
const queue = new DebouncedPromiseQueue(() => Promise.resolve('output'))
26-
const p1 = queue.push('value', 100)
27-
const p2 = queue.push('value', 100)
32+
const queue = new DebouncedPromiseQueue(passThrough)
33+
const p1 = queue.push('a', 100)
34+
const p2 = queue.push('b', 100)
2835
expect(p1).toBe(p2)
2936
vi.advanceTimersToNextTimer()
30-
await expect(p1).resolves.toBe('output')
37+
await expect(p1).resolves.toBe('b')
3138
})
3239
it('returns a new Promise once the callback is called', async () => {
3340
vi.useFakeTimers()
@@ -41,4 +48,144 @@ describe('queues: DebouncedPromiseQueue', () => {
4148
vi.advanceTimersToNextTimer()
4249
await expect(p2).resolves.toBe(1)
4350
})
51+
it('keeps a record of the last queued value', async () => {
52+
vi.useFakeTimers()
53+
const queue = new DebouncedPromiseQueue(passThrough)
54+
const p = queue.push('a', 100)
55+
expect(queue.queuedValue).toBe('a')
56+
vi.advanceTimersToNextTimer()
57+
await expect(p).resolves.toBe('a')
58+
expect(queue.queuedValue).toBeUndefined()
59+
})
60+
it('clears the queued value when the callback returns its promise (not when it resolves)', () => {
61+
vi.useFakeTimers()
62+
const queue = new DebouncedPromiseQueue(async input => {
63+
await setTimeout(100)
64+
return input
65+
})
66+
queue.push('a', 100)
67+
vi.advanceTimersByTime(100)
68+
expect(queue.queuedValue).toBeUndefined()
69+
})
70+
it('clears the queued value when the callback throws an error synchronously', async () => {
71+
vi.useFakeTimers()
72+
const queue = new DebouncedPromiseQueue(() => {
73+
throw new Error('error')
74+
})
75+
const p = queue.push('a', 100)
76+
vi.advanceTimersToNextTimer()
77+
expect(queue.queuedValue).toBeUndefined()
78+
await expect(p).rejects.toThrowError('error')
79+
})
80+
it('clears the queued value when the callback rejects', async () => {
81+
vi.useFakeTimers()
82+
const queue = new DebouncedPromiseQueue(() =>
83+
Promise.reject(new Error('error'))
84+
)
85+
const p = queue.push('a', 100)
86+
vi.advanceTimersToNextTimer()
87+
expect(queue.queuedValue).toBeUndefined()
88+
await expect(p).rejects.toThrowError('error')
89+
})
90+
})
91+
92+
describe.only('debounce: DebounceController', () => {
93+
it('schedules an update and calls the adapter with it', async () => {
94+
vi.useFakeTimers()
95+
const fakeAdapter: UpdateQueueAdapterContext = {
96+
updateUrl: vi.fn<UpdateUrlFunction>(),
97+
getSearchParamsSnapshot() {
98+
return new URLSearchParams()
99+
}
100+
}
101+
const controller = new DebounceController()
102+
const promise = controller.push(
103+
{
104+
key: 'key',
105+
query: 'value',
106+
options: {}
107+
},
108+
100,
109+
fakeAdapter
110+
)
111+
const queue = controller.queues.get('key')
112+
expect(queue).toBeInstanceOf(DebouncedPromiseQueue)
113+
vi.runAllTimers()
114+
await expect(promise).resolves.toEqual(new URLSearchParams('?key=value'))
115+
expect(fakeAdapter.updateUrl).toHaveBeenCalledExactlyOnceWith(
116+
new URLSearchParams('?key=value'),
117+
{
118+
history: 'replace',
119+
scroll: false,
120+
shallow: true
121+
}
122+
)
123+
})
124+
it('isolates debounce queues per key', async () => {
125+
vi.useFakeTimers()
126+
const fakeAdapter: UpdateQueueAdapterContext = {
127+
updateUrl: vi.fn<UpdateUrlFunction>(),
128+
getSearchParamsSnapshot() {
129+
return new URLSearchParams()
130+
}
131+
}
132+
const controller = new DebounceController()
133+
const promise1 = controller.push(
134+
{
135+
key: 'a',
136+
query: 'a',
137+
options: {}
138+
},
139+
100,
140+
fakeAdapter
141+
)
142+
const promise2 = controller.push(
143+
{
144+
key: 'b',
145+
query: 'b',
146+
options: {}
147+
},
148+
200,
149+
fakeAdapter
150+
)
151+
expect(promise1).not.toBe(promise2)
152+
vi.runAllTimers()
153+
await expect(promise1).resolves.toEqual(new URLSearchParams('?a=a'))
154+
// Our snapshot always returns an empty search params object, so there is no
155+
// merging of keys here.
156+
await expect(promise2).resolves.toEqual(new URLSearchParams('?b=b'))
157+
expect(fakeAdapter.updateUrl).toHaveBeenCalledTimes(2)
158+
})
159+
it('keeps a record of pending updates', async () => {
160+
vi.useFakeTimers()
161+
const fakeAdapter: UpdateQueueAdapterContext = {
162+
updateUrl: vi.fn<UpdateUrlFunction>(),
163+
getSearchParamsSnapshot() {
164+
return new URLSearchParams()
165+
}
166+
}
167+
const controller = new DebounceController()
168+
controller.push(
169+
{
170+
key: 'key',
171+
query: 'value',
172+
options: {}
173+
},
174+
100,
175+
fakeAdapter
176+
)
177+
expect(controller.getQueuedQuery('key')).toEqual('value')
178+
vi.runAllTimers()
179+
expect(controller.getQueuedQuery('key')).toBeUndefined()
180+
})
181+
it('falls back to the throttle queue pending values if nothing is debounced', () => {
182+
const throttleQueue = new ThrottledQueue()
183+
throttleQueue.push({
184+
key: 'key',
185+
query: 'value',
186+
options: {}
187+
})
188+
const controller = new DebounceController(throttleQueue)
189+
expect(controller.getQueuedQuery('key')).toEqual('value')
190+
})
44191
})

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

+21-12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { debug } from '../debug'
12
import { timeout } from '../timeout'
23
import { withResolvers } from '../with-resolvers'
34
import {
45
globalThrottleQueue,
6+
ThrottledQueue,
57
type UpdateQueueAdapterContext,
68
type UpdateQueuePushArgs
79
} from './throttle'
@@ -23,14 +25,18 @@ export class DebouncedPromiseQueue<ValueType, OutputType> {
2325
timeout(
2426
() => {
2527
try {
26-
this.callback(value)
27-
.then(output => this.resolvers.resolve(output))
28+
debug('[nuqs queue] Flushing debounce queue', value)
29+
const p = this.callback(value)
30+
debug('[nuqs queue] Reset debounced queue %O', this.queuedValue)
31+
this.queuedValue = undefined
32+
p.then(output => this.resolvers.resolve(output))
2833
.catch(error => this.resolvers.reject(error))
2934
.finally(() => {
30-
// todo: Should we clear the queued value here?
35+
// Reset Promise for next use
3136
this.resolvers = withResolvers<OutputType>()
3237
})
3338
} catch (error) {
39+
this.queuedValue = undefined
3440
this.resolvers.reject(error)
3541
}
3642
},
@@ -39,20 +45,23 @@ export class DebouncedPromiseQueue<ValueType, OutputType> {
3945
)
4046
return this.resolvers.promise
4147
}
42-
43-
public get queued() {
44-
return this.queuedValue
45-
}
4648
}
4749

50+
// --
51+
4852
type DebouncedUpdateQueue = DebouncedPromiseQueue<
4953
Omit<UpdateQueuePushArgs, 'throttleMs'>,
5054
URLSearchParams
5155
>
5256

5357
export class DebounceController {
58+
throttleQueue: ThrottledQueue
5459
queues: Map<string, DebouncedUpdateQueue> = new Map()
5560

61+
constructor(throttleQueue: ThrottledQueue = new ThrottledQueue()) {
62+
this.throttleQueue = throttleQueue
63+
}
64+
5665
public push(
5766
update: Omit<UpdateQueuePushArgs, 'throttleMs'>,
5867
timeMs: number,
@@ -63,8 +72,8 @@ export class DebounceController {
6372
Omit<UpdateQueuePushArgs, 'throttleMs'>,
6473
URLSearchParams
6574
>(update => {
66-
globalThrottleQueue.push(update)
67-
return globalThrottleQueue.flush(adapter)
75+
this.throttleQueue.push(update)
76+
return this.throttleQueue.flush(adapter)
6877
// todo: Figure out cleanup strategy
6978
// .finally(() => {
7079
// this.queues.delete(update.key)
@@ -80,12 +89,12 @@ export class DebounceController {
8089
// The debounced queued values are more likely to be up-to-date
8190
// than any updates pending in the throttle queue, which comes last
8291
// in the update chain.
83-
const debouncedQueued = this.queues.get(key)?.queued?.query
92+
const debouncedQueued = this.queues.get(key)?.queuedValue?.query
8493
if (debouncedQueued !== undefined) {
8594
return debouncedQueued
8695
}
87-
return globalThrottleQueue.getQueuedQuery(key)
96+
return this.throttleQueue.getQueuedQuery(key)
8897
}
8998
}
9099

91-
export const debounceController = new DebounceController()
100+
export const debounceController = new DebounceController(globalThrottleQueue)

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

+12-3
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ export class ThrottledQueue {
106106
// Flush already scheduled
107107
return this.resolvers.promise
108108
}
109+
if (this.updateMap.size === 0) {
110+
// Nothing to flush
111+
return Promise.resolve(getSearchParamsSnapshot())
112+
}
109113
this.resolvers = withResolvers<URLSearchParams>()
110114
const flushNow = () => {
111115
this.lastFlushedAt = performance.now()
@@ -131,9 +135,10 @@ export class ThrottledQueue {
131135
rateLimitFactor *
132136
Math.max(0, Math.min(throttleMs, throttleMs - timeSinceLastFlush))
133137
debug(
134-
'[nuqs queue] Scheduling flush in %f ms. Throttled at %f ms',
138+
`[nuqs queue] Scheduling flush in %f ms. Throttled at %f ms (x%f)`,
135139
flushInMs,
136-
throttleMs
140+
throttleMs,
141+
rateLimitFactor
137142
)
138143
if (flushInMs === 0) {
139144
// Since we're already in the "next tick" from queued updates,
@@ -172,7 +177,11 @@ export class ThrottledQueue {
172177
const transitions = Array.from(this.transitions)
173178
// Restore defaults
174179
this.reset()
175-
debug('[nuqs queue] Flushing queue %O with options %O', items, options)
180+
debug(
181+
'[nuqs queue] Flushing throttle queue %O with options %O',
182+
items,
183+
options
184+
)
176185
for (const [key, value] of items) {
177186
if (value === null) {
178187
search.delete(key)

packages/nuqs/src/useQueryStates.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
268268
adapter
269269
)
270270
if (maxDebounceTime < timeMs) {
271-
// The largest debounce is likely to be the last URL update:
271+
// The largest debounce is likely to be the last URL update,
272+
// so we keep that Promise to return it.
272273
returnedPromise = debouncedPromise
273274
maxDebounceTime = timeMs
274275
}
@@ -283,6 +284,8 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
283284
globalThrottleQueue.push(update)
284285
}
285286
}
287+
// We need to flush the throttle queue, but we may have a pending
288+
// debounced update that will resolve afterwards.
286289
const globalPromise = globalThrottleQueue.flush(adapter)
287290
return returnedPromise ?? globalPromise
288291
},

0 commit comments

Comments
 (0)