diff --git a/frontend/src/lib/utils/debounce.spec.ts b/frontend/src/lib/utils/debounce.spec.ts new file mode 100644 index 00000000..6ee1c067 --- /dev/null +++ b/frontend/src/lib/utils/debounce.spec.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { debounce } from './debounce'; + +describe('debounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not fire before the delay has elapsed', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(199); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('fires exactly once after the delay', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('resets the timer on each call — fires only once after inactivity', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(100); + debounced(); + vi.advanceTimersByTime(100); + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('passes the latest arguments to the callback', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced('first'); + debounced('second'); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledWith('second'); + }); + + it('can fire again after the first invocation settles', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(200); + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/lib/utils/debounce.ts b/frontend/src/lib/utils/debounce.ts new file mode 100644 index 00000000..b8958e45 --- /dev/null +++ b/frontend/src/lib/utils/debounce.ts @@ -0,0 +1,12 @@ +/** + * Returns a debounced version of fn that delays invocation until after + * `delay` ms have elapsed since the last call. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function debounce void>(fn: T, delay: number): T { + let timer: ReturnType; + return ((...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }) as T; +}