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
1 change: 1 addition & 0 deletions async/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"./abortable": "./abortable.ts",
"./deadline": "./deadline.ts",
"./debounce": "./debounce.ts",
"./unstable-debounce": "./unstable_debounce.ts",
"./delay": "./delay.ts",
"./mux-async-iterator": "./mux_async_iterator.ts",
"./unstable-mux-async-iterator": "./unstable_mux_async_iterator.ts",
Expand Down
104 changes: 104 additions & 0 deletions async/unstable_debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.

/**
* A debounced function that will be delayed by a given `wait`
* time in milliseconds. If the method is called again before
* the timeout expires, the previous call will be aborted.
*/
export interface DebouncedFunction<T extends Array<unknown>> {
(...args: T): void;
/** Clears the debounce timeout and omits calling the debounced function. */
clear(): void;
/** Clears the debounce timeout and calls the debounced function immediately. */
flush(): void;
/** Returns a boolean whether a debounce call is pending or not. */
readonly pending: boolean;
}

/** Options for {@linkcode debounce}. */
export interface DebounceOptions {
/** An AbortSignal that clears the debounce timeout when aborted. */
signal?: AbortSignal | undefined;
}

/**
* Creates a debounced function that delays the given `func`
* by a given `wait` time in milliseconds. If the method is called
* again before the timeout expires, the previous call will be
* aborted.
*
* If an {@linkcode AbortSignal} is provided via `options.signal`, aborting the
* signal clears any pending debounce timeout, equivalent to calling
* {@linkcode DebouncedFunction.clear}.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example Usage
* ```ts ignore
* import { debounce } from "@std/async/unstable-debounce";
*
* const controller = new AbortController();
* const log = debounce(
* (event: Deno.FsEvent) =>
* console.log("[%s] %s", event.kind, event.paths[0]),
* 200,
* { signal: controller.signal },
* );
*
* for await (const event of Deno.watchFs("./")) {
* log(event);
* }
*
* // Abort clears any pending debounce
* controller.abort();
* ```
*
* @typeParam T The arguments of the provided function.
* @param fn The function to debounce.
* @param wait The time in milliseconds to delay the function.
* @param options Optional parameters.
* @returns The debounced function.
*/
// deno-lint-ignore no-explicit-any
export function debounce<T extends Array<any>>(
fn: (this: DebouncedFunction<T>, ...args: T) => void,
wait: number,
options?: DebounceOptions,
): DebouncedFunction<T> {
let timeout: number | null = null;
let flush: (() => void) | null = null;

const debounced: DebouncedFunction<T> = ((...args: T) => {
debounced.clear();
flush = () => {
debounced.clear();
fn.call(debounced, ...args);
};
timeout = Number(setTimeout(flush, wait));
}) as DebouncedFunction<T>;

debounced.clear = () => {
if (typeof timeout === "number") {
clearTimeout(timeout);
timeout = null;
flush = null;
}
};

debounced.flush = () => {
flush?.();
};

Object.defineProperty(debounced, "pending", {
get: () => typeof timeout === "number",
});

const signal = options?.signal;
if (signal) {
signal.throwIfAborted();
signal.addEventListener("abort", () => debounced.clear(), { once: true });
}

return debounced;
}
111 changes: 111 additions & 0 deletions async/unstable_debounce_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2018-2026 the Deno authors. MIT license.
import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert";
import { debounce, type DebouncedFunction } from "./unstable_debounce.ts";
import { delay } from "./delay.ts";

Deno.test("debounce() handles called", async () => {
let called = 0;
const d = debounce(() => called++, 100);
d();
d();
d();
assertEquals(called, 0);
assertEquals(d.pending, true);
await delay(200);
assertEquals(called, 1);
assertEquals(d.pending, false);
});

Deno.test("debounce() handles cancelled", async () => {
let called = 0;
const d = debounce(() => called++, 100);
d();
d();
d();
assertEquals(called, 0);
assertEquals(d.pending, true);
d.clear();
await delay(200);
assertEquals(called, 0);
assertEquals(d.pending, false);
});

Deno.test("debounce() handles flush", () => {
let called = 0;
const d = debounce(() => called++, 100);
d();
d();
d();
assertEquals(called, 0);
assertEquals(d.pending, true);
d.flush();
assertEquals(called, 1);
assertEquals(d.pending, false);
});

Deno.test("debounce() handles params and context", async () => {
const params: Array<string | number> = [];
const d: DebouncedFunction<[string, number]> = debounce(
function (param1: string, param2: number) {
assertEquals(d.pending, false);
params.push(param1);
params.push(param2);
assertStrictEquals(d, this);
},
100,
);
// @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'string'.
d(1, 1);
d("foo", 1);
d("bar", 1);
d("baz", 1);
assertEquals(params.length, 0);
assertEquals(d.pending, true);
await delay(200);
assertEquals(params, ["baz", 1]);
assertEquals(d.pending, false);
});

Deno.test("debounce() handles number and string types", async () => {
const params: Array<string> = [];
const fn = (param: string) => params.push(param);
const d: DebouncedFunction<[string]> = debounce(fn, 100);
// @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'string'.
d(1);
d("foo");
assertEquals(params.length, 0);
assertEquals(d.pending, true);
await delay(200);
assertEquals(params, ["foo"]);
assertEquals(d.pending, false);
});

Deno.test("debounce() abort signal clears pending call", async () => {
let called = 0;
const controller = new AbortController();
const d = debounce(() => called++, 100, { signal: controller.signal });
d();
assertEquals(d.pending, true);
controller.abort();
assertEquals(d.pending, false);
await delay(200);
assertEquals(called, 0);
});

Deno.test("debounce() abort signal after flush does not interfere", () => {
let called = 0;
const controller = new AbortController();
const d = debounce(() => called++, 100, { signal: controller.signal });
d();
d.flush();
assertEquals(called, 1);
controller.abort();
assertEquals(called, 1);
});

Deno.test("debounce() throws if signal is already aborted", () => {
assertThrows(
() => debounce(() => {}, 100, { signal: AbortSignal.abort() }),
DOMException,
);
});
Loading