From ee91c071984bd913259e41884bc78c0ec4e2ad67 Mon Sep 17 00:00:00 2001 From: Dmytro Ohorodnykov Date: Mon, 3 Nov 2025 16:05:14 +0200 Subject: [PATCH 1/4] added new event listeners cleaner --- src/elements.ts | 77 ++++++++++++++++++++++++++++++++++++++--------- src/observable.ts | 6 ++-- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/elements.ts b/src/elements.ts index 64c4b1c..2af5d24 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -43,8 +43,11 @@ interface EventListenerOptions { export abstract class BaseView { readonly _data: Obj = {}; readonly _events: Obj = {}; + _mutationObserver: MutationObserver|undefined; + private readonly _mutationObserverCallbacks: Obj = {}; readonly type: string = 'default'; model?: Observable; + isDeleted?: boolean; constructor(readonly _el: T) { // Store a reference to this element within the native browser DOM. @@ -628,9 +631,27 @@ export abstract class BaseView { } } + /** Removes listeners and model data from el */ + private unsubscribe() { + this.isDeleted = true; + if (!Browser.isSafari) this.model?.clear(); + this._mutationObserver?.disconnect(); + this.offAll(); + + for (const child of this.children) { + child.unsubscribe(); + } + + delete (this as any)._data; + delete (this as any)._events; + delete (this as any)._mutationObserverCallbacks; + } + /** Removes this element. */ remove() { this.detach(); + this.unsubscribe(); + // TODO Remove event listeners (including children) // TODO Remove model bindings (including children) // this._el = this._data = this._events = undefined; @@ -679,13 +700,27 @@ export abstract class BaseView { */ off(events: string, callback?: EventCallback) { for (const e of words(events)) { - if (e in this._events) { - this._events[e] = callback ? this._events[e].filter(fn => fn !== callback) : []; + if (callback) { + this._events[e] = this._events[e].filter(fn => fn !== callback); + unbindEvent(this, e, callback); + continue; } - unbindEvent(this, e, callback); + if (this._events[e]) { + for (const eventsCallback of this._events[e]) unbindEvent(this, e, eventsCallback); + } + this._events[e] = []; } } + /** + * Removes all event listeners from this element + */ + offAll() { + Object.entries(this._events).forEach(([eventName, callbacks]) => { + callbacks.forEach((callback) => this.off(eventName, callback)); + }); + } + /** Triggers a specific event on this element. */ trigger(events: string, args: unknown = {}) { for (const e of words(events)) { @@ -703,28 +738,40 @@ export abstract class BaseView { const keyNames = new Set(words(keys)); const event = options?.up ? 'keyup' : 'keydown'; - const target = (this._el === document.body ? document : this._el) as HTMLElement; - target.addEventListener(event, (e: KeyboardEvent) => { + const eventFunction = (e: KeyboardEvent) => { const key = keyCode(e); if (options?.meta ? !e.ctrlKey && !e.metaKey : e.ctrlKey || e.metaKey) return; if (!key || !keyNames.has(key)) return; if (document.activeElement !== this._el && document.activeElement?.shadowRoot?.activeElement !== this._el && Browser.formIsActive) return; callback(e as KeyboardEvent, key); - }); + }; + + const target = (this._el === document.body ? document : this._el) as HTMLElement; + target.addEventListener(event, eventFunction); + + if (!(event in this._events)) this._events[event] = []; + this._events[event].push(eventFunction); } + /** + * Bind an listener when element attribute changed + */ onAttr(name: string, callback: (value: string, initial?: boolean) => void) { - // TODO Reuse existing observers, remove events, disconnect when deleting. - - const observer = new MutationObserver((mutations) => { - for (const m of mutations) { - if (m.type === 'attributes' && m.attributeName === name) { - callback(this.attr(name)); + if (!this._mutationObserver) { + this._mutationObserver = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.type === 'attributes' && m.attributeName && m.attributeName in this._mutationObserverCallbacks) { + for (const attributeCallback of this._mutationObserverCallbacks[m.attributeName]) { + attributeCallback(this.attr(m.attributeName)); + } + } } - } - }); + }); + this._mutationObserver.observe(this._el, {attributes: true}); + } - observer.observe(this._el, {attributes: true}); + if (!(name in this._mutationObserverCallbacks)) this._mutationObserverCallbacks[name] = []; + this._mutationObserverCallbacks[name].push(callback); callback(this.attr(name), true); } diff --git a/src/observable.ts b/src/observable.ts index 8f26ca5..afe358d 100644 --- a/src/observable.ts +++ b/src/observable.ts @@ -3,6 +3,8 @@ // (c) Mathigon // ============================================================================= +import {Browser} from './browser'; + type Callback = (state: T, initial?: boolean) => void; type Expr = (state: T) => void; @@ -111,7 +113,7 @@ export function observe(state: T, parentModel?: Observab } function assign(changes: Partial, clear?: boolean) { - if (clear) state = {} as T; + if (clear && !Browser.isSafari) state = {} as T; batch(() => { for (const [key, value] of Object.entries(changes)) { if (!(key in previous)) (previous as any)[key] = (state as any)[key]; @@ -127,10 +129,10 @@ export function observe(state: T, parentModel?: Observab } function clear() { - state = {} as T; callbackMap.clear(); computedKeys.clear(); watchAllCallbacks.clear(); + if (!Browser.isSafari) state = {} as T; lastKey = 0; } From 567dd853660ad394859e61b83d379f69772a76c5 Mon Sep 17 00:00:00 2001 From: Dmytro Ohorodnykov Date: Mon, 3 Nov 2025 17:12:18 +0200 Subject: [PATCH 2/4] updates 1 --- src/elements.ts | 2 +- src/observable.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/elements.ts b/src/elements.ts index 2af5d24..b1e2e22 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -716,7 +716,7 @@ export abstract class BaseView { * Removes all event listeners from this element */ offAll() { - Object.entries(this._events).forEach(([eventName, callbacks]) => { + Object.entries(this._events || {}).forEach(([eventName, callbacks]) => { callbacks.forEach((callback) => this.off(eventName, callback)); }); } diff --git a/src/observable.ts b/src/observable.ts index afe358d..05e7237 100644 --- a/src/observable.ts +++ b/src/observable.ts @@ -113,7 +113,7 @@ export function observe(state: T, parentModel?: Observab } function assign(changes: Partial, clear?: boolean) { - if (clear && !Browser.isSafari) state = {} as T; + if (clear) state = {} as T; batch(() => { for (const [key, value] of Object.entries(changes)) { if (!(key in previous)) (previous as any)[key] = (state as any)[key]; @@ -129,10 +129,10 @@ export function observe(state: T, parentModel?: Observab } function clear() { + state = {} as T; callbackMap.clear(); computedKeys.clear(); watchAllCallbacks.clear(); - if (!Browser.isSafari) state = {} as T; lastKey = 0; } From aeba509cd98db481ebb52792ad073dd02dc8eb64 Mon Sep 17 00:00:00 2001 From: Dmytro Ohorodnykov Date: Mon, 3 Nov 2025 18:20:05 +0200 Subject: [PATCH 3/4] update 2 --- src/elements.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/elements.ts b/src/elements.ts index b1e2e22..3ad233b 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -651,10 +651,6 @@ export abstract class BaseView { remove() { this.detach(); this.unsubscribe(); - - // TODO Remove event listeners (including children) - // TODO Remove model bindings (including children) - // this._el = this._data = this._events = undefined; } /** Removes all children of this element. */ From 995bc0191fd8ed62c3ba882fca449a017f4b5650 Mon Sep 17 00:00:00 2001 From: Dmytro Ohorodnykov Date: Tue, 4 Nov 2025 17:15:27 +0200 Subject: [PATCH 4/4] updates --- src/elements.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/elements.ts b/src/elements.ts index 3ad233b..db0dd1c 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -634,6 +634,7 @@ export abstract class BaseView { /** Removes listeners and model data from el */ private unsubscribe() { this.isDeleted = true; + // this causes elements freeze in Safari so we skip proxy model clearing there if (!Browser.isSafari) this.model?.clear(); this._mutationObserver?.disconnect(); this.offAll();