Skip to content

fix: closures in for-loop init capture wrong binding - #18195

Open
joelle-a-dev wants to merge 14 commits into
babel:mainfrom
joelle-a-dev:fix-for-init-closure-capture
Open

joelle-a-dev wants to merge 14 commits into
babel:mainfrom
joelle-a-dev:fix-for-init-closure-capture

Conversation

@joelle-a-dev

@joelle-a-dev joelle-a-dev commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor
Q A
Fixed Issues? Fixes #18191
Patch: Bug Fix? Yes
Major: Breaking Change?
Minor: New Feature?
Tests Added + Pass? Yes
Documentation PR Link
Any Dependency Changes?
License MIT

In a loop like for (let i = 0, f = () => i; ...), the closure f is created while the loop head runs, before the loop body starts changing i each time around. f should always see the value i had at that point, but the transform was making f share the same variable the loop body mutates, so calling f() later gave the wrong value.

This gives that closure its own copy of the value, taken once right after the loop head finishes, so later changes to i in the loop body don't affect it.

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 babel#18191
@github-actions

Copy link
Copy Markdown

⚠️ Mixed activity

Activity patterns show a mix of organic and automated signals.

View full analysis →

This is an automated analysis by AgentScan

@babel-bot

babel-bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

Build successful! You can test your changes in the REPL here: https://babeljs.io/repl/build/62167

@pkg-pr-new

pkg-pr-new Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

commit: f1eb0af

@joelle-a-dev

Copy link
Copy Markdown
Contributor Author

@liuxingbaoyu Appreciate a review on this.

@nicolo-ribaudo nicolo-ribaudo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. This test case shows multiple problems:

let res;

for (let i = 0, x = () => i, y = x(); i < 1; i++) {
  res = y;
}

expect(y).toBe(1)
// output
var res;
for (var i = 0, x = function x() {
    return _i2;
  }, _y2 = x(), _i3 = i, _i2 = i; i < 1; i++) {
  res = _y2;
}
expect(y).toBe(1);
  1. The x() call returns undefined instead of 0, because _i2 is not defined yet at that point
  2. There is a useless _i3 = i assignment
  3. y doesn't need to be renamed to _y2 (but ignore this, it's pre-existing and not caused by this PR)

@nicolo-ribaudo nicolo-ribaudo added PR: Bug Fix 🐛 A type of pull request used for our changelog categories and removed agentscan:mixed-signals labels Sep 11, 2026
@nicolo-ribaudo

Copy link
Copy Markdown
Member

@joelle-a-dev I see that you are keeping the branch up to date, but #18195 (review) still needs to be addressed.

@joelle-a-dev

Copy link
Copy Markdown
Contributor Author

@nicolo-ribaudo I pushed the fix, please check now

@joelle-a-dev

Copy link
Copy Markdown
Contributor Author

The failing CI looks like a timeout issue, already happening on main.

Comment thread packages/babel-plugin-transform-block-scoping/src/index.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The snapshot timing and capture-path classification still alter semantics for valid initializer patterns.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity

Open (2)
What changed in this PR

Fixes lexical binding capture for closures created in for initializers.

Changes:

  • Detects initializer closures capturing loop bindings.
  • Introduces snapshot variables for captured values.
  • Adds transformation and runtime regression fixtures.
File Description
src/​loop.ts Detects captures in loop initializers.
src/​index.ts Creates and rewrites snapshot bindings.
regression/​issue-18191/​input.js Adds regression input.
regression/​issue-18191/​output.js Records expected transformation.
regression/​issue-18191/​options.json Configures the regression fixture.
general/​for-head-closure/​exec.js Tests capture after body mutation.
general/​for-head-closure-called-in-head/​exec.js Tests invocation during initialization.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +114 to +119
binding.path.insertAfter(
t.variableDeclarator(
t.identifier(frozenName),
t.identifier(name),
),
);
Comment on lines +62 to +64
if (inHead && inClosure) {
if (id) headClosureCaptures.push(id as NodePath<t.Identifier>);
return null;
@nicolo-ribaudo

Copy link
Copy Markdown
Member

@joelle-a-dev The two issues reported above by copilot seem correct; do you think you could fix it?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: Bug Fix 🐛 A type of pull request used for our changelog categories

Projects

None yet

Development

Successfully merging this pull request may close these issues.

transform-block-scoping: closure created in a for-loop initializer captures the wrong binding

4 participants