Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 0 additions & 66 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions src/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function debounce<T extends (...args: any[]) => void>(fn: T, delayMs: number): T {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] error-handling

delayMs and intervalMs are not validated. Passing NaN or a negative number causes setTimeout to fire immediately and throttle to execute on every call, which may surprise callers.

Suggestion: Consider guarding with if (!Number.isFinite(delayMs) || delayMs < 0) throw new RangeError('delayMs must be a non-negative finite number') (similarly for intervalMs), or document the behavior in JSDoc.

let timer: ReturnType<typeof setTimeout> | undefined;
return ((...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delayMs);
}) as T;
}

export function throttle<T extends (...args: any[]) => void>(fn: T, intervalMs: number): T {
let lastCall = 0;
return ((...args: any[]) => {
const now = Date.now();
if (now - lastCall >= intervalMs) {
lastCall = now;
fn(...args);
}
}) as T;
}
97 changes: 97 additions & 0 deletions test/utils/debounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { debounce, throttle } from "../../src/utils/debounce.js";

describe("debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("delays execution and only fires the last call", () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);

debounced("a");
debounced("b");
debounced("c");

expect(fn).not.toHaveBeenCalled();

vi.advanceTimersByTime(100);

expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("c");
});

it("resets the timer on re-entry", () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);

debounced("first");
vi.advanceTimersByTime(50);
debounced("second");
vi.advanceTimersByTime(50);

expect(fn).not.toHaveBeenCalled();

vi.advanceTimersByTime(50);

expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("second");
});

it("fires immediately with zero delay", () => {
const fn = vi.fn();
const debounced = debounce(fn, 0);

debounced("a");
vi.advanceTimersByTime(0);

expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("a");
});
});

describe("throttle", () => {
it("limits calls within the interval", () => {
const fn = vi.fn();
const throttled = throttle(fn, 100);

throttled("a");
throttled("b");
throttled("c");

expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("a");
});

it("allows calls after the interval elapses", () => {
const fn = vi.fn();
vi.useFakeTimers();
const throttled = throttle(fn, 100);

throttled("first");
expect(fn).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(100);
throttled("second");
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenCalledWith("second");

vi.useRealTimers();
});

it("fires on every call with zero interval", () => {
const fn = vi.fn();
const throttled = throttle(fn, 0);

throttled("a");
throttled("b");
throttled("c");

expect(fn).toHaveBeenCalledTimes(3);
});
});