Repository: exadel-inc/esl (ts)

Issue:
[🐛❗ in esl-event-listener]: performance gap in esl-event-listener auto subscription 
The ESL Event Listener API provides `ESLEventUtils.descriptors` collector to get all the descriptors properties from the object created using the `@listen` annotation. The API is used by `ESLMixinElement` and `ESLBaseElement` as well as it's publically accessible to collect all the descriptors that should provide auto-subscribed ESLEventListener subscriptions.

The current `ESLEventUtils.descriptors` implementation causes a performance issue (reflow and low time of execution) due to `extractValues` utility implementation. 

**Current behavior**
`ESLEventUtils.descriptors` visits all of the enumerable properties that exist on the object, which causes:
 - reflow (due to DOM API getters handling (e.g. `offsetWeight`, `offsetHeight`))
 - causes early initialization of lazy getters as well as accessing heavy getters

**Expected behavior**
`ESLEventUtils.descriptors` should execute fast and do not leas to reflow.

**Additional context**
Basically, there is a 'small' in @listenr implementation as it produces an enumerable property. Potentially that should be handled differently.

**Possible Solution**
Make descriptors auto-collection through the middleware storage (hidden list, or any other similar way) instead of passing through the whole list of object keys and values.

Pull request:
feat(esl-event-listener): bugfix and implementation rework of ESLEventListener descriptors
In the scope of the current issue:
 - [x] Reworked descriptors API in `ESLEventUtils`
 - [x] Low level refactoring 
 - [x] Fixed #1428 (performance issue with the auto subscription mechanism)
 - [x] Simplified and moved to the `esl-event-listener` module implementation of `@listen`
 - [x] Tests for descriptors API
 - [x] `esl-event-listener` internal implementation uses symbols keys if it's accessable

**WIP: result API, naming, and inner implementation details are still in progress. PR is opened to provide the visibility of the general concept of update** 

Closes: #1428



