diff --git a/package-lock.json b/package-lock.json --- a/package-lock.json +++ b/package-lock.json @@ -1260,9 +1260,9 @@ } }, "typescript": { - "version": "3.6.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz", - "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.5.tgz", + "integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==", "dev": true }, "uri-js": { diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "dojo-typings": "^1.11.9", "requirejs": "latest", "tape": "~4.11.0", - "typescript": "~3.6.4", + "typescript": "~4.1.5", "eslint": "6.1.0", "tslint": "5.18.0" } diff --git a/src/main/ts/safe.ts b/src/main/ts/safe.ts --- a/src/main/ts/safe.ts +++ b/src/main/ts/safe.ts @@ -5,6 +5,8 @@ const _oid = typeof Symbol === "function Symbol("__implab__oid__") : "__implab__oid__"; +function _noop() { } + export function oid(instance: null | undefined): undefined; export function oid(instance: NonNullable): string; export function oid(instance: any): string | undefined { @@ -288,9 +290,14 @@ export function delegate(target: any, _m }; } +/** Returns promise which will be resolved after the specified amount of time. + * + * @param timeMs The delay before the promise will be resolved in milliseconds. + * @param ct Optional. A cancellation token for the operation. + */ export function delay(timeMs: number, ct = cancellationNone) { ct.throwIfRequested(); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const h = ct.register(e => { clearTimeout(id); reject(e); @@ -304,6 +311,53 @@ export function delay(timeMs: number, ct }); } +export function debounce(func: (this: This, ...args: T) => R | PromiseLike, wait: number) { + let cancel: (e?: any) => void = _noop; + + const fn = function executedFunction(this: This, ...args: T) { + return new Promise((resolve, reject) => { + + // used to cleanup currently allocated resources + const _cleanup = () => { + cancel = _noop; + clearTimeout(handle); + }; + + // used in case of cancellation of the current operation + const _cancel = (e: any) => { + _cleanup(); + reject(e); + }; + + // performs actual work + const _later = () => { + _cleanup(); + resolve(func.apply(this, args)); + }; + + // cancel previously queued operation + if (cancel !== _noop) + cancel(new Error("Operation cancelled due to debouncing")); + cancel = _cancel; + + const handle = setTimeout(_later, wait); + }); + }; + + fn.cancel = (e?: any) => cancel(e); + + fn.applyAsync = async (thisArg: This, args: T, ct: ICancellation) => { + const h = ct.register(cancel); + try { + await fn.apply(thisArg, args); + } finally { + h.destroy(); + } + }; + + return fn; +} + /** Returns resolved promise, awaiting this method will cause the asynchronous * completion of the rest of the code. */ diff --git a/src/test/ts/tests/SafeTests.ts b/src/test/ts/tests/SafeTests.ts --- a/src/test/ts/tests/SafeTests.ts +++ b/src/test/ts/tests/SafeTests.ts @@ -1,5 +1,6 @@ import { Cancellation } from "../Cancellation"; -import { first, isPromise, firstWhere, delay, nowait, notImplemented } from "../safe"; +import { ICancellation } from "../interfaces"; +import { first, isPromise, firstWhere, delay, nowait, notImplemented, debounce, fork } from "../safe"; import { test } from "./TestTraits"; test("await delay test", async t => { @@ -93,3 +94,63 @@ test("sequemce test", async t => { v = await new Promise(resolve => first(asyncSequence, resolve)); t.equal(v, "a", "The callback should be called for the first element"); }); + +test("debounce tests", async (t, trace) => { + let count = 0; + let rejected = 0; + function increment(step: number = 1) { + count += step; + return count; + } + + const f = debounce(increment, 100); + f().then(undefined, () => rejected++); + f().then(undefined, () => rejected++); + + await f(1); + + t.equal(rejected, 2, "Previous operations should be rejected"); + t.equal(count, 1, "The operation should run once"); + + const acc = debounce( + (...values: number[]) => count = values.reduce((a, v) => v + a, count), + 100 + ); + + acc(1, 2, 3).catch(() => { }); + const result = acc(1, 2, 3); + acc.cancel(); + + try { + await result; + t.notOk("fn.cancel() should make current operation to throw an exception"); + } catch { + t.ok("fn.cancel() should make current operation to throw an exception"); + } + + t.equal(count, 1, "fn.cancel() The operation should not run"); + + acc.cancel(); + await acc(1, 2); + t.equal(count, 4, "The variable arguments list shoud be handled correctly"); + + // create cancellation token + let cancel: (e?: any) => void = notImplemented; + const ct = new Cancellation(c => cancel = c); + + const d = debounce(async (ct2: ICancellation = Cancellation.none) => { + ct2.throwIfRequested(); + trace.debug("do async increment"); + await fork(); + count++; + return count; + }, 0); + + const p = d.applyAsync(null, [ct], ct).then(undefined, () => rejected++); + cancel(); + await p; + + t.equal(count, 4, "Cancellation token should prevent the function execution"); + t.equal(rejected, 3, "Cancellation token should reject operation"); + +});