Skip to content

Commit 590dda5

Browse files
committed
chore: Cleanup & testing
1 parent 466ad87 commit 590dda5

File tree

5 files changed

+118
-26
lines changed

5 files changed

+118
-26
lines changed

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

+15-1
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,23 @@ describe('debounce: DebouncedPromiseQueue', () => {
8787
expect(queue.queuedValue).toBeUndefined()
8888
await expect(p).rejects.toThrowError('error')
8989
})
90+
it('returns a new Promise when an update is pushed while the callback is pending', async () => {
91+
vi.useFakeTimers()
92+
const queue = new DebouncedPromiseQueue(async input => {
93+
await setTimeout(100)
94+
return input
95+
})
96+
const p1 = queue.push('a', 100)
97+
vi.advanceTimersByTime(150) // 100ms debounce + half the callback settle time
98+
const p2 = queue.push('b', 100)
99+
expect(p1).not.toBe(p2)
100+
vi.advanceTimersToNextTimer()
101+
await expect(p1).resolves.toBe('a')
102+
await expect(p2).resolves.toBe('b')
103+
})
90104
})
91105

92-
describe.only('debounce: DebounceController', () => {
106+
describe('debounce: DebounceController', () => {
93107
it('schedules an update and calls the adapter with it', async () => {
94108
vi.useFakeTimers()
95109
const fakeAdapter: UpdateQueueAdapterContext = {

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

+20-16
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,28 @@ export class DebouncedPromiseQueue<ValueType, OutputType> {
1818
this.callback = callback
1919
}
2020

21-
public push(value: ValueType, timeMs: number) {
21+
push(value: ValueType, timeMs: number) {
2222
this.queuedValue = value
2323
this.controller.abort()
2424
this.controller = new AbortController()
2525
timeout(
2626
() => {
27+
// Keep the resolvers in a separate variable to reset the queue
28+
// while the callback is pending, so that the next push can be
29+
// assigned to a new Promise (and not dropped).
30+
const outputResolvers = this.resolvers
2731
try {
2832
debug('[nuqs queue] Flushing debounce queue', value)
29-
const p = this.callback(value)
33+
const callbackPromise = this.callback(value)
3034
debug('[nuqs queue] Reset debounced queue %O', this.queuedValue)
3135
this.queuedValue = undefined
32-
p.then(output => this.resolvers.resolve(output))
33-
.catch(error => this.resolvers.reject(error))
34-
.finally(() => {
35-
// Reset Promise for next use
36-
this.resolvers = withResolvers<OutputType>()
37-
})
36+
this.resolvers = withResolvers<OutputType>()
37+
callbackPromise
38+
.then(output => outputResolvers.resolve(output))
39+
.catch(error => outputResolvers.reject(error))
3840
} catch (error) {
3941
this.queuedValue = undefined
40-
this.resolvers.reject(error)
42+
outputResolvers.reject(error)
4143
}
4244
},
4345
timeMs,
@@ -62,7 +64,7 @@ export class DebounceController {
6264
this.throttleQueue = throttleQueue
6365
}
6466

65-
public push(
67+
push(
6668
update: Omit<UpdateQueuePushArgs, 'throttleMs'>,
6769
timeMs: number,
6870
adapter: UpdateQueueAdapterContext
@@ -73,19 +75,21 @@ export class DebounceController {
7375
URLSearchParams
7476
>(update => {
7577
this.throttleQueue.push(update)
76-
return this.throttleQueue.flush(adapter)
77-
// todo: Figure out cleanup strategy
78-
// .finally(() => {
79-
// this.queues.delete(update.key)
80-
// })
78+
return this.throttleQueue.flush(adapter).finally(() => {
79+
const queuedValue = this.queues.get(update.key)?.queuedValue
80+
if (queuedValue === undefined) {
81+
// Cleanup empty queues
82+
this.queues.delete(update.key)
83+
}
84+
})
8185
})
8286
this.queues.set(update.key, queue)
8387
}
8488
const queue = this.queues.get(update.key)!
8589
return queue.push(update, timeMs)
8690
}
8791

88-
public getQueuedQuery(key: string) {
92+
getQueuedQuery(key: string) {
8993
// The debounced queued values are more likely to be up-to-date
9094
// than any updates pending in the throttle queue, which comes last
9195
// in the update chain.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { ThrottledQueue } from './throttle'
3+
4+
describe('throttle: ThrottleQueue value queueing', () => {
5+
it('should enqueue key & values', () => {
6+
const queue = new ThrottledQueue()
7+
queue.push({ key: 'key', query: 'value', options: {} })
8+
expect(queue.getQueuedQuery('key')).toEqual('value')
9+
})
10+
it('should replace more recent values with the same key', () => {
11+
const queue = new ThrottledQueue()
12+
queue.push({ key: 'key', query: 'a', options: {} })
13+
queue.push({ key: 'key', query: 'b', options: {} })
14+
expect(queue.getQueuedQuery('key')).toEqual('b')
15+
})
16+
it('should enqueue multiple keys', () => {
17+
const queue = new ThrottledQueue()
18+
queue.push({ key: 'key1', query: 'a', options: {} })
19+
queue.push({ key: 'key2', query: 'b', options: {} })
20+
expect(queue.getQueuedQuery('key1')).toEqual('a')
21+
expect(queue.getQueuedQuery('key2')).toEqual('b')
22+
})
23+
it('should enqueue null values (to clear a key from the URL)', () => {
24+
const queue = new ThrottledQueue()
25+
queue.push({ key: 'key', query: 'a', options: {} })
26+
queue.push({ key: 'key', query: null, options: {} })
27+
expect(queue.getQueuedQuery('key')).toBeNull()
28+
})
29+
it('should return an undefined queued value if no push occurred', () => {
30+
const queue = new ThrottledQueue()
31+
expect(queue.getQueuedQuery('key')).toBeUndefined()
32+
})
33+
})
34+
35+
describe('throttle: ThrottleQueue option combination logic', () => {
36+
it('should resolve with the default options', () => {
37+
const queue = new ThrottledQueue()
38+
expect(queue.options).toEqual({
39+
history: 'replace',
40+
scroll: false,
41+
shallow: true
42+
})
43+
})
44+
it('should combine history options (push takes precedence)', () => {
45+
const queue = new ThrottledQueue()
46+
queue.push({ key: 'a', query: null, options: { history: 'replace' } })
47+
queue.push({ key: 'b', query: null, options: { history: 'push' } })
48+
queue.push({ key: 'c', query: null, options: { history: 'replace' } })
49+
expect(queue.options.history).toEqual('push')
50+
})
51+
it('should combine scroll options (true takes precedence)', () => {
52+
const queue = new ThrottledQueue()
53+
queue.push({ key: 'a', query: null, options: { scroll: false } })
54+
queue.push({ key: 'b', query: null, options: { scroll: true } })
55+
queue.push({ key: 'c', query: null, options: { scroll: false } })
56+
expect(queue.options.scroll).toEqual(true)
57+
})
58+
it('should combine shallow options (false takes precedence)', () => {
59+
const queue = new ThrottledQueue()
60+
queue.push({ key: 'a', query: null, options: { shallow: true } })
61+
queue.push({ key: 'b', query: null, options: { shallow: false } })
62+
queue.push({ key: 'c', query: null, options: { shallow: true } })
63+
expect(queue.options.shallow).toEqual(false)
64+
})
65+
})

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

+5-7
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export class ThrottledQueue {
6262
resolvers: Resolvers<URLSearchParams> | null = null
6363
lastFlushedAt = 0
6464

65-
public push({
65+
push({
6666
key,
6767
query,
6868
options,
@@ -89,11 +89,11 @@ export class ThrottledQueue {
8989
)
9090
}
9191

92-
public getQueuedQuery(key: string): string | null | undefined {
92+
getQueuedQuery(key: string): string | null | undefined {
9393
return this.updateMap.get(key)
9494
}
9595

96-
public flush({
96+
flush({
9797
getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation,
9898
rateLimitFactor = 1,
9999
...adapter
@@ -152,7 +152,7 @@ export class ThrottledQueue {
152152
return this.resolvers.promise
153153
}
154154

155-
public reset() {
155+
reset() {
156156
this.updateMap.clear()
157157
this.transitions.clear()
158158
this.options.history = 'replace'
@@ -161,9 +161,7 @@ export class ThrottledQueue {
161161
this.throttleMs = defaultRateLimit.timeMs
162162
}
163163

164-
// --
165-
166-
private applyPendingUpdates(
164+
applyPendingUpdates(
167165
adapter: Required<Omit<UpdateQueueAdapterContext, 'rateLimitFactor'>>
168166
): [URLSearchParams, null | unknown] {
169167
const { updateUrl, getSearchParamsSnapshot } = adapter

packages/nuqs/src/lib/timeout.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1+
// Source:
2+
// https://www.bennadel.com/blog/4195-using-abortcontroller-to-debounce-settimeout-calls-in-javascript.htm
3+
14
export function timeout(callback: () => void, ms: number, signal: AbortSignal) {
2-
const id = setTimeout(callback, ms)
3-
signal.addEventListener('abort', () => clearTimeout(id))
5+
function onTick() {
6+
callback()
7+
signal.removeEventListener('abort', onAbort)
8+
}
9+
const id = setTimeout(onTick, ms)
10+
function onAbort() {
11+
clearTimeout(id)
12+
signal.removeEventListener('abort', onAbort)
13+
}
14+
signal.addEventListener('abort', onAbort)
415
}

0 commit comments

Comments
 (0)