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.
Summary
A mixin factory that directly returns a class expression with a runtime (dynamic) parent loses its binding:
Workaround — assign the class to a local first, then return it:
Root cause
Greetable's body lowers toReturn(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 trailingClassRefthrough theSequence, soGreetableis detected as a class-returning factory. At theconst M = Greetable(Base)site, the HIR (post-monomorphization) then clones the returned class under the binding nameM(module HIR showsClasses: 3 — Base, __anon_class_2, M) but drops the actualGreetable(Base)call and never binds theMlocal — the init statements becomeconsole.log(typeof LocalGet(0))/new LocalGet(0)()whereLocalGet(0)is unassigned.IR evidence (
--trace llvm):call i64 @js_value_typeof(double 0x7FFC000000000001)— typeof of undefined.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
makeLiteralClassetc.). A dynamic-parent mixin wires its parent per call viaRegisterClassParentDynamic, so cloning it as a static class namedMloses the per-call parent AND the call/binding.Fix direction
The factory-class-inlining that clones the returned class under the
const/letbinding name must be skipped when the factory's trailing return contains aRegisterClassParentDynamic(dynamic parent). Thenconst M = make(Base)lowers as a normal call + bind,Mholds the runtime class-ref, andtypeof/newdispatch dynamically (already correct — that's the workingconst C = …; return Cpath).Attempted but insufficient on its own: excluding dynamic-parent
Sequences from codegen'sresolve_class(func_returns_classtagging,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-basebfa0f258d, fails on the branch. Surfaces in the gap testtest_gap_class_advanced(mixin section). Narrow pattern with a trivial workaround, so tracked rather than blocking.