From 0f4649d4b7fd1e9e45d8a102a67e6a5c199b394e Mon Sep 17 00:00:00 2001 From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com> Date: Mon, 21 Oct 2024 02:19:54 -0400 Subject: [PATCH] feat(async/unstable): add `throttle()` function (#6110) --- async/deno.json | 3 +- async/unstable_throttle.ts | 102 ++++++++++++++++++++++++++++++++ async/unstable_throttle_test.ts | 93 +++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 async/unstable_throttle.ts create mode 100644 async/unstable_throttle_test.ts diff --git a/async/deno.json b/async/deno.json index 9f06373c4..e815c8ed2 100644 --- a/async/deno.json +++ b/async/deno.json @@ -11,6 +11,7 @@ "./unstable-mux-async-iterator": "./unstable_mux_async_iterator.ts", "./pool": "./pool.ts", "./retry": "./retry.ts", - "./tee": "./tee.ts" + "./tee": "./tee.ts", + "./unstable-throttle": "./unstable_throttle.ts" } } diff --git a/async/unstable_throttle.ts b/async/unstable_throttle.ts new file mode 100644 index 000000000..cc48ef229 --- /dev/null +++ b/async/unstable_throttle.ts @@ -0,0 +1,102 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. + +/** + * A throttled function that will be executed at most once during the + * specified `timeframe` in milliseconds. + */ +export interface ThrottledFunction> { + (...args: T): void; + /** + * Clears the throttling state. + * {@linkcode ThrottledFunction.lastExecution} will be reset to `NaN` and + * {@linkcode ThrottledFunction.throttling} will be reset to `false`. + */ + clear(): void; + /** + * Execute the last throttled call (if any) and clears the throttling state. + */ + flush(): void; + /** + * Returns a boolean indicating whether the function is currently being throttled. + */ + readonly throttling: boolean; + /** + * Returns the timestamp of the last execution of the throttled function. + * It is set to `NaN` if it has not been called yet. + */ + readonly lastExecution: number; +} + +/** + * Creates a throttled function that prevents the given `func` + * from being called more than once within a given `timeframe` in milliseconds. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { throttle } from "./unstable_throttle.ts" + * import { retry } from "@std/async/retry" + * import { assert } from "@std/assert" + * + * let called = 0; + * await using server = Deno.serve({ port: 0, onListen:() => null }, () => new Response(`${called++}`)); + * + * // A throttled function will be executed at most once during a specified ms timeframe + * const timeframe = 100 + * const func = throttle<[string]>((url) => fetch(url).then(r => r.body?.cancel()), timeframe); + * for (let i = 0; i < 10; i++) { + * func(`http://localhost:${server.addr.port}/api`); + * } + * + * await retry(() => assert(!func.throttling)) + * assert(called === 1) + * assert(!Number.isNaN(func.lastExecution)) + * ``` + * + * @typeParam T The arguments of the provided function. + * @param fn The function to throttle. + * @param timeframe The timeframe in milliseconds in which the function should be called at most once. + * @returns The throttled function. + */ +// deno-lint-ignore no-explicit-any +export function throttle>( + fn: (this: ThrottledFunction, ...args: T) => void, + timeframe: number, +): ThrottledFunction { + let lastExecution = NaN; + let flush: (() => void) | null = null; + + const throttled = ((...args: T) => { + flush = () => { + try { + fn.call(throttled, ...args); + } finally { + lastExecution = Date.now(); + flush = null; + } + }; + if (throttled.throttling) { + return; + } + flush?.(); + }) as ThrottledFunction; + + throttled.clear = () => { + lastExecution = NaN; + }; + + throttled.flush = () => { + lastExecution = NaN; + flush?.(); + throttled.clear(); + }; + + Object.defineProperties(throttled, { + throttling: { get: () => Date.now() - lastExecution <= timeframe }, + lastExecution: { get: () => lastExecution }, + }); + + return throttled; +} diff --git a/async/unstable_throttle_test.ts b/async/unstable_throttle_test.ts new file mode 100644 index 000000000..bfa38ea5f --- /dev/null +++ b/async/unstable_throttle_test.ts @@ -0,0 +1,93 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { + assertEquals, + assertGreater, + assertLess, + assertNotEquals, + assertStrictEquals, +} from "@std/assert"; +import { throttle, type ThrottledFunction } from "./unstable_throttle.ts"; +import { delay } from "./delay.ts"; + +Deno.test("throttle() handles called", async () => { + let called = 0; + const t = throttle(() => called++, 100); + assertEquals(t.throttling, false); + assertEquals(t.lastExecution, NaN); + t(); + const { lastExecution } = t; + t(); + t(); + assertLess(Math.abs(t.lastExecution - Date.now()), 100); + assertEquals(called, 1); + assertEquals(t.throttling, true); + assertEquals(t.lastExecution, lastExecution); + await delay(200); + assertEquals(called, 1); + assertEquals(t.throttling, false); + assertEquals(t.lastExecution, lastExecution); + t(); + assertEquals(called, 2); + assertEquals(t.throttling, true); + assertGreater(t.lastExecution, lastExecution); +}); + +Deno.test("throttle() handles cancelled", () => { + let called = 0; + const t = throttle(() => called++, 100); + t(); + t(); + t(); + assertEquals(called, 1); + assertEquals(t.throttling, true); + assertNotEquals(t.lastExecution, NaN); + t.clear(); + assertEquals(called, 1); + assertEquals(t.throttling, false); + assertEquals(t.lastExecution, NaN); +}); + +Deno.test("debounce() handles flush", () => { + let called = 0; + let arg = ""; + const t = throttle((_arg) => { + arg = _arg; + called++; + }, 100); + t("foo"); + t("bar"); + t("baz"); + assertEquals(called, 1); + assertEquals(arg, "foo"); + assertEquals(t.throttling, true); + assertNotEquals(t.lastExecution, NaN); + for (const _ of [1, 2]) { + t.flush(); + assertEquals(called, 2); + assertEquals(arg, "baz"); + assertEquals(t.throttling, false); + assertEquals(t.lastExecution, NaN); + } +}); + +Deno.test("throttle() handles params and context", async () => { + const params: Array = []; + const t: ThrottledFunction<[string, number]> = throttle( + function (param1: string, param2: number) { + params.push(param1); + params.push(param2); + assertStrictEquals(t, this); + }, + 100, + ); + t("foo", 1); + t("bar", 1); + t("baz", 1); + // @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'string'. + t(1, 1); + assertEquals(params, ["foo", 1]); + assertEquals(t.throttling, true); + await delay(200); + assertEquals(params, ["foo", 1]); + assertEquals(t.throttling, false); +});