Skip to content

Commit 2dab955

Browse files
fix: change trailing false semantics
1 parent 8f31616 commit 2dab955

4 files changed

Lines changed: 157 additions & 83 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ 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+
})
3234
})
3335

3436
describe('@_AsyncDebounce', () => {

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

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { AnyAsyncFunction, AnyFunction } from '../types.js'
2-
import type { DebounceOptions, ThrottleOptions } from './debounce.js'
2+
import type {
3+
AsyncDebounceOptions,
4+
AsyncThrottleOptions,
5+
DebounceOptions,
6+
ThrottleOptions,
7+
} from './debounce.js'
38
import { _asyncDebounce, _asyncThrottle, _debounce, _throttle } from './debounce.js'
49
import type { MethodDecorator } from './decorator.util.js'
510

@@ -26,36 +31,39 @@ export function _Throttle<T extends AnyFunction>(
2631
}
2732

2833
/**
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`.
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 return `undefined` if the config yields no invocation
38+
* (e.g. `trailing: false`), which the method's `T` type can't express.
3339
*
3440
* @experimental
3541
*/
3642
export function _AsyncDebounce<T extends AnyAsyncFunction>(
3743
wait: number,
38-
opt: DebounceOptions = {},
44+
opt: AsyncDebounceOptions = {},
3945
): MethodDecorator<T> {
4046
return (_target, _key, descriptor) => {
4147
const originalFn = descriptor.value!
42-
descriptor.value = _asyncDebounce<T>(originalFn, wait, opt)
48+
descriptor.value = _asyncDebounce<T>(originalFn, wait, opt) as any
4349
return descriptor
4450
}
4551
}
4652

4753
/**
48-
* Like `@_Throttle`, but for async methods. See `@_AsyncDebounce` for the Promise semantics.
54+
* Like `@_Throttle`, but for async methods.
55+
*
56+
* @see {@link _AsyncDebounce} for details about the return type and `trailing: false` behavior.
4957
*
5058
* @experimental
5159
*/
5260
export function _AsyncThrottle<T extends AnyAsyncFunction>(
5361
wait: number,
54-
opt: ThrottleOptions = {},
62+
opt: AsyncThrottleOptions = {},
5563
): MethodDecorator<T> {
5664
return (_target, _key, descriptor) => {
5765
const originalFn = descriptor.value!
58-
descriptor.value = _asyncThrottle<T>(originalFn, wait, opt)
66+
descriptor.value = _asyncThrottle<T>(originalFn, wait, opt) as any
5967
return descriptor
6068
}
6169
}

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

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,27 @@ 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
33-
})
31+
const promise = startTimer(fn, 10, 10)
32+
await vi.runAllTimersAsync()
33+
await promise
34+
})
3435

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
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+
})
4446

4547
describe('_asyncDebounce', () => {
4648
test('should return a real promise (never undefined) resolving with the invocation result', async () => {
@@ -64,7 +66,7 @@ describe('_asyncDebounce', () => {
6466
}, 20)
6567

6668
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
69+
expect(results).toEqual([3, 3, 3])
6870
expect(calls).toBe(1)
6971
})
7072

@@ -88,6 +90,12 @@ describe('_asyncDebounce', () => {
8890

8991
await expect(p).rejects.toThrow('asyncDebounce cancelled')
9092
})
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+
})
9199
})
92100

93101
describe('_asyncThrottle', () => {
@@ -101,4 +109,11 @@ describe('_asyncThrottle', () => {
101109
expect(await fn(7)).toBe(7)
102110
expect(calls).toBe(1)
103111
})
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+
})
104119
})

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

Lines changed: 103 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,6 @@ import { pDefer } from '../promise/pDefer.js'
33
import type { DeferredPromise } from '../promise/pDefer.js'
44
import type { AnyAsyncFunction, AnyFunction } from '../types.js'
55

6-
export interface Cancelable {
7-
cancel: () => void
8-
flush: () => void
9-
}
10-
11-
export interface ThrottleOptions {
12-
/**
13-
* @default true
14-
*/
15-
leading?: boolean
16-
17-
/**
18-
* @default true
19-
*/
20-
trailing?: boolean
21-
}
22-
23-
export interface DebounceOptions {
24-
/**
25-
* @default false
26-
*/
27-
leading?: boolean
28-
29-
/**
30-
* @default true
31-
*/
32-
trailing?: boolean
33-
34-
/**
35-
*
36-
*/
37-
maxWait?: number
38-
}
39-
406
export function _debounce<T extends AnyFunction>(
417
func: T,
428
wait: number,
@@ -161,25 +127,21 @@ export function _throttle<T extends AnyFunction>(
161127
/**
162128
* Like `_debounce`, but for async functions.
163129
*
164-
* Unlike `_debounce` (which returns the previous invocation's stale `result` or `undefined`
165-
* for suppressed calls), `_asyncDebounce` always returns a real Promise. All calls that are
166-
* coalesced into a single invocation resolve (or reject) with that invocation's result.
167-
*
168-
* `cancel()` rejects any pending Promises, so awaiters never hang.
130+
* Unlike `_debounce` (which returns a stale `result`/`undefined` for suppressed calls),
131+
* `_asyncDebounce` always returns a real Promise, resolving with the coalesced invocation's result.
169132
*
170133
* @experimental
171134
*/
172135
export function _asyncDebounce<T extends AnyAsyncFunction>(
173136
func: T,
174137
wait: number,
175-
opt: DebounceOptions = {},
176-
): T & Cancelable {
138+
opt: AsyncDebounceOptions = {},
139+
): AsyncDebounced<T> {
177140
let lastArgs: Parameters<T> | undefined
178141
let lastThis: ThisParameterType<T> | undefined
179142
let timerId: number | undefined
180-
// The Promise shared by all calls coalesced into the next invocation.
181-
// Undefined when there's no pending batch.
182-
let deferred: DeferredPromise<Awaited<ReturnType<T>>> | undefined
143+
// Promise shared by all calls coalesced into the next invocation (undefined when none pending).
144+
let deferred: DeferredPromise<Awaited<ReturnType<T>> | undefined> | undefined
183145

184146
const { leading = false, trailing = true } = opt
185147

@@ -201,7 +163,7 @@ export function _asyncDebounce<T extends AnyAsyncFunction>(
201163
lastArgs = lastThis = undefined
202164
state.lastInvokeTime = time
203165

204-
// Detach the current batch's Promise and settle it with this invocation's result.
166+
// Detach and settle the current batch.
205167
const d = deferred!
206168
deferred = undefined
207169
try {
@@ -212,7 +174,7 @@ export function _asyncDebounce<T extends AnyAsyncFunction>(
212174
}
213175
}
214176

215-
async function leadingEdge(time: number): Promise<Awaited<ReturnType<T>>> {
177+
async function leadingEdge(time: number): Promise<Awaited<ReturnType<T>> | undefined> {
216178
// Reset any `maxWait` timer.
217179
state.lastInvokeTime = time
218180
// Start the timer for the trailing edge.
@@ -246,8 +208,7 @@ export function _asyncDebounce<T extends AnyAsyncFunction>(
246208
return
247209
}
248210
lastArgs = lastThis = undefined
249-
// No invocation will happen for this batch (e.g. trailing=false) - resolve to
250-
// undefined rather than leaving awaiters hanging forever.
211+
// Dropped batch (only reachable with `trailing: false`): no invocation, so resolve `undefined`.
251212
if (deferred) {
252213
const d = deferred
253214
deferred = undefined
@@ -262,7 +223,7 @@ export function _asyncDebounce<T extends AnyAsyncFunction>(
262223
state.lastInvokeTime = 0
263224
state.lastCallTime = undefined
264225
lastArgs = lastThis = timerId = undefined
265-
// Reject pending awaiters so they don't hang.
226+
// Reject pending awaiters.
266227
if (deferred) {
267228
const d = deferred
268229
deferred = undefined
@@ -287,16 +248,15 @@ export function _asyncDebounce<T extends AnyAsyncFunction>(
287248
async function debounced(
288249
this: ThisParameterType<T>,
289250
...args: Parameters<T>
290-
): Promise<Awaited<ReturnType<T>>> {
251+
): Promise<Awaited<ReturnType<T>> | undefined> {
291252
const time = Date.now()
292253
const isInvoking = shouldInvoke(time, state)
293254

294255
lastArgs = args
295256
lastThis = this
296257
state.lastCallTime = time
297258

298-
// Ensure a pending batch Promise exists for this call to join.
299-
deferred ||= pDefer<Awaited<ReturnType<T>>>()
259+
deferred ||= pDefer<Awaited<ReturnType<T>> | undefined>()
300260
const currentDeferred = deferred
301261

302262
if (isInvoking) {
@@ -330,8 +290,8 @@ export function _asyncDebounce<T extends AnyAsyncFunction>(
330290
export function _asyncThrottle<T extends AnyAsyncFunction>(
331291
func: T,
332292
wait: number,
333-
opt: ThrottleOptions = {},
334-
): T & Cancelable {
293+
opt: AsyncThrottleOptions = {},
294+
): AsyncDebounced<T> {
335295
return _asyncDebounce(func, wait, {
336296
leading: true,
337297
trailing: true,
@@ -380,3 +340,92 @@ interface DebounceTimerState {
380340
maxing: boolean
381341
maxWait: number | undefined
382342
}
343+
344+
export interface Cancelable {
345+
cancel: () => void
346+
flush: () => void
347+
}
348+
349+
export interface ThrottleOptions {
350+
/**
351+
* Invoke on the leading edge of the window (immediately, on the first call).
352+
*
353+
* @default true
354+
*/
355+
leading?: boolean
356+
357+
/**
358+
* Invoke on the trailing edge of the window (after `wait` has elapsed).
359+
*
360+
* @default true
361+
*/
362+
trailing?: boolean
363+
}
364+
365+
export interface DebounceOptions {
366+
/**
367+
* Invoke on the leading edge of the window (immediately, on the first call).
368+
*
369+
* @default false
370+
*/
371+
leading?: boolean
372+
373+
/**
374+
* Invoke on the trailing edge of the window (after `wait` has elapsed).
375+
*
376+
* @default true
377+
*/
378+
trailing?: boolean
379+
380+
/**
381+
* Maximum time `func` is allowed to be delayed before it's forcibly invoked.
382+
*/
383+
maxWait?: number
384+
}
385+
386+
export interface AsyncThrottleOptions {
387+
/**
388+
* Invoke on the leading edge of the window (immediately, on the first call).
389+
*
390+
* @default true
391+
*/
392+
leading?: boolean
393+
394+
/**
395+
* Invoke on the trailing edge of the window (after `wait` has elapsed).
396+
*
397+
* When `false`, calls dropped within the window (not served by a leading invocation) resolve
398+
* with `undefined`. There's no invocation for them to await. This is why the returned function
399+
* can resolve with `undefined` even though the original function's return type doesn't express that.
400+
*
401+
* @default true
402+
*/
403+
trailing?: boolean
404+
}
405+
406+
export interface AsyncDebounceOptions extends AsyncThrottleOptions {
407+
/**
408+
* Invoke on the leading edge of the window (immediately, on the first call).
409+
*
410+
* @default false
411+
*/
412+
leading?: boolean
413+
414+
/**
415+
* Maximum time `func` is allowed to be delayed before it's forcibly invoked.
416+
*/
417+
maxWait?: number
418+
}
419+
420+
/**
421+
* The function returned by `_asyncDebounce`/`_asyncThrottle`. Same signature as `T`, but resolves
422+
* `Awaited<ReturnType<T>> | undefined`
423+
*
424+
* This is because some configurations (e.g. `trailing: false`) may drop calls without invoking
425+
* the original function, so there's no result to await.
426+
*/
427+
export type AsyncDebounced<T extends AnyAsyncFunction> = ((
428+
this: ThisParameterType<T>,
429+
...args: Parameters<T>
430+
) => Promise<Awaited<ReturnType<T>> | undefined>) &
431+
Cancelable

0 commit comments

Comments
 (0)