Skip to content

Mixin factory that directly returns a dynamic-parent class expression loses its binding (regression) #5952

Description

@TheHypnoo

Summary

A mixin factory that directly returns a class expression with a runtime (dynamic) parent loses its binding:

class Base { value = "base value"; }
function Greetable<T extends new (...a: any[]) => any>(B: T) {
  return class extends B { greet() { return "hi"; } };   // direct return of `class extends <param>`
}
const M = Greetable(Base);
console.log(typeof M);          // Perry: "undefined"   | Node: "function"
console.log(new M().greet());   // Perry: TypeError: undefined is not a constructor | Node: "hi"

Workaround — assign the class to a local first, then return it:

function Greetable(B: any) {
  const C = class extends B { greet() { return "hi"; } };
  return C;   // returning a LocalGet, not a bare class expression, works
}

Root cause

Greetable's body lowers to Return(Sequence([RegisterClassParentDynamic { class_name: "__anon", parent_expr: LocalGet(param) }, ClassRef("__anon")])).

classref_name / trailing_return_classref (crates/perry-hir/src/lower/misc.rs) resolves the trailing ClassRef through the Sequence, so Greetable is detected as a class-returning factory. At the const M = Greetable(Base) site, the HIR (post-monomorphization) then clones the returned class under the binding name M (module HIR shows Classes: 3 — Base, __anon_class_2, M) but drops the actual Greetable(Base) call and never binds the M local — the init statements become console.log(typeof LocalGet(0)) / new LocalGet(0)() where LocalGet(0) is unassigned.

IR evidence (--trace llvm):

  • Broken (direct return): call i64 @js_value_typeof(double 0x7FFC000000000001) — typeof of undefined.
  • Working (const C = …; return C): call i64 @js_value_typeof(double <class-ref>) — typeof of the runtime class-ref → "function".

The class-clone is only valid for static-parent factories (Effect's makeLiteralClass etc.). A dynamic-parent mixin wires its parent per call via RegisterClassParentDynamic, so cloning it as a static class named M loses the per-call parent AND the call/binding.

Fix direction

The factory-class-inlining that clones the returned class under the const/let binding name must be skipped when the factory's trailing return contains a RegisterClassParentDynamic (dynamic parent). Then const M = make(Base) lowers as a normal call + bind, M holds the runtime class-ref, and typeof/new dispatch dynamically (already correct — that's the working const C = …; return C path).

Attempted but insufficient on its own: excluding dynamic-parent Sequences from codegen's resolve_class (func_returns_class tagging, crates/perry-codegen/src/codegen/helpers.rs). The HIR-level class-clone persists, so the fix belongs in the HIR factory-inlining/monomorphization pass.

Provenance

Regression introduced by the class-expression / factory work on feat/zod-compat-fixtures (PR #5874). Verified: passes at the merge-base bfa0f258d, fails on the branch. Surfaces in the gap test test_gap_class_advanced (mixin section). Narrow pattern with a trivial workaround, so tracked rather than blocking.

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