Skip to content

Commit 5ef6799

Browse files
feat: async debounce (#150)
1 parent 11ab206 commit 5ef6799

8 files changed

Lines changed: 583 additions & 138 deletions

File tree

packages/js-lib/src/decorators/asyncMemo.decorator.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { _assert, _assertTypeOf } from '../error/assert.js'
22
import type { CommonLogger } from '../log/commonLogger.js'
33
import type { AnyAsyncFunction, AnyFunction, AnyObject, MaybeParameters } from '../types.js'
44
import { _objectAssign, MISS } from '../types.js'
5+
import type { MethodDecorator } from './decorator.util.js'
56
import { _getTargetMethodSignature } from './decorator.util.js'
6-
import type { AsyncMemoCache, MethodDecorator } from './memo.util.js'
7+
import type { AsyncMemoCache } from './memo.util.js'
78
import { jsonMemoSerializer } from './memo.util.js'
89

910
export interface AsyncMemoOptions<FN> {

packages/js-lib/src/decorators/debounce.decorator.test.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { test } from 'vitest'
1+
import { describe, expect, test } from 'vitest'
22
import { _since } from '../datetime/index.js'
33
import { pDelay } from '../promise/index.js'
44
import type { AnyFunction, UnixTimestampMillis } from '../types.js'
5-
import { _Debounce } from './debounce.decorator.js'
5+
import { _AsyncDebounce, _AsyncThrottle, _Debounce } from './debounce.decorator.js'
66

77
class C {
88
// @debounce(200, {leading: true, trailing: true})
@@ -27,6 +27,63 @@ async function startTimer(fn: AnyFunction, interval: number, count: number): Pro
2727
await pDelay(1000) // extra wait
2828
}
2929

30-
test('@debounce', async () => {
31-
await startTimer(fn, 10, 10)
30+
describe('@_Debounce', () => {
31+
test('should debounce decorated method calls', async () => {
32+
await startTimer(fn, 10, 10)
33+
})
34+
})
35+
36+
describe('@_AsyncDebounce', () => {
37+
test('should coalesce calls and resolve every caller with a real result (never undefined)', async () => {
38+
class C {
39+
calls = 0
40+
41+
@_AsyncDebounce(20)
42+
async save(n: number): Promise<number> {
43+
this.calls++
44+
return n
45+
}
46+
}
47+
48+
const inst = new C()
49+
const results = await Promise.all([inst.save(1), inst.save(2), inst.save(3)])
50+
51+
expect(results).toEqual([3, 3, 3])
52+
expect(inst.calls).toBe(1)
53+
})
54+
})
55+
56+
describe('@_AsyncThrottle', () => {
57+
test('should invoke on the leading edge immediately', async () => {
58+
class C {
59+
calls = 0
60+
61+
@_AsyncThrottle(50)
62+
async ping(n: number): Promise<number> {
63+
this.calls++
64+
return n
65+
}
66+
}
67+
68+
const inst = new C()
69+
expect(await inst.ping(7)).toBe(7)
70+
expect(inst.calls).toBe(1)
71+
})
72+
73+
test('should resolve in-window calls with undefined when trailing is disabled', async () => {
74+
class C {
75+
calls = 0
76+
77+
@_AsyncThrottle(50, { trailing: false })
78+
async ping(n: number): Promise<number> {
79+
this.calls++
80+
return n
81+
}
82+
}
83+
84+
const inst = new C()
85+
expect(await inst.ping(1)).toBe(1)
86+
expect(await inst.ping(2)).toBeUndefined()
87+
expect(inst.calls).toBe(1)
88+
})
3289
})
Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,69 @@
1-
import type { DebounceOptions, ThrottleOptions } from './debounce.js'
2-
import { _debounce, _throttle } from './debounce.js'
1+
import type { AnyAsyncFunction, AnyFunction } from '../types.js'
2+
import type {
3+
AsyncDebounceOptions,
4+
AsyncThrottleOptions,
5+
DebounceOptions,
6+
ThrottleOptions,
7+
} from './debounce.js'
8+
import { _asyncDebounce, _asyncThrottle, _debounce, _throttle } from './debounce.js'
9+
import type { MethodDecorator } from './decorator.util.js'
310

4-
export function _Debounce(wait: number, opt: DebounceOptions = {}): MethodDecorator {
11+
export function _Debounce<T extends AnyFunction>(
12+
wait: number,
13+
opt: DebounceOptions = {},
14+
): MethodDecorator<T> {
515
return (_target, _key, descriptor) => {
6-
const originalFn = descriptor.value
7-
descriptor.value = _debounce(originalFn as any, wait, opt)
16+
const originalFn = descriptor.value!
17+
descriptor.value = _debounce<T>(originalFn, wait, opt)
818
return descriptor
919
}
1020
}
1121

12-
export function _Throttle(wait: number, opt: ThrottleOptions = {}): MethodDecorator {
22+
export function _Throttle<T extends AnyFunction>(
23+
wait: number,
24+
opt: ThrottleOptions = {},
25+
): MethodDecorator<T> {
1326
return (_target, _key, descriptor) => {
14-
const originalFn = descriptor.value
15-
descriptor.value = _throttle(originalFn as any, wait, opt)
27+
const originalFn = descriptor.value!
28+
descriptor.value = _throttle<T>(originalFn, wait, opt)
29+
return descriptor
30+
}
31+
}
32+
33+
/**
34+
* Like `@_Debounce`, but for async methods: every call returns a real Promise resolving with the
35+
* coalesced invocation's result, so `await`-ing never yields a stale value or a non-promise
36+
*
37+
* Be aware that the decorated method may resolve `undefined` if the config yields no invocation,
38+
* which the method's `T` type can't express.
39+
*
40+
* @experimental
41+
*/
42+
export function _AsyncDebounce<T extends AnyAsyncFunction>(
43+
wait: number,
44+
opt: AsyncDebounceOptions = {},
45+
): MethodDecorator<T> {
46+
return (_target, _key, descriptor) => {
47+
const originalFn = descriptor.value!
48+
descriptor.value = _asyncDebounce<T>(originalFn, wait, opt) as any
49+
return descriptor
50+
}
51+
}
52+
53+
/**
54+
* Like `@_Throttle`, but for async methods.
55+
*
56+
* @see {@link _AsyncDebounce}
57+
*
58+
* @experimental
59+
*/
60+
export function _AsyncThrottle<T extends AnyAsyncFunction>(
61+
wait: number,
62+
opt: AsyncThrottleOptions = {},
63+
): MethodDecorator<T> {
64+
return (_target, _key, descriptor) => {
65+
const originalFn = descriptor.value!
66+
descriptor.value = _asyncThrottle<T>(originalFn, wait, opt) as any
1667
return descriptor
1768
}
1869
}
Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { afterEach, test, vi } from 'vitest'
1+
import { afterEach, describe, expect, test, vi } from 'vitest'
22
import { _since } from '../datetime/index.js'
33
import { pDelay } from '../promise/index.js'
44
import type { AnyFunction, UnixTimestampMillis } from '../types.js'
5-
import { _debounce } from './debounce.js'
5+
import { _asyncDebounce, _asyncThrottle, _debounce } from './debounce.js'
66

77
afterEach(() => {
88
vi.useRealTimers()
@@ -22,22 +22,98 @@ async function startTimer(fn: AnyFunction, interval: number, count: number): Pro
2222
await pDelay(2000) // extra wait
2323
}
2424

25-
test('_debounce', async () => {
26-
vi.useFakeTimers()
25+
describe('_debounce', () => {
26+
test('should debounce calls with leading/trailing/maxWait', async () => {
27+
vi.useFakeTimers()
2728

28-
const fn = _debounce(originalFn, 20, { leading: true, trailing: true, maxWait: 300 })
29+
const fn = _debounce(originalFn, 20, { leading: true, trailing: true, maxWait: 300 })
2930

30-
const promise = startTimer(fn, 10, 10)
31-
await vi.runAllTimersAsync()
32-
await promise
31+
const promise = startTimer(fn, 10, 10)
32+
await vi.runAllTimersAsync()
33+
await promise
34+
})
35+
36+
// Test cases:
37+
// _debounce leading=1 trailing=0 (default)
38+
// _debounce leading=1 trailing=1
39+
// _debounce leading=0 trailing=1
40+
// _debounce leading=0 trailing=0
41+
// _throttle leading=1 trailing=1 (default)
42+
// _throttle leading=1 trailing=0
43+
// _throttle leading=0 trailing=1
44+
// _throttle leading=0 trailing=0
45+
})
46+
47+
describe('_asyncDebounce', () => {
48+
test('should return a real promise (never undefined) resolving with the invocation result', async () => {
49+
let calls = 0
50+
const fn = _asyncDebounce(async (n: number) => {
51+
calls++
52+
return n * 2
53+
}, 20)
54+
55+
const p = fn(5)
56+
expect(p).toBeInstanceOf(Promise)
57+
expect(await p).toBe(10)
58+
expect(calls).toBe(1)
59+
})
60+
61+
test('should coalesce rapid calls into one invocation and resolve all callers with that result', async () => {
62+
let calls = 0
63+
const fn = _asyncDebounce(async (n: number) => {
64+
calls++
65+
return n
66+
}, 20)
67+
68+
const results = await Promise.all([fn(1), fn(2), fn(3)])
69+
expect(results).toEqual([3, 3, 3])
70+
expect(calls).toBe(1)
71+
})
72+
73+
test('should reject all coalesced callers when the invocation throws', async () => {
74+
const fn = _asyncDebounce(async () => {
75+
throw new Error('boom')
76+
}, 20)
77+
78+
const p1 = fn()
79+
const p2 = fn()
80+
81+
await expect(p1).rejects.toThrow('boom')
82+
await expect(p2).rejects.toThrow('boom')
83+
})
84+
85+
test('should reject pending promises on cancel()', async () => {
86+
const fn = _asyncDebounce(async (n: number) => n, 50)
87+
88+
const p = fn(1)
89+
fn.cancel()
90+
91+
await expect(p).rejects.toThrow('asyncDebounce cancelled')
92+
})
93+
94+
test('should resolve dropped calls with undefined when the config yields no invocation (trailing: false)', async () => {
95+
const fn = _asyncDebounce(async (n: number) => n, 20, { trailing: false })
96+
97+
expect(await fn(1)).toBeUndefined()
98+
})
3399
})
34100

35-
// Test cases:
36-
// _debounce leading=1 trailing=0 (default)
37-
// _debounce leading=1 trailing=1
38-
// _debounce leading=0 trailing=1
39-
// _debounce leading=0 trailing=0
40-
// _throttle leading=1 trailing=1 (default)
41-
// _throttle leading=1 trailing=0
42-
// _throttle leading=0 trailing=1
43-
// _throttle leading=0 trailing=0
101+
describe('_asyncThrottle', () => {
102+
test('should invoke on the leading edge immediately', async () => {
103+
let calls = 0
104+
const fn = _asyncThrottle(async (n: number) => {
105+
calls++
106+
return n
107+
}, 50)
108+
109+
expect(await fn(7)).toBe(7)
110+
expect(calls).toBe(1)
111+
})
112+
113+
test('should resolve the leading caller but resolve in-window callers with undefined when trailing is disabled', async () => {
114+
const fn = _asyncThrottle(async (n: number) => n, 50, { trailing: false })
115+
116+
expect(await fn(1)).toBe(1)
117+
expect(await fn(2)).toBeUndefined()
118+
})
119+
})

0 commit comments

Comments
 (0)