diff --git a/async/debounce.ts b/async/debounce.ts index bb6138ab9a81..25e65ea855ef 100644 --- a/async/debounce.ts +++ b/async/debounce.ts @@ -16,12 +16,22 @@ export interface DebouncedFunction> { 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}. + * * @example Usage * ```ts ignore * import { debounce } from "@std/async/debounce"; @@ -39,10 +49,31 @@ export interface DebouncedFunction> { * // output: [modify] /path/to/file * ``` * + * @example With AbortSignal + * ```ts ignore + * import { debounce } from "@std/async/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. * Must be a positive integer. + * @param options Optional parameters. * @throws {RangeError} If `wait` is not a non-negative integer. * @returns The debounced function. */ @@ -50,6 +81,7 @@ export interface DebouncedFunction> { export function debounce>( fn: (this: DebouncedFunction, ...args: T) => void, wait: number, + options?: DebounceOptions, ): DebouncedFunction { if (!Number.isInteger(wait) || wait < 0) { throw new RangeError("'wait' must be a positive integer"); @@ -82,5 +114,11 @@ export function debounce>( get: () => timeout !== null, }); + const signal = options?.signal; + if (signal) { + signal.throwIfAborted(); + signal.addEventListener("abort", () => debounced.clear(), { once: true }); + } + return debounced; } diff --git a/async/debounce_test.ts b/async/debounce_test.ts index 7e0bbb2a3ec6..26cf8154d152 100644 --- a/async/debounce_test.ts +++ b/async/debounce_test.ts @@ -1,6 +1,7 @@ // Copyright 2018-2026 the Deno authors. MIT license. import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; import { debounce, type DebouncedFunction } from "./debounce.ts"; +import { delay } from "./delay.ts"; Deno.test("debounce() handles called", () => { let called = 0; @@ -128,3 +129,33 @@ Deno.test("debounce() re-invocation after flush re-schedules", () => { assertEquals(called, 2); 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, + ); +}); diff --git a/async/deno.json b/async/deno.json index dc84df585695..07b2b4fb6e0f 100644 --- a/async/deno.json +++ b/async/deno.json @@ -6,7 +6,6 @@ "./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", diff --git a/async/unstable_debounce.ts b/async/unstable_debounce.ts deleted file mode 100644 index 46996a249ddc..000000000000 --- a/async/unstable_debounce.ts +++ /dev/null @@ -1,104 +0,0 @@ -// 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> { - (...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>( - fn: (this: DebouncedFunction, ...args: T) => void, - wait: number, - options?: DebounceOptions, -): DebouncedFunction { - let timeout: number | null = null; - let flush: (() => void) | null = null; - - const debounced: DebouncedFunction = ((...args: T) => { - debounced.clear(); - flush = () => { - debounced.clear(); - fn.call(debounced, ...args); - }; - timeout = Number(setTimeout(flush, wait)); - }) as DebouncedFunction; - - 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; -} diff --git a/async/unstable_debounce_test.ts b/async/unstable_debounce_test.ts deleted file mode 100644 index c7138db57e10..000000000000 --- a/async/unstable_debounce_test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// 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 = []; - 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 = []; - 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, - ); -});