fix(transform): preserve hoisted-var slot across unrolled loop copies (#2308) - #2313
Merged
Conversation
added 2 commits
May 28, 2026 23:25
…#2308) The static-loop unroller (`unroll_static_loops`) clones a loop body N times and calls `refresh_local_ids` to rename every body-declared binding to a fresh id, so the copies don't alias (needed for distinct per-iteration closure captures and to avoid duplicate module-init globals, #456). But a `var` declared inside the loop body is function-scoped: a read AFTER the loop references the original id. Renaming each copy's declaration to a fresh id meant the post-loop read bound to an id that NO copy ever wrote, so it observed the initial (undefined/0) value: for (let i = 0; i < 3; i++) { var t = i * 2; sum += t; } return sum + t; // node: 10 (t = 4) — perry (before): 6 (t = 0) Only fires when the loop bounds are constant (the loop is unrolled); with a runtime bound the same code was already correct. Fix: before unrolling each function/method/init/ctor body, compute the set of loop-body-declared ids that are referenced outside their loop body (`compute_loop_escaping_ids`, count-based on the original HIR). These are exactly the hoisted, function-scoped `var`s. `refresh_local_ids` seeds its remap with identity entries for them, and `alloc_fresh` reuses an existing remap entry instead of minting a new id — so every copy keeps the var's original id. Because the unrolled body is straight-line, the last copy's write lands in the slot the post-loop read uses, matching JS. Block-scoped `let`/`const` (which can never be referenced outside the loop body) are absent from the set and keep getting fresh per-copy ids, so per-iteration closure captures stay distinct (verified by a unit test and the `distinctCaptures` e2e case). Tests: two unit tests in unroll.rs (escaping var keeps id; captured let still refreshed) + test-files/test_gap_2308_unrolled_loop_var.ts (byte-for- byte vs node). A `var` assigned inside a *conditional* within an unrolled loop additionally needs the per-branch slot fix from #1803 and is covered there.
The #2308 fix pushed crates/perry-transform/src/unroll.rs to 2032 lines, tripping the lint job's file-size gate (./scripts/check_file_size.sh, cap 2000). Move the file to unroll/mod.rs and extract the #2308 escaping-id analysis (compute_loop_escaping_ids + its count/declared-id helpers) into a sibling unroll/escape_analysis.rs, re-exported via an explicit named use. No behavior change — mod.rs is now 1801 lines, escape_analysis.rs 245.
proggeramlug
force-pushed
the
worktree-fix-2308-unroll-var
branch
from
May 28, 2026 21:37
e99c70c to
eb6f9a7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #2308 — a
vardeclared inside a constant-bound (unrolled) counted loop and read after the loop observed its initial value instead of the last iteration's.Root cause
The static-loop unroller (
crates/perry-transform/src/unroll.rs) clones a loop body N times and callsrefresh_local_idsto rename every body-declared binding to a fresh id, so the copies don't alias — this is required for distinct per-iteration closure captures and to avoid duplicate module-init globals (#456).But a
varis function-scoped: a read after the loop references the original id. Renaming each copy's declaration meant the post-loop read bound to an id no copy ever wrote, so it saw the initial (undefined/0) value. It only fired with constant bounds (runtime-bound loops aren't unrolled and were already correct).Fix
Before unrolling each function/method/init/constructor body, compute the set of loop-body-declared ids that are referenced outside their loop body (
compute_loop_escaping_ids, a count-based analysis on the original HIR —total_uses > uses_inside_loop⇒ escapes). These are exactly the hoisted, function-scopedvars.refresh_local_idsthen seeds its remap with identity entries for those ids, andalloc_freshreuses an existing remap entry instead of minting a new id — so every unrolled copy keeps the var's original id. Since the unrolled body is straight-line, the last copy's write lands in the slot the post-loop read uses, matching JS.Block-scoped
let/const(which can never be referenced outside the loop body) are absent from the set and keep getting fresh per-copy ids, so per-iteration closure captures stay distinct.Tests
unroll.rs:loop_escaping_var_keeps_original_id— every unrolledLet+ the post-loop read keep the original id.loop_local_let_still_refreshed_per_copy— a capturedletstill gets a distinct id per copy and each closure captures its own.test-files/test_gap_2308_unrolled_loop_var.ts— byte-for-byte vsnode --experimental-strip-types(var-after-loop, nested-loop var-after-outer, distinct closure captures).cargo test -p perry-transformgreen (25 tests);cargo fmt --all --checkclean; regression-swept several existing loop/closure/array parity files.Related
A
varassigned inside a conditional within an unrolled loop (for (...) { if (i===2) { var hit = ... } }, read after) additionally needs the per-branchvarslot fix from #1803 — after unrolling it becomes the canonical #1803 shape (oneLetper if-branch + a read past the merge). That's covered by #1803's PR, so this PR's test stays parity-clean independent of it.