/** * Generic debouncer for coalescing rapid calls. * * Calls the callback with the most recent arguments after the delay expires. * If call() is invoked again before the delay expires, the timer resets. */ export class Debouncer { private timer: ReturnType | null = null private pendingArgs: T | null = null private disposed = false private executing = false constructor( private readonly callback: (...args: T) => void, private readonly delayMs: number ) {} /** * Schedule a debounced call with the given arguments. * Resets the timer if already pending. */ call(...args: T): void { if (this.disposed) return this.pendingArgs = args this.clearTimer() this.timer = setTimeout(() => { this.timer = null const savedArgs = this.pendingArgs this.pendingArgs = null if (savedArgs !== null) { this.executing = true try { this.callback(...savedArgs) } finally { this.executing = false } } }, this.delayMs) } private clearTimer(): void { if (this.timer !== null) { clearTimeout(this.timer) this.timer = null } } /** * Immediately invoke the callback with pending arguments. * Clears the pending state. */ flush(): void { if (this.disposed) return this.clearTimer() const args = this.pendingArgs this.pendingArgs = null if (args !== null) { this.callback(...args) } } /** * Cancel any pending call without invoking the callback. */ cancel(): void { this.clearTimer() this.pendingArgs = null } /** * Check if there is a pending debounced call or if the callback is currently executing. */ isPending(): boolean { return this.pendingArgs !== null || this.executing } /** * Cancel and mark as disposed. Future calls are ignored. */ dispose(): void { this.cancel() this.disposed = true } /** * Check if the debouncer has been disposed. */ isDisposed(): boolean { return this.disposed } }