Repository navigation
Description
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.
Activity
test(parity): triage test_gap_class_advanced as known regression (#5952)
Improve Zod compatibility support (#5874)
proggeramlug commentedon Jul 9, 2026
Root cause (investigated; deeper than a contained fix)
The class itself is fine —
new M(),m.value,m.greet(), andm instanceof Baseall work (they route through compile-time class tracking). The bug is narrower: the runtime value ofM(the stored mixin-factory result) is const-folded toundefined, sotypeof M/String(M)/M.namereadundefined.Precise isolation (current
main):So it fires only for a factory that directly returns a dynamic-parent class expression (
return class extends P {}, lowered toReturn(Sequence([RegisterClassParentDynamic{class_name:"__anon", parent_expr}, ClassRef("__anon")]))). The declaration form (const K = …; return K, returnsLocalGet(K)) and any non-directly-returned form work.The
mkcall is still emitted (it registers the class), butM's value is folded to a compile-time0x7FFC000000000001(TAG_UNDEFINED) fed straight tojs_value_typeof— i.e. a value-tracking pass evaluates the returnedClassRef("__anon")(a FUNCTION-LOCAL anon class, not in module-scopeclass_ids) toundefined.Ruled out (guarded, no effect):
inline::is_inlinable(skip factories returningRegisterClassParentDynamic) andinline::factory_specialize::classref_name(skip the dynamic-parentSequence). Both left the fold intact, so the responsible pass is the codegen local-value-alias / return-value const-propagation that resolvesLocalGet(M)to the ClassRef and then toundefinedbecause the anon class id isn't resolvable at the call-site scope. Fixing it means making that resolution materialize a proper INT32 class-ref value for a function-local anon dynamic-parent class (or suppressing the const-fold for it) — a codegen value-tracking change, not a transform-level guard.proggeramlug commentedon Jul 9, 2026
Update: deeper than a codegen symptom-fix
Traced further: after the factory transform, the module has a class literally named
Minmodule.classes, theconst MLetis removed, andtypeof Mstill lowers to a staleLocalGet(0)whose id has no entry inctx.locals/module_globalsand nolocal_id_to_namemapping — so it hits the codegen soft-fallback and returnsundefined. (new M()/instanceofuse a class-named HIR node, so they resolve correctly.)A codegen soft-fallback fix ("stale LocalGet whose name is a registered class → resolve to the class ref") does not fire, because the stale id carries no name to look up. The correct fix must live in the transform that creates class
Mfromconst M = mk(Base): it needs to either keep a value binding (Let { M, init: ClassRef(M) }, as the working captured-class factory does — that path keepsLet { Cls, init: ClassRef("Inner") }) or rewrite the survivingLocalGet(M)references toClassRef(M). I could not locate that specific transform (it is neitherinline::is_inlinable-gated inlining norfactory_specialize's clone path, whose clone name is…__inline_N, not the binding name). Leaving this for a focused pass with the ability to bisect the factory→class-Mcreation site.proggeramlug commentedon Jul 13, 2026
Root cause found — and two corrections to this issue
Fixed in #6355. Two things on this issue were stale; correcting them for the record.
1. The provenance section is wrong — PR #5874 is exonerated.
The #5874 payload was fully reverted from
mainin5afa08fca(PR #6015,"revert: remove the #5874 payload from main — restore 322 test262 regressions"),
and
typeof Mis stillundefinedon currentmain. So the bisect thatpointed at #5874 is stale. The real code — the mixin fast path and its missing
value binding — dates to
1e65d9c22(2026-04-11), long before #5874.2.
test_gap_class_advancedno longer covers this. It matchesnode --experimental-strip-typesbyte-for-byte onmaintoday, so the gap suitehad lost coverage of this bug. #6355 adds
test-files/test_gap_5952_mixin_factory_binding.tsas the new coverage.The actual root cause
It is not in
perry-transform's factory specialization, and not in codegen'svalue tracking. It is the mixin fast path in HIR lowering.
pre_scan_mixin_functions(crates/perry-hir/src/lower/pre_scan.rs) records everyfunction Mixin(B) { return class extends B { … } }— one param, one statement,returning a class expression that
extendsthat param. That's exactly the failingshape, and exactly why the two workarounds in the report (
const K = …; return K,or any extra statement in the body) work: they don't match the pre-scan, so they
fall through to the normal
Callpath.At a
const M = Mixin(Base)call site,crates/perry-hir/src/lower/stmt.rscopiesthe mixin's class AST, rewrites
extendsto the concrete base, registers it as areal class under the binding name
M— and thencontinues, never emittingthe value binding for
M. The siblingconst X = class {…}arm a few lines aboveends with
emit_class_expression_value_binding(...), which pushesLet { name, init: ClassRef(name) }. The mixin arm just forgot it.That explains the exact split in the symptom: everything keyed off the class NAME
(
New { class_name: "M" },InstanceOf { ty: "M" }) resolved fine, sonew M().greet()andm instanceof Baseworked; everything that reads the VALUE(
typeof M,M.name,String(M)) hit an unbound local and folded toundefined.--print-hirshows it directly — classMis there, theLetis not:specialize_captured_class_factoriesnever sees this shape — theLetis alreadygone before the HIR reaches
perry-transform. It is untouched by the fix and its#740 win (a static-parent factory with a captured param baked in per call site)
still fires.
Also fixed as part of this:
M.namemust be""— a directly-returned classexpression gets no NamedEvaluation from the call site's
const M =— butjs_register_class_nametreated a zero-length name as a no-op, so the class neverlanded in
CLASS_NAMESand.namereadundefinedeven once the binding existed.Full gap-suite A/B (298 tests) against a pristine
origin/maintoolchain: zeroregressions.
fix(hir): bind the value of a mixin-factory result (#5952) (#6355)