fix(hir): hoist top-level var entry slot in function-expression bodies#6428
Merged
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughFunction-expression lowering now creates undefined-initialized storage for newly hoisted top-level ChangesFunction-expression
Estimated code review effort: 3 (Moderate) | ~15 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
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
A
varis function-scoped and hoisted: a declaration textually after a statement that reads/writes it is still bound (asundefined) from function entry. Perry honored this for function declarations and arrows, but not for function expressions — so avarused before its own late declaration folded to a constantundefinedat every earlier read.Root cause
The fn-expr lowering has a top-level
varpre-pass (issue #838) that pre-registers the hoisted local and adds it tohoisted_id_set. That only fixes the forward-capture case (a closure created before the declaration reads the var through a prealloc box). Avarmerely 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 latevar 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
LocalIdfor both the loop use and the late redeclaration); only the entryLetwas missing, so codegen resolved earlierLocalGets to a foldedundefined.Real-world impact: React
cloneElementdrops all propsReact 19's
cloneElement/createElementare exactly this shape:Compiled as a function expression, the loop body's
hasOwnProperty.call(config, propName)sawpropName === undefined, so the!hasOwn(...) || …chain short-circuited andprops[propName] = config[propName]never ran — cloneElement returned the element's original props with none ofconfigmerged in. RadixSlot(shadcn<Button asChild>) merges its props onto its child via cloneElement, so<Button asChild><Link/></Button>rendered a bare<a href>with nodata-slot/data-variant/className.Fix
The top-level
varpre-pass now also pushes the undefined-initialised entryLetintonested_var_prologue, exactly like the nested-varpre-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. Addedtest-files/test_gap_var_hoist_forin_fn_expr.ts(byte-identical tonode --experimental-strip-types) covering the cloneElement shape plus forward-varreads in function expressions, object methods, arrows, and if-branchvars. 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
varhoisting in function expressions so variables are available withundefinedvalues before their declarations.for-inloops, arrow functions, object methods, and conditional branches.Tests
varreferences and related function-expression scenarios.