From 1f9424e5c23ebb9babec8a8c501bc4bd7af5c907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=B3=C3=B0i=20Karlsson?= Date: Wed, 15 Jul 2026 15:21:23 +0200 Subject: [PATCH 1/4] feat: async debounce --- .../src/decorators/asyncMemo.decorator.ts | 3 +- .../src/decorators/debounce.decorator.test.ts | 42 ++- .../src/decorators/debounce.decorator.ts | 57 +++- .../js-lib/src/decorators/debounce.test.ts | 65 +++- packages/js-lib/src/decorators/debounce.ts | 321 ++++++++++++++---- .../js-lib/src/decorators/decorator.util.ts | 11 + .../js-lib/src/decorators/memo.decorator.ts | 3 +- packages/js-lib/src/decorators/memo.util.ts | 13 +- 8 files changed, 429 insertions(+), 86 deletions(-) diff --git a/packages/js-lib/src/decorators/asyncMemo.decorator.ts b/packages/js-lib/src/decorators/asyncMemo.decorator.ts index 720a16fe6..c077eff3b 100644 --- a/packages/js-lib/src/decorators/asyncMemo.decorator.ts +++ b/packages/js-lib/src/decorators/asyncMemo.decorator.ts @@ -2,8 +2,9 @@ import { _assert, _assertTypeOf } from '../error/assert.js' import type { CommonLogger } from '../log/commonLogger.js' import type { AnyAsyncFunction, AnyFunction, AnyObject, MaybeParameters } from '../types.js' import { _objectAssign, MISS } from '../types.js' +import type { MethodDecorator } from './decorator.util.js' import { _getTargetMethodSignature } from './decorator.util.js' -import type { AsyncMemoCache, MethodDecorator } from './memo.util.js' +import type { AsyncMemoCache } from './memo.util.js' import { jsonMemoSerializer } from './memo.util.js' export interface AsyncMemoOptions { diff --git a/packages/js-lib/src/decorators/debounce.decorator.test.ts b/packages/js-lib/src/decorators/debounce.decorator.test.ts index 732edc7b1..68f3c2c35 100644 --- a/packages/js-lib/src/decorators/debounce.decorator.test.ts +++ b/packages/js-lib/src/decorators/debounce.decorator.test.ts @@ -1,8 +1,8 @@ -import { test } from 'vitest' +import { describe, expect, test } from 'vitest' import { _since } from '../datetime/index.js' import { pDelay } from '../promise/index.js' import type { AnyFunction, UnixTimestampMillis } from '../types.js' -import { _Debounce } from './debounce.decorator.js' +import { _AsyncDebounce, _AsyncThrottle, _Debounce } from './debounce.decorator.js' class C { // @debounce(200, {leading: true, trailing: true}) @@ -30,3 +30,41 @@ async function startTimer(fn: AnyFunction, interval: number, count: number): Pro test('@debounce', async () => { await startTimer(fn, 10, 10) }) + +describe('@_AsyncDebounce', () => { + test('should coalesce calls and resolve every caller with a real result (never undefined)', async () => { + class C { + calls = 0 + + @_AsyncDebounce(20) + async save(n: number): Promise { + this.calls++ + return n + } + } + + const inst = new C() + const results = await Promise.all([inst.save(1), inst.save(2), inst.save(3)]) + + expect(results).toEqual([3, 3, 3]) + expect(inst.calls).toBe(1) + }) +}) + +describe('@_AsyncThrottle', () => { + test('should invoke on the leading edge immediately', async () => { + class C { + calls = 0 + + @_AsyncThrottle(50) + async ping(n: number): Promise { + this.calls++ + return n + } + } + + const inst = new C() + expect(await inst.ping(7)).toBe(7) + expect(inst.calls).toBe(1) + }) +}) diff --git a/packages/js-lib/src/decorators/debounce.decorator.ts b/packages/js-lib/src/decorators/debounce.decorator.ts index 3a402bdb8..2fdc9a09f 100644 --- a/packages/js-lib/src/decorators/debounce.decorator.ts +++ b/packages/js-lib/src/decorators/debounce.decorator.ts @@ -1,18 +1,61 @@ +import type { AnyAsyncFunction, AnyFunction } from '../types.js' import type { DebounceOptions, ThrottleOptions } from './debounce.js' -import { _debounce, _throttle } from './debounce.js' +import { _asyncDebounce, _asyncThrottle, _debounce, _throttle } from './debounce.js' +import type { MethodDecorator } from './decorator.util.js' -export function _Debounce(wait: number, opt: DebounceOptions = {}): MethodDecorator { +export function _Debounce( + wait: number, + opt: DebounceOptions = {}, +): MethodDecorator { return (_target, _key, descriptor) => { - const originalFn = descriptor.value - descriptor.value = _debounce(originalFn as any, wait, opt) + const originalFn = descriptor.value! + descriptor.value = _debounce(originalFn, wait, opt) return descriptor } } -export function _Throttle(wait: number, opt: ThrottleOptions = {}): MethodDecorator { +export function _Throttle( + wait: number, + opt: ThrottleOptions = {}, +): MethodDecorator { return (_target, _key, descriptor) => { - const originalFn = descriptor.value - descriptor.value = _throttle(originalFn as any, wait, opt) + const originalFn = descriptor.value! + descriptor.value = _throttle(originalFn, wait, opt) + return descriptor + } +} + +/** + * Like `@_Debounce`, but for async methods. Guarantees every call returns a real Promise that + * resolves (or rejects) with the coalesced invocation's result, so the declared `Promise` return + * type stays accurate. Unlike `@_Debounce`, await`-ing a decorated method never silently yields + * `undefined`. + * + * @experimental + */ +export function _AsyncDebounce( + wait: number, + opt: DebounceOptions = {}, +): MethodDecorator { + return (_target, _key, descriptor) => { + const originalFn = descriptor.value! + descriptor.value = _asyncDebounce(originalFn, wait, opt) + return descriptor + } +} + +/** + * Like `@_Throttle`, but for async methods. See `@_AsyncDebounce` for the Promise semantics. + * + * @experimental + */ +export function _AsyncThrottle( + wait: number, + opt: ThrottleOptions = {}, +): MethodDecorator { + return (_target, _key, descriptor) => { + const originalFn = descriptor.value! + descriptor.value = _asyncThrottle(originalFn, wait, opt) return descriptor } } diff --git a/packages/js-lib/src/decorators/debounce.test.ts b/packages/js-lib/src/decorators/debounce.test.ts index a6c1db8bf..240331ea5 100644 --- a/packages/js-lib/src/decorators/debounce.test.ts +++ b/packages/js-lib/src/decorators/debounce.test.ts @@ -1,8 +1,8 @@ -import { afterEach, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { _since } from '../datetime/index.js' import { pDelay } from '../promise/index.js' import type { AnyFunction, UnixTimestampMillis } from '../types.js' -import { _debounce } from './debounce.js' +import { _asyncDebounce, _asyncThrottle, _debounce } from './debounce.js' afterEach(() => { vi.useRealTimers() @@ -41,3 +41,64 @@ test('_debounce', async () => { // _throttle leading=1 trailing=0 // _throttle leading=0 trailing=1 // _throttle leading=0 trailing=0 + +describe('_asyncDebounce', () => { + test('should return a real promise (never undefined) resolving with the invocation result', async () => { + let calls = 0 + const fn = _asyncDebounce(async (n: number) => { + calls++ + return n * 2 + }, 20) + + const p = fn(5) + expect(p).toBeInstanceOf(Promise) + expect(await p).toBe(10) + expect(calls).toBe(1) + }) + + test('should coalesce rapid calls into one invocation and resolve all callers with that result', async () => { + let calls = 0 + const fn = _asyncDebounce(async (n: number) => { + calls++ + return n + }, 20) + + const results = await Promise.all([fn(1), fn(2), fn(3)]) + expect(results).toEqual([3, 3, 3]) // trailing edge invokes with the last args + expect(calls).toBe(1) + }) + + test('should reject all coalesced callers when the invocation throws', async () => { + const fn = _asyncDebounce(async () => { + throw new Error('boom') + }, 20) + + const p1 = fn() + const p2 = fn() + + await expect(p1).rejects.toThrow('boom') + await expect(p2).rejects.toThrow('boom') + }) + + test('should reject pending promises on cancel()', async () => { + const fn = _asyncDebounce(async (n: number) => n, 50) + + const p = fn(1) + fn.cancel() + + await expect(p).rejects.toThrow('asyncDebounce cancelled') + }) +}) + +describe('_asyncThrottle', () => { + test('should invoke on the leading edge immediately', async () => { + let calls = 0 + const fn = _asyncThrottle(async (n: number) => { + calls++ + return n + }, 50) + + expect(await fn(7)).toBe(7) + expect(calls).toBe(1) + }) +}) diff --git a/packages/js-lib/src/decorators/debounce.ts b/packages/js-lib/src/decorators/debounce.ts index d7a0f5294..7ce96a9aa 100644 --- a/packages/js-lib/src/decorators/debounce.ts +++ b/packages/js-lib/src/decorators/debounce.ts @@ -1,4 +1,6 @@ -import type { AnyFunction } from '../types.js' +import { pDefer } from '../promise/pDefer.js' +import type { DeferredPromise } from '../promise/pDefer.js' +import type { AnyAsyncFunction, AnyFunction } from '../types.js' export interface Cancelable { cancel: () => void @@ -39,78 +41,51 @@ export function _debounce( wait: number, opt: DebounceOptions = {}, ): T & Cancelable { - let lastArgs: any - let lastThis: any - let result: any + let lastArgs: Parameters | undefined + let lastThis: ThisParameterType | undefined + let result: ReturnType let timerId: number | undefined - let lastCallTime: number | undefined - let lastInvokeTime = 0 const maxing = 'maxWait' in opt - const { leading = false, trailing = true } = opt - const maxWait = maxing ? Math.max(Number(opt.maxWait) || 0, wait) : opt.maxWait + const state: DebounceTimerState = { + lastCallTime: undefined, + lastInvokeTime: 0, + wait, + maxing, + maxWait: maxing ? Math.max(Number(opt.maxWait) || 0, wait) : opt.maxWait, + } - function invokeFunc(time: number): any { + function invokeFunc(time: number): ReturnType { const args = lastArgs const thisArg = lastThis lastArgs = lastThis = undefined - lastInvokeTime = time - result = func.apply(thisArg, args) + state.lastInvokeTime = time + result = func.apply(thisArg, args!) return result } - function startTimer(pendingFunc: AnyFunction, wait: number): number { - return setTimeout(pendingFunc, wait) - } - - function cancelTimer(id: number): void { - clearTimeout(id) - } - - function leadingEdge(time: number): any { + function leadingEdge(time: number): ReturnType { // Reset any `maxWait` timer. - lastInvokeTime = time + state.lastInvokeTime = time // Start the timer for the trailing edge. - timerId = startTimer(timerExpired, wait) + timerId = startTimer(timerExpired, state.wait) // Invoke the leading edge. return leading ? invokeFunc(time) : result } - function remainingWait(time: number): number { - const timeSinceLastCall = time - lastCallTime! - const timeSinceLastInvoke = time - lastInvokeTime - const timeWaiting = wait - timeSinceLastCall - - return maxing ? Math.min(timeWaiting, maxWait! - timeSinceLastInvoke) : timeWaiting - } - - function shouldInvoke(time: number): boolean { - const timeSinceLastCall = time - lastCallTime! - const timeSinceLastInvoke = time - lastInvokeTime - - // Either this is the first call, activity has stopped and we're at the - // trailing edge, the system time has gone backwards and we're treating - // it as the trailing edge, or we've hit the `maxWait` limit. - return ( - lastCallTime === undefined || - timeSinceLastCall >= wait || - timeSinceLastCall < 0 || - (maxing && timeSinceLastInvoke >= maxWait!) - ) - } - - function timerExpired(): any { + function timerExpired(): void { const time = Date.now() - if (shouldInvoke(time)) { - return trailingEdge(time) + if (shouldInvoke(time, state)) { + trailingEdge(time) + return } // Restart the timer. - timerId = startTimer(timerExpired, remainingWait(time)) + timerId = startTimer(timerExpired, remainingWait(time, state)) } - function trailingEdge(time: number): any { + function trailingEdge(time: number): ReturnType { timerId = undefined // Only invoke if we have `lastArgs` which means `func` has been @@ -126,11 +101,12 @@ export function _debounce( if (timerId !== undefined) { cancelTimer(timerId) } - lastInvokeTime = 0 - lastArgs = lastCallTime = lastThis = timerId = undefined + state.lastInvokeTime = 0 + state.lastCallTime = undefined + lastArgs = lastThis = timerId = undefined } - function flush(): any { + function flush(): ReturnType { return timerId === undefined ? result : trailingEdge(Date.now()) } @@ -138,26 +114,26 @@ export function _debounce( return timerId !== undefined } - function debounced(this: any, ...args: any[]): any { + function debounced(this: ThisParameterType, ...args: Parameters): ReturnType { const time = Date.now() - const isInvoking = shouldInvoke(time) + const isInvoking = shouldInvoke(time, state) lastArgs = args lastThis = this - lastCallTime = time + state.lastCallTime = time if (isInvoking) { if (timerId === undefined) { - return leadingEdge(lastCallTime) + return leadingEdge(state.lastCallTime) } - if (maxing) { + if (state.maxing) { // Handle invocations in a tight loop. - timerId = startTimer(timerExpired, wait) - return invokeFunc(lastCallTime) + timerId = startTimer(timerExpired, state.wait) + return invokeFunc(state.lastCallTime) } } if (timerId === undefined) { - timerId = startTimer(timerExpired, wait) + timerId = startTimer(timerExpired, state.wait) } return result } @@ -180,3 +156,226 @@ export function _throttle( maxWait: wait, }) } + +/** + * Like `_debounce`, but for async functions. + * + * Unlike `_debounce` (which returns the previous invocation's stale `result` or `undefined` + * for suppressed calls), `_asyncDebounce` always returns a real Promise. All calls that are + * coalesced into a single invocation resolve (or reject) with that invocation's result. + * + * `cancel()` rejects any pending Promises, so awaiters never hang. + * + * @experimental + */ +export function _asyncDebounce( + func: T, + wait: number, + opt: DebounceOptions = {}, +): T & Cancelable { + let lastArgs: Parameters | undefined + let lastThis: ThisParameterType | undefined + let timerId: number | undefined + // The Promise shared by all calls coalesced into the next invocation. + // Undefined when there's no pending batch. + let deferred: DeferredPromise>> | undefined + + const { leading = false, trailing = true } = opt + + const maxing = 'maxWait' in opt + const maxWait = maxing ? Math.max(Number(opt.maxWait) || 0, wait) : opt.maxWait + + const state: DebounceTimerState = { + lastCallTime: undefined, + lastInvokeTime: 0, + wait, + maxing, + maxWait, + } + + async function invokeFunc(time: number): Promise { + const args = lastArgs + const thisArg = lastThis + + lastArgs = lastThis = undefined + state.lastInvokeTime = time + + // Detach the current batch's Promise and settle it with this invocation's result. + const d = deferred! + deferred = undefined + try { + const result = await func.apply(thisArg, args!) + d.resolve(result) + } catch (err) { + d.reject(err as Error) + } + } + + async function leadingEdge(time: number): Promise>> { + // Reset any `maxWait` timer. + state.lastInvokeTime = time + // Start the timer for the trailing edge. + timerId = startTimer(timerExpired, state.wait) + // Capture the batch before `invokeFunc` detaches it. + const d = deferred! + // Invoke the leading edge. + if (leading) { + await invokeFunc(time) + } + return d + } + + function timerExpired(): void { + const time = Date.now() + if (shouldInvoke(time, state)) { + void trailingEdge(time) + return + } + // Restart the timer. + timerId = startTimer(timerExpired, remainingWait(time, state)) + } + + async function trailingEdge(time: number): Promise { + timerId = undefined + + // Only invoke if we have `lastArgs` which means `func` has been + // debounced at least once. + if (trailing && lastArgs && deferred) { + await invokeFunc(time) + return + } + lastArgs = lastThis = undefined + // No invocation will happen for this batch (e.g. trailing=false) - resolve to + // undefined rather than leaving awaiters hanging forever. + if (deferred) { + const d = deferred + deferred = undefined + d.resolve(undefined) + } + } + + function cancel(): void { + if (timerId !== undefined) { + cancelTimer(timerId) + } + state.lastInvokeTime = 0 + state.lastCallTime = undefined + lastArgs = lastThis = timerId = undefined + // Reject pending awaiters so they don't hang. + if (deferred) { + const d = deferred + deferred = undefined + d.reject(new Error('asyncDebounce cancelled')) + } + } + + async function flush(): Promise> | undefined> { + if (timerId === undefined || !deferred) { + return undefined + } + const d = deferred + cancelTimer(timerId) + await trailingEdge(Date.now()) + return d + } + + function pending(): boolean { + return timerId !== undefined + } + + async function debounced( + this: ThisParameterType, + ...args: Parameters + ): Promise>> { + const time = Date.now() + const isInvoking = shouldInvoke(time, state) + + lastArgs = args + lastThis = this + state.lastCallTime = time + + // Ensure a pending batch Promise exists for this call to join. + deferred ||= pDefer>>() + const currentDeferred = deferred + + if (isInvoking) { + if (timerId === undefined) { + return leadingEdge(state.lastCallTime) + } + if (state.maxing) { + // Handle invocations in a tight loop. + timerId = startTimer(timerExpired, state.wait) + await invokeFunc(state.lastCallTime) + return currentDeferred + } + } + if (timerId === undefined) { + timerId = startTimer(timerExpired, state.wait) + } + return currentDeferred + } + + debounced.cancel = cancel + debounced.flush = flush + debounced.pending = pending + return debounced as any +} + +/** + * Like `_throttle`, but for async functions. See `_asyncDebounce` for the Promise semantics. + * + * @experimental + */ +export function _asyncThrottle( + func: T, + wait: number, + opt: ThrottleOptions = {}, +): T & Cancelable { + return _asyncDebounce(func, wait, { + leading: true, + trailing: true, + ...opt, + maxWait: wait, + }) +} + +function remainingWait(time: number, state: DebounceTimerState): number { + const { lastCallTime, lastInvokeTime, wait, maxing, maxWait } = state + const timeSinceLastCall = time - lastCallTime! + const timeSinceLastInvoke = time - lastInvokeTime + const timeWaiting = wait - timeSinceLastCall + + return maxing ? Math.min(timeWaiting, maxWait! - timeSinceLastInvoke) : timeWaiting +} + +function shouldInvoke(time: number, state: DebounceTimerState): boolean { + const { lastCallTime, lastInvokeTime, wait, maxing, maxWait } = state + const timeSinceLastCall = time - lastCallTime! + const timeSinceLastInvoke = time - lastInvokeTime + + // Either this is the first call, activity has stopped and we're at the + // trailing edge, the system time has gone backwards and we're treating + // it as the trailing edge, or we've hit the `maxWait` limit. + return ( + lastCallTime === undefined || + timeSinceLastCall >= wait || + timeSinceLastCall < 0 || + (maxing && timeSinceLastInvoke >= maxWait!) + ) +} + +function startTimer(pendingFunc: AnyFunction, wait: number): number { + return setTimeout(pendingFunc, wait) +} + +function cancelTimer(id: number): void { + clearTimeout(id) +} + +interface DebounceTimerState { + lastCallTime: number | undefined + lastInvokeTime: number + wait: number + maxing: boolean + maxWait: number | undefined +} diff --git a/packages/js-lib/src/decorators/decorator.util.ts b/packages/js-lib/src/decorators/decorator.util.ts index dbe16a8ed..6fdeb187c 100644 --- a/packages/js-lib/src/decorators/decorator.util.ts +++ b/packages/js-lib/src/decorators/decorator.util.ts @@ -1,5 +1,16 @@ import type { AnyObject, InstanceId } from '../types.js' +/** + * Generic override of Typescript's built in legacy MethodDecorator, that + * allows us to infer the parameters of the decorated method from the parameters + * of a decorator. + */ +export type MethodDecorator = ( + target: AnyObject, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => TypedPropertyDescriptor | undefined + /** * @returns * e.g `NameOfYourClass.methodName` diff --git a/packages/js-lib/src/decorators/memo.decorator.ts b/packages/js-lib/src/decorators/memo.decorator.ts index ff71f3130..fe6b24a7d 100644 --- a/packages/js-lib/src/decorators/memo.decorator.ts +++ b/packages/js-lib/src/decorators/memo.decorator.ts @@ -2,8 +2,9 @@ import { _assert, _assertTypeOf } from '../error/assert.js' import type { CommonLogger } from '../log/commonLogger.js' import type { AnyFunction, AnyObject, MaybeParameters } from '../types.js' import { _objectAssign } from '../types.js' +import type { MethodDecorator } from './decorator.util.js' import { _getTargetMethodSignature } from './decorator.util.js' -import type { MemoCache, MethodDecorator } from './memo.util.js' +import type { MemoCache } from './memo.util.js' import { jsonMemoSerializer, MapMemoCache } from './memo.util.js' export interface MemoOptions { diff --git a/packages/js-lib/src/decorators/memo.util.ts b/packages/js-lib/src/decorators/memo.util.ts index 507d4d485..af04226a1 100644 --- a/packages/js-lib/src/decorators/memo.util.ts +++ b/packages/js-lib/src/decorators/memo.util.ts @@ -1,6 +1,6 @@ import { _isPrimitive } from '../is.util.js' import { pDelay } from '../promise/pDelay.js' -import type { AnyObject, UnixTimestamp } from '../types.js' +import type { UnixTimestamp } from '../types.js' import { MISS } from '../types.js' export type MemoSerializer = (args: any[]) => any @@ -147,14 +147,3 @@ export class MapAsyncMemoCache implements AsyncMemoCache this.m.clear() } } - -/** - * Generic override of Typescript's built in legacy MethodDecorator, that - * allows us to infer the parameters of the decorated method from the parameters - * of a decorator. - */ -export type MethodDecorator = ( - target: AnyObject, - propertyKey: string | symbol, - descriptor: TypedPropertyDescriptor, -) => TypedPropertyDescriptor | undefined From 8f31616be9b87f378c9518b339a01ea3deec7c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=B3=C3=B0i=20Karlsson?= Date: Wed, 15 Jul 2026 15:36:14 +0200 Subject: [PATCH 2/4] fix: error cast and spelling stuff --- packages/js-lib/src/decorators/debounce.decorator.ts | 2 +- packages/js-lib/src/decorators/debounce.ts | 3 ++- packages/js-lib/src/decorators/decorator.util.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/js-lib/src/decorators/debounce.decorator.ts b/packages/js-lib/src/decorators/debounce.decorator.ts index 2fdc9a09f..a189312a9 100644 --- a/packages/js-lib/src/decorators/debounce.decorator.ts +++ b/packages/js-lib/src/decorators/debounce.decorator.ts @@ -28,7 +28,7 @@ export function _Throttle( /** * Like `@_Debounce`, but for async methods. Guarantees every call returns a real Promise that * resolves (or rejects) with the coalesced invocation's result, so the declared `Promise` return - * type stays accurate. Unlike `@_Debounce`, await`-ing a decorated method never silently yields + * type stays accurate. Unlike `@_Debounce`, `await`-ing a decorated method never silently yields * `undefined`. * * @experimental diff --git a/packages/js-lib/src/decorators/debounce.ts b/packages/js-lib/src/decorators/debounce.ts index 7ce96a9aa..addb30ff2 100644 --- a/packages/js-lib/src/decorators/debounce.ts +++ b/packages/js-lib/src/decorators/debounce.ts @@ -1,3 +1,4 @@ +import { _anyToError } from '../error/error.util.js' import { pDefer } from '../promise/pDefer.js' import type { DeferredPromise } from '../promise/pDefer.js' import type { AnyAsyncFunction, AnyFunction } from '../types.js' @@ -207,7 +208,7 @@ export function _asyncDebounce( const result = await func.apply(thisArg, args!) d.resolve(result) } catch (err) { - d.reject(err as Error) + d.reject(_anyToError(err)) } } diff --git a/packages/js-lib/src/decorators/decorator.util.ts b/packages/js-lib/src/decorators/decorator.util.ts index 6fdeb187c..9cc436239 100644 --- a/packages/js-lib/src/decorators/decorator.util.ts +++ b/packages/js-lib/src/decorators/decorator.util.ts @@ -1,7 +1,7 @@ import type { AnyObject, InstanceId } from '../types.js' /** - * Generic override of Typescript's built in legacy MethodDecorator, that + * Generic override of TypeScript's built-in legacy MethodDecorator, that * allows us to infer the parameters of the decorated method from the parameters * of a decorator. */ From e50ebcb488d4c9b14256df8c7ede76162e053731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=B3=C3=B0i=20Karlsson?= Date: Wed, 15 Jul 2026 16:20:16 +0200 Subject: [PATCH 3/4] fix: change trailing false semantics --- .../src/decorators/debounce.decorator.test.ts | 6 +- .../src/decorators/debounce.decorator.ts | 28 ++-- .../js-lib/src/decorators/debounce.test.ts | 49 ++++-- packages/js-lib/src/decorators/debounce.ts | 157 ++++++++++++------ 4 files changed, 157 insertions(+), 83 deletions(-) diff --git a/packages/js-lib/src/decorators/debounce.decorator.test.ts b/packages/js-lib/src/decorators/debounce.decorator.test.ts index 68f3c2c35..20e5b4cd8 100644 --- a/packages/js-lib/src/decorators/debounce.decorator.test.ts +++ b/packages/js-lib/src/decorators/debounce.decorator.test.ts @@ -27,8 +27,10 @@ async function startTimer(fn: AnyFunction, interval: number, count: number): Pro await pDelay(1000) // extra wait } -test('@debounce', async () => { - await startTimer(fn, 10, 10) +describe('@_Debounce', () => { + test('should debounce decorated method calls', async () => { + await startTimer(fn, 10, 10) + }) }) describe('@_AsyncDebounce', () => { diff --git a/packages/js-lib/src/decorators/debounce.decorator.ts b/packages/js-lib/src/decorators/debounce.decorator.ts index a189312a9..9c8b3d361 100644 --- a/packages/js-lib/src/decorators/debounce.decorator.ts +++ b/packages/js-lib/src/decorators/debounce.decorator.ts @@ -1,5 +1,10 @@ import type { AnyAsyncFunction, AnyFunction } from '../types.js' -import type { DebounceOptions, ThrottleOptions } from './debounce.js' +import type { + AsyncDebounceOptions, + AsyncThrottleOptions, + DebounceOptions, + ThrottleOptions, +} from './debounce.js' import { _asyncDebounce, _asyncThrottle, _debounce, _throttle } from './debounce.js' import type { MethodDecorator } from './decorator.util.js' @@ -26,36 +31,39 @@ export function _Throttle( } /** - * Like `@_Debounce`, but for async methods. Guarantees every call returns a real Promise that - * resolves (or rejects) with the coalesced invocation's result, so the declared `Promise` return - * type stays accurate. Unlike `@_Debounce`, `await`-ing a decorated method never silently yields - * `undefined`. + * Like `@_Debounce`, but for async methods: every call returns a real Promise resolving with the + * coalesced invocation's result, so `await`-ing never yields a stale value or a non-promise + * + * Be aware that the decorated method may resolve `undefined` if the config yields no invocation, + * which the method's `T` type can't express. * * @experimental */ export function _AsyncDebounce( wait: number, - opt: DebounceOptions = {}, + opt: AsyncDebounceOptions = {}, ): MethodDecorator { return (_target, _key, descriptor) => { const originalFn = descriptor.value! - descriptor.value = _asyncDebounce(originalFn, wait, opt) + descriptor.value = _asyncDebounce(originalFn, wait, opt) as any return descriptor } } /** - * Like `@_Throttle`, but for async methods. See `@_AsyncDebounce` for the Promise semantics. + * Like `@_Throttle`, but for async methods. + * + * @see {@link _AsyncDebounce} * * @experimental */ export function _AsyncThrottle( wait: number, - opt: ThrottleOptions = {}, + opt: AsyncThrottleOptions = {}, ): MethodDecorator { return (_target, _key, descriptor) => { const originalFn = descriptor.value! - descriptor.value = _asyncThrottle(originalFn, wait, opt) + descriptor.value = _asyncThrottle(originalFn, wait, opt) as any return descriptor } } diff --git a/packages/js-lib/src/decorators/debounce.test.ts b/packages/js-lib/src/decorators/debounce.test.ts index 240331ea5..1b16b867a 100644 --- a/packages/js-lib/src/decorators/debounce.test.ts +++ b/packages/js-lib/src/decorators/debounce.test.ts @@ -22,25 +22,27 @@ async function startTimer(fn: AnyFunction, interval: number, count: number): Pro await pDelay(2000) // extra wait } -test('_debounce', async () => { - vi.useFakeTimers() +describe('_debounce', () => { + test('should debounce calls with leading/trailing/maxWait', async () => { + vi.useFakeTimers() - const fn = _debounce(originalFn, 20, { leading: true, trailing: true, maxWait: 300 }) + const fn = _debounce(originalFn, 20, { leading: true, trailing: true, maxWait: 300 }) - const promise = startTimer(fn, 10, 10) - await vi.runAllTimersAsync() - await promise -}) + const promise = startTimer(fn, 10, 10) + await vi.runAllTimersAsync() + await promise + }) -// Test cases: -// _debounce leading=1 trailing=0 (default) -// _debounce leading=1 trailing=1 -// _debounce leading=0 trailing=1 -// _debounce leading=0 trailing=0 -// _throttle leading=1 trailing=1 (default) -// _throttle leading=1 trailing=0 -// _throttle leading=0 trailing=1 -// _throttle leading=0 trailing=0 + // Test cases: + // _debounce leading=1 trailing=0 (default) + // _debounce leading=1 trailing=1 + // _debounce leading=0 trailing=1 + // _debounce leading=0 trailing=0 + // _throttle leading=1 trailing=1 (default) + // _throttle leading=1 trailing=0 + // _throttle leading=0 trailing=1 + // _throttle leading=0 trailing=0 +}) describe('_asyncDebounce', () => { test('should return a real promise (never undefined) resolving with the invocation result', async () => { @@ -64,7 +66,7 @@ describe('_asyncDebounce', () => { }, 20) const results = await Promise.all([fn(1), fn(2), fn(3)]) - expect(results).toEqual([3, 3, 3]) // trailing edge invokes with the last args + expect(results).toEqual([3, 3, 3]) expect(calls).toBe(1) }) @@ -88,6 +90,12 @@ describe('_asyncDebounce', () => { await expect(p).rejects.toThrow('asyncDebounce cancelled') }) + + test('should resolve dropped calls with undefined when the config yields no invocation (trailing: false)', async () => { + const fn = _asyncDebounce(async (n: number) => n, 20, { trailing: false }) + + expect(await fn(1)).toBeUndefined() + }) }) describe('_asyncThrottle', () => { @@ -101,4 +109,11 @@ describe('_asyncThrottle', () => { expect(await fn(7)).toBe(7) expect(calls).toBe(1) }) + + test('should resolve the leading caller but resolve in-window callers with undefined when trailing is disabled', async () => { + const fn = _asyncThrottle(async (n: number) => n, 50, { trailing: false }) + + expect(await fn(1)).toBe(1) + expect(await fn(2)).toBeUndefined() + }) }) diff --git a/packages/js-lib/src/decorators/debounce.ts b/packages/js-lib/src/decorators/debounce.ts index addb30ff2..2c269de8a 100644 --- a/packages/js-lib/src/decorators/debounce.ts +++ b/packages/js-lib/src/decorators/debounce.ts @@ -3,40 +3,6 @@ import { pDefer } from '../promise/pDefer.js' import type { DeferredPromise } from '../promise/pDefer.js' import type { AnyAsyncFunction, AnyFunction } from '../types.js' -export interface Cancelable { - cancel: () => void - flush: () => void -} - -export interface ThrottleOptions { - /** - * @default true - */ - leading?: boolean - - /** - * @default true - */ - trailing?: boolean -} - -export interface DebounceOptions { - /** - * @default false - */ - leading?: boolean - - /** - * @default true - */ - trailing?: boolean - - /** - * - */ - maxWait?: number -} - export function _debounce( func: T, wait: number, @@ -161,25 +127,21 @@ export function _throttle( /** * Like `_debounce`, but for async functions. * - * Unlike `_debounce` (which returns the previous invocation's stale `result` or `undefined` - * for suppressed calls), `_asyncDebounce` always returns a real Promise. All calls that are - * coalesced into a single invocation resolve (or reject) with that invocation's result. - * - * `cancel()` rejects any pending Promises, so awaiters never hang. + * Unlike `_debounce` (which returns a stale `result`/`undefined` for suppressed calls), + * `_asyncDebounce` always returns a real Promise, resolving with the coalesced invocation's result. * * @experimental */ export function _asyncDebounce( func: T, wait: number, - opt: DebounceOptions = {}, -): T & Cancelable { + opt: AsyncDebounceOptions = {}, +): AsyncDebounced { let lastArgs: Parameters | undefined let lastThis: ThisParameterType | undefined let timerId: number | undefined - // The Promise shared by all calls coalesced into the next invocation. - // Undefined when there's no pending batch. - let deferred: DeferredPromise>> | undefined + // Promise shared by all calls coalesced into the next invocation (undefined when none pending). + let deferred: DeferredPromise> | undefined> | undefined const { leading = false, trailing = true } = opt @@ -201,7 +163,7 @@ export function _asyncDebounce( lastArgs = lastThis = undefined state.lastInvokeTime = time - // Detach the current batch's Promise and settle it with this invocation's result. + // Detach and settle the current batch. const d = deferred! deferred = undefined try { @@ -212,7 +174,7 @@ export function _asyncDebounce( } } - async function leadingEdge(time: number): Promise>> { + async function leadingEdge(time: number): Promise> | undefined> { // Reset any `maxWait` timer. state.lastInvokeTime = time // Start the timer for the trailing edge. @@ -246,8 +208,7 @@ export function _asyncDebounce( return } lastArgs = lastThis = undefined - // No invocation will happen for this batch (e.g. trailing=false) - resolve to - // undefined rather than leaving awaiters hanging forever. + // Dropped batch (only reachable with `trailing: false`): no invocation, so resolve `undefined`. if (deferred) { const d = deferred deferred = undefined @@ -262,7 +223,7 @@ export function _asyncDebounce( state.lastInvokeTime = 0 state.lastCallTime = undefined lastArgs = lastThis = timerId = undefined - // Reject pending awaiters so they don't hang. + // Reject pending awaiters. if (deferred) { const d = deferred deferred = undefined @@ -287,7 +248,7 @@ export function _asyncDebounce( async function debounced( this: ThisParameterType, ...args: Parameters - ): Promise>> { + ): Promise> | undefined> { const time = Date.now() const isInvoking = shouldInvoke(time, state) @@ -295,8 +256,7 @@ export function _asyncDebounce( lastThis = this state.lastCallTime = time - // Ensure a pending batch Promise exists for this call to join. - deferred ||= pDefer>>() + deferred ||= pDefer> | undefined>() const currentDeferred = deferred if (isInvoking) { @@ -330,8 +290,8 @@ export function _asyncDebounce( export function _asyncThrottle( func: T, wait: number, - opt: ThrottleOptions = {}, -): T & Cancelable { + opt: AsyncThrottleOptions = {}, +): AsyncDebounced { return _asyncDebounce(func, wait, { leading: true, trailing: true, @@ -380,3 +340,92 @@ interface DebounceTimerState { maxing: boolean maxWait: number | undefined } + +export interface Cancelable { + cancel: () => void + flush: () => void +} + +export interface ThrottleOptions { + /** + * Invoke on the leading edge of the window (immediately, on the first call). + * + * @default true + */ + leading?: boolean + + /** + * Invoke on the trailing edge of the window (after `wait` has elapsed). + * + * @default true + */ + trailing?: boolean +} + +export interface DebounceOptions { + /** + * Invoke on the leading edge of the window (immediately, on the first call). + * + * @default false + */ + leading?: boolean + + /** + * Invoke on the trailing edge of the window (after `wait` has elapsed). + * + * @default true + */ + trailing?: boolean + + /** + * Maximum time `func` is allowed to be delayed before it's forcibly invoked. + */ + maxWait?: number +} + +export interface AsyncThrottleOptions { + /** + * Invoke on the leading edge of the window (immediately, on the first call). + * + * @default true + */ + leading?: boolean + + /** + * Invoke on the trailing edge of the window (after `wait` has elapsed). + * + * When `false`, calls dropped within the window (not served by a leading invocation) resolve + * with `undefined`. There's no invocation for them to await. This is why the returned function + * can resolve with `undefined` even though the original function's return type doesn't express that. + * + * @default true + */ + trailing?: boolean +} + +export interface AsyncDebounceOptions extends AsyncThrottleOptions { + /** + * Invoke on the leading edge of the window (immediately, on the first call). + * + * @default false + */ + leading?: boolean + + /** + * Maximum time `func` is allowed to be delayed before it's forcibly invoked. + */ + maxWait?: number +} + +/** + * The function returned by `_asyncDebounce`/`_asyncThrottle`. Same signature as `T`, but resolves + * `Awaited> | undefined` + * + * This is because some configurations (e.g. `trailing: false`) may drop calls without invoking + * the original function, so there's no result to await. + */ +export type AsyncDebounced = (( + this: ThisParameterType, + ...args: Parameters +) => Promise> | undefined>) & + Cancelable From 19c14c6b98a515da05411509efd4f0af816c58c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=B3=C3=B0i=20Karlsson?= Date: Wed, 15 Jul 2026 16:53:09 +0200 Subject: [PATCH 4/4] fix: add AsyncCancelable and missing test case --- .../src/decorators/debounce.decorator.test.ts | 17 +++++++++++++++++ packages/js-lib/src/decorators/debounce.ts | 12 +++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/js-lib/src/decorators/debounce.decorator.test.ts b/packages/js-lib/src/decorators/debounce.decorator.test.ts index 20e5b4cd8..4e1dcd709 100644 --- a/packages/js-lib/src/decorators/debounce.decorator.test.ts +++ b/packages/js-lib/src/decorators/debounce.decorator.test.ts @@ -69,4 +69,21 @@ describe('@_AsyncThrottle', () => { expect(await inst.ping(7)).toBe(7) expect(inst.calls).toBe(1) }) + + test('should resolve in-window calls with undefined when trailing is disabled', async () => { + class C { + calls = 0 + + @_AsyncThrottle(50, { trailing: false }) + async ping(n: number): Promise { + this.calls++ + return n + } + } + + const inst = new C() + expect(await inst.ping(1)).toBe(1) + expect(await inst.ping(2)).toBeUndefined() + expect(inst.calls).toBe(1) + }) }) diff --git a/packages/js-lib/src/decorators/debounce.ts b/packages/js-lib/src/decorators/debounce.ts index 2c269de8a..88006c7aa 100644 --- a/packages/js-lib/src/decorators/debounce.ts +++ b/packages/js-lib/src/decorators/debounce.ts @@ -346,6 +346,16 @@ export interface Cancelable { flush: () => void } +/** + * Like `Cancelable`, but for the async debounced functions: `flush()` resolves with the flushed + * invocation's result (or `undefined` if nothing was pending), and `pending()` is exposed. + */ +export interface AsyncCancelable { + cancel: () => void + flush: () => Promise + pending: () => boolean +} + export interface ThrottleOptions { /** * Invoke on the leading edge of the window (immediately, on the first call). @@ -428,4 +438,4 @@ export type AsyncDebounced = (( this: ThisParameterType, ...args: Parameters ) => Promise> | undefined>) & - Cancelable + AsyncCancelable>>