Skip to content

A non-capturing function-body class declaration shares one class across evaluations, so a factory's heritage chain collapses (mk(null) === mk(A)) #9502

Description

@proggeramlug

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions