Compatibility
Published
Most advice about member renaming ends at a familiar warning: protect your files together or they will not agree on names. That is correct, and it undersells the failure. Measured across a real module boundary, two files protected separately did not fail to find each other's properties. They found the wrong ones, in order, with every value present and nothing thrown.
The experiment, and why an ordinary test arm could not produce it
Every other measurement in this series runs one file through the protector and compares the output. That design cannot answer a question about two files, so this one was built for the purpose: a small module that constructs a record with four fields, a second module that requires it and reads those fields, and a runner that protects each file on its own with the same member pattern -- exactly what a per-file build step does.
The second module deliberately declares one extra property above the shared ones, in a helper of its own. That single line is the whole experiment, because it changes the order in which names are first seen in that file. It is also entirely realistic: two modules that share a record almost never mention its fields in the same order.
As a control, the same two pieces of code were protected together in a single run, as separate script chunks of one page, and the same reads were performed. The unprotected version of the same code is the third column.
Every value arrives one slot out of step
With each file protected separately, the reads across the module boundary were all wrong, and none of them was empty. The product code read back the quantity. The quantity read back the unit price. The unit price read back the currency string. The computed total, which multiplies two of those, came out as NaN.
The reason is visible in the emitted files. Generated member names are handed out from a counter, in the order the names are first encountered, and each run starts its counter over. In the first file the shared fields are seen in their natural order, so they take the first four names. In the second file the extra helper property is seen first, so it takes the first name and everything after it shifts by one. Both files then use the same four generated identifiers, and each file means something different by them.
That is worse than a mismatch. A mismatch would produce undefined, which is a value people recognise as a symptom. A shift produces real data of the right type in the wrong field, which passes type checks, renders in a template, gets stored, and is investigated as a data-entry problem. The reads inside each individual file stayed correct throughout, which removes the last obvious clue: the module that builds the record can describe it perfectly.
The property names visible on the object confirm the collision directly. Both objects report the same four generated names, and both are internally consistent. Nothing in either file is broken. The disagreement exists only in the space between them, which is the one place no file can see.
Protecting the same code in one run was correct in every read
The control run settles what to do about it. With both pieces of code protected together, the generated names were assigned once and shared: the product code took the same identifier in both halves, and the extra helper property in the second half took the next unused name rather than reusing an existing one. Every cross-boundary read returned the right value and the computed total was correct.
The mechanism behind that is the same counter. One run keeps one map, so the second time a name is seen it resolves to the identifier already chosen for it. Two runs keep two maps that both begin at the same starting point, which is precisely why their names collide instead of merely differing.
This is what the member renaming documentation means when it says to keep every related file in the same project. The measurement adds a reason to take it seriously: the penalty for ignoring it is not a build that visibly fails, it is a build that runs and lies.
Which shapes are exposed, and which are not
The exposure follows the boundary, not the file count. Any object that is constructed in one protected unit and read in another is affected: a record passed between modules protected separately, a message posted to a worker whose script was protected in its own run, a payload sent between a page bundle and an independently protected widget, and anything stored by one build and read by another.
Two categories are unaffected. Names reached through strings survive, because a string literal is not an identifier -- a lookup table keyed by strings, a required-field list, a field name embedded in a template. And anything both written and read inside the same protected unit is renamed on both sides consistently, which is why in-module behaviour looks perfect while the boundary is broken.
Export and import names are a separate matter with their own article on this site, and they behave differently: a module's exported name is part of a linkage the loader performs, rather than a property read at run time. The failure measured here is about the fields inside the objects that cross the boundary, not about the names the modules are linked by.
A test that cannot detect this, and one that can
There is a specific check to avoid, because it passes in exactly the situation it was written to catch. Comparing one read of a field against another read of the same field -- a value fetched twice, an object compared against a copy of itself, a round trip that writes and reads through the same code -- compares two renamed reads. Under renaming both sides return the same wrong thing, so the comparison succeeds. The same trap appeared elsewhere in this pass's measurements, where a key check comparing an exported value against its own source passed while both sides were undefined.
The check that works compares a value against something written outside the build. Take the record across the boundary and compare one field to a literal you typed, or to a value the other side chose. Print the key list of the object as it arrives and the key list as it was built. If both sides show generated names and the values are wrong, you are looking at the shift described here rather than at a data problem.
For a build pipeline, the structural answer is simpler than any test. Give the protector every related file in one run so the name map is shared, and if some file genuinely must be protected separately, keep the member pattern away from every property that crosses the boundary. A prefix convention for private members enforces that automatically, because a shared record's fields will never carry the prefix.
Why this is hard to spot in review, and easy to reproduce
Read either file on its own and it is obviously correct. The builder assigns four fields and returns them; the consumer reads four fields and multiplies two of them. The protected output is equally coherent: each file uses a consistent set of generated names throughout. There is no line anywhere that a reviewer could point at, because the defect is a relationship between two artefacts rather than a property of either.
The reproduction is small enough to run before adopting member renaming at all. Write a module that returns a record, write a second module that reads it, declare one extra property in the second module above the shared ones, protect each file in its own run, and print the values the second module reads. If they are shifted rather than empty, your pipeline is protecting per file.
Adding the extra property is the part people leave out, and it is what turns a mismatch into a shift. The same experiment was run a second time with that line removed, so both files saw the same names in the same order, and every cross-boundary read came back correct with the total right -- while both files still used generated names throughout. In other words a per-file pipeline can pass this test today and be one added property away from swapping values silently, which is the most dangerous result on the page precisely because it looks like a pass.
The upgrade case is the same failure with a longer fuse
The same counter behaviour has a second consequence that this site has measured before: two builds of the same code base assign names in source order, so adding a property above existing ones shifts every later name. A record written to disk by one release and read by the next then lands one slot out of step, with every value present and attached to the wrong name.
The version measured here is the sharper one, because it needs no upgrade and no user data. It happens on the first deploy, between two files of the same release, and the only requirement is that they were protected in separate runs. Anything that survives across releases -- a stored preference, a cached document, a queued job -- carries the longer-fused variant.
Both come from the same design and have the same fix. Names are per run and positional. Keep everything that must agree inside one run, and keep everything that crosses a boundary outside the pattern.
Frequently asked questions
Can I obfuscate my JavaScript files one at a time?
You can, but not with member renaming turned on for names that cross between them. Measured here, two files protected in separate runs each restarted the generated-name counter, so the same identifier meant a different property in each file and every cross-file read returned the wrong value. Protecting the same code in one run was correct in every read.
What actually happens when two files are protected separately?
Their generated names collide rather than merely differ. Names are assigned from a counter in the order they are first seen, and each run starts over, so a file that mentions an extra property first shifts every later name by one. In the measured run the product code read back the quantity, the quantity read back the unit price, and the computed total came out as NaN.
Why is a shifted value worse than an undefined one?
Because it passes every check that a missing value would fail. The data has the right type, renders in a template, satisfies a presence test and gets stored. Nothing is thrown, and the module that built the record can describe it perfectly, so the evidence points away from the build. An undefined value at least announces itself.
Does protecting everything in one run really fix it?
In the measured control, yes. One run keeps one name map, so a name seen a second time resolves to the identifier already chosen for it. The shared fields took the same generated names in both halves, the newly seen property took the next unused name, and every cross-boundary read returned the right value.
Which objects are exposed to this?
Anything constructed in one protected unit and read in another: records passed between separately protected modules, messages posted to a worker script protected in its own run, payloads exchanged with an independently protected widget, and anything stored by one build and read by another. Names reached through strings are unaffected, because a string literal is not an identifier.
My separately protected files work fine. Am I safe?
Possibly only by coincidence. The same experiment run with the consumer declaring nothing above the shared fields produced correct values on both protection profiles, because both files then saw the same names in the same order and their counters happened to agree. Adding one property above the shared ones in either file is enough to shift them apart, so a green result on a per-file pipeline is a snapshot rather than a guarantee.
Is this the same as renaming exported module names?
No, and the distinction matters. An export name is part of the linkage a module loader performs, and it has its own article on this site. What is measured here is the field names inside objects that cross a boundary at run time, which fail differently and much more quietly.
What test detects this in an existing build?
Compare a field against a literal you typed or a value the other side chose, and print both key lists. Do not compare two reads of the same field against each other, because under renaming both sides return the same wrong value and the comparison passes. That trap also appeared elsewhere in this pass, where a check comparing a value against its own source passed while both sides were undefined.
Related reading