Repro
function mk(P: any): any { class D extends (P ?? Object) {} return D; }
const A = mk(null);
const B = mk(A);
const C = mk(B);
console.log("ident " + (A === B) + " " + (B === C));
console.log("gp " + (Object.getPrototypeOf(B) === A));
node : ident false false
gp true
perry: ident true true
gp false
Each call to mk must produce a distinct class whose superclass is the
previous one. Perry produces one class for all three evaluations, so A === B,
and the heritage edge B extends A is unrepresentable — getPrototypeOf(B)
answers with whatever single heritage the shared template recorded (Object)
instead of A.
Why it happens
A class DECLARATION in a function body only gets a per-evaluation heap class
object when fresh_binding is true —
crates/perry-hir/src/lower_decl/body_stmt.rs:
let fresh_binding = has_private_elements
|| !computed_keys.is_empty()
|| (!captured_exprs.is_empty() && !has_static_state);
captured_exprs comes from ctx.lookup_class_captures(...), which sees the
class BODY. A class whose only per-evaluation input is its heritage
expression captures nothing in its body, has no private elements and no
computed keys — so it keeps the shared-template lowering and every evaluation
answers to one class id.
Both neighbouring shapes are already correct, which is what makes this the
narrow remaining case:
// class EXPRESSION — correct, `same? false`
function mk(P: any): any { return class D extends (P ?? Object) {}; }
// capture-carrying DECLARATION — correct, `same? false`
function mk(P: any, tag: any): any {
class D extends (P ?? Object) { t: any = tag; }
return D;
}
Widening the predicate to class.extends_expr.is_some() is the obvious fix and
is a lowering change with a wide blast radius: extends_expr is Some for
a great many ordinary shapes (class X extends _mod.Base {} inside a webpack
factory, aliased heritage, lexically-shadowed parent names — see
lower_decl/class_decl.rs), so every function-nested class with a non-static
parent name would start materializing a per-evaluation class object. That is
why #9406 left it alone: it was fixing a SIGSEGV, not re-shaping lowering.
Relationship to #9406 and #6465
#9406 fixed the crash this shape used to produce (the second evaluation stashed
ClassRef(D) against D itself, so super() re-entered D's own constructor
forever). It deliberately did not change identity. Before that fix,
getPrototypeOf(B) === A accidentally read true — because the self-edge made
getPrototypeOf(B) answer B, and B === A. It now reads false, which is
the honest answer for one collapsed class.
#6465 is the stale tracker for this. It is closed (2026-07-17, when the
capturing half landed), but lower_decl/body_stmt.rs still says:
Capturing classes with public static state retain the shared-template path for
now, but private elements always require a fresh evaluation — including
private static state. Prototype identity is still shared per template — the
remaining gap tracked on #6465.
so the code points a reader at a closed issue. That comment should name this
issue instead.
Environment
perrymaster, Node oracle 26.5.1 (.node-version). Reproduces on
e284cabc3 (#9406's merge base) and with #9406 applied — it is not a
regression from that PR, and that PR does not fix it.
Repro
Each call to
mkmust produce a distinct class whose superclass is theprevious one. Perry produces one class for all three evaluations, so
A === B,and the heritage edge
B extends Ais unrepresentable —getPrototypeOf(B)answers with whatever single heritage the shared template recorded (
Object)instead of
A.Why it happens
A class DECLARATION in a function body only gets a per-evaluation heap class
object when
fresh_bindingis true —crates/perry-hir/src/lower_decl/body_stmt.rs:captured_exprscomes fromctx.lookup_class_captures(...), which sees theclass BODY. A class whose only per-evaluation input is its heritage
expression captures nothing in its body, has no private elements and no
computed keys — so it keeps the shared-template lowering and every evaluation
answers to one class id.
Both neighbouring shapes are already correct, which is what makes this the
narrow remaining case:
Widening the predicate to
class.extends_expr.is_some()is the obvious fix andis a lowering change with a wide blast radius:
extends_exprisSomefora great many ordinary shapes (
class X extends _mod.Base {}inside a webpackfactory, aliased heritage, lexically-shadowed parent names — see
lower_decl/class_decl.rs), so every function-nested class with a non-staticparent name would start materializing a per-evaluation class object. That is
why #9406 left it alone: it was fixing a SIGSEGV, not re-shaping lowering.
Relationship to #9406 and #6465
#9406 fixed the crash this shape used to produce (the second evaluation stashed
ClassRef(D)againstDitself, sosuper()re-enteredD's own constructorforever). It deliberately did not change identity. Before that fix,
getPrototypeOf(B) === Aaccidentally readtrue— because the self-edge madegetPrototypeOf(B)answerB, andB === A. It now readsfalse, which isthe honest answer for one collapsed class.
#6465 is the stale tracker for this. It is closed (2026-07-17, when the
capturing half landed), but
lower_decl/body_stmt.rsstill says:so the code points a reader at a closed issue. That comment should name this
issue instead.
Environment
perrymaster, Node oracle 26.5.1 (.node-version). Reproduces one284cabc3(#9406's merge base) and with #9406 applied — it is not aregression from that PR, and that PR does not fix it.