Summary
A captured variable that is reassigned inside a labeled block is not boxed: closures capture the creation-time value and never observe the reassignment. Reads through the closure see a stale snapshot while direct reads in the same scope see the live value.
Repro (vs node --experimental-strip-types)
function factory() {
let arr: any = [];
const read = () => arr.length;
outer: {
arr = [1, 2, 3, 4];
break outer;
}
console.log("len:", read());
console.log("json:", JSON.stringify(arr));
}
factory();
node:
perry (main):
The closure reads .length === 0 on the stale empty array while JSON.stringify in the same scope prints all four elements.
Root cause
The boxing analysis in crates/perry-codegen/src/boxed_vars.rs is inconsistent on Stmt::Labeled: collect_write_ids_in_stmt (~1179) HAS a Labeled arm, but five sibling walkers do not —
collect_closure_refs_and_writes_in_stmt (_ => {} at ~737)
collect_outer_writes_in_stmt (~970)
collect_for_init_ids (~641)
collect_self_recursive_closure_ids (~596)
collect_let_types_in_stmts (~1362-1367)
A reassignment inside a labeled block is invisible to outer_writes (and a closure inside a labeled block contributes no closure_refs), so the captured+mutated variable is never boxed → the closure keeps a creation-time snapshot.
Why this matters
Minified bundles emit labeled blocks (e: { … break e }) pervasively; hand-written repros don't — which is exactly why this class of bug reproduces only in large bundles. The failure signature (a captured array reading .length === 0 while JSON.stringify in the same scope shows the elements, and a fresh local array "fixing" it) matches the long-standing unexplained misbehavior seen in a 13.8MB Next.js app-page bundle's webpack factory.
Suggested fix
Add Stmt::Labeled arms (recurse into the body) to all five walkers, mirroring the existing arm at ~1179.
Summary
A captured variable that is reassigned inside a labeled block is not boxed: closures capture the creation-time value and never observe the reassignment. Reads through the closure see a stale snapshot while direct reads in the same scope see the live value.
Repro (vs
node --experimental-strip-types)node:
perry (main):
The closure reads
.length === 0on the stale empty array whileJSON.stringifyin the same scope prints all four elements.Root cause
The boxing analysis in
crates/perry-codegen/src/boxed_vars.rsis inconsistent onStmt::Labeled:collect_write_ids_in_stmt(~1179) HAS a Labeled arm, but five sibling walkers do not —collect_closure_refs_and_writes_in_stmt(_ => {}at ~737)collect_outer_writes_in_stmt(~970)collect_for_init_ids(~641)collect_self_recursive_closure_ids(~596)collect_let_types_in_stmts(~1362-1367)A reassignment inside a labeled block is invisible to
outer_writes(and a closure inside a labeled block contributes noclosure_refs), so the captured+mutated variable is never boxed → the closure keeps a creation-time snapshot.Why this matters
Minified bundles emit labeled blocks (
e: { … break e }) pervasively; hand-written repros don't — which is exactly why this class of bug reproduces only in large bundles. The failure signature (a captured array reading.length === 0whileJSON.stringifyin the same scope shows the elements, and a fresh local array "fixing" it) matches the long-standing unexplained misbehavior seen in a 13.8MB Next.js app-page bundle's webpack factory.Suggested fix
Add
Stmt::Labeledarms (recurse into the body) to all five walkers, mirroring the existing arm at ~1179.