Skip to content

fix(hir): hoist top-level var entry slot in function-expression bodies#6428

Merged
proggeramlug merged 1 commit into
mainfrom
fix/var-hoist-forin-fn-expr
Jul 15, 2026
Merged

fix(hir): hoist top-level var entry slot in function-expression bodies#6428
proggeramlug merged 1 commit into
mainfrom
fix/var-hoist-forin-fn-expr

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

A var is function-scoped and hoisted: a declaration textually after a statement that reads/writes it is still bound (as undefined) from function entry. Perry honored this for function declarations and arrows, but not for function expressions — so a var used before its own late declaration folded to a constant undefined at every earlier read.

Root cause

The fn-expr lowering has a top-level var pre-pass (issue #838) that pre-registers the hoisted local and adds it to hoisted_id_set. That only fixes the forward-capture case (a closure created before the declaration reads the var through a prealloc box). A var merely read/written before its declaration but not captured by any closure got no box and — crucially — no undefined-initialised entry slot, so its storage first materialised at the late var x = … statement. The fn-decl/arrow path (predefine_var_bindings_in_function_body) always emits that entry slot; the fn-expr path did not.

HIR was already correct (one shared LocalId for both the loop use and the late redeclaration); only the entry Let was missing, so codegen resolved earlier LocalGets to a folded undefined.

Real-world impact: React cloneElement drops all props

React 19's cloneElement/createElement are exactly this shape:

exports.cloneElement = function (element, config, children) {
  var props = assign({}, element.props), key = element.key;
  if (null != config)
    for (propName in (void 0 !== config.key && (key = "" + config.key), config))
      !hasOwnProperty.call(config, propName) || "key" === propName ||  ||
        (props[propName] = config[propName]);
  var propName = arguments.length - 2;   // hoisted; declared AFTER the loop
  
};

Compiled as a function expression, the loop body's hasOwnProperty.call(config, propName) saw propName === undefined, so the !hasOwn(...) || … chain short-circuited and props[propName] = config[propName] never ran — cloneElement returned the element's original props with none of config merged in. Radix Slot (shadcn <Button asChild>) merges its props onto its child via cloneElement, so <Button asChild><Link/></Button> rendered a bare <a href> with no data-slot/data-variant/className.

Fix

The top-level var pre-pass now also pushes the undefined-initialised entry Let into nested_var_prologue, exactly like the nested-var pre-pass a few lines below (and the fn-decl/arrow path).

Testing

Reproduces at -O0, confirming a lowering bug rather than an LLVM-optimisation artifact. Added test-files/test_gap_var_hoist_forin_fn_expr.ts (byte-identical to node --experimental-strip-types) covering the cloneElement shape plus forward-var reads in function expressions, object methods, arrows, and if-branch vars. Verified end-to-end: a real Next.js 16 app's shadcn <Button asChild> buttons now render with their full class list, matching Node byte-for-byte in the visible DOM.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed var hoisting in function expressions so variables are available with undefined values before their declarations.
    • Corrected hoisting behavior in for-in loops, arrow functions, object methods, and conditional branches.
  • Tests

    • Added regression coverage for forward var references and related function-expression scenarios.

A `var` is function-scoped and hoisted: a declaration that appears
textually AFTER a statement that reads or writes it is still bound (as
`undefined`) from function entry. The function-declaration / arrow path
(`predefine_var_bindings_in_function_body`) emits an undefined-initialised
entry slot for every such `var`, but the function-EXPRESSION path did not:
its top-level `var` pre-pass (issue #838) pre-registered the hoisted local
and added it to `hoisted_id_set` — which only makes the forward-CAPTURE
case work (a closure created before the declaration reads the box). A
`var` merely read/written before its own textual declaration but NOT
captured by any closure got no prealloc box and no entry slot, so its
storage first materialised at the late `var x = …` statement and every
earlier read folded to a constant `undefined`.

React 19's `cloneElement`/`createElement` are exactly this shape: one
hoisted `propName` drives a `for (propName in config)` loop and is then
redeclared `var propName = arguments.length - 2`. Compiled as a function
expression (`exports.cloneElement = function (…) {…}`), the loop body's
`hasOwnProperty.call(config, propName)` saw `propName === undefined`, so
the `!hasOwn(...) || …` chain short-circuited and `props[propName] =
config[propName]` never ran — cloneElement dropped every config prop.
Downstream, Radix `Slot` (shadcn `<Button asChild>`) merges its props onto
its child via cloneElement, so `<Button asChild><Link/></Button>` rendered
a bare `<a href>` with no `data-slot`/`data-variant`/`className`.

Fix: the top-level `var` pre-pass now also pushes the undefined-initialised
entry `Let` into `nested_var_prologue`, exactly like the nested-`var`
pre-pass a few lines below (and the fn-decl/arrow path). HIR was already
correct (one shared LocalId for both the loop use and the late redecl);
only the entry slot was missing.

Repros at O0, so it is a lowering bug, not an LLVM-optimisation artifact.
Added test-files/test_gap_var_hoist_forin_fn_expr.ts covering the
cloneElement shape plus forward-var reads in function expressions, methods,
arrows, and if-branch `var`s.
@coderabbitai

coderabbitai Bot commented Jul 15, 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: e76d0d28-d89f-4590-ac76-0fc66e413428

📥 Commits

Reviewing files that changed from the base of the PR and between 885a8f5 and b53f3fb.

📒 Files selected for processing (2)
  • crates/perry-hir/src/lower/expr_function.rs
  • test-files/test_gap_var_hoist_forin_fn_expr.ts

📝 Walkthrough

Walkthrough

Function-expression lowering now creates undefined-initialized storage for newly hoisted top-level var bindings. A regression test covers for-in loops, forward reads, arrow functions, object methods, and conditional declarations.

Changes

Function-expression var hoisting

Layer / File(s) Summary
Materialize hoisted var slots
crates/perry-hir/src/lower/expr_function.rs
The top-level var pre-pass reuses the binding name and emits an undefined-initialized Stmt::Let for each newly hoisted binding.
Validate hoist behavior
test-files/test_gap_var_hoist_forin_fn_expr.ts
Regression cases verify hoisting across for-in loops, forward reads, arrow functions, object-literal methods, and conditional branches.

Estimated code review effort: 3 (Moderate) | ~15 minutes

Possibly related PRs

  • PerryTS/perry#5270: Modifies function-expression lowering and top-level var hoisting.
  • PerryTS/perry#5474: Updates the same function-expression hoisting pre-pass and storage registration.
  • PerryTS/perry#6038: Represents hoisted bindings with Stmt::Let and undefined initializers.

Suggested reviewers: andrewtdiz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely matches the main change: hoisting the entry slot for top-level var bindings in function expressions.
Description check ✅ Passed Covers summary, root cause, fix, and testing well, but omits the template's explicit Changes and Related issue sections.
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.
✨ 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/var-hoist-forin-fn-expr

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 9c4c6dd into main Jul 15, 2026
26 checks passed
@proggeramlug
proggeramlug deleted the fix/var-hoist-forin-fn-expr branch July 15, 2026 06:40
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.

1 participant