Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
118b003
fix: closures in for-loop init capture wrong binding
joelle-a-dev Aug 20, 2026
6dd760a
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Aug 28, 2026
95a1c29
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Aug 29, 2026
f0df800
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Aug 30, 2026
b987fbf
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Aug 31, 2026
c8e9203
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Sep 6, 2026
e1d4b5a
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Sep 7, 2026
0a6014a
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Sep 10, 2026
2b303e1
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Sep 16, 2026
1d07498
fix: place head-closure snapshot right after its binding's declarator
joelle-a-dev Sep 17, 2026
7feaa07
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Sep 17, 2026
974a4e6
fix: remove incorrect claim from head-closure snapshot comment
joelle-a-dev Sep 17, 2026
b325f81
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Sep 18, 2026
f1eb0af
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Sep 18, 2026
4b2dab1
fix: copy head binding after the whole for initializer runs
joelle-a-dev Oct 2, 2026
8d9fcce
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Oct 2, 2026
17af7a2
fix: add proposed test case
joelle-a-dev Oct 4, 2026
94046d2
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Oct 4, 2026
2140ea8
Merge branch 'main' into fix-for-init-closure-capture
joelle-a-dev Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix: closures in for-loop init capture wrong binding
for (let i = 0, f = () => i; ...) creates a closure over i inside
the loop head. That closure should keep seeing the value i had
before the loop started, but the transform let it share the same
variable the loop body mutates, so f() returned the wrong value.

Now that closure gets its own copy of the value, frozen right after
the loop head runs, so it stays correct no matter what the loop body
does to i afterward.

Fixes #18191
  • Loading branch information
joelle-a-dev committed Aug 20, 2026
commit 118b00308a8a1f6c2e8e68b629451993d81a1876
29 changes: 27 additions & 2 deletions packages/babel-plugin-transform-block-scoping/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ export default declare((api, opts: Options) => {
headScope.crawl();
binding = headScope.getOwnBinding(name)!;
}
const { usages, capturedInClosure, hasConstantViolations } =
getUsageInBody(binding, path);
const {
usages,
capturedInClosure,
hasConstantViolations,
headClosureCaptures,
} = getUsageInBody(binding, path);

if (
headScope.parent!.hasBinding(name) ||
Expand All @@ -95,6 +99,27 @@ export default declare((api, opts: Options) => {
name = newName;
}

if (headClosureCaptures.length > 0) {
// A closure created in the loop head (e.g. the `f` in
// `for (let i = 0, f = () => i; ...)`) closes over the
// one-time environment used to evaluate the head, which is
// never updated again once the loop starts iterating. Give
// it its own binding, snapshotted once right after the
// head finishes evaluating, so later mutations of `name`
// inside the loop don't leak into it.
const frozenName = headScope.generateUid(name);
for (const capturePath of headClosureCaptures) {
capturePath.replaceWith(t.identifier(frozenName));
}
headPath.pushContainer(
"declarations",
t.variableDeclarator(
t.identifier(frozenName),
t.identifier(name),
),
);
}

if (capturedInClosure) {
markNeedsBodyWrap();
captured.push(name);
Expand Down
34 changes: 28 additions & 6 deletions packages/babel-plugin-transform-block-scoping/src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,43 @@ export function getUsageInBody(
const seen = new WeakSet<t.Node>();

let capturedInClosure = false;
// References to the binding that occur inside a closure created in the
// loop head (e.g. `for (let i = 0, f = () => i; ...)`). Per spec, such a
// closure captures the one-time environment used to evaluate the head,
// which is never touched again once the per-iteration environments are
// created, so these references must not be treated as regular in-body
// usages of the per-iteration binding.
const headClosureCaptures: NodePath<t.Identifier>[] = [];

const constantViolations = filterMap(binding.constantViolations, path => {
const { inBody, inClosure } = relativeLoopLocation(path, loopPath);
if (!inBody) return null;
capturedInClosure ||= inClosure;
const { inBody, inHead, inClosure } = relativeLoopLocation(path, loopPath);

const id = path.isUpdateExpression()
? path.get("argument")
: path.isAssignmentExpression()
? path.get("left")
: null;
if (id) seen.add(id.node);

if (inHead && inClosure) {
if (id) headClosureCaptures.push(id as NodePath<t.Identifier>);
return null;
Comment thread
joelle-a-dev marked this conversation as resolved.
Outdated
}
if (!inBody) return null;
capturedInClosure ||= inClosure;

return id as NodePath<t.Identifier> | null;
});

const references = filterMap(binding.referencePaths, path => {
if (seen.has(path.node)) return null;

const { inBody, inClosure } = relativeLoopLocation(path, loopPath);
const { inBody, inHead, inClosure } = relativeLoopLocation(path, loopPath);

if (inHead && inClosure) {
headClosureCaptures.push(path as NodePath<t.Identifier>);
return null;
}
if (!inBody) return null;
capturedInClosure ||= inClosure;

Expand All @@ -70,21 +88,25 @@ export function getUsageInBody(
capturedInClosure,
hasConstantViolations: constantViolations.length > 0,
usages: references.concat(constantViolations),
headClosureCaptures,
};
}

function relativeLoopLocation(path: NodePath, loopPath: NodePath<t.Loop>) {
const bodyPath = loopPath.get("body");
const headPath = loopPath.isForStatement() ? loopPath.get("init") : null;
let inClosure = false;

for (let currPath = path; currPath; currPath = currPath.parentPath) {
if (currPath.isFunction() || currPath.isClass() || currPath.isMethod()) {
inClosure = true;
}
if (currPath === bodyPath) {
return { inBody: true, inClosure };
return { inBody: true, inHead: false, inClosure };
} else if (headPath && currPath === headPath) {
return { inBody: false, inHead: true, inClosure };
} else if (currPath === loopPath) {
return { inBody: false, inClosure };
return { inBody: false, inHead: false, inClosure };
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
let log = [];
for (let i = 0, f = () => i; i < 1; ) {
i = 42;
log.push(f());
}
expect(log).toEqual([0]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
for (let i = 0, f = () => i; i < 1; ) {
i = 42;
console.log(f());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"plugins": ["transform-block-scoping"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
for (var i = 0, f = () => _i, _i = i; i < 1;) {
i = 42;
console.log(f());
}
Loading