From 7bfc09207d3606aa88c2a1bda371b907824c4499 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 8 May 2026 17:47:25 -0400 Subject: [PATCH] feat: debounce helper with flush Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/debounce.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++ src/lib/debounce.ts | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/lib/debounce.test.ts create mode 100644 src/lib/debounce.ts diff --git a/src/lib/debounce.test.ts b/src/lib/debounce.test.ts new file mode 100644 index 0000000..4c1c58a --- /dev/null +++ b/src/lib/debounce.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { debounce } from "./debounce"; + +describe("debounce", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("delays a single call by the wait period", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + d("a"); + expect(fn).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("a"); + }); + + it("collapses rapid calls into a single trailing call", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + d("a"); d("b"); d("c"); + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("c"); + }); + + it("flush() invokes pending call immediately", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + d("a"); + d.flush(); + expect(fn).toHaveBeenCalledWith("a"); + }); + + it("flush() with no pending call is a no-op", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + d.flush(); + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/debounce.ts b/src/lib/debounce.ts new file mode 100644 index 0000000..892d0b8 --- /dev/null +++ b/src/lib/debounce.ts @@ -0,0 +1,40 @@ +export type Debounced = ((...args: Args) => void) & { + flush: () => void; + cancel: () => void; +}; + +export function debounce( + fn: (...args: Args) => void, + waitMs: number +): Debounced { + let timer: ReturnType | null = null; + let pendingArgs: Args | null = null; + + const debounced = (...args: Args): void => { + pendingArgs = args; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + const a = pendingArgs!; + pendingArgs = null; + fn(...a); + }, waitMs); + }; + + debounced.flush = (): void => { + if (timer === null) return; + clearTimeout(timer); + timer = null; + const a = pendingArgs!; + pendingArgs = null; + fn(...a); + }; + + debounced.cancel = (): void => { + if (timer !== null) clearTimeout(timer); + timer = null; + pendingArgs = null; + }; + + return debounced; +}