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..cb22bae67 --- /dev/null +++ b/src/modules/esl-mixin-element/test/mixin.attributes.test.ts @@ -0,0 +1,90 @@ +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'; + static override observedAttributes = [TestMixin.is]; + + 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 initialized', 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 96% 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 8bc9946fb..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; diff --git a/src/modules/esl-mixin-element/ui/esl-mixin-attr.ts b/src/modules/esl-mixin-element/ui/esl-mixin-attr.ts index 11f904e1f..9ab777bc9 100644 --- a/src/modules/esl-mixin-element/ui/esl-mixin-attr.ts +++ b/src/modules/esl-mixin-element/ui/esl-mixin-attr.ts @@ -1,33 +1,27 @@ import {ESLMixinRegistry} from './esl-mixin-registry'; import type {ESLMixinElement, ESLMixinElementInternal} from './esl-mixin-element'; -let instance: ESLMixinAttributesObserver; +const instances = new Map(); export class ESLMixinAttributesObserver { protected observer = new MutationObserver( (records: MutationRecord[]) => records.forEach(this.handleRecord, this) ); - constructor() { - if (instance) return instance; - // eslint-disable-next-line @typescript-eslint/no-this-alias - instance = this; + private constructor(protected readonly type: string) { + if (instances.has(type)) return instances.get(type)!; + instances.set(type, this); } protected handleRecord(record: MutationRecord): void { const name = record.attributeName; const target = record.target as HTMLElement; if (!name || !target) return; - const mixins = ESLMixinRegistry.getAll(target) as ESLMixinElementInternal[]; - for (const mixin of mixins) { - const observed = (mixin.constructor as typeof ESLMixinElement).observedAttributes; - if (!observed.includes(record.attributeName)) return; - mixin.attributeChangedCallback(name, record.oldValue, target.getAttribute(name)); - } + const mixin = ESLMixinRegistry.get(target, this.type) as ESLMixinElementInternal; + mixin && mixin.attributeChangedCallback(name, record.oldValue, target.getAttribute(name)); } public observe(mixin: ESLMixinElement): void { const {observedAttributes} = mixin.constructor as typeof ESLMixinElement; - if (!observedAttributes.length) return; this.observer.observe(mixin.$host, { attributes: true, attributeFilter: observedAttributes, @@ -42,4 +36,20 @@ export class ESLMixinAttributesObserver { attributeOldValue: true }); } + + private static instanceFor(mixin: ESLMixinElement): ESLMixinAttributesObserver | null { + const type = mixin.constructor as typeof ESLMixinElement; + if (!type.is || !type.observedAttributes || !type.observedAttributes.length) return null; + return new ESLMixinAttributesObserver(type.is); + } + + public static observe(mixin: ESLMixinElement): void { + const observer = this.instanceFor(mixin); + observer && observer.observe(mixin); + } + + 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 e7ce4f217..176fbe211 100644 --- a/src/modules/esl-mixin-element/ui/esl-mixin-element.ts +++ b/src/modules/esl-mixin-element/ui/esl-mixin-element.ts @@ -37,13 +37,13 @@ export class ESLMixinElement implements ESLBaseComponent, ESLDomElementRelated { /** Callback of mixin instance initialization */ protected connectedCallback(): void { - (new ESLMixinAttributesObserver()).observe(this); + ESLMixinAttributesObserver.observe(this); ESLEventUtils.subscribe(this); } /** Callback to execute on mixin instance destroy */ protected disconnectedCallback(): void { - (new ESLMixinAttributesObserver()).unobserve(this); + ESLMixinAttributesObserver.unobserve(this); ESLEventUtils.unsubscribe(this); }