Skip to content
Merged
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
68 changes: 55 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,36 +33,80 @@ Import:

```js
// ESM
import { debounce } from 'perfect-debounce'
import { debounce } from "perfect-debounce";

// CommonJS
const { debounce } = require('perfect-debounce')
const { debounce } = require("perfect-debounce");
```

Debounce function:

```js
const debounced = debounce(async () => {
// Some heavy stuff
}, 25)
}, 25);

// Control methods:
debounced.cancel(); // Cancel any pending call
await debounced.flush(); // Immediately invoke pending call (if any)
debounced.isPending(); // Returns true if a call is pending
```

When calling `debounced`, it will wait at least for `25ms` as configured before actually calling our function. This helps to avoid multiple calls.
When calling `debounced`, it will wait at least for `25ms` as configured before actually calling your function. This helps to avoid multiple calls.

### Control Methods

The returned debounced function provides additional control methods:

- `debounced.cancel()`: Cancel any pending invocation that has not yet occurred.
- `await debounced.flush()`: Immediately invoke the pending function call (if any) and return its result.
- `debounced.isPending()`: Returns `true` if there is a pending invocation waiting to be called, otherwise `false`.

#### Example usage

```js
const debounced = debounce(async (value) => {
// Some async work
return value * 2;
}, 100);

debounced(1);
debounced(2);
debounced(3);

// Check if a call is pending
console.log(debounced.isPending()); // true

// Immediately invoke the pending call
const result = await debounced.flush();
console.log(result); // 6

// Cancel any further pending calls
debounced.cancel();
```

To avoid initial wait, we can set `leading: true` option. It will cause function to be immediately called if there is no other call:

```js
const debounced = debounce(async () => {
// Some heavy stuff
}, 25, { leading: true })
const debounced = debounce(
async () => {
// Some heavy stuff
},
25,
{ leading: true },
);
```

If executing async function takes longer than debounce value, duplicate calls will be still prevented a last call will happen. To disable this behavior, we can set `trailing: false` option:

```js
const debounced = debounce(async () => {
// Some heavy stuff
}, 25, { trailing: false })
const debounced = debounce(
async () => {
// Some heavy stuff
},
25,
{ trailing: false },
);
```

## 💻 Development
Expand All @@ -81,14 +125,12 @@ Based on [sindresorhus/p-debounce](https://github.com/sindresorhus/p-debounce).
Published under [MIT License](./LICENSE).

<!-- Badges -->

[npm-version-src]: https://img.shields.io/npm/v/perfect-debounce?style=flat-square
[npm-version-href]: https://npmjs.com/package/perfect-debounce

[npm-downloads-src]: https://img.shields.io/npm/dm/perfect-debounce?style=flat-square
[npm-downloads-href]: https://npmjs.com/package/perfect-debounce

[github-actions-src]: https://img.shields.io/github/actions/workflow/status/unjs/perfect-debounce/ci.yml?branch=main&style=flat-square
[github-actions-href]: https://github.com/unjs/perfect-debounce/actions?query=workflow%3Aci

[codecov-src]: https://img.shields.io/codecov/c/gh/unjs/perfect-debounce/main?style=flat-square
[codecov-href]: https://codecov.io/gh/unjs/perfect-debounce
55 changes: 50 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ export interface DebounceOptions {
readonly trailing?: boolean;
}

type DebouncedReturn<ArgumentsT extends unknown[], ReturnT> = ((
...args: ArgumentsT
) => Promise<ReturnT>) & {
/**
* Cancel pending function call
*/
cancel: () => void;
/**
* Immediately invoke pending function call
*/
flush: () => Promise<ReturnT> | undefined;
/**
* Get pending function call
*/
isPending: () => boolean;
};

const DEBOUNCE_DEFAULTS: DebounceOptions = {
trailing: true,
};
Expand All @@ -39,7 +56,7 @@ export function debounce<ArgumentsT extends unknown[], ReturnT>(
fn: (...args: ArgumentsT) => PromiseLike<ReturnT> | ReturnT,
wait = 25,
options: DebounceOptions = {},
) {
): DebouncedReturn<ArgumentsT, ReturnT> {
// Validate options
options = { ...DEBOUNCE_DEFAULTS, ...options };
if (!Number.isFinite(wait)) {
Expand Down Expand Up @@ -74,11 +91,11 @@ export function debounce<ArgumentsT extends unknown[], ReturnT>(
return currentPromise;
};

return function (...args: ArgumentsT) {
const debounced = function (...args: ArgumentsT) {
if (options.trailing) {
trailingArgs = args;
}
if (currentPromise) {
if (options.trailing) {
trailingArgs = args;
}
return currentPromise;
}
return new Promise<ReturnT>((resolve) => {
Expand All @@ -88,6 +105,7 @@ export function debounce<ArgumentsT extends unknown[], ReturnT>(
timeout = setTimeout(() => {
timeout = null;
const promise = options.leading ? leadingValue : applyFn(this, args);
trailingArgs = null;
for (const _resolve of resolveList) {
_resolve(promise);
}
Expand All @@ -101,7 +119,34 @@ export function debounce<ArgumentsT extends unknown[], ReturnT>(
resolveList.push(resolve);
}
});
} as DebouncedReturn<ArgumentsT, ReturnT>;

const _clearTimeout = (timer: NodeJS.Timeout) => {
if (timer) {
clearTimeout(timer);
timeout = null;
}
};

debounced.isPending = () => !!timeout;

debounced.cancel = () => {
_clearTimeout(timeout);
resolveList = [];
trailingArgs = null;
};

debounced.flush = () => {
_clearTimeout(timeout);
if (!trailingArgs || currentPromise) {
return;
}
const args = trailingArgs;
trailingArgs = null;
return applyFn(this, args);
};

return debounced;
}

async function _applyPromised(fn: () => any, _this: unknown, args: any[]) {
Expand Down
49 changes: 48 additions & 1 deletion test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setTimeout as delay } from "node:timers/promises";
import { test, expect } from "vitest";
import { test, expect, vi } from "vitest";
import inRange from "in-range";
import timeSpan from "time-span";
import { debounce } from "../src";
Expand Down Expand Up @@ -106,6 +106,53 @@ test.concurrent("fn takes longer than wait", async () => {
expect(count).toBe(2);
});

test("cancel method of debounced", async () => {
const fn = vi.fn(async () => {
await delay(50);
return 1;
});
const debounced = debounce(fn, 100);

debounced();
debounced.cancel();
await delay(150);

expect(fn).not.toHaveBeenCalled();
});

test("flush method of debounced with immediate call", async () => {
const fn = vi.fn(async (value: number) => {
await delay(50);
return value * 2;
});
const debounced = debounce(fn, 100);

[1, 2, 3].map((value) => debounced(value));

const flushResult = await debounced.flush();
expect(flushResult).toBe(6);

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

test("isPending method of debounced", async () => {
const fn = vi.fn(async (value) => {
await delay(50);
return value;
});
const debounced = debounce(fn, 100);

const promises = [1, 2].map((value) => debounced(value));

expect(debounced.isPending()).toBe(true);
await delay(150);
expect(debounced.isPending()).toBe(false);
expect(fn).toHaveBeenCalledTimes(1);

await Promise.all(promises);
});

// Factory to create a separate class for each test below
// * Each test replaces methods in the class with a debounced variant,
// hence the need to start with fresh class for each test.
Expand Down