Repository: exadel-inc/esl (ts)

Issue:
[🐛 in esl-utils]: 'Uncaught in promise' after `debounce.cancel()` call
### Steps:

- `const debounced = debounce(fn, 200);`
- Make sure you do not access and process `debounced.promise`
- `debounced.cancel()`
- Observe the console

**Current result: **
'Uncaught in promise `undefined`' appears in the console due to Promise.reject() creation. 

**Expected behavior**
No errors in the console are expected


Pull request:
fix(esl-utils): debounced unhandled rejection
The scope of this PR is to fix the issue described in #1801, and to refactor code from a function to a class

closes #1801

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b7777a56d..7aa49cee3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,7 +25,11 @@ jobs:
           team: 'ESL Core Maintainers'
           GITHUB_TOKEN: ${{ secrets.PERMISSION_CHECK_TOKEN }}
 
+      - if: ${{ steps.permissions.outputs.isTeamMember == 'true' }}
+        name: Access Check Passed
+        run: echo ${{ github.actor }} is in 'ESL Core Maintainers' group
       - if: ${{ steps.permissions.outputs.isTeamMember == 'false' }}
+        name: Access Denied
         run: exit 1
 
       # Start workflow
diff --git a/src/modules/esl-utils/async/promise/defered.ts b/src/modules/esl-utils/async/promise/defered.ts
index 4d75a14cf..49ffe82a6 100644
--- a/src/modules/esl-utils/async/promise/defered.ts
+++ b/src/modules/esl-utils/async/promise/defered.ts
@@ -1,23 +1,43 @@
-/** Deferred object represents promise with it's resolve/reject methods */
-export type Deferred<T> = {
-  /** Wrapped promise */
-  promise: Promise<T>;
-  /** Function that resolves wrapped promise */
-  resolve: (arg: T) => void;
-  /** Function that rejects wrapped promise */
-  reject: (arg?: any) => void;
-};
+import {memoize} from '../../decorators/memoize';
 
-/**
- * Creates Deferred Object that wraps promise and its resolve and reject callbacks
- */
+/** Deferred object that represents promise with its resolve/reject methods */
+export class Deferred<T> {
+  protected _status: 'pending' | 'resolved' | 'rejected' = 'pending';
+  protected _value: T | undefined;
+  protected _callbacks: [(arg: T) => void, (arg?: any) => void];
+
+  /** @returns promise based on {@link Deferred} state*/
+  @memoize()
+  public get promise(): Promise<T> {
+    if (this._status === 'resolved') return Promise.resolve(this._value as T);
+    if (this._status === 'rejected') return Promise.reject(this._value);
+    return new Promise<T>((res, rej) => {
+      this._callbacks = [res, rej];
+    });
+  }
+
+  /** Resolves deferred promise */
+  public resolve(arg: T): Deferred<T> {
+    if (this._status === 'pending') {
+      this._value = arg;
+      this._status = 'resolved';
+      this._callbacks && this._callbacks[0](arg);
+    }
+    return this;
+  }
+
+  /** Rejects deferred promise */
+  public reject(arg?: any): Deferred<T> {
+    if (this._status === 'pending') {
+      this._value = arg;
+      this._status = 'rejected';
+      this._callbacks && this._callbacks[1](arg);
+    }
+    return this;
+  }
+}
+
+/** Creates Deferred Object that wraps promise and its resolve and reject callbacks */
 export function createDeferred<T>(): Deferred<T> {
-  let reject: any;
-  let resolve: any;
-  // Both reject and resolve will be assigned anyway while the Promise constructing.
-  const promise = new Promise<T>((res, rej) => {
-    resolve = res;
-    reject = rej;
-  });
-  return {promise, resolve, reject};
+  return new Deferred();
 }


Test patch:
diff --git a/src/modules/esl-utils/async/test/promise/defered.test.ts b/src/modules/esl-utils/async/test/promise/defered.test.ts
index d636ebf9b..07b6d0deb 100644
--- a/src/modules/esl-utils/async/test/promise/defered.test.ts
+++ b/src/modules/esl-utils/async/test/promise/defered.test.ts
@@ -1,14 +1,77 @@
 import {createDeferred} from '../../promise/defered';
+import {promisifyTimeout} from '../../promise/timeout';
 
 describe('async/promise/deferred', () => {
-  test('Deferred resolves promise when it is resolved', () => {
+  test('Resolve of Deferred produces resolved promise', () => {
     const def$$ = createDeferred();
     def$$.resolve(1);
     return def$$.promise.then((n) => expect(n).toBe(1));
   });
-  test('Deferred rejects promise when it is rejected', () => {
+
+  test('Rejected Deferred produces rejected promise', () => {
+    const def$$ = createDeferred();
+    def$$.reject(1);
+    return def$$.promise.catch((n) => expect(n).toBe(1));
+  });
+
+  test('Deferred resolves initially requested promise when resolved', () => {
+    const def$$ = createDeferred();
+    def$$.promise;
+    def$$.resolve(1);
+    return def$$.promise.then((n) => expect(n).toBe(1));
+  });
+
+  test('Deferred rejects initially requested promise when rejected', () => {
     const def$$ = createDeferred();
+    def$$.promise;
     def$$.reject(1);
     return def$$.promise.catch((n) => expect(n).toBe(1));
   });
+
+  describe('Rejected Deferred doesn`t lead to uncaught in promise', () => {
+    const throwFn = jest.fn((reason) => {throw reason;});
+
+    beforeAll(() => {
+      process.env.LISTENING_TO_UNHANDLED_REJECTION = String(true);
+      process.on('unhandledRejection', throwFn);
+    });
+
+    afterAll(() => {
+      process.off('unhandledRejection', throwFn);
+    });
+
+    test('Deferred doesn`t lead to uncaught', async () => {
+      const def$$ = createDeferred();
+      def$$.reject(1);
+      await promisifyTimeout(0);
+      expect(throwFn).not.toBeCalled();
+    });
+  });
+
+  describe('Resolved/rejected Deferred is finalized', () => {
+    test('Resolved Deferred can not be re-resolved', () => {
+      const def$$ = createDeferred();
+      def$$.resolve(1);
+      def$$.resolve(2);
+      expect(def$$.promise).resolves.toBe(1);
+    });
+    test('Resolved Deferred can not be rejected', () => {
+      const def$$ = createDeferred();
+      def$$.resolve(1);
+      def$$.reject();
+      expect(def$$.promise).resolves.toBe(1);
+    });
+    test('Rejected Deferred can not be re-resolved', () => {
+      const def$$ = createDeferred();
+      def$$.reject(1);
+      def$$.reject(2);
+      expect(def$$.promise).rejects.toBe(1);
+    });
+    test('Rejected Deferred can not be rejected', () => {
+      const def$$ = createDeferred();
+      def$$.reject(1);
+      def$$.resolve(2);
+      expect(def$$.promise).rejects.toBe(1);
+    });
+  });
 });