Skip to content

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

Closed

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.

Activity

  1. proggeramlug commented on Jul 9, 2026

    @proggeramlug
    Contributor

    Root cause (investigated; deeper than a contained fix)

    The class itself is fine — new M(), m.value, m.greet(), and m instanceof Base all work (they route through compile-time class tracking). The bug is narrower: the runtime value of M (the stored mixin-factory result) is const-folded to undefined, so typeof M / String(M) / M.name read undefined.

    Precise isolation (current main):

    function mkI(P:any){ return class extends P {}; }  const B = mkI(Base); typeof B  // "undefined" ✗
    function mkD(P:any){ const K = class extends P {}; return K; }  const C = mkD(Base); typeof C  // "function" ✓
    function mkX(P:any){ console.log(""); return class extends P {}; }  const A = mkX(Base); typeof A  // "function" ✓

    So it fires only for a factory that directly returns a dynamic-parent class expression (return class extends P {}, lowered to Return(Sequence([RegisterClassParentDynamic{class_name:"__anon", parent_expr}, ClassRef("__anon")]))). The declaration form (const K = …; return K, returns LocalGet(K)) and any non-directly-returned form work.

    The mk call is still emitted (it registers the class), but M's value is folded to a compile-time 0x7FFC000000000001 (TAG_UNDEFINED) fed straight to js_value_typeof — i.e. a value-tracking pass evaluates the returned ClassRef("__anon") (a FUNCTION-LOCAL anon class, not in module-scope class_ids) to undefined.

    Ruled out (guarded, no effect): inline::is_inlinable (skip factories returning RegisterClassParentDynamic) and inline::factory_specialize::classref_name (skip the dynamic-parent Sequence). Both left the fold intact, so the responsible pass is the codegen local-value-alias / return-value const-propagation that resolves LocalGet(M) to the ClassRef and then to undefined because 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.

  2. proggeramlug commented on Jul 9, 2026

    @proggeramlug
    Contributor

    Update: deeper than a codegen symptom-fix

    Traced further: after the factory transform, the module has a class literally named M in module.classes, the const M Let is removed, and typeof M still lowers to a stale LocalGet(0) whose id has no entry in ctx.locals/module_globals and no local_id_to_name mapping — so it hits the codegen soft-fallback and returns undefined. (new M()/instanceof use 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 M from const 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 keeps Let { Cls, init: ClassRef("Inner") }) or rewrite the surviving LocalGet(M) references to ClassRef(M). I could not locate that specific transform (it is neither inline::is_inlinable-gated inlining nor factory_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-M creation site.

  3. proggeramlug commented on Jul 13, 2026

    @proggeramlug
    Contributor

    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 main in 5afa08fca (PR #6015,
    "revert: remove the #5874 payload from main — restore 322 test262 regressions"),
    and typeof M is still undefined on current main. So the bisect that
    pointed 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_advanced no longer covers this. It matches
    node --experimental-strip-types byte-for-byte on main today, so the gap suite
    had lost coverage of this bug. #6355 adds
    test-files/test_gap_5952_mixin_factory_binding.ts as the new coverage.

    The actual root cause

    It is not in perry-transform's factory specialization, and not in codegen's
    value tracking. It is the mixin fast path in HIR lowering.

    pre_scan_mixin_functions (crates/perry-hir/src/lower/pre_scan.rs) records every
    function Mixin(B) { return class extends B { … } } — one param, one statement,
    returning a class expression that extends that param. That's exactly the failing
    shape, 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 Call path.

    At a const M = Mixin(Base) call site, crates/perry-hir/src/lower/stmt.rs copies
    the mixin's class AST, rewrites extends to the concrete base, registers it as a
    real class under the binding name M — and then continues, never emitting
    the value binding for M
    . The sibling const X = class {…} arm a few lines above
    ends with emit_class_expression_value_binding(...), which pushes
    Let { 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, so
    new M().greet() and m instanceof Base worked; everything that reads the VALUE
    (typeof M, M.name, String(M)) hit an unbound local and folded to undefined.
    --print-hir shows it directly — class M is there, the Let is not:

    Classes: 3
      - Base
      - __anon_class_2
      - M                                                     <- synthesized by the fast path
    Init statements: 7
      [0] Expr(console.log("typeof:", TypeOf(LocalGet(0))))   <- LocalGet(0) never assigned
      [2] Let { id: 1, name: "m", init: New { class_name: "M" } }   <- works: keyed by name
    

    specialize_captured_class_factories never sees this shape — the Let is already
    gone 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.name must be "" — a directly-returned class
    expression gets no NamedEvaluation from the call site's const M = — but
    js_register_class_name treated a zero-length name as a no-op, so the class never
    landed in CLASS_NAMES and .name read undefined even once the binding existed.

    Full gap-suite A/B (298 tests) against a pristine origin/main toolchain: zero
    regressions.

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