Correctness
Published
A custom element is an unusually good obfuscation target — it is self-contained, it is often the thing you actually sell, and it is frequently embedded on pages you do not control. It also has an unusually large number of names that are not yours. The browser calls some of your methods by name, and reads some of your object keys by name, and it does this without ever appearing in your code as a caller. Rename one of them and the failure shows up nowhere near the rename.
Why this class of bug is quiet
Ordinary renaming is safe because the tool can see both ends. It renames a declaration and every reference to it in the same pass, and the program is unchanged. The unsafe cases are the ones where the other end is not in the file — a server field name, a template binding, a storage key.
Custom elements add a category most exclusion lists miss: the other end is the platform. When you write connectedCallback() { … } nothing in your source ever calls it. The browser does, by that exact string, when the element enters the document. To an obfuscator, an uncalled method looks like a private implementation detail — the ideal rename candidate. Afterwards your element is inserted into the page, nothing runs, and there is no error at all. It simply does not initialise.
This only bites when member renaming is switched on. Name mangling of local variables cannot cause it, because a method name on a class is not a local. It is Protect Members and its equivalents in other tools that reach these names — which is exactly the option that page warns needs an opt-in list rather than an opt-out one.
The platform-owned names
Reserve all of these. They are called or read by the browser itself:
connectedCallback, disconnectedCallback, adoptedCallback, attributeChangedCallback — the custom element lifecycle.
observedAttributes — a static getter the browser reads once at definition time to decide which attribute changes to report. Rename it and attributeChangedCallback stops firing even though it survived intact.
formAssociated and the form lifecycle callbacks — formAssociatedCallback, formDisabledCallback, formResetCallback, formStateRestoreCallback — if your element participates in forms.
- Anything you implement to satisfy a platform interface:
handleEvent on an object passed to addEventListener, toJSON, then, iterator methods, and the well-known symbol protocols.
Note that the tag name is a different matter. customElements.define('acme-chart', AcmeChart) passes a string literal, and a string is still the same string after being moved into a table or encoded — the value that reaches the registry is unchanged. Renaming the class AcmeChart is also fine, because the registry never sees the class name. It is the method names, not the tag, that need reserving.
The trap almost nobody lists: dictionary keys
Every browser API that takes an options object reads that object’s keys by name. Web IDL calls these dictionaries, and they are ordinary object literals in your code with no marker of any kind saying “the platform reads this”.
this.attachShadow({ mode: 'open', delegatesFocus: true });
this.dispatchEvent(new CustomEvent('acme-change', {
detail: { total },
bubbles: true,
composed: true
}));
new IntersectionObserver(cb, { rootMargin: '0px', threshold: 0.5 });
Rename mode and attachShadow receives a dictionary with no recognised member, which is not an error — unspecified members take their defaults, and a missing mode is a TypeError only because mode happens to be required. Rename composed and there is no error at all: your event simply stops crossing the shadow boundary, so every consumer listening on the host page silently receives nothing. That is a support ticket that takes a day to trace and looks nothing like an obfuscation problem.
The same applies to detail. It is a Web IDL member on the way in, and it is a public contract on the way out, because the host page reads event.detail.total. Both halves of that path are outside your protected code.
Your own public contract
Beyond the platform names, a component library has a documented surface that consumers depend on. Reserve it deliberately rather than discovering it:
- Reflected properties. If
<acme-chart max-value="10"> maps to this.maxValue, the property name is half of a public API and the attribute string is the other half.
- Public methods. Anything a host page calls on the element —
refresh(), reset(), exportData().
- Event detail shape. Every field a consumer reads off your
CustomEvent.
- Slot and part names.
<slot name="header"> and part="legend" are matched from the host’s markup and CSS. They live in template strings and stylesheets, not in renameable identifiers, but if your JavaScript builds them dynamically the pieces need reserving.
- Framework statics. Library-based components declare metadata the library reads by name — a static
properties or styles field, a render method, a createRenderRoot hook. The library resolves these as strings, so they are contracts even though they look internal.
What is safely protectable
This is not a list of reasons to skip protection — the valuable part of a component is almost never its lifecycle plumbing. What remains after reserving the above is usually the entire point of the library:
- Private methods and fields, especially
#private class fields, which no external caller can touch by definition.
- Layout, rendering and diffing algorithms — typically the largest and most copyable part of a chart, editor or grid component.
- Licensing and entitlement checks. A component embedded on a customer’s page is exactly the case where a readable gate gets removed; protect that path hard, and consider VM protection for the check itself.
- String literals throughout, including your internal messages — moving them into a table means searching the bundle for a label no longer lands on the logic behind it.
- Control flow inside your own methods, which is unaffected by any external contract.
The practical configuration is the one Protect Members recommends generally: mark private members by convention, rename only those, and leave everything else alone. For a component library that inverts the risk completely — instead of discovering the platform’s names by breaking them, you never touch them. Inline directives let you go further and protect only the module holding the algorithm, leaving the element class itself readable.
Testing a protected component
Unit tests that import the class and call methods directly will not catch any of this, because they call the names as written. The test has to go through the DOM:
- Insert the element into a document and assert it initialised — this covers
connectedCallback.
- Change an observed attribute after insertion and assert the element reacts — this covers
observedAttributes, which is the one most likely to fail silently.
- Listen on the host document for your custom event and assert it arrives with its detail intact — this covers
composed, bubbles and detail in one go.
- Remove the element and assert cleanup ran.
- Load the protected build on a plain HTML page with no bundler, which is how most consumers will use it.
Run that suite against the protected artifact, not the source. If you are chasing an intermittent difference between runs, protect with a fixed seed so the output stops moving underneath you.
The short version
Web components obfuscate well, but they widen the definition of “public name” further than most projects. The browser calls your lifecycle methods by name and reads your options-object keys by name, and neither appears as a caller in your source. Reserve the lifecycle callbacks, observedAttributes, the form callbacks, every Web IDL dictionary key you pass to a platform API, and your documented element surface. Protect the algorithm, the licensing path and the strings — which is where the value was all along.
Related reading