Repository: exadel-inc/esl
Pull request #1651 (2023-05-12 04:05:25)

feat(esl-mixin-element): v2.0.0 improvements
- Closes: #1560 
- Updates and fixes of the `ESLMixinELement` API


Patch:
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 270e08995..ff1f3148b 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -21,7 +21,7 @@ extends:
 # TS configuration
 parser: '@typescript-eslint/parser'
 parserOptions:
-  project: 'tsconfig.eslint.json'
+  project: 'tsconfig.json'
   sourceType: module
 
 # Lint additional plugins
diff --git a/package.json b/package.json
index 1b5c6cba5..c4c2821eb 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
     "start": "npm run clean-pages && concurrently --kill-others \"npm run watch:eleventy\" \"npm run watch:ts\" \"npm run watch:less\"",
     "clean": "rimraf modules polyfills",
     "build": "npm run clean && concurrently \"npm run build:less\" \"npm run build:ts\" \"npm run build:docs\"",
-    "build:ts": "tsc --project tsconfig.json",
+    "build:ts": "tsc --project tsconfig.build.json",
     "build:less": "npm run build:less:cpy && npm run build:less:css",
     "build:less:cpy": "copyfiles \"src/**/*.less\" \".\" --up 1 --all",
     "build:less:css": "foreach -x \"lessc #{path} #{dir}/#{name}.css\" -i \"**/*.mixin.less\" -g \"modules/{*,*/*,{draft,esl-utils,esl-forms}/*/*}.less\"",
diff --git a/src/modules/esl-mixin-element/README.md b/src/modules/esl-mixin-element/README.md
index 1186b34dd..268293c30 100644
--- a/src/modules/esl-mixin-element/README.md
+++ b/src/modules/esl-mixin-element/README.md
@@ -1,6 +1,6 @@
 # [ESL](../../../) Mixin Element
 
