Skip to content

fix(hir): destructured var binding reuses its hoisted boxed slot for forward closure capture#5474

Merged
proggeramlug merged 2 commits into
mainfrom
fix/destructured-var-forward-capture
Jun 20, 2026
Merged

fix(hir): destructured var binding reuses its hoisted boxed slot for forward closure capture#5474
proggeramlug merged 2 commits into
mainfrom
fix/destructured-var-forward-capture

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Symptom

A var { x } = ... whose bound name is captured by a closure declared earlier in the same function read undefined:

function consumer() {
  function parse() { return dSq.COMPARATOR; }  // captures dSq
  var { t: dSq } = factory();                  // destructuring var, declared after
  return parse();
}
consumer();   // node: 42 ; perry (before): TypeError: Cannot read properties of undefined

Root cause

The function-body var-hoist pre-pass correctly pre-defines and boxes a forward-captured var slot. But the destructuring-leaf lowering (destructuring/pattern_binding.rs, Pat::Ident arm) allocated a fresh local for the bound name instead of reusing that pre-hoisted boxed id — the plain Pat::Ident var-decl path already reused it, the destructuring leaf did not. So the = factory() assignment wrote a different slot than the box the closure captured, and the closure read the never-written box. Additionally the function-expression body's inline var pre-pass (lower/expr_function.rs) only registered plain Pat::Ident vars, never destructuring-var leaves (esbuild's CJS module factory is a function expression).

Fix

  • destructuring/pattern_binding.rs: the Pat::Ident leaf reuses an existing var-hoisted binding of the same name before allocating fresh (gated on var_hoisted_ids membership, so shadowing let/const/catch params still get fresh slots).
  • lower/expr_function.rs: the inline pre-pass walks the whole pattern via collect_var_binding_names_from_pat, pre-registering destructured var leaves.
  • lower_decl/{block,mod}.rs: expose collect_var_binding_names_from_pat.

Validation

  • New e2e crates/perry/tests/issue_cjs_destructured_var_forward_capture.rs — passes (incl. multi-consumer + circular-module high-fidelity semver shape).
  • cargo test -p perry-hir -p perry-codegen: all pass, no regressions (incl. issue_4841_namespace_cjs_reexport, issue_4972_derived_class_capture_super).

Note

A distinct, deeper edge case (a class instance constructed before the capturing var is assigned, same scope) remains — perry's class-capture snapshots by value at construction time. semver doesn't hit it (its instances are built by later callers). Documented as a follow-up.

