Skip to content

Commit 1f9424e

Browse files
feat: async debounce
1 parent 8b9a403 commit 1f9424e

8 files changed

Lines changed: 429 additions & 86 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: 40 additions & 2 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})
@@ -30,3 +30,41 @@ async function startTimer(fn: AnyFunction, interval: number, count: number): Pro
3030
test('@debounce', async () => {
3131
await startTimer(fn, 10, 10)
3232
})
33+
34+
describe('@_AsyncDebounce', () => {
35+
test('should coalesce calls and resolve every caller with a real result (never undefined)', async () => {
36+
class C {
37+
calls = 0
38+
39+
@_AsyncDebounce(20)
40+
async save(n: number): Promise<number> {
41+
this.calls++
42+
return n
43+
}
44+
}
45+
46+
const inst = new C()
47+
const results = await Promise.all([inst.save(1), inst.save(2), inst.save(3)])
48+
49+
expect(results).toEqual([3, 3, 3])
50+
expect(inst.calls).toBe(1)
51+
})
52+
})
53+
54+
describe('@_AsyncThrottle', () => {
55+
test('should invoke on the leading edge immediately', async () => {
56+
class C {
57+
calls = 0
58+
59+
@_AsyncThrottle(50)
60+
async ping(n: number): Promise<number> {
61+
this.calls++
62+
return n
63+
}
64+
}
65+
66+
const inst = new C()
67+
expect(await inst.ping(7)).toBe(7)
68+
expect(inst.calls).toBe(1)
69+
})
70+
})
Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,61 @@
1+
import type { AnyAsyncFunction, AnyFunction } from '../types.js'
12
import type { DebounceOptions, ThrottleOptions } from './debounce.js'
2-
import { _debounce, _throttle } from './debounce.js'
3+
import { _asyncDebounce, _asyncThrottle, _debounce, _throttle } from './debounce.js'
4+
import type { MethodDecorator } from './decorator.util.js'
35

4-
export function _Debounce(wait: number, opt: DebounceOptions = {}): MethodDecorator {
6+
export function _Debounce<T extends AnyFunction>(
7+
wait: number,
8+
opt: DebounceOptions = {},
9+
): MethodDecorator<T> {
510
return (_target, _key, descriptor) => {
6-
const originalFn = descriptor.value
7-
descriptor.value = _debounce(originalFn as any, wait, opt)
11+
const originalFn = descriptor.value!
12+
descriptor.value = _debounce<T>(originalFn, wait, opt)
813
return descriptor
914
}
1015
}
1116

12-
export function _Throttle(wait: number, opt: ThrottleOptions = {}): MethodDecorator {
17+
export function _Throttle<T extends AnyFunction>(
18+
wait: number,
19+
opt: ThrottleOptions = {},
20+
): MethodDecorator<T> {
1321
return (_target, _key, descriptor) => {
14-
const originalFn = descriptor.value
15-
descriptor.value = _throttle(originalFn as any, wait, opt)
22+
const originalFn = descriptor.value!
23+
descriptor.value = _throttle<T>(originalFn, wait, opt)
24+
return descriptor
25+
}
26+
}
27+
28+
/**
29+
* Like `@_Debounce`, but for async methods. Guarantees every call returns a real Promise that
30+
* resolves (or rejects) with the coalesced invocation's result, so the declared `Promise<T>` return
31+
* type stays accurate. Unlike `@_Debounce`, await`-ing a decorated method never silently yields
32+
* `undefined`.
33+
*
34+
* @experimental
35+
*/
36+
export function _AsyncDebounce<T extends AnyAsyncFunction>(
37+
wait: number,
38+
opt: DebounceOptions = {},
39+
): MethodDecorator<T> {
40+
return (_target, _key, descriptor) => {
41+
const originalFn = descriptor.value!
42+
descriptor.value = _asyncDebounce<T>(originalFn, wait, opt)
43+
return descriptor
44+
}
45+
}
46+
47+
/**
48+
* Like `@_Throttle`, but for async methods. See `@_AsyncDebounce` for the Promise semantics.
49+
*
50+
* @experimental
51+
*/
52+
export function _AsyncThrottle<T extends AnyAsyncFunction>(
53+
wait: number,
54+
opt: ThrottleOptions = {},
55+
): MethodDecorator<T> {
56+
return (_target, _key, descriptor) => {
57+
const originalFn = descriptor.value!
58+
descriptor.value = _asyncThrottle<T>(originalFn, wait, opt)
1659
return descriptor
1760
}
1861
}

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

Lines changed: 63 additions & 2 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()
@@ -41,3 +41,64 @@ test('_debounce', async () => {
4141
// _throttle leading=1 trailing=0
4242
// _throttle leading=0 trailing=1
4343
// _throttle leading=0 trailing=0
44+
45+
describe('_asyncDebounce', () => {
46+
test('should return a real promise (never undefined) resolving with the invocation result', async () => {
47+
let calls = 0
48+
const fn = _asyncDebounce(async (n: number) => {
49+
calls++
50+
return n * 2
51+
}, 20)
52+
53+
const p = fn(5)
54+
expect(p).toBeInstanceOf(Promise)
55+
expect(await p).toBe(10)
56+
expect(calls).toBe(1)
57+
})
58+
59+
test('should coalesce rapid calls into one invocation and resolve all callers with that result', async () => {
60+
let calls = 0
61+
const fn = _asyncDebounce(async (n: number) => {
62+
calls++
63+
return n
64+
}, 20)
65+
66+
const results = await Promise.all([fn(1), fn(2), fn(3)])
67+
expect(results).toEqual([3, 3, 3]) // trailing edge invokes with the last args
68+
expect(calls).toBe(1)
69+
})
70+
71+
test('should reject all coalesced callers when the invocation throws', async () => {
72+
const fn = _asyncDebounce(async () => {
73+
throw new Error('boom')
74+
}, 20)
75+
76+
const p1 = fn()
77+
const p2 = fn()
78+
79+
await expect(p1).rejects.toThrow('boom')
80+
await expect(p2).rejects.toThrow('boom')
81+
})
82+
83+
test('should reject pending promises on cancel()', async () => {
84+
const fn = _asyncDebounce(async (n: number) => n, 50)
85+
86+
const p = fn(1)
87+
fn.cancel()
88+
89+
await expect(p).rejects.toThrow('asyncDebounce cancelled')
90+
})
91+
})
92+
93+
describe('_asyncThrottle', () => {
94+
test('should invoke on the leading edge immediately', async () => {
95+
let calls = 0
96+
const fn = _asyncThrottle(async (n: number) => {
97+
calls++
98+
return n
99+
}, 50)
100+
101+
expect(await fn(7)).toBe(7)
102+
expect(calls).toBe(1)
103+
})
104+
})

0 commit comments

Comments
 (0)