Skip to content

compiler/vm: closures over let/const/class declared inside a loop body share one binding instead of getting a fresh one per iteration #132

Description

@nooga

Bug

Found while verifying the fix for #128. A closure created inside a loop body that captures a let/const/class declared inside that same loop body sees the same, final shared value for every closure, instead of the value from its own iteration. This is the classic "let in a for-loop" JS pattern that block-scoping is specifically designed to support, and it's broken for both plain variables and classes.

Confirmed present on main (39ad7e4), independent of #128 (i.e. this isn't something introduced by the #128 fix — it reproduces identically before and after).

Repro 1: plain let

function f() {
  let out = [];
  for (let i = 0; i < 3; i++) {
    let x = i * 10;
    out.push(() => x);
  }
  return out.map(g => g());
}
console.log(f());

Expected: [0, 10, 20] (each iteration's let x is a fresh binding closed over independently).
Actual: [2, 2, 2] (all three closures read the same final slot).

Repro 2: class declared inside the loop body

function f() {
  let out = [];
  for (let i = 0; i < 3; i++) {
    class C { static id() { return i; } }
    out.push(() => C.id());
  }
  return out.map(g => g());
}
console.log(f());

Expected: [0, 1, 2].
Actual: [2, 2, 2].

(Before #128 landed, this repro didn't even get this far — it crashed with TypeError: undefined is not a function because the class name wasn't visible to the nested closure at all. After #128, it runs but returns the wrong per-iteration values, which is what this issue is about.)

Notes

  • Sibling (non-loop) blocks reusing the same name are unaffected — e.g. if (flag) { let x = 1; ... } else { let x = 2; ... } resolves correctly. The bug is specifically about iteration of the same block, not block scoping in general.
  • This is likely a significant real-world blocker: "register an event handler / push a closure per loop iteration that captures the loop-scoped variable" is an extremely common JS pattern (was the textbook reason let was added to the language over var).
  • Given the shared-slot behavior, the loop's per-iteration environment (the "CreatePerIterationEnvironment" step in the for statement spec) doesn't appear to be implemented — each iteration's block-scoped declarations should get a fresh copy in a for (let ...) loop, seeded from the previous iteration's values.

Not filed against noderati; this is a paserati compiler/VM gap.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions