Compatibility

Does Obfuscation Break addEventListener Options?

The third argument to addEventListener used to be a boolean and is now a dictionary, and that change quietly moved four important behaviours -- fire once, passive, capture phase, and removal by signal -- out of the language and into a set of well-known property names. Names in an object are rename sites. Behaviours implied by a boolean are not. This article measures what that difference costs.

What was measured

The sample uses a real EventTarget and real Event and CustomEvent objects, all of which are platform types in current Node. It registers one listener with { once: true } and one without, dispatches twice, and records which fired. It dispatches a CustomEvent carrying a detail payload and reads fields out of it. It dispatches a cancelable event, calls preventDefault, and checks both the return of dispatchEvent and the event's own defaultPrevented. And it registers a listener with a signal so it can confirm the listener stops firing after the controller aborts.

Each of those is a contract in a different direction, which is why they are worth measuring in one file. The options dictionary is a name the platform reads out of an object you built. The detail payload is an object the platform carries without looking inside. defaultPrevented is a name the platform writes onto an object it built. And the methods are the platform's own.

Protected in five configurations -- the default target, the modern target, both gate profiles and the string-transform profile -- every line matched the unprotected run. The once-only listener fired once and the ordinary listener fired twice. The custom payload came through with both fields. The cancelable event was cancelled, so dispatch reported false and the event reported that its default had been prevented. The signal-removed listener fired once and then stopped.

Event type names are string literals, so they are outside every rename site by construction, and there is no syntax here to lower for an older target. As everywhere else in this series, the member column is where the result lives.

A once-only listener that fires every time

With a member pattern matching once, capture and signal, the protected file produced no error and a full set of output. Two lines had changed.

The first: where the source recorded once, always, always across two dispatches, the protected build recorded once, always, once, always. The listener registered with { once: true } fired on both dispatches, because the platform looked for a property called once, found a generated name instead, and registered an ordinary permanent listener.

It is worth being concrete about what that means outside a test file. The once option is how a great deal of application code guarantees that something happens exactly one time: a submit handler that must not double-post, an initialisation routine that must not run twice, a one-shot analytics event, a modal confirmation that resolves a promise. Turning every one of those into a permanent listener does not throw. It duplicates.

The second changed line is the same defect wearing different clothes. The listener registered with a signal for cleanup kept firing after the controller was aborted -- one dispatch in the source, two in the protected build. Removal-by-signal is the modern idiom for tearing down listeners when a component unmounts, and it stops working with no symptom other than handlers accumulating.

preventDefault stops preventing anything

A second pattern, matching detail and cancelable, produced a different and equally quiet set of failures. The payload reads went to undefined, which is unsurprising. The interesting part is what happened to cancellation.

In the source, dispatching a cancelable event on which a listener calls preventDefault returns false, and the event reports defaultPrevented as true. In the protected build the same dispatch returned true and the event reported false. The cancelable key never reached the platform, so the event was constructed as non-cancelable, and preventDefault on a non-cancelable event is specified to do nothing at all -- silently, by design.

That is a browser behaviour worth internalising independently of obfuscation: preventDefault has no error path. It is a request that the platform is free to ignore, and it ignores it whenever the event was not constructed as cancelable. Renaming the key that makes an event cancelable therefore disables every cancellation in that code path without producing a single diagnostic.

For a custom event system this manifests as validation that no longer blocks, confirmations that no longer stop an action, and drag or navigation handlers that no longer suppress the default. All of it in a build where the console is clean.

The payload is yours; the envelope is not

The most useful positive result in this sample is the boundary between the two. A pattern matching the field names inside the detail payload -- the sample's own sku, qty and n -- measured identical on both targets.

The reason is that the platform never opens that object. detail is carried from the dispatch site to the listener as an opaque value, so the write and the read are both in your code and a consistent rename is invisible. This is the same structure as the held value handed to a FinalizationRegistry, and it is the shape worth looking for when you want to know whether a given name is safe.