Patch:
diff --git a/src/modules/esl-base-element/core/esl-base-element.ts b/src/modules/esl-base-element/core/esl-base-element.ts
index 549acd735..bc0acf15b 100644
--- a/src/modules/esl-base-element/core/esl-base-element.ts
+++ b/src/modules/esl-base-element/core/esl-base-element.ts
@@ -28,7 +28,7 @@ export abstract class ESLBaseElement extends HTMLElement {
     this._connected = true;
     this.classList.add((this.constructor as typeof ESLBaseElement).is);
 
-    ESLEventUtils.descriptors(this).forEach((desc) => ESLEventUtils.subscribe(this, desc));
+    ESLEventUtils.subscribe(this);
   }
   protected disconnectedCallback(): void {
     this._connected = false;
diff --git a/src/modules/esl-event-listener/core.ts b/src/modules/esl-event-listener/core.ts
index 3253e46de..b7cdceff6 100644
--- a/src/modules/esl-event-listener/core.ts
+++ b/src/modules/esl-event-listener/core.ts
@@ -5,6 +5,7 @@ export type {
   ESLListenerEventMap,
   ESLListenerDescriptor,
   ESLListenerDescriptorFn,
+  ESLListenerDescriptorExt,
   ESLListenerTarget,
   ESLListenerDefinition
 } from './core/types';
diff --git a/src/modules/esl-event-listener/core/api.ts b/src/modules/esl-event-listener/core/api.ts
index e88845787..5dd9c2eae 100644
--- a/src/modules/esl-event-listener/core/api.ts
+++ b/src/modules/esl-event-listener/core/api.ts
@@ -1,7 +1,7 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {dispatchCustomEvent} from '../../esl-utils/dom/events/misc';
-import {extractValues} from '../../esl-utils/misc/object';
 import {ESLEventListener} from './listener';
+import {getAutoDescriptors, isEventDescriptor, initDescriptor} from './descriptors';
 
 import type {
   ESLListenerHandler,
@@ -11,45 +11,71 @@ import type {
   ESLListenerDescriptorFn
 } from './types';
 
-
 @ExportNs('EventUtils')
 export class ESLEventUtils {
   /**
    * Dispatches custom event. Alias for {@link dispatchCustomEvent}
    * @see dispatchCustomEvent
    */
-  public static dispatch(el: EventTarget, eventName: string, eventInit?: CustomEventInit): boolean {
-    return dispatchCustomEvent(el, eventName, eventInit);
-  }
+  public static dispatch = dispatchCustomEvent;
 
-  /** Type guard to check if the passed function is typeof {@link ESLListenerDescriptorFn} */
-  public static isEventDescriptor(obj: any): obj is ESLListenerDescriptorFn {
-    return typeof obj === 'function' && Object.hasOwnProperty.call(obj, 'event');
-  }
+
+  /** @deprecated alias for {@link getAutoDescriptors} */
+  public static descriptors = getAutoDescriptors;
 
   /** Gets {@link ESLListenerDescriptorFn}s of the passed object */
-  public static descriptors(host: unknown, auto: boolean = true): ESLListenerDescriptorFn[] {
-    return extractValues(host, (value) => ESLEventUtils.isEventDescriptor(value) && value.auto === auto);
-  }
+  public static getAutoDescriptors = getAutoDescriptors;
+
+  /**
+   * Decorates passed `key` of the `host` as an {@link ESLListenerDescriptorFn} using `desc` meta information
+   * @param host - object holder of the function to decorate
+   * @param key - string key of the function in holder object
+   * @param desc - descriptor meta information to assign
+   * @returns ESLListenerDescriptorFn created on the host
+   */
+  public static initDescriptor = initDescriptor;
+
+  /** Type guard to check if the passed function is typeof {@link ESLListenerDescriptorFn} */
+  public static isEventDescriptor = isEventDescriptor;
+
 
   /**
    * Gets currently subscribed listeners of the host
-   * @param host - host element (listeners context)
+   * @param host - host object (listeners context) to associate subscription
    * @param criteria - optional set of criteria {@link ESLListenerCriteria} to filter listeners list
    */
   public static listeners(host: unknown, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
     return ESLEventListener.get(host, ...criteria);
   }
 
-  /** Subscribes decorated `handler` method of the `host` */
+  /**
+   * Subscribes all auto descriptors of the host
+   * @param host - host object (listeners context) to find descriptors and associate subscription
+   * */
+  public static subscribe(host: unknown): ESLEventListener[];
+  /**
+   * Subscribes decorated as an {@link ESLListenerDescriptorFn} `handler` function
+   * @param host - host object (listeners context) to associate subscription
+   * @param handler - handler function decorated as {@link ESLListenerDescriptorFn}
+   */
   public static subscribe(host: unknown, handler: ESLListenerHandler): ESLEventListener[];
-  /** Subscribes `handler` function with the passed event type */
+  /**
+   * Subscribes `handler` function with the passed event type or {@link ESLListenerDescriptor} with event type declared
+   * @param host - host object (listeners context) to associate subscription
+   * @param descriptor - event or {@link ESLListenerDescriptor} with defined event type
+   * @param handler - handler function to subscribe
+   */
   public static subscribe<EType extends keyof ESLListenerEventMap>(
     host: unknown,
     descriptor: EType | ESLListenerDescriptor<EType>,
     handler: ESLListenerHandler<ESLListenerEventMap[EType]>
   ): ESLEventListener[];
-  /** Subscribes `handler` descriptor function with the passed additional descriptor data */
+  /**
+   * Subscribes `handler` function decorated with {@link ESLListenerDescriptorFn} with the passed additional {@link ESLListenerDescriptor} data
+   * @param host - host object (listeners context) to associate subscription
+   * @param descriptor - {@link ESLListenerDescriptor} additional data
+   * @param handler - handler function decorated as {@link ESLListenerDescriptorFn}
+   */
   public static subscribe<EType extends keyof ESLListenerEventMap>(
     host: unknown,
     descriptor: Partial<ESLListenerDescriptor>,
@@ -57,17 +83,21 @@ export class ESLEventUtils {
   ): ESLEventListener[];
   public static subscribe(
     host: unknown,
-    eventDesc: string | Partial<ESLListenerDescriptor> | ESLListenerHandler,
+    eventDesc?: string | Partial<ESLListenerDescriptor> | ESLListenerHandler,
     handler: ESLListenerHandler = eventDesc as ESLListenerDescriptorFn
   ): ESLEventListener[] {
-    if (typeof handler !== 'function') return [];
+    if (arguments.length === 1) {
+      const descriptors = getAutoDescriptors(host);
+      // TODO: flatMap when ES5 will be out of support list
+      return descriptors.reduce(
+        (acc, desc) => acc.concat(ESLEventListener.subscribe(host, desc)),
+        [] as ESLEventListener[]
+      );
+    }
     if (typeof eventDesc === 'string') eventDesc = {event: eventDesc};
-    if (ESLEventUtils.isEventDescriptor(handler) && eventDesc !== handler) eventDesc = Object.assign({}, handler, eventDesc);
-
-    const listeners = ESLEventListener.createOrResolve(host, handler, eventDesc as ESLListenerDescriptor);
-    const subscribed = listeners.filter((listener) => listener.subscribe());
-    if (!subscribed.length) console.warn('[ESL]: Empty subscription %o', Object.assign({}, eventDesc, {handler}));
-    return subscribed;
+    const listeners = ESLEventListener.subscribe(host, handler, eventDesc as ESLListenerDescriptor);
+    if (!listeners.length) console.warn('[ESL]: Empty subscription %o', Object.assign({}, eventDesc, {handler}));
+    return listeners;
   }
 
   /**
diff --git a/src/modules/esl-event-listener/core/descriptors.ts b/src/modules/esl-event-listener/core/descriptors.ts
new file mode 100644
index 000000000..d25e91102
--- /dev/null
+++ b/src/modules/esl-event-listener/core/descriptors.ts
@@ -0,0 +1,74 @@
+import {isObject} from '../../esl-utils/misc/object/types';
+
+import type {ESLListenerDescriptorExt, ESLListenerDescriptorFn} from './types';
+
+/** Key to store listeners on the host */
+const DESCRIPTORS = (window.Symbol || String)('__esl_descriptors');
+
+/**
+ * @param host - host object
+ * @param createIfNotExists - create keys store on the host if not exists
+ * @returns object own descriptors keys or an empty array
+ */
+function getOwnDescriptors(host: object, createIfNotExists = false): string[] {
+  if (Object.hasOwnProperty.call(host, DESCRIPTORS)) return (host as any)[DESCRIPTORS];
+  const value: string[]  = [];
+  if (createIfNotExists) Object.defineProperty(host, DESCRIPTORS, {value, configurable: true});
+  return value;
+}
+
+/** Collects descriptors key from the whole prototype chain */
+function getDescriptorsKeysFor<T extends object>(host: T): (keyof T)[] {
+  const store: Record<string, boolean> = {};
+  for (let proto = host; proto && proto !== Object.prototype ; proto = Object.getPrototypeOf(proto)) {
+    getOwnDescriptors(proto).forEach((key) => store[key] = true);
+  }
+  return Object.keys(store) as (keyof T)[];
+}
+
+/** Type guard to check if the passed function is typeof {@link ESLListenerDescriptorFn} */
+export function isEventDescriptor(obj: any): obj is ESLListenerDescriptorFn {
+  if (typeof obj !== 'function' || !Object.hasOwnProperty.call(obj, 'event')) return false;
+  return typeof obj.event === 'string' || typeof obj.event === 'function';
+}
+
+/** Gets {@link ESLListenerDescriptorFn}s of the passed object */
+export function getAutoDescriptors(host: unknown): ESLListenerDescriptorFn[] {
+  if (!isObject(host)) return [];
+  const keys = getDescriptorsKeysFor(host);
+  const values = keys.map((key) => host[key]);
+  return values.filter((desc: ESLListenerDescriptorFn) => isEventDescriptor(desc) && desc.auto === true);
+}
+
+/**
+ * Decorates passed `key` of the `host` as an {@link ESLListenerDescriptorFn} using `desc` meta information
+ * @param host - object holder of the function to decorate
+ * @param key - string key of the function in holder object
+ * @param desc - descriptor meta information to assign
+ * @returns ESLListenerDescriptorFn created on the host
+ */
+export function initDescriptor<T extends object>(
+  host: T,
+  key: keyof T & string,
+  desc: ESLListenerDescriptorExt
+): ESLListenerDescriptorFn {
+  const fn = host[key];
+  if (typeof fn !== 'function') throw new TypeError(`[ESL] Init Descriptor: ${key} is not a function`);
+
+  // Inherit event meta information from the prototype key
+  if (desc.inherit) {
+    const superDesc = Object.getPrototypeOf(host)[key];
+    if (!isEventDescriptor(superDesc)) throw new ReferenceError(`[ESL] Init Descriptor: no parent event descriptor found for ${key}`);
+    desc = Object.assign({auto: false}, superDesc, desc);
+  } else {
+    desc = Object.assign({auto: false}, desc);
+  }
+
+  // Marks key to be auto-collectable
+  if (desc.auto) {
+    const value = getOwnDescriptors(host, true);
+    if (!value.includes(key)) value.push(key);
+  }
+
+  return Object.assign(fn, desc) as ESLListenerDescriptorFn;
+}
diff --git a/src/modules/esl-event-listener/core/listener.ts b/src/modules/esl-event-listener/core/listener.ts
index cb86cd1ee..d085a8fad 100644
--- a/src/modules/esl-event-listener/core/listener.ts
+++ b/src/modules/esl-event-listener/core/listener.ts
@@ -11,12 +11,13 @@ import type {
   ESLListenerTarget,
   ESLListenerDefinition,
   ESLListenerDescriptor,
+  ESLListenerDescriptorFn,
   ESLListenerHandler,
   ESLListenerCriteria
 } from './types';
 
 /** Key to store listeners on the host */
-const STORE = '__esl_listeners';
+const LISTENERS = (window.Symbol || String)('__esl_listeners');
 
 /**
  * Splits and deduplicates event string
@@ -141,21 +142,38 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
    */
   public static get(host?: any, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
     if (!host) return [];
-    const listeners = (host[STORE] || []) as ESLEventListener[];
+    const listeners = (host[LISTENERS] || []) as ESLEventListener[];
     if (!criteria.length) return listeners;
     return listeners.filter((listener) => criteria.every(listener.matches, listener));
   }
   /** Adds listener to the listener store of the host object */
   protected static add(host: any, instance: ESLEventListener): void {
     if (!host) return;
-    if (!Object.hasOwnProperty.call(host, STORE)) host[STORE] = [];
-    host[STORE].push(instance);
+    if (!Object.hasOwnProperty.call(host, LISTENERS)) host[LISTENERS] = [];
+    host[LISTENERS].push(instance);
   }
   /** Removes listener from the listener store of the host object */
   protected static remove(host: any, instance: ESLEventListener): void {
     const listeners = ESLEventListener.get(host);
     const value = listeners.filter((listener) => listener !== instance);
-    Object.defineProperty(host, STORE, {value, configurable: true});
+    Object.defineProperty(host, LISTENERS, {value, configurable: true});
+  }
+
+  /**
+   * Subscribes `handler` function with the passed event type or {@link ESLListenerDescriptor} with event type declared
+   * @param host - host object (listeners context) to associate subscription
+   * @param handler - handler function to subscribe
+   * @param descriptor - event or {@link ESLListenerDescriptor} with defined event type
+   */
+  public static subscribe(
+    host: unknown,
+    handler: ESLListenerHandler | ESLListenerDescriptorFn,
+    descriptor: ESLListenerDescriptor = handler as ESLListenerDescriptorFn
+  ): ESLEventListener[]  {
+    if (typeof handler !== 'function') return [];
+    const eventDesc = handler !== descriptor ? Object.assign({}, handler, descriptor) : descriptor;
+    const listeners = ESLEventListener.createOrResolve(host, handler, eventDesc);
+    return listeners.filter((listener) => listener.subscribe());
   }
 
   /** Creates or resolve existing event listeners by handler and descriptors */
diff --git a/src/modules/esl-event-listener/core/types.ts b/src/modules/esl-event-listener/core/types.ts
index 9091b3e50..3d7b59844 100644
--- a/src/modules/esl-event-listener/core/types.ts
+++ b/src/modules/esl-event-listener/core/types.ts
@@ -54,3 +54,9 @@ export type ESLListenerCriteria = undefined | keyof ESLListenerEventMap | ESLLis
 /** Function decorated as {@link ESLListenerDescriptor} */
 export type ESLListenerDescriptorFn<EType extends keyof ESLListenerEventMap = string> =
   ESLListenerHandler<ESLListenerEventMap[EType]> & ESLListenerDescriptor<EType>;
+
+/** Descriptor to create {@link ESLEventListener} based on class property */
+export type ESLListenerDescriptorExt<T extends keyof ESLListenerEventMap = string> = Partial<ESLListenerDescriptor<T>> & {
+  /** Defines if the listener metadata should be inherited from the method of the superclass */
+  inherit?: boolean;
+};
diff --git a/src/modules/esl-mixin-element/ui/esl-mixin-element.ts b/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
index ab2397364..efa47093e 100644
--- a/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
+++ b/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
@@ -46,7 +46,7 @@ export class ESLMixinElement implements AttributeTarget {
       });
     }
 
-    ESLEventUtils.descriptors(this).forEach((desc) => ESLEventUtils.subscribe(this, desc));
+    ESLEventUtils.subscribe(this);
   }
 
   /** Callback to execute on mixin instance destroy */
diff --git a/src/modules/esl-utils/decorators/listen.ts b/src/modules/esl-utils/decorators/listen.ts
index ced28d972..1532c5b6c 100644
--- a/src/modules/esl-utils/decorators/listen.ts
+++ b/src/modules/esl-utils/decorators/listen.ts
@@ -1,16 +1,11 @@
 import {ESLEventUtils} from '../../esl-event-listener/core';
 
 import type {PropertyProvider} from '../misc/functions';
-import type {ESLListenerHandler, ESLListenerEventMap, ESLListenerDescriptor} from '../dom/events';
+import type {ESLListenerHandler, ESLListenerEventMap, ESLListenerDescriptorExt} from '../dom/events';
 
 type ListenDecorator<EType extends Event> =
   (target: any, property: string, descriptor: TypedPropertyDescriptor<ESLListenerHandler<EType>>) => void;
 
-type ESLListenerDescriptorExt<T extends keyof ESLListenerEventMap = string> = Partial<ESLListenerDescriptor<T>> & {
-  /** Defines if the listener metadata should be inherited from the method of the superclass */
-  inherit?: boolean;
-};
-
 /**
  * Decorator to declare listener ({@link ESLEventListener}) meta information
  * Defines auto-subscribable event
@@ -24,24 +19,13 @@ export function listen<K extends keyof ESLListenerEventMap>(event: K | PropertyP
  */
 export function listen<K extends keyof ESLListenerEventMap>(desc: ESLListenerDescriptorExt<K>): ListenDecorator<ESLListenerEventMap[K]>;
 
-export function listen(desc: string | ESLListenerDescriptorExt): ListenDecorator<Event> {
-  return function listener<T extends ESLListenerHandler>(target: HTMLElement,
-                                                         propertyKey: string,
-                                                         descriptor: TypedPropertyDescriptor<T>): void {
-    const superDesc = Object.getPrototypeOf(target)[propertyKey];
+export function listen(desc: string | PropertyProvider<string> | ESLListenerDescriptorExt): ListenDecorator<Event> {
+  return function listener<Host extends object>(target: Host, propertyKey: keyof Host & string): void {
+    // Map short event or event provider value to descriptor object
     desc = typeof desc === 'string' || typeof desc === 'function' ? {event: desc} : desc;
-    desc = Object.assign({auto: true}, desc.inherit && ESLEventUtils.isEventDescriptor(superDesc) ? superDesc : {}, desc);
-
-    const fn = descriptor.value || descriptor.get && descriptor.get.call(target);
-    if (typeof fn !== 'function') {
-      throw new TypeError('Only class methods can be decorated via listener decorator');
-    }
-    if (ESLEventUtils.isEventDescriptor(fn) && fn.event !== desc.event) {
-      throw new TypeError(`Method ${propertyKey} already decorated as ESLListenerDescriptor`);
-    }
+    // Makes auto collectable/subscribable description if not inherited
+    desc = Object.assign(desc.inherit ? {} : {auto: true}, desc);
 
-    Object.assign(fn, desc);
-    // Allow collecting
-    descriptor.enumerable = true;
+    ESLEventUtils.initDescriptor(target, propertyKey, desc);
   };
 }


Test patch:
diff --git a/src/modules/esl-event-listener/test/descriptor.test.ts b/src/modules/esl-event-listener/test/descriptor.test.ts
new file mode 100644
index 000000000..a7fb4e517
--- /dev/null
+++ b/src/modules/esl-event-listener/test/descriptor.test.ts
@@ -0,0 +1,181 @@
+import {ESLEventUtils} from '../core';
+
+describe('dom/events: ESLEventUtils: ESLListenerDescriptor Utils', () => {
+  describe('ESLEventUtils.isEventDescriptor', () => {
+    function fakeDesc() {}
+    fakeDesc.event = false;
+
+    function fakeDesc2() {}
+    fakeDesc2.event = {};
+
+    function descEvent() {}
+    descEvent.event = 'event';
+
+    function descEventProvider() {}
+    descEventProvider.event = () => 'event';
+
+    test.each([
+      [undefined, false],
+      [null, false],
+      ['', false],
+      [{}, false],
+      [{event: ''}, false],
+      [function noop() {}, false],
+      [fakeDesc, false],
+      [fakeDesc2, false],
+
+      [descEvent, true],
+      [descEventProvider, true]
+    ])(
+      'isEventDescriptor(%p) = %s',
+      (sample: any, check: boolean) => expect(ESLEventUtils.isEventDescriptor(sample)).toBe(check)
+    );
+  });
+
+  describe('ESLEventUtils.initDescriptor', () => {
+    test('ESLEventUtils.initDescriptor: missing key throws error', () => {
+      const host: any = {};
+      expect(() => ESLEventUtils.initDescriptor(host, 'fn', {event: 'event'})).toThrowError();
+    });
+
+    test('ESLEventUtils.initDescriptor: incorrect key type throws error', () => {
+      const host = {fn: null};
+      expect(() => ESLEventUtils.initDescriptor(host, 'fn', {event: 'event'})).toThrowError();
+    });
+
+    test('ESLEventUtils.initDescriptor: returns host key', () => {
+      const host = {fn: () => void 0};
+      const desc = ESLEventUtils.initDescriptor(host, 'fn', {event: 'event'});
+      expect(desc).toBe(host.fn);
+    });
+
+    test('ESLEventUtils.initDescriptor: merge descriptor if called twice', () => {
+      const host = {fn: () => void 0};
+      const meta = {event: () => 'event'};
+      ESLEventUtils.initDescriptor(host, 'fn', {selector: 'a'});
+      const desc = ESLEventUtils.initDescriptor(host, 'fn', meta);
+      expect(desc.event).toBe(meta.event);
+      expect(desc.selector).toBe('a');
+    });
+
+    describe('ESLEventUtils.initDescriptor: auto-subscription management', () => {
+      test('ESLEventUtils.initDescriptor: simple subscription is not auto by default', () => {
+        const host = {fn: () => void 0};
+        const desc = ESLEventUtils.initDescriptor(host, 'fn', {event: 'event'});
+        expect(desc.auto).toBe(false);
+        expect(ESLEventUtils.getAutoDescriptors(host)).not.toContain(desc);
+      });
+
+      test('ESLEventUtils.initDescriptor: auto=true makes descriptor auto-subscribable', () => {
+        const host = {fn: () => void 0};
+        const desc = ESLEventUtils.initDescriptor(host, 'fn', {event: 'event', auto: true});
+        expect(desc.auto).toBe(true);
+        expect(ESLEventUtils.getAutoDescriptors(host)).toContain(desc);
+      });
+    });
+
+    describe('ESLEventUtils.initDescriptor: inheritance cases', () => {
+      const proto = {fn: () => void 0};
+      ESLEventUtils.initDescriptor(proto, 'fn', {event: 'event', selector: 'a'});
+
+      test('ESLEventUtils.initDescriptor: override creates new descriptor', () => {
+        const host = {fn: () => void 0};
+        Object.setPrototypeOf(host, proto);
+        const desc = ESLEventUtils.initDescriptor(host, 'fn', {event: 'event2'});
+        expect(desc).toBe(host.fn);
+        expect(desc.event).toBe('event2');
+        expect(desc.selector).toBeUndefined();
+      });
+
+      test('ESLEventUtils.initDescriptor: can inherit descriptor from prototype', () => {
+        const host = {fn: () => void 0};
+        Object.setPrototypeOf(host, proto);
+        const desc = ESLEventUtils.initDescriptor(host, 'fn', {inherit: true, selector: 'b'});
+        expect(desc).toBe(host.fn);
+        expect(desc.event).toBe('event');
+        expect(desc.selector).toBe('b');
+      });
+
+      test('ESLEventUtils.initDescriptor: can inherit descriptor from prototype', () => {
+        const host = {fn2: () => void 0};
+        Object.setPrototypeOf(host, proto);
+        expect(() => ESLEventUtils.initDescriptor(host, 'fn2', {inherit: true, selector: 'b'})).toThrowError();
+      });
+
+      test('ESLEventUtils.initDescriptor: inheriting of type (auto collectable) of the prototype', () => {
+        const parent = {fn: () => void 0};
+        const parentDesc = ESLEventUtils.initDescriptor(parent, 'fn', {event: 'event', auto: true});
+        const host = {fn: () => void 0};
+        Object.setPrototypeOf(host, parent);
+        const desc = ESLEventUtils.initDescriptor(host, 'fn', {inherit: true});
+        expect(parentDesc.auto).toBe(true);
+        expect(desc.auto).toBe(true);
+        expect(ESLEventUtils.getAutoDescriptors(host)).toContain(desc);
+      });
+    });
+  });
+
+  describe('ESLEventUtils.getAutoDescriptors', () => {
+    describe('ESLEventUtils.getAutoDescriptors: empty cases', () => {
+      test.each([
+        [undefined],
+        [null],
+        [''],
+        [{}],
+        [{event: ''}],
+        [{onEvent() {}}],
+        [new (class Test {onEvent() {}})()]
+      ])('host = %p', (host) => expect(ESLEventUtils.getAutoDescriptors(host)).toEqual([]));
+    });
+
+    test('ESLEventUtils.getAutoDescriptors: catch prototype-level declared descriptor', () => {
+      class Test { onEvent() {}}
+      ESLEventUtils.initDescriptor(Test.prototype, 'onEvent', {event: 'event', auto: true});
+
+      const instance = new Test();
+      expect(ESLEventUtils.getAutoDescriptors(instance)).toEqual([Test.prototype.onEvent]);
+    });
+
+    test('ESLEventUtils.getAutoDescriptors: does not catch prototype-level declared manual descriptor', () => {
+      class Test { onEvent() {}}
+      ESLEventUtils.initDescriptor(Test.prototype, 'onEvent', {event: 'event', auto: false});
+
+      const instance = new Test();
+      expect(ESLEventUtils.getAutoDescriptors(instance)).toEqual([]);
+    });
+
+    describe('ESLEventUtils.getAutoDescriptors: handles deep inheritance cases', () => {
+      class Base { onEvent() {}}
+      ESLEventUtils.initDescriptor(Base.prototype, 'onEvent', {event: 'event', auto: true});
+
+      test('ESLEventUtils.getAutoDescriptors: catch superclass level descriptor', () => {
+        class Child extends Base {}
+        const instance = new Child();
+        expect(ESLEventUtils.getAutoDescriptors(instance)).toEqual([Base.prototype.onEvent]);
+      });
+
+      test('ESLEventUtils.getAutoDescriptors: simple override exclude descriptor from auto-subscription', () => {
+        class Child extends Base {
+          public override onEvent() {}
+        }
+        const instance = new Child();
+        expect(ESLEventUtils.getAutoDescriptors(instance)).toEqual([]);
+      });
+
+      test('ESLEventUtils.getAutoDescriptors: override with descriptor declaration consumes overridden descriptor', () => {
+        class Child extends Base {
+          public override onEvent() {}
+        }
+        ESLEventUtils.initDescriptor(Child.prototype, 'onEvent', {inherit: true});
+        const instance = new Child();
+        expect(ESLEventUtils.getAutoDescriptors(instance)).toEqual([Child.prototype.onEvent]);
+      });
+    });
+
+    test('ESLEventUtils.getAutoDescriptors: low level API consumes own properties as well', () => {
+      const obj = {onEvent: () => void 0};
+      ESLEventUtils.initDescriptor(obj, 'onEvent', {event: 'event', auto: true});
+      expect(ESLEventUtils.getAutoDescriptors(obj)).toEqual([obj.onEvent]);
+    });
+  });
+});
diff --git a/src/modules/esl-event-listener/test/utils.test.ts b/src/modules/esl-event-listener/test/utils.test.ts
deleted file mode 100644
index d361bed25..000000000
--- a/src/modules/esl-event-listener/test/utils.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import {ESLEventUtils} from '../core';
-
-describe('dom/events: ESLEventUtils', () => {
-  // TODO: extend tests + possibly move descriptor finding to separate module?
-  describe('descriptors', () => {
-    test('basic test 1', () => {
-      const fn1 = () => undefined;
-      const fn2 = () => undefined;
-      fn1.event = fn2.event = 'test';
-
-      const obj = {onClick: fn1};
-      const proto = {onEvent: fn2};
-      Object.setPrototypeOf(obj, proto);
-
-      const desc = ESLEventUtils.descriptors(obj);
-      expect(Array.isArray(desc)).toBe(true);
-      expect(desc.includes(fn1));
-      expect(desc.includes(fn2));
-    });
-    test('basic test 2', () => {
-      const obj: any = document.createElement('div');
-
-      expect(ESLEventUtils.descriptors(obj)).toEqual([]);
-
-      obj.onEvent = Object.assign(() => undefined, {event: 'event', auto: true});
-
-      expect(ESLEventUtils.descriptors(obj).length).toEqual(1);
-      expect(ESLEventUtils.descriptors(obj)[0]).toEqual(obj.onEvent);
-    });
-  });
-});
diff --git a/src/modules/esl-utils/decorators/test/listen.test.ts b/src/modules/esl-utils/decorators/test/listen.test.ts
index 8d265b533..2bb66a445 100644
--- a/src/modules/esl-utils/decorators/test/listen.test.ts
+++ b/src/modules/esl-utils/decorators/test/listen.test.ts
@@ -14,8 +14,8 @@ describe('Decorator: @listen', () => {
     customElements.define('test-listen-1', Test);
 
     const test = new Test();
-    expect(ESLEventUtils.descriptors(test).length).toBe(1);
-    expect(ESLEventUtils.descriptors(test)[0].event).toBe('click');
+    expect(ESLEventUtils.getAutoDescriptors(test).length).toBe(1);
+    expect(ESLEventUtils.getAutoDescriptors(test)[0].event).toBe('click');
   });
 
   test('Decorator listen should accept one argument call with an event type provider', () => {
@@ -26,8 +26,8 @@ describe('Decorator: @listen', () => {
     customElements.define('test-listen-1-provider', Test);
 
     const test = new Test();
-    expect(ESLEventUtils.descriptors(test).length).toBe(1);
-    expect(typeof ESLEventUtils.descriptors(test)[0].event).toBe('function');
+    expect(ESLEventUtils.getAutoDescriptors(test).length).toBe(1);
+    expect(typeof ESLEventUtils.getAutoDescriptors(test)[0].event).toBe('function');
   });
 
   test('Multiple @listen declarations should be auto-subscribed correctly with a full event definition', () => {
@@ -40,7 +40,7 @@ describe('Decorator: @listen', () => {
     customElements.define('test-listen-2', Test);
 
     const test = new Test();
-    expect(ESLEventUtils.descriptors(test).length).toBe(2);
+    expect(ESLEventUtils.getAutoDescriptors(test).length).toBe(2);
   });
 
   test('Event listener definitions from the superclass should be handled correctly', () => {
@@ -55,7 +55,7 @@ describe('Decorator: @listen', () => {
     customElements.define('test-listen-3', TestInh);
 
     const test = new TestInh();
-    expect(ESLEventUtils.descriptors(test).length).toBe(2);
+    expect(ESLEventUtils.getAutoDescriptors(test).length).toBe(2);
   });
 
   test('Override event listener definition without decorator should exclude subscription', () => {
@@ -69,7 +69,7 @@ describe('Decorator: @listen', () => {
     customElements.define('test-listen-4', TestInh);
 
     const test = new TestInh();
-    expect(ESLEventUtils.descriptors(test).length).toBe(0);
+    expect(ESLEventUtils.getAutoDescriptors(test).length).toBe(0);
   });
 
   test('Override event listener definition with a @listen({inherit: true}) inherits event description meta information', () => {
@@ -84,7 +84,7 @@ describe('Decorator: @listen', () => {
     customElements.define('test-listen-5', TestInh);
 
     const test = new TestInh();
-    expect(ESLEventUtils.descriptors(test).length).toBe(1);
+    expect(ESLEventUtils.getAutoDescriptors(test).length).toBe(1);
     expect((test.onEvent as any as ESLListenerDescriptorFn).event).toBe('event1');
   });
 
@@ -100,7 +100,7 @@ describe('Decorator: @listen', () => {
     customElements.define('test-listen-6', TestInh);
 
     const test = new TestInh();
-    expect(ESLEventUtils.descriptors(test).length).toBe(1);
+    expect(ESLEventUtils.getAutoDescriptors(test).length).toBe(1);
     expect((test.onEvent as any as ESLListenerDescriptorFn).event).toBe('event2');
     expect((test.onEvent as any as ESLListenerDescriptorFn).selector).toBe('e');
   });
@@ -117,7 +117,7 @@ describe('Decorator: @listen', () => {
     customElements.define('test-listen-7', TestInh);
 
     const test = new TestInh();
-    expect(ESLEventUtils.descriptors(test).length).toBe(1);
+    expect(ESLEventUtils.getAutoDescriptors(test).length).toBe(1);
     expect((test.onEvent as any as ESLListenerDescriptorFn).event).toBe('event2');
     expect((test.onEvent as any as ESLListenerDescriptorFn).selector).toBe(undefined);
   });
@@ -132,4 +132,38 @@ describe('Decorator: @listen', () => {
       new Test();
     }).toThrowError();
   });
+
+  describe('@listen creates auto-subscribable(collectable) descriptors', () => {
+    class Test extends HTMLElement {
+      @listen({event: 'event-auto'})
+      onEventAuto() {}
+      @listen({event: 'event-manual', auto: false})
+      onEventManual() {}
+    }
+    class TestChild extends Test {
+      @listen({inherit: true})
+      onEventAuto() {}
+      @listen({inherit: true})
+      onEventManual() {}
+    }
+    customElements.define('test-listen-auto', Test);
+    customElements.define('test-listen-auto-inherit', TestChild);
+
+    test('@listen creates auto-subscribable(collectable) descriptor by default', () => {
+      const test = new Test();
+      expect(ESLEventUtils.getAutoDescriptors(test)).toContain(Test.prototype.onEventAuto);
+    });
+    test('@listen does not creates auto-subscribable(collectable) descriptor if auto passed as false', () => {
+      const test = new Test();
+      expect(ESLEventUtils.getAutoDescriptors(test)).not.toContain(Test.prototype.onEventManual);
+    });
+    test('@listen inherits positive subscribe-ability', () => {
+      const test = new TestChild();
+      expect(ESLEventUtils.getAutoDescriptors(test)).toContain(TestChild.prototype.onEventAuto);
+    });
+    test('@listen inherits negative subscribe-ability', () => {
+      const test = new TestChild();
+      expect(ESLEventUtils.getAutoDescriptors(test)).not.toContain(TestChild.prototype.onEventManual);
+    });
+  });
 });