Skip to content

fix(hir): materialize the implicit undefined for init-less let/const (#6871) - #6877

Merged
proggeramlug merged 1 commit into
mainfrom
fix/6871-uninit-let-loop-reset
Jul 27, 2026
Merged

fix(hir): materialize the implicit undefined for init-less let/const (#6871)#6877
proggeramlug merged 1 commit into
mainfrom
fix/6871-uninit-let-loop-reset

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #6871.

Problem

A let x; declared inside a loop body kept the previous iteration's value
instead of starting from undefined, whenever the assignment happened inside a
nested loop:

const src: string[][] = [["a"], [], ["b"]];
for (let i = 0; i < src.length; i++) {
  let f: string[] | undefined;
  for (const n of src[i]) { if (!f) f = []; f.push(n); }
  console.log(f);
}
node perry (before)
["a"] / undefined / ["b"] ["a"] / ["a"] / ["a","b"]

Executing a lexical declaration initializes the binding to undefined
(ECMA-262 CreateMutableBinding + InitializeBinding), and re-executing it
creates a fresh binding — so every iteration must start from undefined.

Assignment placed directly in the loop body was already correct, and writing
= undefined explicitly was already correct. That combination is what kept
this hidden.

Cause

HIR faithfully emitted Stmt::Let { init: None }. Codegen allocates the slot
once in the entry block and emits no store for a None init, so the stale
value survived the loop back-edge.

This cannot be fixed in codegen without new information: Stmt::Let carries no
var/let distinction, and ctx.var_hoisted_ids never reaches perry-codegen.
Lowering is where is_var_decl is known, so the implicit initializer is
materialized there instead.

var is deliberately excluded — it is function-scoped and hoisted, so
re-executing var x; keeps the prior value.

Test

test-files/test_gap_uninit_let_loop_reset.ts — nested for-of, nested
while, nested for, scalar and string accumulators, an outer while, a
nested loop that never runs, plus the two forms that were already correct
(explicit = undefined, assignment directly in the body) to lock them in.
Byte-identical to node --experimental-strip-types.

Regression check

This touches every let declaration, so beyond the unit tests I ran a
differential sweep of 120 test_gap_* files against
node --experimental-strip-types: 114 pass, 2 fail, 4 skipped. Both
failures (test_gap_2159_defineproperty_class_prototype,
test_gap_2514_settracesigint) are already listed in
test-parity/known_failures.json.

cargo test -p perry-hir matches main exactly (18 passed / 1 failed —
logical_property_assignment_short_circuits_the_store_4586 fails identically
on origin/main, verified by reverting just this hunk).

Related

While testing I found the mirror-image defect in var: var x; in a loop is
reset each iteration when it should keep its value. That is pre-existing
(reproduces on released 0.5.1239, before this change) and is filed separately
as #6876. This PR neither fixes nor worsens it; the test documents the case in
a comment rather than asserting it, so no known failure gets baked in.

How this was found

Compiling the Milo compiler (https://github.com/milo-language/milo) with Perry.
Its parser collects declaration attributes with exactly this shape:

let attrs: Attribute[] | undefined;
while (this.at(TokenKind.At)) { if (!attrs) attrs = []; attrs.push(...); }

so a struct's @derive(...) leaked onto every following declaration and the
compiled compiler rejected valid programs ("'@derive' is not supported on
functions — 'arenaNew'"). Changing that one declaration to = undefined fixed
it, confirming the cause.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed uninitialized let variables retaining values from previous loop iterations.
    • Ensured let declarations reset to undefined correctly, including in nested loops.
  • Tests

    • Added coverage for nested loop scenarios, uninitialized declarations, and reads before assignment.

…6871)

A `let x;` declared inside a loop body kept the previous iteration's
value instead of being reset to undefined, whenever the assignment
happened inside a NESTED loop:

    for (let i = 0; i < src.length; i++) {
      let f: string[] | undefined;
      for (const n of src[i]) { if (!f) f = []; f.push(n); }
      // node:  a, undefined, b
      // perry: a, a,         ab
    }

Executing a lexical declaration initializes the binding to undefined
(ECMA-262 CreateMutableBinding + InitializeBinding), and re-executing it
creates a FRESH binding — so each iteration must start from undefined.
HIR faithfully emitted `Stmt::Let { init: None }`, but codegen allocates
the slot once in the entry block and emits no store for a None init, so
the stale value survived the back-edge. Assignment directly in the loop
body was already correct, which is what kept this hidden.

Fixing it in codegen is not possible without new information: `Stmt::Let`
carries no var/let distinction and `ctx.var_hoisted_ids` never reaches
perry-codegen. Lowering is where `is_var_decl` is known, so materialize
the initializer there instead.

`var` is deliberately excluded — it is function-scoped and hoisted, so
re-executing `var x;` keeps the prior value, and the test locks that in.

Found compiling the Milo compiler (32k lines of TS) with Perry. Its
parser collects declaration attributes with exactly this shape:

    let attrs: Attribute[] | undefined;
    while (this.at(TokenKind.At)) { if (!attrs) attrs = []; ... }

so a struct's `@derive(...)` leaked onto every following declaration and
the compiled compiler rejected valid programs with spurious errors
("'@derive' is not supported on functions"). Changing that one
declaration to `= undefined` fixed it, confirming the cause.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e3778bbd-24fb-4d87-a0fc-0a78aed8f933

📥 Commits

Reviewing files that changed from the base of the PR and between 0910cc7 and 1e6eaf9.

📒 Files selected for processing (3)
  • changelog.d/6871-uninit-let-loop-reset.md
  • crates/perry-hir/src/destructuring/var_decl.rs
  • test-files/test_gap_uninit_let_loop_reset.ts

📝 Walkthrough

Walkthrough

The compiler now explicitly initializes uninitialized let and const declarations to undefined during lowering. Regression tests cover nested loops, assignments, reads, and non-executing inner loops.

Changes

Uninitialized let reset

Layer / File(s) Summary
Materialize lexical declaration initializers
crates/perry-hir/src/destructuring/var_decl.rs
Lowering emits undefined for uninitialized let and const declarations while preserving existing var behavior.
Validate nested-loop reset behavior
test-files/test_gap_uninit_let_loop_reset.ts, changelog.d/6871-uninit-let-loop-reset.md
Regression cases cover nested loop variants, conditional assignment, reads before assignment, explicit undefined, and inner loops that do not execute; the changelog records the fix.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

  • #6876: Concerns the complementary var persistence behavior; this change intentionally preserves existing var handling and does not address that issue.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the HIR fix and the uninitialized let/const loop-reset bug tied to #6871.
Description check ✅ Passed The PR description covers the problem, cause, test coverage, and related issue, even though it uses custom headings instead of the template.
Linked Issues check ✅ Passed The code and regression test address #6871 by materializing implicit undefined for init-less lexical declarations in loop bodies and preserving var behavior.
Out of Scope Changes check ✅ Passed The diff stays focused on the loop-reset fix, its regression test, and a related changelog note without unrelated feature work.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/6871-uninit-let-loop-reset

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@proggeramlug
proggeramlug merged commit c3dc207 into main Jul 27, 2026
29 of 30 checks passed
@proggeramlug
proggeramlug deleted the fix/6871-uninit-let-loop-reset branch July 27, 2026 02:50
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.

codegen: uninitialized let in a loop body is not reset per iteration when assigned from a nested loop

1 participant