The envelope around it is a different matter. A pattern matching type and defaultPrevented -- names the platform writes onto the event object it constructs -- returned undefined for both. Your code is asking a platform-built object for properties it does not have, and the answer to that question is never an error.

So the line runs exactly where you would draw it if you thought about who reads what. Inside detail: your names, safe. The keys of the init dictionary and the properties of the event itself: the platform's names, not safe. The methods, being the platform's too, fail loudly -- a pattern matching addEventListener, dispatchEvent and preventDefault threw TypeError: bus._0x1 is not a function on the first call.

Why this one is worse than it looks

Most compatibility hazards get caught because something throws where a human is watching. This one does not throw, and the two failures it produces are both of a kind that developers habitually attribute to something else.

A handler running twice reads as a race condition. It appears under load, it does not reproduce reliably in a debugger, and the usual response is to add a guard variable -- which works, hides the cause, and leaves every other once listener in the codebase still broken. Listeners accumulating after teardown reads as a memory leak, and profiling it will show exactly that, pointing at the symptom rather than at a renamed key.

Both are also latent rather than immediate. A duplicated one-shot handler is harmless until the day the action behind it is not idempotent, and a leaked listener is harmless until enough of them accumulate. Protection settings changed weeks earlier will not be the first suspect.

The generalisation this series keeps arriving at applies cleanly: renaming is safe when a name is reached only by code the tool can see, and unsafe when the name is part of a contract with something outside the file. An options dictionary is a contract with the platform, and this is the case where breaking that contract produces no error at all.

How to check your own build

One assertion covers most of the risk and it takes three lines. Register a listener with { once: true }, dispatch twice, and assert the handler ran once. If it ran twice, your member pattern reached the options dictionary and every one-shot listener in the build is now permanent.

Add a second for cancellation if your code depends on it: dispatch a cancelable event whose listener calls preventDefault, and assert that dispatchEvent returned false. A true there means the event was built without its cancelable flag.

For teardown, register a listener with a signal, abort, dispatch, and assert nothing ran. That check is worth having anyway, because it is also the one that catches ordinary cleanup bugs that have nothing to do with protection.

The mitigation is the usual one and it is sufficient here: anchor member renaming to names your application owns. once, capture, passive, signal, detail, cancelable, bubbles and type are the platform's names. The contents of your detail payload are yours, and this measurement confirms they stay that way.

Frequently asked questions

Does obfuscation break addEventListener?

Not on its own. Registration, dispatch, custom event payloads, cancellation and signal-based removal all behaved identically across five protection configurations, including both target versions and the string-transform profile. Event type names are string literals and therefore outside every rename site.

Why does my once-only listener now fire every time?

Because member renaming reached the options dictionary. The platform looks for a property called once, finds a generated name instead, and registers an ordinary permanent listener. Measured, a handler that fired once across two dispatches fired twice after renaming, with nothing thrown and nothing logged.

Can renaming stop preventDefault from working?

Yes, indirectly, and quietly. If the cancelable key of the event init dictionary is renamed, the event is constructed as non-cancelable, and preventDefault on a non-cancelable event is specified to do nothing. Measured, dispatchEvent returned true where it had returned false and the event reported defaultPrevented as false.

Are the fields inside a CustomEvent detail payload safe to rename?

Yes. The platform carries detail as an opaque value and never reads inside it, so the write at the dispatch site and the read in the listener are both in your code and a consistent rename is invisible. Measured identical on both targets. The detail key itself is a platform name and is not safe.

Why do listeners registered with a signal keep firing after abort?

Because the signal key inside the options dictionary was renamed, so the platform never associated the listener with a controller. Removal-by-signal is the common teardown idiom for components, and when it stops working the symptom is handlers accumulating, which profiles as a memory leak rather than as a naming problem.

How much does a duplicated handler actually matter?

It depends entirely on whether the action behind it is idempotent, which is why this failure is dangerous rather than merely annoying. A duplicated analytics ping is noise; a duplicated submit handler is a second order. The failure is also latent, appearing only when the action changes, long after the protection settings that caused it were chosen.

Related reading