Skip to content

fix(hir): a nested class's own name beats an enclosing binding in new - #8153

Merged
proggeramlug merged 2 commits into
mainfrom
fix/8040-nested-class-name-shadow
Aug 16, 2026
Merged

fix(hir): a nested class's own name beats an enclosing binding in new#8153
proggeramlug merged 2 commits into
mainfrom
fix/8040-nested-class-name-shadow

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Refs #8040.

What breaks

A class C declared inside a nested function, constructed by new C() from one of its own method bodies, throws TypeError: undefined is not a constructor whenever some enclosing scope also declares a binding named C. Node runs it — the class's own name binding is the nearest one.

var A: any;                       // enclosing binding, never assigned
const g = () => {
  class A {
    static mk() { return new A(); }   // <- throws under Perry
    m() { return "ok"; }
  }
  return A;
};
console.log(g().mk().m());        // node: "ok"   perry: TypeError: undefined is not a constructor

Root cause

Two arms of ident lowering disagreed about the same name.

The bare-ident read arm (arm_ident.rs) already applied the JS nearest-binding rule through forward_class_names + forward_class_decl_depth, so a plain A inside the method resolved to ClassRef("A")typeof A in that same method returns "function".

The new <Ident> arm did not. It snapshotted ctx.lookup_local("A") unconditionally, found the enclosing scope's binding, and rerouted the construct to NewDynamic { callee: LocalGet(<outer slot>) }. A method compiles to its own function, so that slot index names an unrelated, uninitialized local there; the callee evaluates to undefined and the construct throws.

Everything up to that point is silent: the class registers, its methods exist, and every reference to the name other than new resolves correctly.

The fix

Extract the rule into LoweringContext::forward_class_shadows_local and call it from both arms so they cannot drift again:

  • a local declared in the CURRENT scope always wins (a sibling param/var is nearer than any class);
  • otherwise the binding at the greater scope depth wins.

The depth half is what preserves the case the reroute exists for: a module-scope class e still loses to a factory-local let e, so mysql2's bundled chunk keeps constructing the local's value. That direction has its own test in this PR.

Why it matters

Next 16 ships exactly this shape in the webpack chunk that inlines @opentelemetry/api: the module IIFE declares var g,h,i,j,… and an inner factory declares

class i {
  static getInstance(){ return this._instance || (this._instance = new i), this._instance }
  active(){ return this._getContextManager().active() }
}

getInstance() threw, so the module factory aborted mid-initialization; webpack's module cache then handed the tracer a {} for @opentelemetry/api, and context never got its active().

Validation

  • cargo test -p perry-hir — 312 lib tests plus every integration suite, all green, exit 0.

  • Sabotage: with the new guard forced off, nested_class_shadowing_outer_var_constructs_the_class_not_the_local fails printing the exact defect:

    `new A()` inside A's own method must not construct through an enclosing-scope local slot: [
        Return(Some(NewDynamic { callee: LocalGet(0), args: [], byte_offset: 132 })),
    ]
    

    The companion test passes either way by design — it exists to catch over-triggering, not to detect this bug.

  • cargo fmt --all -- --check and scripts/check_file_size.sh clean.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed nested class construction when a class name conflicts with an outer local variable.
    • Ensured new expressions resolve to the nearest valid class or local binding.
    • Preserved expected behavior for factory- and method-local variables that intentionally shadow class names.
  • Tests

    • Added regression coverage for nested classes, repeated class names, and local shadowing scenarios.

@proggeramlug
proggeramlug force-pushed the fix/8040-nested-class-name-shadow branch from 23f70d1 to 8822bf3 Compare August 15, 2026 09:26
@coderabbitai

coderabbitai Bot commented Aug 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: 04b64f45-68a9-4600-843d-c62ae6470a9a

📥 Commits

Reviewing files that changed from the base of the PR and between 499e296 and 72c8b74.

📒 Files selected for processing (5)
  • changelog.d/8153-nested-class-name-shadow.md
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_new.rs
  • crates/perry-hir/src/lower/lower_expr/arm_ident.rs
  • crates/perry-hir/src/lower/tests.rs

Included review availability: Your plan includes up to 8 reviews per rolling hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Nested class-name resolution now compares class and local declaration depth. Identifier and new lowering use the shared predicate. Regression tests cover nested classes, valid local shadowing, repeated class names, and dynamic construction.

Changes

Nested class shadowing

Layer / File(s) Summary
Centralized binding resolution
crates/perry-hir/src/lower/context.rs, crates/perry-hir/src/lower/lower_expr/arm_ident.rs
forward_class_shadows_local centralizes class-versus-local resolution and applies lexical declaration depth. Identifier lowering uses the shared predicate.
Constructor lowering integration
crates/perry-hir/src/lower/expr_new.rs
new lowering suppresses an unrelated outer local when a nearer class declaration exists. The fallback path no longer restores that local binding.
Regression coverage and changelog
crates/perry-hir/src/lower/tests.rs, changelog.d/8153-nested-class-name-shadow.md
Tests cover nested classes, multiple same-named classes, factory-local precedence, and method-local dynamic construction. The changelog records the fix and validation.Estimated code review effort: 3 (Moderate)

Possibly related PRs

  • PerryTS/perry#6730: Both changes adjust constructor resolution in expr_new.rs, but this PR handles nested class shadowing.
  • PerryTS/perry#8077: Both changes update HIR binding resolution and expr_new.rs, but this PR handles class bindings rather than proxies.

Suggested reviewers: thehypnoo, jdalton

Merge Risk: ⚪ Minimal · up to 72c8b

This localized fix aligns nested-class construction with JavaScript name resolution and includes targeted regression tests; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix for nested class-name shadowing in new expressions.
Description check ✅ Passed The description clearly explains the bug, root cause, fix, related issue, regression tests, and validation results.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/8040-nested-class-name-shadow

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

Copy link
Copy Markdown
Contributor Author

End-to-end on the #8040 fixture

Measured on tests/release/packages/next-app-route (copy of the fixture; app compiled as an executable rather than the dylib+provider split, since the provider crates are not on main).

main alone cannot start the fixture — Error: Cannot find module './chunks/2.js' — so the run below is this branch merged with #8146 (fix/8040-runtime-relative-chunk-require), which is the startup half.

before with this fix + #8146
startup PERRY_NEXT_APP_ROUTE_READY same
per request TypeError: active is not a function, HTTP 500, empty body no active error anywhere, HTTP 200, empty body after ~5 min
harness assertion not reached generated handler bypassed routeModule.handle

So the tracer failure this PR targets is gone from the real app. What is behind it is a different defect, not a variant of this one:

  1. The response body is never written. GET /api/benchmark?id=… answers 200 with content-length: 0 after ~5 minutes; node's oracle answers 207 with the JSON payload immediately. (With perry-host.js's own guard left as a throw, that same request is a 500 — the 500 is the harness's catch, not the route.)
  2. A property write on the required routeModule object is not observed by the generated handler. perry-host.js does routeModule.handle = async (req, ctx) => { … } over the binding it got from require("./.next/server/app/api/benchmark/route.js"), and the compiled handler never enters it — 20/20 requests. That is what its bypass guard reports.

Neither reproduces in the otel harness this PR is validated against; they want their own issue.

Ralph Küpper added 2 commits August 15, 2026 21:53
`class C` declared inside a nested function, constructed by `new C()` from
one of its own method bodies, threw `TypeError: undefined is not a
constructor` whenever an enclosing scope also declared `var C` / `let C`.

The bare-ident read arm already applied the JS nearest-binding rule, so a
plain `C` in the same method resolved to the class. The `new <Ident>` arm
did not: it snapshotted `lookup_local("C")` unconditionally and rerouted the
construct to `NewDynamic { LocalGet(<outer slot>) }`. A method compiles to
its own function, where that slot index names an unrelated uninitialized
local, so the callee evaluated to `undefined`.

Extract the rule into `LoweringContext::forward_class_shadows_local` and use
it from both arms so they cannot drift again. The depth half of the rule
keeps the case the reroute exists for: a module-scope `class e` still loses
to a factory-local `let e`.

Next 16's webpack chunk for the bundled `@opentelemetry/api` is this shape,
which is why a production App Route could not serve a request (#8040).
The collision rename accidentally immunises every duplicate single-letter
class, so only the first `class <letter>` of a name reaches the reroute.
That asymmetry is why the bundled @opentelemetry/api lost `context` and
`propagation` but kept `trace`, and why the symptom moves when unrelated
code is added to the file. Add that shape plus an over-trigger guard for a
method-scope local named after its own class.
@proggeramlug
proggeramlug force-pushed the fix/8040-nested-class-name-shadow branch from b1a1495 to 72c8b74 Compare August 15, 2026 19:53
@proggeramlug
proggeramlug marked this pull request as ready for review August 16, 2026 02:44
@proggeramlug
proggeramlug merged commit 0c5dbc9 into main Aug 16, 2026
46 of 59 checks passed
@proggeramlug
proggeramlug deleted the fix/8040-nested-class-name-shadow branch August 16, 2026 05:33
proggeramlug pushed a commit that referenced this pull request Aug 16, 2026
Merged rather than rebased: an earlier rebase of this branch silently
dropped five pushed commits, one of which (the landing-pad regression
revert, re-landed here as c133250) is load-bearing — without it a plain
try/catch aborts FATAL "no landing pad" under the default statepoint
build, because #7982 retypes every JS catch pad to a zero-action cleanup.

Conflict resolutions:

* crates/perry-hir/src/lower/expr_new.rs — main's `forward_class_shadows_local`
  (#8153) supersedes this branch's `is_current_class_self` gate on the callee
  snapshot. The depth rule keeps the mysql2 case working (a module-scope
  `class e` must not beat a factory-local `let e`) and is the form the branch's
  own class_self_new_shadowing tests are written against.
* crates/perry/src/commands/compile/cjs_wrap/wrap.rs — main's #8146 structure
  (explicit prefix test that STRIPS the leading `./`, `.json` fallthrough
  outside the block), plus this branch's bare `'.'` / `'..'` join. The latter
  is shipped behaviour the changeset claims: `js_require_path_module` resolves
  those through `directory_module_candidates`, and without the join the
  registry key stays a bare `.` and can never hit.
* crates/perry/src/commands/compile/build_cache.rs — both knobs kept.
  `PERRY_LL_RS4GC_OPTNONE_INSTRS` is already registered on main (#8128, with
  its own comment) and this branch listed it a second time; keeping main's
  line leaves both it and `PERRY_LL_O0_MAX_FN_BYTES` (#8144) registered
  exactly once each rather than duplicating one of them.
* crates/perry-codegen/src/codegen/entry.rs — comment-only divergence, main's.
* crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs —
  `__perry_path_specifier` -> `__perry_path_spec` rename, main's.

Retargeted the two cjs_wrap tests that pinned this branch's pre-#8146
ternary form onto #8146's emitted shape.
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