Reaching Closures to Remove Event Handlers Later

A reflective look at why CotomyElement needed event ergonomics, how closure identity broke dispatch and removal symmetry, and why a registry became the practical compromise.

Previous article: Dynamic HTML Boundaries in CotomyElement

Why event handling had to exist in CotomyElement

CotomyElement is fundamentally a wrapper around HTMLElement. Once that boundary was defined, event handling was not optional. A DOM wrapper that cannot register and control events is incomplete for real screen behavior.

I also added convenience methods such as click and change. The intent was modest and practical. They were jQuery-style ergonomics to reduce boilerplate at call sites, not an attempt to invent a new event model.

The core event API remained on and off. Convenience calls existed to make frequent cases shorter and easier to scan in business UI code.

The first phase worked because the requirements were small

Early on, event handling was mostly add-only.

The assumption was simple. Handlers were attached, pages rendered, user actions were processed, and full element removal ended the lifecycle in normal flows. Explicitly removing specific handlers was not a frequent requirement, so that edge stayed quiet.

At that stage, no major failure pattern was visible. The API felt good enough, and I moved on.

Where the trouble started: function identity under closures

In plain on usage, identity is straightforward. A function reference is passed in, and the same reference can be used later for off.

The delegated subtree pattern changed that. CotomyElement provides onSubTree so one parent can react to events from matching descendants. In that path, the original handler is wrapped in a closure that checks selector matching first.

public onSubTree(event: string | string[], selector: string, handle: (e: Event) => void | Promise<void>, options?: AddEventListenerOptions): this {
    const delegate: EventHandler = (e: Event) => {
        const target = e.target as HTMLElement | null;
        if (target && target.closest(selector)) {
            return handle(e);
        }
    };
    const events = Array.isArray(event) ? event : [event];
    events.forEach(eventName => {
        const entry = new HandlerEntry(handle, delegate, options);
        EventRegistry.instance.on(eventName, this, entry);
    });
    return this;
}

That closure is a new function instance created at registration time. JavaScript compares functions by reference identity, not by source similarity or behavior. Two functions that look identical are still different if they are different instances.

This matters for removal symmetry and any logic that attempts to resolve handlers by identity.

To remove a listener with removeEventListener, the runtime needs the same effective function reference that was attached. In delegated registration, the attached listener is delegate, while the public API naturally passes the original handle. If the system only compares one side, the lookup can fail even when intent is correct.

What the source code reveals

The internal design in src/view.ts records both layers of function identity.

class HandlerEntry {
    public constructor(public readonly handle: EventHandler, public readonly wrapper?: EventHandler, public readonly options?: AddEventListenerOptions) {
    }

    public get current(): EventHandler {
        return this.wrapper ?? this.handle;
    }

    /**
     * Comparison mode
     * "strict": Exact match (matches including wrapper)
     * "remove": For deletion (ignores wrapper = treats as wildcard)
     */
    public equals(entry: HandlerEntry, mode?: "strict" | "remove"): boolean;
    public equals(handle: EventHandler, options?: AddEventListenerOptions, wrapper?: EventHandler, mode?: "strict" | "remove"): boolean;
    public equals(entryOrHandle: HandlerEntry | EventHandler, optionsOrMode?: AddEventListenerOptions | "strict" | "remove", wrapper?: EventHandler, mode?: "strict" | "remove"): boolean {
        let targetHandle: EventHandler;
        let targetWrapper: EventHandler | undefined;
        let targetOptions: AddEventListenerOptions | undefined;
        let compareMode: "strict" | "remove" = "strict";

        if (entryOrHandle instanceof HandlerEntry) {
            targetHandle = entryOrHandle.handle;
            targetWrapper = entryOrHandle.wrapper;
            targetOptions = entryOrHandle.options;
            compareMode = (optionsOrMode as "strict" | "remove") ?? "strict";

        } else {
            targetHandle = entryOrHandle;

            if (typeof optionsOrMode === "string") {
                compareMode = optionsOrMode;
                targetWrapper = wrapper;
                targetOptions = undefined;
            } else {
                targetOptions = optionsOrMode;
                targetWrapper = wrapper;
                compareMode = mode ?? "strict";
            }
        }

        if (this.handle !== targetHandle) {
            return false;
        }

        if (compareMode === "strict" && this.wrapper !== targetWrapper) {
            return false;
        }

        return HandlerEntry.optionsEquals(this.options, targetOptions);
    }
}

handle is the original user-facing function. wrapper stores the delegated closure when one exists. current is what actually gets bound to addEventListener.

The options comparison is also strict and reference-based for signal.

public static optionsEquals(left?: AddEventListenerOptions, right?: AddEventListenerOptions): boolean {
    const getBoolean = (options: AddEventListenerOptions | undefined, key: "capture" | "once" | "passive"): boolean =>
        options?.[key] ?? false;
    const getSignal = (options: AddEventListenerOptions | undefined): AbortSignal | undefined =>
        options?.signal;

    const leftSignal = getSignal(left);
    const rightSignal = getSignal(right);
    const signalsEqual = leftSignal === rightSignal;

    return getBoolean(left, "capture") === getBoolean(right, "capture")
        && getBoolean(left, "once") === getBoolean(right, "once")
        && getBoolean(left, "passive") === getBoolean(right, "passive")
        && signalsEqual;
}

Strict mode requires full identity consistency, including wrapper. Remove mode intentionally relaxes that wrapper check. In remove mode, the original public handler is treated as the authoritative identity, even if the internally attached listener is a wrapper. Options are still matched via capture, once, passive, and signal identity.

That distinction is the center of the workaround.

A short confession from the middle of development

There was a moment when I seriously considered a simpler rule: delegated subtree handlers would be register-only and effectively non-dispatchable through symmetric identity operations. I postponed the structural fix because usage was internal and the pressure was low. It stayed that way longer than it should have.

If you build systems long enough, you eventually discover that postponing a structural problem feels easier than solving it immediately. I am not proud of it, but I suspect I am not alone.

The pragmatic solution: keep an internal registry

Since closure identity cannot be reconstructed after the fact, the practical direction was to preserve registration entries explicitly.

The registry stores handlers per event and per element instance.

class HandlerRegistory {
    private _registory: Map<string, HandlerEntry[]> = new Map();

    public add(event: string, entry: HandlerEntry): void {
        if (entry.options?.once) {
            this.remove(event, entry);
        }
        if (!this.find(event, entry)) {
            this.ensure(event).push(entry);
            this.target.element.addEventListener(event, entry.current, entry.options);
        }
    }

    public remove(event: string, entry?: HandlerEntry): void {
        // ...
        for (const e of list) {
            if (e.equals(entry, "remove")) {
                this.target.element.removeEventListener(event, e.current, e.options?.capture ?? false);
            } else {
                remaining.push(e);
            }
        }
    }
}

And a higher registry maps by instance identity.

class EventRegistry {
    private _registry: Map<string, HandlerRegistory> = new Map();

    private map(target: IEventTarget): HandlerRegistory {
        const instanceId = target.instanceId;
        let registry = this._registry.get(instanceId);
        if (!registry) {
            registry = new HandlerRegistory(target);
            this._registry.set(instanceId, registry);
        }
        return registry;
    }
}

The behavior is deliberate.

In strict mode, wrapper identity must match, which protects exact duplication checks and precise entry lookup.

In remove mode, wrapper comparison is relaxed, so an off call with the original handle can still remove delegated entries whose actual attached listener is an internal closure.

This is not about pretending identity problems do not exist. It is about preserving enough registration context so lifecycle operations remain deterministic.

Why this matters for lifecycle predictability

UI runtime stability is mostly about lifecycle boundaries, not syntax convenience.

Event listeners are lifecycle resources. If registration and unregistration use mismatched identity semantics, handlers remain attached longer than intended, or disappear unexpectedly when unrelated comparisons collide. Both cases produce hard-to-trace behavior drift.

The registry adds indirection, but it centralizes ownership. It gives CotomyElement a predictable place to resolve what was actually attached, under which options, and under which element instance.

That predictability becomes especially important when screens are dynamic, subtree delegation is common, and handlers are created through closures as a normal implementation detail.

Not elegant, but reliable

This solution is pragmatic.

It introduces indirection.

It is not conceptually elegant.

But it works reliably.

Until a better model emerges, this registry approach is the compromise that keeps event lifecycle behavior predictable.

The broader lesson is uncomfortable and useful at the same time. API ergonomics and structural correctness often pull in different directions. A convenient surface can hide identity complexity, and that complexity eventually asks for explicit internal structure.

This was not about adding a feature. It was about restoring identity in a system that had lost it.

Development Backstory

This article is part of the Cotomy Development Backstory, which traces how Cotomy’s architecture emerged through real project constraints.

Series articles: Building Systems Alone , Early Architecture Attempts , The First DOM Wrapper and Scoped CSS , API Standardization and the Birth of Form Architecture , Page Representation and the Birth of a Controller Structure , The CotomyElement Constructor Is the Core , Dynamic HTML Boundaries in CotomyElement , Reaching Closures to Remove Event Handlers Later, and The Birth of the Page Controller .

Next article: The Birth of the Page Controller

Learn Cotomy

Cotomy is a DOM-first UI runtime for long-lived business applications.