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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions