# eventize — lifecycle & cleanup What an emitter holds and what releases it. Load when writing teardown code, or when something stays subscribed or stays in memory longer than expected. ## What an emitter holds The hidden `Symbol.for('eventize')` slot carries two collaborators: - the **listener registry** — one bucket per event name, plus one for wildcard (`'*'`) listeners; - the **retained-event log** — the set of names carrying a retain *policy*, kept separate from the map of names to their last retained *value*. A name can carry a policy with no value yet: retain it, never emit it. The slot needs an extensible object. `eventize(Object.freeze(obj))` (and the `seal` / `preventExtensions` equivalents) throws a `TypeError` naming the cause. An object eventized *before* it was frozen is unaffected. **Retained payloads are strong references, not clones.** `retain(ε, 'foo')` plus `emit(ε, 'foo', bigObject)` keeps that exact reference alive until it is overwritten, cleared, or the emitter is collected. A later subscriber gets the same object back, `===`. `retainClear(ε, 'foo')` is the antidote for a large payload. ## What each `off()` form releases | Form | Listeners removed | Retained state | | --- | --- | --- | | `off(ε)` | all | every value **and** every policy | | `off(ε, undefined)` | all — same branch as `off(ε)`, **not** a no-op | every value and every policy | | `off(ε, '*')` | all | every value and every policy | | `off(ε, ['*', …])` — wildcard anywhere | all | every value and every policy | | `off(ε, [null, …])` / `off(ε, [undefined, …])` | all | every value and every policy | | `off(ε, eventName)` | every listener for that name | value **and** policy for that name | | `off(ε, [eventName, …])` — no `'*'` | every listener for each listed name | value and policy per listed name | | `off(ε, listenerFunc)` | that function, from every event and under every context, plus the registrations that draw it as some *other* listener's context, `on(ε, name, other, fn)` (both since v6.0.0) | untouched | | `off(ε, listenerFunc, context)` | that function, only where it was registered with exactly that context | untouched | | `off(ε, listenerObject)` | every subscription of that object — object alone, method name, and either shape carrying it as a context, `on(ε, name, fn, obj)` and `on(ε, name, obj, ctx)` | untouched | | `off(ε, listenerObject, context)` | that object, only where it was registered with exactly that context — the narrowing form of the row above | untouched | | `off(ε, eventName, listenerObject)` | only that object, on that one event | value **and** policy for that name — even when a sibling listener survives | | `off(ε, '*', listenerObject)` | only that object's wildcard subscription | untouched | | `off(ε, [eventName, …], listenerObject)` | **nothing** | untouched — a complete no-op | | the `on()` / `once()` handle | its own listener(s) only | untouched | Three rows to watch: - **`off(ε, undefined)` wipes the emitter.** `undefined == null`, so it takes the bare `off(ε)` branch. Wrapping it in an array does not contain it: each element is processed on its own, so `off(ε, ['foo', undefined])` wipes too. A name list built at runtime is the realistic way in — filter it, or keep the handle. - **`off(ε, eventName, listenerObject)` unretains the whole name**, even though it detaches one object's subscription and leaves siblings running. Intended API, unchanged since v4.0.0 — the call resets the event, not just the subscription. Keep the `on()` handle and call that if you want the detach alone. - **`off(ε, [eventName, …], listenerObject)` does nothing at all.** The store has no array-plus-object form. Use `off(ε, [names])` without the object, or `unretain(ε, [names])`. ## Handles `on()` and `once()` return `() => void`. No properties, and a second call is inert — which matters because `on()` and `once()` both aggregate onto one registration, so two handles can share it and a double call must not take the sibling's down with it. A call releases two things: - **the emitter, unconditionally** — the closure's capture is nulled on the first call, so a spent handle held in an array pins nothing: not the emitter, not the registry, not the retained-event log, not any retained payload; - **the listener, only when it actually left the store** — if the call merely decremented a shared reference count, or discharged one of several pending obligations, the listener stays registered and populated. A `once()` handle has a second way to be spent, and it is the usual one: the dispatch that discharges its obligation releases the capture too, whether or not anyone calls the handle. A fired `once()` therefore holds nothing from the moment it fires. ```js const subs = []; subs.push(on(ε, 'foo', service)); subs.push(once(ε, 'bar', service)); // teardown: subs.forEach((unsubscribe) => unsubscribe()); ``` **A handle you have not spent pins the emitter — by design.** The array above is as leaky as any array of live references if `forEach` never runs. A `once()` whose event actually fired is the exception: discharging its obligation releases the handle's capture, so it holds nothing afterwards without anyone calling it. A `once()` that ends any other way never reaches that discharge — `off(ε, 'foo')` before the event fires detaches the listener and leaves the handle pinning the emitter. `off(ε, listenerObject)` is the alternative: it removes every matching registration in one call, however many handles they were split across. ### Reference counting decides the real release point ```js const h1 = on(ε, 'foo', service); // refCount = 1 const h2 = on(ε, 'foo', service); // same subscription → refCount = 2 h1(); getSubscriptionCount(ε); // => 1 — not detached yet h1(); getSubscriptionCount(ε); // => 1 — a consumed handle is inert h2(); getSubscriptionCount(ε); // => 0 ``` The retention window belongs to the registration, not to the handle: until the count hits zero, `service` stays reachable through the still-registered listener. Since v6.0.0 a `once()` on the same identity aggregates onto that very listener rather than registering its own — it adds a pending obligation instead of a count, and the listener lives while either is outstanding. ## When a `once()` is actually spent Only when a dispatch called something. An event name that matches nothing on the listener object leaves the subscription in place — which is what makes a late-bound handler work. A name whose only match is an inherited `Object.prototype` member counts as no match, so `once(ε, 'toString', {})` stays subscribed. A listener object with an `.emit()` method is answered by that fallback and released. A throwing listener *was* called, but the auto-unsubscribe runs after the call returns and a throw never returns — so a one-shot that throws survives and fires again on the next matching `emit()`, until one invocation completes normally. **The same "settle only after return" rule lets a `once()` fire twice a different way: a callback that re-emits its own event before returning.** The listener is still fully subscribed at that point, so the nested `emit()` dispatches to it again. Not a separate defect — it is the deliberate absence of a recursion guard (`A → B → A` forwarding and same-event re-emission already overflow the stack by design) meeting the throwing-listener rule above; both are intended, and a `once()` firing twice under self re-emission is the two of them combined, not a new decision. ```js let calls = 0; once(ε, 'ping', () => { calls += 1; if (calls === 1) emit(ε, 'ping'); // re-entrant — this once() hasn't settled yet }); emit(ε, 'ping'); calls; // => 2 ``` A callback that knows it re-enters settles itself: the handle `once()` returned detaches the listener straight away, so releasing it ahead of the nested `emit()` leaves nothing for that emit to reach. ```js let calls = 0; const unsubscribe = once(ε, 'ping', () => { calls += 1; unsubscribe(); // now, rather than after the return if (calls === 1) emit(ε, 'ping'); }); emit(ε, 'ping'); calls; // => 1 ``` ## `onceAsync` cancellation ```js const controller = new AbortController(); const promise = onceAsync(ε, 'never-fires', {signal: controller.signal}); controller.abort(); await promise; // rejects — name: 'AbortError' getSubscriptionCount(ε); // => 0 ``` Aborting unsubscribes and rejects with the signal's `reason`, or with an `AbortError` `DOMException` when it has none (including `abort(null)`, where `fetch()` would hand back the bare `null`). An already-aborted signal rejects without subscribing. An argument error — an empty array of event names, a `NaN` priority, an `options.signal` that isn't shaped like an `AbortSignal` — throws synchronously out of `onceAsync()` at the call site rather than rejecting, so a fire-and-forget call with no `await`/`catch` fails at the mistake instead of becoming an unhandled rejection, and a malformed `signal` never leaves a subscription behind. The signal-shape check runs first, ahead of even the abort check, so an `options.signal` missing `addEventListener` always throws — it never gets the chance to reject with an abort reason, even if it also happens to already read `aborted: true`. The abort check in turn runs before `once()`'s own validation gets a chance to, so `onceAsync(ε, [], {signal: alreadyAborted})` rejects with the abort reason instead of throwing the empty-array error — the checks race in a fixed order. **Without a signal, an `onceAsync()` on an event that never fires is a leak by construction**: the listener, the `resolve` closure and the caller's whole `await` continuation stay attached for the emitter's lifetime, and there is no handle to release them. Pass a signal tied to the caller's own teardown whenever the event might legitimately never come. **A signal does not help against `off()` clearing the emitter from the other side.** `off(ε)` (or any form reaching the internal `once()`) empties the store, but the abort listener lives on the `AbortSignal`, not on the emitter — `off()` has no way to know the signal exists. The promise and everything its closure captured, emitter included, stay alive until the signal itself fires or is garbage collected: ```js const promise = onceAsync(ε, 'foo', {signal: controller.signal}); off(ε); // clears the store — the promise is still pending afterwards getSubscriptionCount(ε); // => 0 controller.abort(); await promise; // rejects only now ``` A signal shared across many emitters accumulates one such orphaned promise per emitter that got `off()`'d instead of aborted. Give a signal that might outlive its emitter its own `AbortController`, and call `abort()` from whatever code retires that emitter. ## Dynamically generated retain names Supported, with no eviction, no cap and no TTL — the library does not get to guess which generated name is still needed. The log grows by one entry per distinct name retained *and* emitted, however often it is re-emitted afterwards. Cleanup is the caller's job: `unretain(ε, name)` for one, `unretain(ε, '*')` for all. ## Verifying cleanup ```js getSubscriptionCount(ε); // listeners registered, named + wildcard getSubscribedEventNames(ε); // which names still have a listener, wildcard as EVENT_CATCH_EM_ALL, order unspecified getRetainedCount(ε); // event names holding a retained value getRetainedEventNames(ε); // every name carrying a retain policy, fired or not ``` `getSubscribedEventNames(ε).length === 0` agrees with `getSubscriptionCount(ε) === 0` exactly, and names what's left when it doesn't — the listener-side counterpart to `getRetainedEventNames()`. `getRetainedEventNames(ε).length >= getRetainedCount(ε)` always holds. All four return `0` / `[]` for a non-eventized object rather than throwing. Calling handles back only empties the listener registry. A teardown that stops there leaves the retained-event log exactly as full as it was — add `unretain(ε, '*')`, or use a single `off(ε)` to cover both halves.