Skip to content

fix(codegen,hir): cross-module member-new fills captures from the class's decl-site snapshot - #5640

Merged
proggeramlug merged 3 commits into
mainfrom
fix/cross-module-member-new-capture-5437
Jun 24, 2026
Merged

fix(codegen,hir): cross-module member-new fills captures from the class's decl-site snapshot#5640
proggeramlug merged 3 commits into
mainfrom
fix/cross-module-member-new-capture-5437

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

What

A new ns.Class(...) construct where Class is imported from another module supplied no capture args — the captures live in the class's home module and aren't visible at the cross-module new site — so a captured value (e.g. a hoisted function) resolved to garbage/undefined and method calls on it threw value is not a function.

Found via Next.js dynamic/API routes (#5437): the app-route-turbo rJ route-module-class constructor does this.methods = r_(e) where r_ is a captured module-scope fn; cross-module member-new dropped it → route __init aborted → require(route.js) undefined → 500 on every dynamic/API route.

Fix

At ctor entry, rebind cross-module-captured params from the class's decl-site capture snapshot via js_class_capture_value_or (the same mechanism as the W6/TDZ capture fixes), driven by a new additive HIR Expr node emitted at the cross-module member-new site. (perry-hir ir/walkers/hash + static_field_meta.rs codegen.)

Validation

  • Regression test issue_5437_cross_module_member_new_capture (1 passed); broad capture/inheritance test issue_4972_derived_class_capture_super (3 passed) — no regression. 5 minimal repros of the cross-module member-new-capture shape match node.
  • In the bundle: the rJ/r_ value is not a function is eliminated; static routes stay byte-identical (no fix(codegen): #5437 — inline method-dispatch tower must build args per-case arity, not one global rest decision #5622 regression). (Dynamic routes still 500 on a separate, deeper scale-only blocker — captured undefined losing its NaN-box tag — tracked separately.)

Refs #5437.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed cross-module class construction so captured values are recovered correctly when a class is instantiated via a namespace import.
    • Improved constructor capture rebinds to support param-first selection with correct fallback to decl-site snapshots.
    • Ensured capture rebinds are applied early enough (at constructor entry) so captured reads work correctly before super().
  • Tests
    • Added regression tests for issue #5437, including param-first behavior and cross-module “member-new” capture recovery.

…l-site snapshot in the ctor

A function-nested class that captures an enclosing-scope local (e.g. a
hoisted sibling `function r_`) and is constructed via a member-callee
`new ns.C(...)` from a DIFFERENT compiled module routes to the runtime
construct path (`construct_registered_class_ref`) — which supplies NO
capture args, since the captured enclosing local is out of scope at the
construction site and the class can't be resolved statically there.
The synthesized `__perry_cap_*` ctor params then bound to garbage and the
captured local read as a non-callable: Next.js's app-route-turbo `rJ`
constructor (`this.methods = r_(e)`, constructed via
`new w.AppRouteRouteModule({...})` from the app-route template chunk)
threw `TypeError: value is not a function`, so the route module's init
aborted before self-registering its exports and every dynamic/API route
500'd with 'Cannot find module route.js'.

The W6 same-module member-new fix only filled captures from the decl-site
snapshot when the `new` site was statically routed to
`lower_new_member_captured` — the cross-module runtime construct never
reached it. Move the snapshot recovery INTO the synthesized constructor
body: at ctor entry each `__perry_cap_*` param is rebound to
`js_class_capture_value_or(class_id, slot, param)`. The ctor body is
compiled in the class's home module, where `class_name` resolves to its
real `class_id`, so EVERY construction path — inline, same-module member,
and cross-module runtime — recovers the captured value from the class's
own decl-site snapshot. Same-module bare-`new` is unchanged: the live cap
arg equals the snapshot value, so `_or` returns the identical value.

Adds a `fallback: Option<Box<Expr>>` to `Expr::ClassCaptureValue` (codegen
emits `js_class_capture_value_or` when present, plain
`js_class_capture_value` otherwise — the static-method prologue path is
unchanged); the walkers and stable-hash descend it.
@coderabbitai

coderabbitai Bot commented Jun 24, 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: c44f7aae-6b8a-4a49-ab77-ee4fc978a161

📥 Commits

Reviewing files that changed from the base of the PR and between f6f80e1 and 4b2b9c1.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/runtime_decls/strings.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-codegen/src/runtime_decls/strings.rs

📝 Walkthrough

Walkthrough

Extends Expr::ClassCaptureValue with fallback-aware data, updates constructor lowering and runtime selection for capture recovery, and adds regression tests for cross-module member new and param-first rebind behavior.

Changes

Cross-module capture recovery via ClassCaptureValue fallback

Layer / File(s) Summary
Expr::ClassCaptureValue IR contract and infrastructure
crates/perry-hir/src/ir/expr.rs, crates/perry-hir/src/walker/expr_ref.rs, crates/perry-hir/src/walker/expr_mut.rs, crates/perry-hir/src/stable_hash/expr.rs
Adds fallback and prefer_fallback to Expr::ClassCaptureValue, updates both walkers to traverse the fallback child when present, and extends stable hashing to cover the new fields.
Constructor prologue rebind synthesis
crates/perry-hir/src/lower_decl/class_captures.rs
Sets fallback: None for static capture reads and changes constructor synthesis to emit param-first rebind statements, this.__perry_cap_* assignments, and reordered insertion around super().
Runtime helper and codegen selection
crates/perry-codegen/src/runtime_decls/strings.rs, crates/perry-runtime/src/object/class_constructors.rs, crates/perry-codegen/src/expr/static_field_meta.rs
Declares and implements js_param_or_class_capture_value, then updates Expr::ClassCaptureValue lowering to choose between live params, decl-site snapshots, fallback expressions, or undefined.
Regression tests for capture recovery
crates/perry/tests/issue_5437_capture_rebind_param_first.rs, crates/perry/tests/issue_5437_cross_module_member_new_capture.rs
Adds regression coverage for param-first rebind behavior and cross-module member-new capture recovery using compiled TypeScript fixtures and runtime output checks.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PerryTS/perry#5178: Also changes constructor-synthesized this.__perry_cap_* handling during class initialization.
  • PerryTS/perry#5213: Also modifies class_captures.rs and ClassCaptureValue-related lowering.
  • PerryTS/perry#5568: Introduces the related snapshot-or-fallback runtime/codegen path that this PR extends with param-first selection.

Poem

🐇 I hopped through the modules with a twitchy nose,
Found a snapshot, a fallback, and where the capture grows.
If new comes from afar, the right value lands true—
No rabbit gets baffled, no TypeError gets through.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description has useful detail, but it does not follow the required template and omits Summary/Changes/Checklist sections. Rewrite it using the repository template: add Summary, Changes, Related issue, Test plan, Screenshots/output, and Checklist sections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific and clearly describes the core cross-module capture fix, even if it omits the param-first nuance.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/cross-module-member-new-capture-5437

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-hir/src/lower_decl/class_captures.rs (1)

527-536: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Rebind captures before pre-super() user code.

Because insert_at is after super(), legal derived-ctor code that reads a captured outer before super() is remapped to fresh_param_id but runs before snapshot recovery. Insert only the this.__perry_cap_* assignments after super(); the local-only rebinds can run at function entry.

Proposed ordering fix
-    let insert_at = super_pos.map(|p| p + 1).unwrap_or(0);
-    // Order at `insert_at`: rebinds (param = snapshot-or-param) FIRST, then
-    // the `this.__perry_cap_* = param` field stashes, then the user body.
-    let prologue: Vec<Stmt> = rebind_stmts.into_iter().chain(assignment_stmts).collect();
-    for (i, stmt) in prologue.into_iter().enumerate() {
-        ctor.body.insert(insert_at + i, stmt);
-    }
+    let rebind_count = rebind_stmts.len();
+    for (i, stmt) in rebind_stmts.into_iter().enumerate() {
+        ctor.body.insert(i, stmt);
+    }
+
+    let assignment_insert_at = super_pos
+        .map(|p| p + 1 + rebind_count)
+        .unwrap_or(rebind_count);
+    for (i, stmt) in assignment_stmts.into_iter().enumerate() {
+        ctor.body.insert(assignment_insert_at + i, stmt);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/lower_decl/class_captures.rs` around lines 527 - 536,
Reorder the derived-constructor prologue in class_captures::lower_decl so only
the `this.__perry_cap_*` assignment statements are inserted after `super()`,
while the local capture rebinds run at function entry. Update the
`super_pos`/`insert_at` insertion logic and the `prologue` construction so
`rebind_stmts` are emitted before any pre-`super()` user code, preserving
snapshot recovery before captured-outer reads.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-hir/src/lower_decl/class_captures.rs`:
- Around line 470-489: The ctor capture rebinding in class_captures should
prefer the live parameter value and only fall back to the decl-site snapshot
when the param is missing/undefined. Update the logic around the ctor capture
recovery path in the class capture lowering code so the binding for each capture
checks the current constructor param first, and uses ClassCaptureValue or the
class snapshot only as a fallback for the cross-module member-new case. Keep
this change localized to the capture rebinding flow used by the ctor body and
the __perry_cap_* stashing path.

---

Outside diff comments:
In `@crates/perry-hir/src/lower_decl/class_captures.rs`:
- Around line 527-536: Reorder the derived-constructor prologue in
class_captures::lower_decl so only the `this.__perry_cap_*` assignment
statements are inserted after `super()`, while the local capture rebinds run at
function entry. Update the `super_pos`/`insert_at` insertion logic and the
`prologue` construction so `rebind_stmts` are emitted before any pre-`super()`
user code, preserving snapshot recovery before captured-outer reads.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a3e5296-a51e-47e6-8827-ec06d0ed0656

📥 Commits

Reviewing files that changed from the base of the PR and between 7cd0d0f and 43d894b.

📒 Files selected for processing (7)
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-hir/src/ir/expr.rs
  • crates/perry-hir/src/lower_decl/class_captures.rs
  • crates/perry-hir/src/stable_hash/expr.rs
  • crates/perry-hir/src/walker/expr_mut.rs
  • crates/perry-hir/src/walker/expr_ref.rs
  • crates/perry/tests/issue_5437_cross_module_member_new_capture.rs

Comment thread crates/perry-hir/src/lower_decl/class_captures.rs
@proggeramlug
proggeramlug marked this pull request as draft June 24, 2026 14:54
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Converting to draft — CodeRabbit's two correctness findings are valid:

  1. (Major) the ctor rebind is snapshot-FIRST (ClassCaptureValue(fallback: param)), so a same-module new with a mutated captured outer (let x='a'; class C{ctor(){this.x=x}} x='b'; new C()) gets the stale decl-site 'a' instead of the live 'b'. It must prefer the live param and use the snapshot only when the param is absent/undefined (the cross-module member-new signal).
  2. (Quick) insert the capture rebinds at function entry (before any pre-super() user code); only the this.__perry_cap_* field stashes go after super().
    Reworking + adding a same-module-mutated regression test before un-drafting.

Reworks the synthesized-constructor capture rebind for two correctness
findings on the cross-module member-new fix:

- Param-first recovery: the live new-site cap arg now wins whenever
  present; the decl-site snapshot is consulted only when the param is
  undefined (the cross-module construct signal). Previously the
  snapshot-first js_class_capture_value_or could override a same-module
  new C() whose captured outer was mutated after the class declaration
  (let x="a"; class C{ctor(){this.x=x}}; x="b"; new C() gave "a",
  node gives "b"). New runtime FFI js_param_or_class_capture_value plus
  a prefer_fallback flag on Expr::ClassCaptureValue (default false keeps
  the snapshot-first behavior for static-method prologue rebinds).

- Rebind placement: the param rebinds now go at function entry, before
  any pre-super() user code (a derived ctor may read a captured outer
  before super()); only the this.__perry_cap_* field stashes wait until
  after super().

Adds regression tests for both findings (match node --experimental-strip-types).
@proggeramlug
proggeramlug marked this pull request as ready for review June 24, 2026 16:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/object/class_constructors.rs`:
- Around line 223-235: The helper js_param_or_class_capture_value is using live
undefined as the missing-argument signal, which causes same-module captures to
fall back to stale decl-site values instead of preserving an actual undefined.
Fix this by adding a separate presence flag or an unambiguous sentinel in the
synthesized constructor path, and update js_param_or_class_capture_value to only
use CLASS_CAPTURE_VALUES for truly absent capture args. Also extend the
ctor-related tests to cover same-module captures that become undefined at
runtime.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 05d6014d-a708-4861-a215-e0380d75779f

📥 Commits

Reviewing files that changed from the base of the PR and between 43d894b and f6f80e1.

📒 Files selected for processing (7)
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-hir/src/ir/expr.rs
  • crates/perry-hir/src/lower_decl/class_captures.rs
  • crates/perry-hir/src/stable_hash/expr.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry/tests/issue_5437_capture_rebind_param_first.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-hir/src/lower_decl/class_captures.rs

Comment on lines +223 to +235
pub extern "C" fn js_param_or_class_capture_value(param: f64, class_id: u32, index: u32) -> f64 {
if param.to_bits() != crate::value::TAG_UNDEFINED {
return param;
}
// param is `undefined` (cross-module construct dropped the cap arg):
// recover from the decl-site snapshot when one is registered for this
// class; otherwise stay `undefined`.
CLASS_CAPTURE_VALUES.with(|m| {
m.borrow()
.get(&class_id)
.and_then(|v| v.get(index as usize).copied())
.map(f64::from_bits)
.unwrap_or(param)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

undefined cannot safely mean “missing capture arg”.

Line 224 treats any live undefined capture as the cross-module fallback signal. That still misbinds same-module cases like let x = "a"; class C { ... } x = undefined; new C(): the helper will resurrect the stale decl-site snapshot instead of preserving the real live undefined. This also leaves a regression gap in the new tests. You'll need a separate presence bit or another sentinel that cannot collide with a valid JS value, then thread that through the synthesized ctor path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/class_constructors.rs` around lines 223 -
235, The helper js_param_or_class_capture_value is using live undefined as the
missing-argument signal, which causes same-module captures to fall back to stale
decl-site values instead of preserving an actual undefined. Fix this by adding a
separate presence flag or an unambiguous sentinel in the synthesized constructor
path, and update js_param_or_class_capture_value to only use
CLASS_CAPTURE_VALUES for truly absent capture args. Also extend the ctor-related
tests to cover same-module captures that become undefined at runtime.

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