Summary
Closures created inside the body of a for(;;) or for-of loop that is itself inside a generator lose their per-iteration binding once any yield in the body is resumed. All closures end up reading the same iteration's value (typically iter 0's). Interpreter only — bytecode mode is spec-conformant.
Why
ES2026 §14.7.4.4 requires let/const for-init bindings to pin per-iteration: the textbook fns.push(() => i) case yields [0, 1, 2], not [3, 3, 3]. That works correctly in Goccia without yield in the body. Adding yield does not change the closure-pinning semantics per spec, but in the interpreter it does — the closures end up collapsed onto the same scope.
Reproducer
const obj = {
*gen() {
const fns = [];
for (let i = 0; i < 3; i++) {
fns.push(() => i);
yield i;
}
return fns.map(f => f());
},
};
const g = obj.gen();
g.next(); g.next(); g.next();
console.log(g.next().value); // expected [0, 1, 2], actual [0, 0, 0]
Run with --compat-traditional-for-loop. The same pattern with for (const i of [0, 1, 2]) (no compat flag needed) also returns [0, 0, 0] in the interpreter.
Current behavior
All closures captured across the generator's iterations return the same value.
Expected behavior
Each closure returns the iteration's value at capture time, matching what the same code produces without a yield in the body and what the bytecode VM produces.
Scope notes
Summary
Closures created inside the body of a
for(;;)orfor-ofloop that is itself inside a generator lose their per-iteration binding once anyyieldin the body is resumed. All closures end up reading the same iteration's value (typically iter 0's). Interpreter only — bytecode mode is spec-conformant.Why
ES2026 §14.7.4.4 requires
let/constfor-init bindings to pin per-iteration: the textbookfns.push(() => i)case yields[0, 1, 2], not[3, 3, 3]. That works correctly in Goccia withoutyieldin the body. Addingyielddoes not change the closure-pinning semantics per spec, but in the interpreter it does — the closures end up collapsed onto the same scope.Reproducer
Run with
--compat-traditional-for-loop. The same pattern withfor (const i of [0, 1, 2])(no compat flag needed) also returns[0, 0, 0]in the interpreter.Current behavior
All closures captured across the generator's iterations return the same value.
Expected behavior
Each closure returns the iteration's value at capture time, matching what the same code produces without a
yieldin the body and what the bytecode VM produces.Scope notes
update; here the closure is in the body and the trigger is yield.for-ofand traditionalfor(;;)in the interpreter; bytecode VM is correct in both.EvaluateBlock'sfinally-clause Free of the block scope on yield exception, or a scope-chain reuse path when the body resumes.