-Version: *1.0.0*
+Version: *2.0.0*
 
 Authors: *Alexey Stsefanovich (ala'n)*
 
@@ -13,10 +13,14 @@ simplify component creation.
 
 ### Mixin Element static API
 - `MyMixinElement.is` - property that defines connection attribute name
-- `MyMixinElement.observedAttributes` - array of additional attributes to observe
-
+- `MyMixinElement.observedAttributes` - array of additional attributes to observe  
+  _Note: mixin primary attribute (`is`) is under observation independently of `observedAttributes` value_
 - `MyMixinElement.register` - register component inside `ESLMixinRegistry`
-- `MyMixinElement.get` - returns an `ESLMixinRegistry` instance attached to the passed element
+- `MyMixinElement.get` - returns an `ESLMixinElement` instance attached to the passed element
+
+### ESLMixinElement base class static API
+- `ESLMixinElement.get($el, name)` - returns mixin instance by the element and mixin name
+- `ESLMixinElement.getAll($el)` - returns all mixins initialized on passed host element
 
 ### Base Element API
 Properties:
@@ -27,7 +31,8 @@ Attributes:
 - `connectedCallback` - called when the element is appended to the DOM
 - `disconnectedCallback` - called when the element is disconnected from the DOM
 - `attributeChangedCallback` - called when the observable attribute is changed
-  (happens when the attribute is accessed for writing, independently of the actual value change)
+  (happens when the attribute is accessed for writing, independently of the actual value change).
+  Note that mixin primary attribute value observation happens independently of `observedAttributes` value
 
 - `$$cls` - checks or changes element CSS classes (uses CSSClassUtils)
 - `$$attr` - checks or changes element attributes
@@ -40,9 +45,9 @@ Attributes:
 
 - `@attr` - to map string type property to HTML attribute
 - `@boolAttr` - to map boolean property to HTML boolean (marker) attribute state
-- `@jsonAttr` - to map object property to HTML attribute using JSON format to serialize / deserialize value
+- `@jsonAttr` - to map object property to HTML attribute using JSON format to serialize/deserialize value
 
-- `@listen` - decorate method with `ESLListenerDescriptor` props
+- `@listen` - decorate a method with `ESLListenerDescriptor` props
 
 Use the `@prop` decorator to override a property created via `@attr`, `@boolAttr`, or `@jsonAttr` at the parent level
 with non-attribute accessor value.
@@ -53,7 +58,10 @@ with non-attribute accessor value.
 import {ESLMixinElement, attr, boolAttr, jsonAttr, listen} from '@exadel/esl';
 
 class MyMixinComponent extends ESLMixinElement {
-  static is = 'my-mixin-attr';
+  static override is = 'my-mixin-attr';
+  static override obserevedAttriutes = [
+    /* attribute to observe additionally to mixin attribute */
+  ];
 
   /** Reflects 'my-string-prop' attribute */
   @attr() public myStringProp: string;
@@ -66,6 +74,10 @@ class MyMixinComponent extends ESLMixinElement {
     super.connectedCallback();
     // Init my component
   }
+  
+  attributeChangedCallback(attributeName: string, oldValue: string, newValue: string) {
+    // Called on attribute value change
+  }
 
   disconnectedCallback() {
     // Unsubscribe listeners, revert side effects
diff --git a/src/modules/esl-mixin-element/ui/esl-mixin-attr.ts b/src/modules/esl-mixin-element/ui/esl-mixin-attr.ts
new file mode 100644
index 000000000..32f159904
--- /dev/null
+++ b/src/modules/esl-mixin-element/ui/esl-mixin-attr.ts
@@ -0,0 +1,69 @@
+import {ESLMixinRegistry} from './esl-mixin-registry';
+import type {ESLMixinElement, ESLMixinElementInternal} from './esl-mixin-element';
+
+// Singleton cache for ESLMixinAttributesObserver instances
+const instances = new Map<string, ESLMixinAttributesObserver>();
+
+/**
+ * Internal {@link ESLMixinElement}s observedAttributes mutation listener.
+ * Creates a single instance per mixin type
+ * Ignores mixin primary attribute changes (they are observed by {@link ESLMixinRegistry} ootb)
+ */
+export class ESLMixinAttributesObserver {
+  protected observer = new MutationObserver(
+    (records: MutationRecord[]) => records.forEach(this.handleRecord, this)
+  );
+
+  private constructor(protected readonly type: string) {
+    if (instances.has(type)) return instances.get(type)!;
+    instances.set(type, this);
+  }
+
+  /** Processes single mutation record */
+  protected handleRecord(record: MutationRecord): void {
+    const name = record.attributeName;
+    const target = record.target as HTMLElement;
+    if (!name || !target) return;
+    const mixin = ESLMixinRegistry.get(target, this.type) as ESLMixinElementInternal;
+    mixin && mixin.attributeChangedCallback(name, record.oldValue, target.getAttribute(name));
+  }
+
+  /** Subscribes to the {@link ESLMixinElement} host instance mutations */
+  public observe(mixin: ESLMixinElement): void {
+    const {is, observedAttributes} = mixin.constructor as typeof ESLMixinElement;
+    const attributeFilter = observedAttributes.filter((name: string) => name !== is);
+    if (!attributeFilter.length) return;
+    this.observer.observe(mixin.$host, {
+      attributes: true,
+      attributeFilter,
+      attributeOldValue: true
+    });
+  }
+
+  /** Unsubscribes from the {@link ESLMixinElement} host instance mutations */
+  public unobserve(mixin: ESLMixinElement): void {
+    this.observer.observe(mixin.$host, {
+      attributes: true,
+      attributeFilter: []
+    });
+  }
+
+  private static instanceFor(mixin: ESLMixinElement): ESLMixinAttributesObserver | null {
+    const {is, observedAttributes} = mixin.constructor as typeof ESLMixinElement;
+    const attributes = (observedAttributes || []).filter((name: string) => name !== is);
+    if (!is || !attributes.length) return null;
+    return new ESLMixinAttributesObserver(is);
+  }
+
+  /** Subscribes to the {@link ESLMixinElement} host instance mutations */
+  public static observe(mixin: ESLMixinElement): void {
+    const observer = this.instanceFor(mixin);
+    observer && observer.observe(mixin);
+  }
+
+  /** Unsubscribes from the {@link ESLMixinElement} host instance mutations */
+  public static unobserve(mixin: ESLMixinElement): void {
+    const observer = this.instanceFor(mixin);
+    observer && observer.unobserve(mixin);
+  }
+}
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 5e26b3d9b..176fbe211 100644
--- a/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
+++ b/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
@@ -1,8 +1,11 @@
-import {setAttr} from '../../esl-utils/dom/attr';
 import {prop} from '../../esl-utils/decorators';
+import {setAttr} from '../../esl-utils/dom/attr';
+import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {ESLEventUtils} from '../../esl-utils/dom/events';
 import {CSSClassUtils} from '../../esl-utils/dom/class';
+
 import {ESLMixinRegistry} from './esl-mixin-registry';
+import {ESLMixinAttributesObserver} from './esl-mixin-attr';
 
 import type {
   ESLEventListener,
@@ -18,6 +21,7 @@ import type {ESLDomElementRelated} from '../../esl-utils/abstract/dom-target';
  * Mixin elements attaches to the DOM element via attribute.
  * Allows multiple mixin elements per one DOM element
  */
+@ExportNs('Mixin')
 export class ESLMixinElement implements ESLBaseComponent, ESLDomElementRelated {
   /** Root attribute to identify mixin targets. Should contain dash in the name. */
   static is: string;
@@ -27,32 +31,19 @@ export class ESLMixinElement implements ESLBaseComponent, ESLDomElementRelated {
   /** Event to indicate component significant state change that may affect other components state */
   @prop('esl:refresh') public REFRESH_EVENT: string;
 
-  /** Additional attributes observer */
-  private _attr$$: MutationObserver;
-
   public constructor(
     public readonly $host: HTMLElement
   ) {}
 
   /** Callback of mixin instance initialization */
-  public connectedCallback(): void {
-    const {observedAttributes} = this.constructor as typeof ESLMixinElement;
-    if (observedAttributes.length) {
-      this._attr$$ = new MutationObserver(this._onAttrMutation.bind(this));
-      this._attr$$.observe(this.$host, {
-        attributes: true,
-        attributeFilter: observedAttributes,
-        attributeOldValue: true
-      });
-    }
-
+  protected connectedCallback(): void {
+    ESLMixinAttributesObserver.observe(this);
     ESLEventUtils.subscribe(this);
   }
 
   /** Callback to execute on mixin instance destroy */
-  public disconnectedCallback(): void {
-    if (this._attr$$) this._attr$$.disconnect();
-
+  protected disconnectedCallback(): void {
+    ESLMixinAttributesObserver.unobserve(this);
     ESLEventUtils.unsubscribe(this);
   }
 
@@ -60,16 +51,7 @@ export class ESLMixinElement implements ESLBaseComponent, ESLDomElementRelated {
    * Callback to handle changing of additional attributes.
    * Happens when attribute accessed for writing independently of the actual value change
    */
-  public attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {}
-
-  /** Attribute change mutation record processor */
-  private _onAttrMutation(records: MutationRecord[]): void {
-    records.forEach(({attributeName, oldValue}: MutationRecord) => {
-      if (!attributeName) return;
-      const newValue = this.$$attr(attributeName);
-      this.attributeChangedCallback(attributeName, oldValue, newValue);
-    });
-  }
+  protected attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {}
 
   /** Subscribes `handler` method marked with `@listen` decorator */
   public $$on(handler: ESLListenerHandler): ESLEventListener[];
@@ -122,14 +104,20 @@ export class ESLMixinElement implements ESLBaseComponent, ESLDomElementRelated {
     return ESLEventUtils.dispatch(this.$host, eventName, eventInit);
   }
 
-  /** Returns mixin instance by element */
-  public static get<T extends typeof ESLMixinElement>(this: T, $el: HTMLElement): InstanceType<T> | null {
-    return ESLMixinRegistry.get($el, this.is) as InstanceType<T>;
-  }
   /** Register current mixin definition */
   public static register(): void {
     (new ESLMixinRegistry()).register(this);
   }
+
+  // Public Registry API
+  public static readonly get = ESLMixinRegistry.get;
+  public static readonly getAll = ESLMixinRegistry.getAll;
 }
 
-export type ConstructableESLMixin = typeof ESLMixinElement & (new($root: HTMLElement) => ESLMixinElement);
+export type ESLMixinElementInternal = ESLMixinElement & {
+  connectedCallback(): void;
+  disconnectedCallback(): void;
+  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
+};
+
+export type ESLMixinElementConstructable = typeof ESLMixinElement & (new($root: HTMLElement) => ESLMixinElement);
diff --git a/src/modules/esl-mixin-element/ui/esl-mixin-registry.ts b/src/modules/esl-mixin-element/ui/esl-mixin-registry.ts
index 709c046fb..0c63b6327 100644
--- a/src/modules/esl-mixin-element/ui/esl-mixin-registry.ts
+++ b/src/modules/esl-mixin-element/ui/esl-mixin-registry.ts
@@ -1,7 +1,11 @@
-import type {ESLMixinElement, ConstructableESLMixin} from './esl-mixin-element';
+import type {
+  ESLMixinElement,
+  ESLMixinElementInternal,
+  ESLMixinElementConstructable
+} from './esl-mixin-element';
 
 // Private key to store mixin instances
-const STORE = '__esl_mixins';
+const STORE = (window.Symbol || String)('__esl_mixins');
 
 // Singleton for registry
 let global: ESLMixinRegistry;
@@ -9,7 +13,7 @@ let global: ESLMixinRegistry;
 /** Registry to store and initialize {@link ESLMixinElement} instances */
 export class ESLMixinRegistry {
   /** Map that stores available mixins under their identifier (attribute) */
-  protected store = new Map<string, ConstructableESLMixin>();
+  protected store = new Map<string, ESLMixinElementConstructable>();
   /** MutationObserver instance to track DOM changes and init mixins on-fly */
   protected mutation$$ = new MutationObserver(this._onMutation.bind(this));
 
@@ -27,7 +31,7 @@ export class ESLMixinRegistry {
   }
 
   /** Register mixin definition */
-  public register(mixin: ConstructableESLMixin): void {
+  public register(mixin: ESLMixinElementConstructable): void {
     if (!mixin.is || mixin.is.indexOf('-') === -1) {
       throw Error(`[ESL]: Illegal mixin attribute name "${mixin.is}"`);
     }
@@ -46,7 +50,8 @@ export class ESLMixinRegistry {
       subtree: true,
       childList: true,
       attributes: true,
-      attributeFilter: this.observedAttributes
+      attributeFilter: this.observedAttributes,
+      attributeOldValue: true
     });
   }
 
@@ -61,7 +66,7 @@ export class ESLMixinRegistry {
   /** Invalidates all mixins on the element */
   public invalidateAll(el: HTMLElement): void {
     const hasStore = Object.hasOwnProperty.call(el, STORE);
-    this.store.forEach((mixin: ConstructableESLMixin, name: string) => {
+    this.store.forEach((mixin: ESLMixinElementConstructable, name: string) => {
       if (el.hasAttribute(name)) {
         ESLMixinRegistry.init(el, mixin);
       } else if (hasStore) {
@@ -71,52 +76,51 @@ export class ESLMixinRegistry {
   }
 
   /** Invalidates passed mixin on the element */
-  public invalidate(el: HTMLElement, mixin: string): void {
-    if (el.hasAttribute(mixin)) {
-      const mixinType = this.store.get(mixin);
-      mixinType && ESLMixinRegistry.init(el, mixinType);
+  public invalidate(el: HTMLElement, name: string, oldValue: string | null): void {
+    const newValue = el.getAttribute(name);
+    if (newValue === null) return ESLMixinRegistry.destroy(el, name);
+    const instance = ESLMixinRegistry.get(el, name) as ESLMixinElementInternal;
+    if (instance) {
+      instance.attributeChangedCallback(name, oldValue, newValue);
     } else {
-      ESLMixinRegistry.destroy(el, mixin);
+      const type = this.store.get(name);
+      type && ESLMixinRegistry.init(el, type);
     }
   }
 
-  /** Destroys passed mixin on the element */
-  public destroy(el: HTMLElement, mixin: string | ConstructableESLMixin | undefined): void {
-    if (typeof mixin === 'string') mixin = this.store.get(mixin);
-    if (!mixin) return;
-    const current = ESLMixinRegistry.get(el, mixin.is);
-    if (current) current.disconnectedCallback();
-  }
-
-  /** Destroys all mixins on the element */
-  public destroyAll(el: HTMLElement): void {
-    const store = (el as any)[STORE] as Record<string, ESLMixinElement> | undefined;
-    store && Object.keys(store).forEach((name) => ESLMixinRegistry.destroy(el, name));
-  }
-
   /** Handles DOM mutation list */
   protected _onMutation(mutations: MutationRecord[]): void {
     mutations.forEach((record: MutationRecord) => {
-      if (record.type === 'attributes' && record.attributeName && this.store.has(record.attributeName)) {
-        this.invalidate(record.target as HTMLElement, record.attributeName);
+      if (record.type === 'attributes' && record.attributeName) {
+        this.invalidate(record.target as HTMLElement, record.attributeName, record.oldValue);
       }
       if (record.type === 'childList') {
         record.addedNodes.forEach((node) => {
           if (node.nodeType === Node.ELEMENT_NODE) this.invalidateRecursive(node as HTMLElement);
         });
         record.removedNodes.forEach((node) => {
-          if (node.nodeType === Node.ELEMENT_NODE) this.destroyAll(node as HTMLElement);
+          if (node.nodeType === Node.ELEMENT_NODE) ESLMixinRegistry.destroyAll(node as HTMLElement);
         });
       }
     });
   }
 
+  /** Returns mixin instance by element */
+  public static get<T extends ESLMixinElementConstructable>(this: T, $el: HTMLElement): InstanceType<T> | null;
+  /** Returns mixin instance by element and mixin name */
+  public static get($el: HTMLElement, name: string): ESLMixinElement | null;
   /** @returns mixin instance by name */
-  public static get(el: HTMLElement, mixin: string): ESLMixinElement | null {
+  public static get(this: any, el: HTMLElement, mixin: string = this.is): ESLMixinElement | null {
     const store = (el as any)[STORE] as Record<string, ESLMixinElement> | undefined;
     return (store && store[mixin]) || null;
   }
 
+  /** @returns all mixins initialized on passed host element */
+  public static getAll(el: HTMLElement): ESLMixinElement[] {
+    const store = (el as any)[STORE] as Record<string, ESLMixinElement> | undefined;
+    return store ? Object.keys(store).map((key: string) => store[key]) : [];
+  }
+
   /** @returns if the passed mixin exists on the element */
   public static has(el: HTMLElement, mixin: string): boolean {
     return !!this.get(el, mixin);
@@ -126,15 +130,15 @@ export class ESLMixinRegistry {
   private static set(el: HTMLElement, mixin: ESLMixinElement): void {
     if (!Object.hasOwnProperty.call(el, STORE)) Object.defineProperty(el, STORE, {value: {}, configurable: true});
     const store = (el as any)[STORE] as Record<string, ESLMixinElement>;
-    store[(mixin.constructor as ConstructableESLMixin).is] = mixin;
+    store[(mixin.constructor as ESLMixinElementConstructable).is] = mixin;
   }
 
   /** Inits mixin instance on the element */
-  private static init(el: HTMLElement, Mixin: ConstructableESLMixin): ESLMixinElement | null {
+  private static init(el: HTMLElement, Mixin: ESLMixinElementConstructable): ESLMixinElement | null {
     if (this.has(el, Mixin.is)) return null;
     const instance = new Mixin(el);
     ESLMixinRegistry.set(el, instance);
-    instance.connectedCallback();
+    (instance as ESLMixinElementInternal).connectedCallback();
     return instance;
   }
 
@@ -144,7 +148,13 @@ export class ESLMixinRegistry {
     if (!store) return;
     const instance = store[mixin];
     if (!instance) return;
-    instance.disconnectedCallback();
+    (instance as ESLMixinElementInternal).disconnectedCallback();
     delete store[mixin];
   }
+
+  /** Destroys all mixins on the element */
+  private static destroyAll(el: HTMLElement): void {
+    const store = (el as any)[STORE] as Record<string, ESLMixinElement> | undefined;
+    store && Object.keys(store).forEach((name) => ESLMixinRegistry.destroy(el, name));
+  }
 }
diff --git a/tsconfig.eslint.json b/tsconfig.build.json
similarity index 81%
rename from tsconfig.eslint.json
rename to tsconfig.build.json
index c26f3b40a..b2e83580d 100644
--- a/tsconfig.eslint.json
+++ b/tsconfig.build.json
@@ -1,11 +1,12 @@
 {
   "extends": "./tsconfig.json",
   "include": [
-    "pages/src",
     "src/modules",
     "src/polyfills"
   ],
   "exclude": [
+    "*.test.ts",
+    "**/test/*.ts",
     "modules",
     "polyfills",
     "node_modules"
diff --git a/tsconfig.json b/tsconfig.json
index 1bb5aac69..20a8b2570 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,12 +21,11 @@
     "outDir": "./"
   },
   "include": [
+    "pages/src",
     "src/modules",
     "src/polyfills"
   ],
   "exclude": [
-    "*.test.ts",
-    "**/test/*.ts",
     "modules",
     "polyfills",
     "node_modules"


Test patch:
diff --git a/src/modules/esl-mixin-element/test/mixin.attributes.test.ts b/src/modules/esl-mixin-element/test/mixin.attributes.test.ts
new file mode 100644
index 000000000..f6d62fda4
--- /dev/null
+++ b/src/modules/esl-mixin-element/test/mixin.attributes.test.ts
@@ -0,0 +1,89 @@
+import {ESLMixinElement} from '../ui/esl-mixin-element';
+import MockedFn = jest.MockedFn;
+
+describe('ESLMixinElement: attribute observation', () => {
+  class TestMixin extends ESLMixinElement {
+    static override is = 'test-mixin-oattr';
+
+    override attributeChangedCallback: MockedFn<(name: string, oldV: string, newV: string) => void>;
+    constructor(el: HTMLElement) {
+      super(el);
+      this.attributeChangedCallback = jest.fn();
+    }
+  }
+  TestMixin.register();
+  class TestMixin2 extends TestMixin {
+    static override is = 'test-mixin-oattr-2';
+    static override observedAttributes = [TestMixin2.is, 'a', 'b'];
+  }
+  TestMixin2.register();
+  class TestMixin3 extends TestMixin {
+    static override is = 'test-mixin-oattr-3';
+    static override observedAttributes = [TestMixin3.is, 'a', 'c'];
+  }
+  TestMixin3.register();
+
+  describe('mixin `is` observed correctly', () => {
+    const $host = document.createElement('div');
+
+    beforeAll(() => {
+      $host.setAttribute(TestMixin.is, '');
+      document.body.appendChild($host);
+      return Promise.resolve();
+    });
+    afterAll(() => {
+      document.body.removeChild($host);
+    });
+
+    test('mixin initialized', () => {
+      expect(TestMixin.get($host)).toBeInstanceOf(TestMixin);
+    });
+
+    test('mixin new value handled correctly', async () => {
+      const mixin = TestMixin.get($host)!;
+      const oldValue = $host.getAttribute(TestMixin.is);
+      mixin.attributeChangedCallback.mockReset();
+
+      const newValue = 'test1';
+      $host.setAttribute(TestMixin.is, newValue);
+      await Promise.resolve();
+
+      expect(mixin.attributeChangedCallback).toBeCalledTimes(1);
+      expect(mixin.attributeChangedCallback).toBeCalledWith(TestMixin.is, oldValue, newValue);
+    });
+  });
+
+  describe('observed attributes handled correctly', () => {
+    const $host = document.createElement('div');
+
+    beforeAll(() => {
+      $host.setAttribute(TestMixin2.is, '');
+      $host.setAttribute(TestMixin3.is, '');
+      document.body.appendChild($host);
+      return Promise.resolve();
+    });
+    afterAll(() => {
+      document.body.removeChild($host);
+    });
+
+    test.each([
+      ['a', TestMixin2],
+      ['b', TestMixin2],
+      ['a', TestMixin3],
+      ['c', TestMixin3],
+      [TestMixin2.is, TestMixin2],
+      [TestMixin3.is, TestMixin3],
+    ])('attr %s handled by mixin %o', async (attrName: string, Mixin: typeof TestMixin) => {
+      const mixin = Mixin.get($host)!;
+      const oldValue = $host.getAttribute(attrName);
+      mixin.attributeChangedCallback.mockReset();
+
+      const newValue = `test-${attrName}-${performance.now()}`;
+      $host.setAttribute(attrName, newValue);
+      await Promise.resolve();
+
+      expect(mixin.attributeChangedCallback).toBeCalledTimes(1);
+      expect(mixin.attributeChangedCallback).toBeCalledWith(attrName, oldValue, newValue);
+    });
+  });
+});
diff --git a/src/modules/esl-mixin-element/test/mixin.attr-helpers.test.ts b/src/modules/esl-mixin-element/test/mixin.decorators.test.ts
similarity index 93%
rename from src/modules/esl-mixin-element/test/mixin.attr-helpers.test.ts
rename to src/modules/esl-mixin-element/test/mixin.decorators.test.ts
index 0ad3350d6..b48a4a913 100644
--- a/src/modules/esl-mixin-element/test/mixin.attr-helpers.test.ts
+++ b/src/modules/esl-mixin-element/test/mixin.decorators.test.ts
@@ -3,7 +3,7 @@ import {attr, boolAttr, jsonAttr} from '../../esl-utils/decorators';
 
 describe('ESLMixinElement: attribute mixins correctly reflect to the $host', () => {
   class TestMixin extends ESLMixinElement {
-    static override is = 'test-mixin-2';
+    static override is = 'test-mixin-attr-decorators';
 
     @attr() public val: string | null;
 
@@ -21,7 +21,7 @@ describe('ESLMixinElement: attribute mixins correctly reflect to the $host', ()
   });
 
   test('@attr', async () => {
-    const mixin = TestMixin.get($el) as TestMixin;
+    const mixin = TestMixin.get($el)!;
     expect(mixin.val).toBe('');
     mixin.val = 'a';
     expect($el.getAttribute('val')).toBe('a');
diff --git a/src/modules/esl-mixin-element/test/mixin.test.ts b/src/modules/esl-mixin-element/test/mixin.test.ts
index f08a8fc9b..23719b007 100644
--- a/src/modules/esl-mixin-element/test/mixin.test.ts
+++ b/src/modules/esl-mixin-element/test/mixin.test.ts
@@ -7,13 +7,30 @@ describe('ESLMixinElement', () => {
       static override is = 'test-mixin';
     }
 
-    test('init', async () => {
+    test('Existing ESLMixinElement attribute handled on registration', () => {
       const $el = document.createElement('div');
       $el.toggleAttribute(TestMixin.is, true);
       document.body.appendChild($el);
       TestMixin.register();
       expect(TestMixin.get($el)).toBeInstanceOf(ESLMixinElement);
+    });
 
+    test('ESLMixinElement appears on new element with attribute', async () => {
+      TestMixin.register();
+      const $el = document.createElement('div');
+      $el.toggleAttribute(TestMixin.is, true);
+      document.body.appendChild($el);
+      await Promise.resolve(); // Wait for next microtask
+      expect(TestMixin.get($el)).toBeInstanceOf(ESLMixinElement);
+    });
+
+    test('ESLMixinElement removed when attribute is removed on new element with attribute', async () => {
+      TestMixin.register();
+      const $el = document.createElement('div');
+      $el.toggleAttribute(TestMixin.is, true);
+      document.body.appendChild($el);
+      await Promise.resolve(); // Wait for next microtask
+      expect(TestMixin.get($el)).toBeInstanceOf(ESLMixinElement);
       $el.toggleAttribute(TestMixin.is, false);
       await Promise.resolve(); // Wait for next microtask
       expect(TestMixin.get($el)).toBe(null);
@@ -22,6 +39,15 @@ describe('ESLMixinElement', () => {
       expect(TestMixin.get($el)).toBeInstanceOf(ESLMixinElement);
     });
 
+    test('ESLMixinElement can be resolved by mixin name', async () => {
+      const $el = document.createElement('div');
+      $el.toggleAttribute(TestMixin.is, true);
+      document.body.appendChild($el);
+      TestMixin.register();
+      await Promise.resolve(); // Wait for next microtask
+      expect(ESLMixinElement.get($el, TestMixin.is)).toBe(TestMixin.get($el));
+    });
+
     test('init child', async () => {
       TestMixin.register();
 
@@ -35,7 +61,7 @@ describe('ESLMixinElement', () => {
       expect(TestMixin.get($el)).toBeInstanceOf(ESLMixinElement);
     });
 
-    afterAll(() => {
+    afterEach(() => {
       while (document.body.lastElementChild) document.body.removeChild(document.body.lastElementChild);
     });
   });