diff --git a/README.md b/README.md index b84171b..f4cc1c2 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,10 @@ 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: @@ -44,25 +44,69 @@ 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 @@ -81,14 +125,12 @@ Based on [sindresorhus/p-debounce](https://github.com/sindresorhus/p-debounce). Published under [MIT License](./LICENSE). + [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 diff --git a/src/index.ts b/src/index.ts index cf9fa8e..9329c11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,23 @@ export interface DebounceOptions { readonly trailing?: boolean; } +type DebouncedReturn = (( + ...args: ArgumentsT +) => Promise) & { + /** + * Cancel pending function call + */ + cancel: () => void; + /** + * Immediately invoke pending function call + */ + flush: () => Promise | undefined; + /** + * Get pending function call + */ + isPending: () => boolean; +}; + const DEBOUNCE_DEFAULTS: DebounceOptions = { trailing: true, }; @@ -39,7 +56,7 @@ export function debounce( fn: (...args: ArgumentsT) => PromiseLike | ReturnT, wait = 25, options: DebounceOptions = {}, -) { +): DebouncedReturn { // Validate options options = { ...DEBOUNCE_DEFAULTS, ...options }; if (!Number.isFinite(wait)) { @@ -74,11 +91,11 @@ export function debounce( 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((resolve) => { @@ -88,6 +105,7 @@ export function debounce( timeout = setTimeout(() => { timeout = null; const promise = options.leading ? leadingValue : applyFn(this, args); + trailingArgs = null; for (const _resolve of resolveList) { _resolve(promise); } @@ -101,7 +119,34 @@ export function debounce( resolveList.push(resolve); } }); + } as DebouncedReturn; + + 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[]) { diff --git a/test/index.test.ts b/test/index.test.ts index f446b20..4972e7e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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"; @@ -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.