// Trailing-edge debounce: the last call of a burst wins, `ms` after the burst stops. // One timer, always cancellable — a component that unmounts mid-burst must not fire // a filter into a tree that is gone, which is what a leaked timer does. import { invalidValueError } from './errors'; export interface Debounced { (...args: A): void; /** Drop the pending call. Wire it to the component's cleanup. */ cancel(): void; /** Run the pending call now, if there is one. Blur and submit both need this. */ flush(): void; /** True while a call is waiting. */ pending(): boolean; } export const DEBOUNCE_DEFAULT_MS = 250; export function debounce( fn: (...args: A) => void, ms: number = DEBOUNCE_DEFAULT_MS, ): Debounced { if (!Number.isFinite(ms) || ms < 0) { throw invalidValueError('debounce', ms, 'a finite, non-negative delay in milliseconds'); } let timer: ReturnType | undefined; let queued: A | undefined; const cancel = (): void => { if (timer !== undefined) clearTimeout(timer); timer = undefined; queued = undefined; }; const run = (): void => { const args = queued; // Cleared BEFORE the call: `fn` may schedule the next burst, and clearing after would // discard the call it just queued. cancel(); if (args !== undefined) fn(...args); }; const debounced = (...args: A): void => { queued = args; if (timer !== undefined) clearTimeout(timer); timer = setTimeout(run, ms); }; return Object.assign(debounced, { cancel, flush: run, pending: (): boolean => timer !== undefined, }); }