Surfaced bringing up @anthropic-ai/claude-code natively (semver's var { safeRe, t } = require('./re')).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed destructured var declarations being inaccessible to closures that capture the binding.
    • Improved hoisting/preallocation for destructuring patterns in var declarators, including scenarios involving for-loop destructuring and parameter destructuring, to ensure correct reassignment/visibility behavior.
  • Tests

    • Added a regression test covering destructured var forward-capture semantics, validating expected runtime output.

…r forward closure capture

A `var { x } = ...` whose name is captured by a closure declared EARLIER in the
same function read `undefined`:

    function consumer() {
      function parse() { return dSq.COMPARATOR; }  // captures dSq
      var { t: dSq } = factory();                  // destructuring var, declared after
      return parse();
    }

The function-body var-hoist pre-pass correctly pre-defines + boxes a forward-
captured `var` slot, but the destructuring leaf lowering allocated a FRESH local
for the bound name instead of reusing the pre-hoisted boxed id (the plain
`Pat::Ident` var path already reused it; the destructuring leaf did not). So the
`= factory()` write landed in a different slot than the box the closure captured,
and the closure read the never-written box.

- destructuring/pattern_binding.rs: the `Pat::Ident` leaf reuses an existing
  var-hoisted binding of the same name before allocating fresh (gated on
  var_hoisted_ids membership so shadowing let/const/catch still get fresh slots).
- lower/expr_function.rs: the fn-expression body's inline var pre-pass now walks
  the whole pattern (via collect_var_binding_names_from_pat), pre-registering
  destructured var leaves (esbuild's CJS module factory is a fn-expression).
- lower_decl/{block,mod}.rs: expose collect_var_binding_names_from_pat.

Surfaced bringing up `@anthropic-ai/claude-code` natively: semver's
`var { safeRe, t } = require('./re')` with a class method capturing `t`.
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Fixes a compiler bug where destructuring var bindings captured by closures read undefined. The fix exposes collect_var_binding_names_from_pat as pub(crate), extends lower_fn_expr_anon's var pre-registration to all destructuring patterns, adds an is_var_decl parameter to destructuring lowering to distinguish var from let/const, reuses pre-hoisted slots in pattern binding allocation instead of allocating fresh locals, threads the flag through all pattern paths, and validates with a regression test.

Changes

Destructured var forward capture fix

Layer / File(s) Summary
Expose collect_var_binding_names_from_pat and refactor conditionals
crates/perry-hir/src/lower_decl/block.rs, crates/perry-hir/src/lower_decl/mod.rs
collect_var_binding_names_from_pat changes from private to pub(crate), imports are expanded, Ident match guards in cic_expr and cic_assign_target are refactored to inner if in_cl blocks, and mod.rs re-exports the helper alongside two other collection functions.
Extend var pre-registration to destructuring patterns
crates/perry-hir/src/lower/expr_function.rs
Replaces the Pat::Ident-only branch in lower_fn_expr_anon's top-level var pre-pass with a loop over all binding names from collect_var_binding_names_from_pat, pre-defining and marking each as hoisted when not already in scope.
Update pattern binding signatures and implement slot reuse
crates/perry-hir/src/destructuring/pattern_binding.rs
Extends lower_pattern_binding and lower_pattern_binding_into with is_var_decl parameter. In Pat::Ident leaf lowering, searches ctx.var_hoisted_ids for pre-hoisted slots when is_var_decl is true; reuses and updates matching slot type, or falls back to define_local for non-var destructuring or when no pre-hoisted slot exists.
Thread is_var_decl through array and object patterns
crates/perry-hir/src/destructuring/pattern_binding.rs
Updates lower_array_pattern_binding signature and propagates is_var_decl through rest elements, Pat::Assign elements, and regular elements. Similarly threads is_var_decl through object pattern KeyValue and Rest property lowering.
Update all lower_pattern_binding call sites
crates/perry-hir/src/destructuring/var_decl.rs, crates/perry-hir/src/lower/stmt.rs, crates/perry-hir/src/lower_decl/body_stmt.rs, crates/perry-hir/src/lower_patterns.rs
Pass is_var_decl: true for var destructuring and is_var_decl: false for let/const destructuring and function parameters across all call sites in for loops, variable declarations, and parameter destructuring.
Regression test
crates/perry/tests/issue_cjs_destructured_var_forward_capture.rs
End-to-end test writes a TypeScript program with a memoized-module pattern, a class before a destructuring var, and a closure capturing the destructured binding; compiles with perry compile and asserts stdout is exactly class: rx\nfn: 0\n.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PerryTS/perry#5252: Routes destructuring for (var …) declarators through lower_pattern_binding and populates ctx.var_hoisted_ids — the pre-hoisting infrastructure this PR builds on to reuse slots in Pat::Ident allocation.
  • PerryTS/perry#5270: Modifies var hoisting and pre-registration logic in lower_fn_expr_anon, directly adjacent to the pre-registration pass extended here for destructuring patterns.

Poem

🐇 Hippity-hop through patterns new and strange,
Where { x, y } once left bindings out of range,
Now is_var_decl guides the lowering way,
Pre-hoisted slots reused without delay —
Closures capture truth, no more undefined fray! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main change: fixing destructured var bindings to reuse hoisted slots for forward closure capture, which is the central fix in this PR.
Description check ✅ Passed The PR description includes all required template sections: a clear summary, detailed changes with file paths, related issue reference (n/a), and test plan with validation results.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/destructured-var-forward-capture

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-hir/src/destructuring/pattern_binding.rs`:
- Around line 375-409: The hoisted-slot reuse logic in the `None` branch does
not check whether the current destructuring binding is a `var` declaration or a
non-`var` declaration (let/const), causing non-var destructuring to incorrectly
reuse an outer hoisted var slot instead of creating a fresh binding. Modify the
reuse condition to only allow slot reuse for `var` declarations, ensuring that
`let`/`const` destructuring bindings always create fresh local bindings instead
of reusing existing hoisted var slots. This requires checking the declaration
kind before the existing find operation that searches through
ctx.var_hoisted_ids.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 063a1979-c571-4fea-9e78-3e48eeab0f61

📥 Commits

Reviewing files that changed from the base of the PR and between e32321a and 723fa2b.

📒 Files selected for processing (5)
  • crates/perry-hir/src/destructuring/pattern_binding.rs
  • crates/perry-hir/src/lower/expr_function.rs
  • crates/perry-hir/src/lower_decl/block.rs
  • crates/perry-hir/src/lower_decl/mod.rs
  • crates/perry/tests/issue_cjs_destructured_var_forward_capture.rs

Comment thread crates/perry-hir/src/destructuring/pattern_binding.rs Outdated
CodeRabbit: `lower_pattern_binding_into` is shared by `let`/`const`
destructuring too, so the var-hoisted-slot reuse in the `Pat::Ident` leaf
could make an inner `let { x }` alias an outer hoisted `var x` of the same
name, breaking lexical shadowing. Thread an `is_var_decl` flag through
`lower_pattern_binding`/`_into`/`lower_array_pattern_binding` and gate the
reuse arm on it; non-`var` leaves always allocate a fresh binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ4RdAWQhxvqyS9vWeLoTB

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/perry-hir/src/lower/stmt.rs (1)

1391-1430: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve const immutability in for destructuring.

Both non-var paths call lower_pattern_binding(..., true, false), so for (const { a } = obj; ...) creates mutable locals. Use var_decl.kind != ast::VarDeclKind::Const for the mutable argument while keeping is_var_decl=false.

🐛 Proposed fix
                         } else {
+                            let mutable = var_decl.kind != ast::VarDeclKind::Const;
                             for decl in var_decl.decls.iter().skip(1) {
@@
                                     let stmts = crate::destructuring::lower_pattern_binding(
-                                        ctx, &decl.name, init_expr, true, false,
+                                        ctx, &decl.name, init_expr, mutable, false,
                                     )?;
@@
                                 module.init.push(Stmt::Let {
                                     id,
                                     name,
                                     ty: Type::Any,
-                                    mutable: true,
+                                    mutable,
                                     init: init_expr,
                                 });
@@
                                     let stmts = crate::destructuring::lower_pattern_binding(
-                                        ctx, &decl.name, init_expr, true, false,
+                                        ctx, &decl.name, init_expr, mutable, false,
                                     )?;
@@
                                     Some(Box::new(Stmt::Let {
                                         id,
                                         name,
                                         ty: Type::Any,
-                                        mutable: true,
+                                        mutable,
                                         init: init_expr,
                                     }))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/lower/stmt.rs` around lines 1391 - 1430, The
lower_pattern_binding calls in the for loop destructuring handling have
hardcoded true for the mutable parameter, causing const destructuring
declarations to incorrectly create mutable locals. Replace the hardcoded true
value with var_decl.kind != ast::VarDeclKind::Const for both
lower_pattern_binding calls (one around line 1393 and one around line 1419) to
properly preserve const immutability, while keeping the is_var_decl parameter as
false.
crates/perry-hir/src/destructuring/pattern_binding.rs (1)

386-423: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Mirror hoisted-slot reuse for object shorthand leaves.

This fixes Pat::Ident leaves reached through recursive lowering, but { dSq } shorthand is handled by ObjectPatProp::Assign and still falls through to ctx.define_local(...). A forward-captured var { dSq } = factory() can therefore still write a fresh slot while the earlier closure reads the pre-hoisted boxed slot.

🐛 Proposed fix
                             None if ctx.scope_depth == 0
                                 && ctx.inside_block_scope == 0
                                 && ctx.pre_registered_module_vars.remove(&name) =>
                             {
                                 ctx.pre_registered_module_var_decls.remove(&name);
                                 let id = ctx.lookup_local(&name).unwrap();
                                 if let Some(existing_ty) = ctx.locals.lookup_type_mut(&name) {
                                     *existing_ty = ty.clone();
                                 }
                                 id
                             }
+                            None if is_var_decl => {
+                                let reuse = {
+                                    let hoisted = &ctx.var_hoisted_ids;
+                                    ctx.locals
+                                        .iter_named(&name)
+                                        .find(|(_, (_, lid, _))| hoisted.contains(lid))
+                                        .map(|(pos, (_, lid, _))| (pos, *lid))
+                                };
+                                if let Some((reuse_pos, id)) = reuse {
+                                    *ctx.locals.type_mut_at(reuse_pos) = ty.clone();
+                                    id
+                                } else {
+                                    ctx.define_local(name.clone(), ty.clone())
+                                }
+                            }
                             None => ctx.define_local(name.clone(), ty.clone()),
                         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/destructuring/pattern_binding.rs` around lines 386 -
423, The hoisted-slot reuse logic for `var` declarations in destructuring is
currently only applied to `Pat::Ident` leaves in the `None if is_var_decl =>`
block, but it is not applied to object shorthand destructuring handled by
`ObjectPatProp::Assign`. This causes object shorthand patterns like `{ dSq }` to
create fresh slots instead of reusing pre-hoisted boxed slots, resulting in
forward-captured closures reading undefined values. Apply the same hoisted-slot
reuse logic (checking `ctx.var_hoisted_ids` and reusing existing locals when
appropriate) to the `ObjectPatProp::Assign` case to ensure consistency between
named and shorthand destructuring patterns.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/perry-hir/src/destructuring/pattern_binding.rs`:
- Around line 386-423: The hoisted-slot reuse logic for `var` declarations in
destructuring is currently only applied to `Pat::Ident` leaves in the `None if
is_var_decl =>` block, but it is not applied to object shorthand destructuring
handled by `ObjectPatProp::Assign`. This causes object shorthand patterns like
`{ dSq }` to create fresh slots instead of reusing pre-hoisted boxed slots,
resulting in forward-captured closures reading undefined values. Apply the same
hoisted-slot reuse logic (checking `ctx.var_hoisted_ids` and reusing existing
locals when appropriate) to the `ObjectPatProp::Assign` case to ensure
consistency between named and shorthand destructuring patterns.

In `@crates/perry-hir/src/lower/stmt.rs`:
- Around line 1391-1430: The lower_pattern_binding calls in the for loop
destructuring handling have hardcoded true for the mutable parameter, causing
const destructuring declarations to incorrectly create mutable locals. Replace
the hardcoded true value with var_decl.kind != ast::VarDeclKind::Const for both
lower_pattern_binding calls (one around line 1393 and one around line 1419) to
properly preserve const immutability, while keeping the is_var_decl parameter as
false.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c77ac44a-c9ec-4a95-b402-2f2834c91248

📥 Commits

Reviewing files that changed from the base of the PR and between 723fa2b and 01d35ac.

📒 Files selected for processing (5)
  • crates/perry-hir/src/destructuring/pattern_binding.rs
  • crates/perry-hir/src/destructuring/var_decl.rs
  • crates/perry-hir/src/lower/stmt.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry-hir/src/lower_patterns.rs

@proggeramlug
proggeramlug merged commit b4d2ec6 into main Jun 20, 2026
15 checks passed
@proggeramlug
proggeramlug deleted the fix/destructured-var-forward-capture branch June 20, 2026 05:05
@coderabbitai coderabbitai Bot mentioned this pull request Jun 28, 2026
18 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants