Skip to content

fix(class): new.target reflects the actual constructed class, not the enclosing one#5061

Merged
proggeramlug merged 1 commit into
mainfrom
fix/reflect-construct-error-messages
Jun 13, 2026
Merged

fix(class): new.target reflects the actual constructed class, not the enclosing one#5061
proggeramlug merged 1 commit into
mainfrom
fix/reflect-construct-error-messages

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Surfaced while verifying #2768.

Bug

HIR hardcoded new.target inside a class constructor to a literal { name: <enclosing-class> } (lower/expr_misc.rs, plus the .name member folds in expr_member.rs / lower_expr.rs). Two consequences:

  • new.target was always the class whose body runs. For new Derived() where Derived extends Base and Base's constructor is inlined via super(), new.target was Base, not Derived.
  • new.target === SomeClass was always false — a fresh object literal never equals the class reference — which silently breaks the abstract-base-class guard idiom if (new.target === Abstract) throw ….

new.target.prototype was also always undefined.

Fix

Lower new.target (and its .name / .prototype / ?. member reads) to the real Expr::NewTarget. Codegen resolves it to the leaf class ref (INT32_TAG | class_id — the same value Expr::ClassRef produces, so identity / .name / .prototype all work) via a new_target_stack slot pushed around the inlined constructor body, in both construction paths:

  • the ordinary inline-new path (lower_call/new.rs), and
  • the scalar-replacement path for a non-escaping const c = new C() (stmt/let_stmt.rs).

Using the codegen slot (not the runtime js_new_target_* cell) keeps a non-constructor method called from the constructor body — compiled as a separate function with an empty new_target_stack — correctly reading undefined.

Validation (all byte-identical to node --experimental-strip-types)

  • new Derived() new.target is the leaf class across no-own-ctor, explicit-super(), field-bearing, and 3-level chains.
  • new.target === C identity, .name, .prototype === C.prototype all correct.
  • Abstract-base guard if (new.target === Abstract) throw fires for the direct new Abstract() and not for a subclass.
  • Nested construction (new Outer() whose ctor does new Inner()) and scalar-replaced const c = new C() both restore the correct target; const t = new.target binding works.
  • A method called from a constructor reads new.target === undefined (no leak).
  • All perry-codegen + perry-hir unit tests pass.
  • Gap suite: the fix binary's failure set is identical to a pristine-main baseline — the 33 apparent deltas were load-induced timeouts in the auto-optimize sweep and all 33 pass cleanly when re-run in isolation (0 regressions).

Known remaining limitation

Reflect.construct(KnownClass, args, differentNewTarget) where the explicit newTarget differs from the target still folds to inline new KnownClass(args) (HIR Reflect.construct fold), so new.target inside is KnownClass rather than differentNewTarget. Threading a differing newTarget into a registered class's constructor needs the runtime class-ref construct path to honor the new.target cell (it already does for plain-function targets — Reflect.construct(fn, args, nt) is correct). Left as-is to avoid a runtime regression (the prior fold behavior is preserved, non-throwing); tracked under #2768.

Code-only PR — version bump + changelog left for merge time.

… enclosing one

HIR hardcoded `new.target` inside a class constructor to a literal
`{ name: <enclosing-class> }` (expr_misc.rs / expr_member.rs /
lower_expr.rs). So `new.target` was always the class whose BODY runs —
for `new Derived()` that inlines `Base`'s constructor via super(), it
was `Base`, not `Derived` — and `new.target === SomeClass` was always
false (a fresh object never equals the class ref), breaking the
abstract-base-class guard idiom `if (new.target === Abstract) throw`.

Lower `new.target` (and `new.target.name` / `.prototype` / `?.` member
reads) to the real `Expr::NewTarget`. Codegen resolves it to the active
constructor's LEAF class ref (`INT32_TAG | class_id`, the same value
`Expr::ClassRef` produces) via a `new_target_stack` slot pushed around
the inlined constructor body — in both the ordinary inline-`new` path
(lower_call/new.rs) and the scalar-replacement path for a non-escaping
`const c = new C()` (stmt/let_stmt.rs). Using the codegen slot rather
than the runtime cell keeps a non-constructor method called from the
ctor body — compiled separately, empty new_target_stack — correctly
reading `undefined`.

Now matches Node: `new Derived()` new.target is the leaf class across
no-own-ctor / explicit-super / field-init-bearing / multi-level chains;
`new.target === C`, `.name`, `.prototype` all correct; abstract guards
fire; nested and scalar-replaced construction restore the outer target.

Surfaced while verifying #2768 (whose functional acceptance criteria —
newTarget honored, array-like args, constructor/proxy validation — all
pass on current main).
@proggeramlug proggeramlug merged commit c92d16c into main Jun 13, 2026
13 checks passed
@proggeramlug proggeramlug deleted the fix/reflect-construct-error-messages branch June 13, 2026 07:29
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