Compatibility
Published
This is the article that four other pages on this site have been pointing at. The pages on recursion, closures, streams and async/await each measured their own subject, found it clean, and then said in as many words that generators and lazy iteration go through separate machinery and were not covered. They were being careful rather than pessimistic, and the caution turns out to have been justified: on the default output target, lazy iteration is the one surface in this whole series where the base configuration -- no options, nothing enabled -- changes what your program does.
What was measured
The sample is seven small programs in one file. A for-of loop over a finite generator. A loop that breaks early, with a counter inside the generator recording how many values were actually produced. A generator that pushes a marker as it produces and a loop body that pushes a marker as it consumes, so the interleaving is visible. A hand-written iterable with its own Symbol.iterator. A conversion through Array.from. An iterable with a return cleanup hook, exited early. And an infinite generator consumed by a loop that breaks after three values.
That last one deserves a note on method. It has no assertion attached to it, because reaching the line after the loop is the assertion. An infinite source consumed lazily terminates; an infinite source consumed eagerly cannot. Writing the case that way meant the measurement could not report a comfortable false negative.
Each program was protected in five configurations: the default output target, the modern output target, both gate profiles and the string-transform profile. The modern target and its gate variant came back byte-identical to the original on every one of the seven cases. The default target, its gate variant and the string profile changed four of the seven.
The values themselves were never wrong. A finite generator iterated to completion produced 1,2,3,4 before and after. The hand-written iterable produced a,b,c before and after. If your loops run over finite sources with no side effects and you only look at the values, this defect is invisible, which is precisely why it is worth writing down.
The rewrite, read out of the emitted file
The default target does not support for-of directly, so it lowers it. The emitted file contains a helper that every rewritten loop calls first. Stripped of its generated names, it does this: if the argument is null, or if it already has a numeric length, hand it straight back. Otherwise, if it has a Symbol.iterator, run the iterator to exhaustion, push every value into a fresh array, and hand back the array. The loop itself then becomes an ordinary indexed for loop over that array.
Everything follows from that one design. The length check is why arrays are completely unaffected: an array is handed back as itself, untouched, and the indexed loop over it behaves exactly as the original. Strings and array-like objects take the same fast path. So the large majority of real loops in real code go through this transform and come out perfect.
Anything else -- a generator, a Map, a Set, a custom iterable, anything whose values are produced on demand -- takes the second path and is fully drained into an array before the loop body executes a single time. For a Map or a Set that is merely wasteful and completely invisible, because they are finite, already in memory, and produce no side effects when read. For a generator it is a different program.
The transform is also where a second, quieter thing goes missing. The drain loop calls next until the iterator reports it is done. It never calls return. There is no place in an indexed loop over a finished array where a cleanup hook could be called, because by the time the loop starts, iteration is already over.
Four measured symptoms, in order of how loudly they fail
The loudest is the infinite source. The protected program did not hang and did not silently produce a wrong answer; it threw RangeError: Invalid array length from inside the drain helper. That is the good failure mode, in the sense that it stops. It is a poor failure mode in the sense that it happens after the process has spent time and memory filling an array it will never use, and the message says nothing about iteration.
The next is the count. The loop that breaks after three values was measured pulling three values from its generator before protection and 100 after -- every value the generator could produce. A break in a rewritten loop exits the indexed loop. It cannot un-produce values that were generated before the loop began. If the generator reads a file per value, calls an API per value, or advances a cursor per value, all of that work happens, and it happens whether or not the loop body ever asks for it.
The third is ordering, and it is the one most likely to be misdiagnosed. The unprotected run interleaved production and consumption exactly as written: produce 1, consume 1, produce 2, consume 2, produce 3, consume 3. The protected run produced all three, then consumed all three. No value was lost and no value was wrong. But any generator whose production step touches shared state that the loop body also touches now sees that state in a different order, and the failure will surface somewhere else entirely, as a data bug with no obvious connection to the loop.
The fourth is the quietest and has no symptom at all at the site of the problem. The iterable with a cleanup hook reported cleanup-ran=yes before protection and cleanup-ran=no after. Nothing was thrown and no value changed. If that hook closes a file handle, releases a connection or clears a lock, the resource simply stays held, and the only evidence is the resource itself, much later.
The modern target is clean, and that is the practical fix
All five of the symptoms above belong to the default output target and the two profiles built on it. The modern target and its gate variant were byte-identical to the original on every case in this sample, including the infinite generator and the cleanup hook, because there is no lowering to do: the modern target emits for-of as for-of and the language runs it.
So the mitigation is a build setting rather than a code change. If your deployment targets browsers and runtimes that support for-of natively -- which is every browser released in roughly the last decade -- selecting the modern output target removes this entire class of behaviour, and removes it for all of the symptoms at once rather than one at a time.
If you are pinned to the older target for a reason, the code-level workaround is to stop asking a for-of loop to be lazy. Drive the iterator by hand with an explicit while loop calling next, which is a shape the transform does not touch, or convert deliberately with something that takes a count. Both are more verbose and both do exactly what they say.
Do not reach for Array.from as the workaround. It converts eagerly by definition, so on an infinite source it fails the same way, and on a side-effecting source it fires everything -- it just does so visibly, on a line you wrote, which is at least honest.
One caveat about this page rather than about your code. The lowering behaviour described here is a tracked defect rather than a designed trade-off, it was re-measured against a freshly built engine on the day this article was published, and it is expected to be fixed. Treat the four symptoms above as a dated measurement, not as a permanent property of the older target, and re-run the counter check on your own build rather than assuming this page is still current.
Member renaming reaches the protocol itself
The iteration protocol is a set of names the language reads: Symbol.iterator, then next, then value and done on the object next returns. A symbol is never a rename site, but the other three are ordinary property names, and if a member pattern matches them the language stops finding what it needs.
Measured on the modern target, so that the lowering above is out of the picture: a pattern matching next broke the hand-written iterable with TypeError: undefined is not a function. A pattern matching value and done produced RangeError: Invalid array length, because a done that always reads undefined is always falsy and the loop never ends. Both are loud, immediate and easy to trace.
One arm was neither loud nor immediate. On the modern target, a pattern matching only return left every value, every count and every ordering correct, and changed exactly one thing: cleanup-ran went from yes to no. The declaration in the sample is a quoted string key, and it was still renamed -- the emitted file shows "_0x1":function(){...} where the source says "return". There is no matching access site anywhere in the file to keep consistent, because the caller is the language.
That is worth separating from the keyword-named-member behaviour this site documents elsewhere, which is about an access site being written in quoted form and therefore surviving. Here there is no access site at all. Quoting a declaration key does not protect it, and a protocol method is the one kind of member whose only caller is guaranteed to be someone other than you.
One over-broad pattern, and how to check your own build
The control arm in this sample was supposed to be boring. It matched two names the file owns, items and from, on an object built for the purpose. It threw TypeError: Array._0x2 is not a function, because from is also the name of Array.from, and member renaming is name-based rather than type-aware. A pattern written to describe your own data model reached a built-in constructor's static method in the same file.
That is a general hazard rather than an iteration one, but iteration is where you meet it, because the short conversion names -- from, of, keys, values, entries, next -- are simultaneously plausible field names and real parts of the standard library. Anchor member patterns to names that are distinctive to your application, and prefer a prefix or a suffix over a bare common word.
To check the lowering behaviour in your own build, take the smallest generator you have, put a counter inside it, loop over it with a break, and compare the counter before and after protection. One number, one comparison. If it changed, you are on the default target and the rest of this article applies to you.
To check the protocol behaviour, assert on quantity rather than on the absence of an exception -- count the values a loop actually consumed and compare the runs. A loop that produces the right values in the wrong order, or that holds a resource it should have released, throws nothing at all, so a test that only asks whether an error occurred will pass on every build.
Frequently asked questions
Does obfuscation break for-of loops?
Not for arrays, which are the majority of real loops. On the default output target a for-of loop over an array is rewritten into an indexed loop over that same array and behaves identically, and strings and array-like objects take the same path. The behaviour changes when the source is lazy -- a generator or a custom iterable -- because the rewrite drains it into an array before the loop body runs. The modern output target emits for-of unchanged and was byte-identical to the original on every case measured.
Why does my generator run to completion even though I break out of the loop?
Because on the default output target the generator was already exhausted before your loop started. The rewrite converts a non-array iterable into an array first, then loops over the array by index, so a break exits the indexed loop rather than stopping production. A loop measured pulling three values before protection pulled one hundred after. Switching to the modern output target restores the original behaviour.
Will an infinite generator still work after protection?
Not on the default output target. The rewrite tries to collect every value into an array before the loop begins, which cannot terminate, and the measured result was a RangeError reporting an invalid array length. It fails rather than hanging, but it fails after consuming time and memory, and the error message does not mention iteration. On the modern output target the same program ran correctly.
My iterator cleanup hook stopped running. Is that related?
Yes, and it is the quietest symptom of the same rewrite. The drain loop calls next until the iterator reports it is done and never calls return, so a cleanup hook attached to early exit has nowhere to be called from. Measured, the hook went from running to not running with nothing thrown and no value changed. If the hook releases a handle, a connection or a lock, the resource stays held and the only evidence appears much later.
Does member renaming affect iteration?
It can, because next, value and done are ordinary property names that the language reads by name. Measured on the modern output target, renaming next broke a hand-written iterable with a TypeError, and renaming value and done produced a RangeError because a done that always reads undefined is always falsy. Symbol.iterator is not affected, because a symbol is never a rename site.
Is quoting a property name enough to protect it from renaming?
No. A quoted declaration key is still a rename site: the cleanup hook in this sample is declared as a quoted string and the emitted file shows it renamed to a generated name. What survives renaming is a quoted access site, which is a different thing and is why some keyword-named platform calls keep working. A method whose only caller is the language has no access site in your file to stay consistent with.
Which loops should I test first after enabling protection?
Any loop whose source produces values on demand rather than holding them: generators, database or file cursors, paginated fetchers, and any custom object with its own Symbol.iterator. Put a counter inside the producer, break early, and compare the count across builds. Loops over arrays, strings and array-like objects need no special attention, and loops over a Map or a Set are drained but produce identical results because they are finite and free of side effects.
Related reading