How it works
Read ForBodyEvaluation as a loop with a fixed order of steps:
ForLoopEvaluation evaluates let i = 0 in an initial loopEnv, then calls ForBodyEvaluation.
CreatePerIterationEnvironment runs once before the first test. It builds a new Declarative Environment Record whose outer environment is the same as the old one's, creates a binding for each name in perIterationBindings, and initializes it with the previous record's current value.
- Each pass then runs, in the current record: the test, the body, a new
CreatePerIterationEnvironment, and finally the increment.
Two consequences follow from that order. The increment never touches the record the body used, and the body's last write to i is what gets copied forward. For const declarations, ForLoopEvaluation passes an empty perIterationBindings list, so no copy is made, which is the spec reason for (const i = 0; …; i++) increments the one immutable binding and throws.
You can see the order by putting closures in the other clauses:
const fns = [];
for (let i = 0; i < 3; fns.push(() => i), i++) {}
console.log(fns.map((fn) => fn())); // → [ 1, 2, 3 ]
const inTest = [];
for (let i = 0; (inTest.push(() => i), i < 3); i++) {}
console.log(inTest.map((fn) => fn())); // → [ 0, 1, 2, 3 ]
const bodyEdits = [];
for (let i = 0; i < 6; i++) {
bodyEdits.push(() => i);
i++; // changes this iteration's `i`, then the value is copied forward
}
console.log(bodyEdits.map((fn) => fn())); // → [ 1, 3, 5 ]
The closure in the increment clause is created after the new record exists and before i++, so the increment then mutates the very record it captured: it sees 1, 2, 3, not 0, 1, 2. The closure in the test is created in the record the body will use, so it keeps that iteration's final value, and the fourth test (which fails) adds a record with 3.
Edge cases
- Mutating the counter in the body is visible later. The
bodyEdits loop above skips by two because the in-body i++ is copied into the next record, and each closure keeps the value its own record ended with.
for…of and for…in are different machinery. With a let or const head, ForIn/OfBodyEvaluation creates a new Declarative Environment Record for every iteration and binds the next value into it. There is no copy because there is no increment, so for (const x of list) is safe; a var head is still one binding.
- A
var declared in the body is still one binding per function call, whatever the loop head does, as the first exercise shows.
Real-world scenario
The classic production form of this bug is attaching listeners in a loop and reading state when the event fires, long after the loop ended. The handler isn't slow or flaky, it just reads the final counter, and the symptom (undefined or the last item) looks like a data problem:
const buttons = [new EventTarget(), new EventTarget(), new EventTarget()];
const labels = ['save', 'undo', 'redo'];
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', () => console.log('var:', labels[i]));
}
for (let j = 0; j < buttons.length; j++) {
buttons[j].addEventListener('click', () => console.log('let:', labels[j]));
}
buttons[0].dispatchEvent(new Event('click'));
// → var: undefined
// → let: save
With var, the first button's handler reads labels[3], which is undefined. The example uses Node's EventTarget so it runs without a browser. Prefer iterating over the data (labels.forEach((label, index) => …) or for (const [index, label] of labels.entries())) so each handler captures its own argument instead of a shared index.
Trade-offs
A let loop head is the smallest change and keeps index-based control (skipping, reverse iteration, early exit). for…of or array methods remove the counter entirely, so there is no mutable shared state to get wrong, at the cost of needing entries() when you want the index. An IIFE or .bind per iteration is a pre-ES2015 workaround that adds a function call and noise in code that can use let. Keep any mutation of the loop variable inside the body out of loops whose closures matter, since the next record inherits it.