Skip to content

fix(hir): bind the value of a mixin-factory result (#5952)#6355

Merged
proggeramlug merged 3 commits into
mainfrom
fix/5952-mixin-factory-binding
Jul 13, 2026
Merged

fix(hir): bind the value of a mixin-factory result (#5952)#6355
proggeramlug merged 3 commits into
mainfrom
fix/5952-mixin-factory-binding

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #5952.

Symptom

class Base { value = "base value"; }
function Greetable(B: any) {
  return class extends B { greet() { return "hi"; } };
}
const M = Greetable(Base);
typeof M      // node: "function"   perry: "undefined"
M.name        // node: ""           perry: TypeError: Cannot read properties of undefined
new M().greet()  // "hi" on both

new M() and instanceof M worked; only the runtime value of M was missing.

Root cause — the mixin fast path in HIR lowering, not the factory pass

pre_scan_mixin_functions (crates/perry-hir/src/lower/pre_scan.rs) records every
function Mixin(B) { return class extends B { … } } — a single param, a single
statement, returning a class expression that extends that param. A
const M = Mixin(Base) call site then takes a fast path in
crates/perry-hir/src/lower/stmt.rs which copies the mixin's class AST, rewrites
its extends clause to the concrete base, and registers it as a real class under
the binding name M. That's what makes new M(), instanceof M and the
inherited field/method work.

The path then continued straight out of the declaration — it never emitted 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 simply forgot it. Every
consumer that keys off the class NAME (New { class_name: "M" },
InstanceOf { ty: "M" }) resolved fine, while every consumer that reads the
VALUE (typeof M, M.name, String(M), M === M) hit an unbound local and
folded to undefined.

--print-hir on the repro shows it exactly — class M is present, 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
  [1] Expr(console.log("name:", PropertyGet(LocalGet(0), "name")))
  [2] Let { id: 1, name: "m", init: New { class_name: "M" } }   <- works: keyed by name

This is not resolve_factory_return_class / specialize_captured_class_factories
in perry-transform. That pass never sees this shape: the Let is already gone by
the time the HIR reaches it. I verified the localization by dumping the HIR rather
than by inspection — the transform pass is reached only for the shapes the fast
path declines (see the matrix below), and all of those already worked.

Fix

  1. crates/perry-hir/src/lower/stmt.rs — call
    emit_class_expression_value_binding before the continue, so M holds
    ClassRef("M"), exactly like the sibling class-expression arm.

  2. .name — the synthesized class is keyed by the binding name, but a
    directly-returned class expression gets no NamedEvaluation from the call
    site's const M =, so per spec M.name is "" (or the class expression's
    own name for return class Marker extends B {}). pre_scan_mixin_functions
    now records that name (MixinFn { param_name, class_expr_name, class_ast })
    and the call site registers it via the existing class_display_names
    override, so codegen emits the spec name rather than the registration key.

  3. crates/perry-runtime/.../class_meta.rsjs_register_class_name
    treated a zero-length name as a no-op, so the anonymous class never landed in
    CLASS_NAMES and .name still read undefined. An anonymous class's .name
    IS the empty string; membership in CLASS_NAMES means "this id is a real
    class", which stays true for an anonymous one. Only the null pointer and the
    reserved id 0 are rejected now.

  4. prototype_equality.rs — registering an empty name makes its previously
    unreachable !name.is_empty() filter live and wrong: it would treat an
    anonymous class as unregistered and collapse its instances onto
    Object.prototype. It now tests registration, not the name's emptiness.

The factory-specialization pass is untouched and still fires

specialize_captured_class_factories exists to monomorphize a class-returning
factory per call site: it clones the returned class with the factory's captured
params baked in as constants, drops the synthesized __perry_cap_* ctor
params/fields, and rewrites the Call to a ClassRef of the clone (issue #740
Effect's makeLiteralClass shape). Nothing in this PR touches that pass, and the
mixin fast path it competes with is strictly narrower (single param, single-stmt
return class extends <param>, called with one identifier naming a known class).
Every other shape still routes through Callfactory_specialize unchanged.
Its canonical win is covered by the new fixture (shape 7) and verified green
before and after:

function makeTagged(tag: string) {
  class Inner { readonly _tag = tag; who() { return "tagged:" + this._tag; } }
  return Inner;
}
const Tagged = makeTagged("S");
new Tagged().who()   // "tagged:S"  — capture still baked in, main and branch
Tagged.name          // "Inner"

Shape matrix vs node --experimental-strip-types

shape main this PR
direct return class extends B {} typeof undefined, .name TypeError ✅ matches node (typeof, .name === "", new, instanceof self+base, constructor, prototype, inherited field + method)
return class Marker extends B {} typeof undefined ✅ matches (.name === "Marker")
const K = class extends B {}; return K ✅ except inherited field unchanged (still ✅ except inherited field)
via an intermediate variable
two mixins composed, S(G(Base)) ✅ except inherited field unchanged
mixin over a mixin result (Top = S(Mid)) typeof Mid undefined; inherited field; instanceof Mid typeof ✅; inherited field / instanceof Mid unchanged
static-parent captured-param factory (#740) ✅ preserved

The residual "inherited field is undefined" cells are a separate,
pre-existing
gap — instance field initializers of the base are not replayed
through a RegisterClassParentDynamic parent edge (prototype methods resolve
fine through it). Identical on main and on this branch, byte for byte; filed
separately rather than folded in here. The fixture asserts only the cells that
match node.

Validation

  • Full gap suite A/B, 298 tests, pristine origin/main (be5ed2bfc) toolchain
    vs this branch, same perry-dev profile: 3 output deltas, zero regressions.
    • test_gap_5952_mixin_factory_binding — the new fixture: fails on main at line 1
      (typeof undefined), byte-identical to node on this branch.
    • test_gap_console_methodsconsole.time values only (0.003ms vs 0.014ms);
      varies run-to-run on the same binary.
    • test_gap_module_const_local_shadow — a pre-existing uninitialized-memory read
      (x+3.06e-311); the value changes on every run of the same binary on both sides.
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh clean.
  • New fixture test-files/test_gap_5952_mixin_factory_binding.ts covers the whole
    neighbourhood above and is byte-identical to node --experimental-strip-types.

Note on the issue's provenance section

#5952 blames PR #5874. That is stale: the #5874 payload was fully reverted from
main in 5afa08fca (PR #6015) and the bug still reproduces on current main.
The mixin fast path — and its missing value binding — dates to 1e65d9c22
(2026-04-11), long before #5874. #5874 is exonerated. test_gap_class_advanced,
named in the issue as the surfacing gap test, also matches node on main today, so
the suite no longer covered this; the new fixture is the coverage.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed mixin-generated classes so their bindings work correctly with typeof, .name, string conversion, instanceof, and related runtime checks.
    • Improved handling of anonymous class expressions and prototype identity during strict equality comparisons.
    • Preserved empty class names when registering runtime class metadata.
  • Tests

    • Added coverage for direct, named, composed, chained, and parameterized mixin factories.

Ralph Küpper added 3 commits July 13, 2026 09:46
`const M = Mixin(Base)` where `Mixin` is the pre-scanned
`function Mixin(B) { return class extends B {…} }` shape took the
mixin fast path in `lower/stmt.rs`: it synthesized a real class under the
binding name `M` and then `continue`d, never emitting the value binding
for `M`. `new M()` and `instanceof M` kept working (both key off the
class NAME), but `typeof M` / `M.name` / `String(M)` read an unassigned
local and saw `undefined`.

Emit `emit_class_expression_value_binding` before the `continue`, exactly
as the sibling `const X = class {…}` arm does, so `M` holds
`ClassRef("M")`.

Also register the spec `.name`: the synthesized class is keyed by the
binding name, but a directly-returned class expression gets no
NamedEvaluation, so `M.name` is `""` (or the class expression's own name
when it has one). `pre_scan_mixin_functions` now records that name.
)

`js_register_class_name` treated a zero-length name as a no-op, so the
synthesized mixin class from `const M = Mixin(Base)` never landed in
CLASS_NAMES and `M.name` read `undefined` instead of `""`. An anonymous
class expression's `.name` IS the empty string; membership in CLASS_NAMES
means 'this id is a real class', which stays true for an anonymous one.

Registering an empty name makes the previously-unreachable
`!name.is_empty()` filter in `prototype_equality` live and wrong — it
would collapse an anonymous class's instances onto Object.prototype.
Test the registration, not the name's emptiness.
Byte-compared against node --experimental-strip-types. Covers the direct
return, a named returned class expression, return-via-const,
return-via-intermediate, two mixins composed, a mixin over a mixin result,
and the static-parent captured-param factory whose specialization must
survive. Fails on main at the first line (typeof undefined).
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change introduces structured mixin metadata, binds synthesized mixin classes to their target variables, preserves anonymous class identity, and adds regression coverage for dynamic-parent mixin factories.

Changes

Mixin factory binding

Layer / File(s) Summary
Structured mixin metadata
crates/perry-hir/src/lower/lowering_context.rs, crates/perry-hir/src/lower/mod.rs, crates/perry-hir/src/lower/pre_scan.rs
Adds MixinFn with parameter, class-expression name, and class AST fields; updates the registry, re-export, and pre-scan recording.
Mixin class binding and regression coverage
crates/perry-hir/src/lower/stmt.rs, test-files/test_gap_5952_mixin_factory_binding.ts
Clones templates through MixinFn, records display names, emits the target class value binding, and tests factory, inheritance, identity, and name behavior.
Anonymous class identity
crates/perry-runtime/src/object/class_registry/class_meta.rs, crates/perry-runtime/src/builtins/formatting/prototype_equality.rs
Accepts empty class names and preserves their registered prototype identity during equality-related prototype normalization.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and clearly describes the main change: binding mixin-factory results.
Description check ✅ Passed The description covers the summary, fix details, related issue, and validation, with only minor deviations from the template.
Linked Issues check ✅ Passed The PR addresses #5952 by restoring the runtime value binding for mixin-factory results and adds coverage for the reported behavior.
Out of Scope Changes check ✅ Passed The name-registration and prototype-equality updates support the documented fix, so the changes stay within scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/5952-mixin-factory-binding

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
crates/perry-hir/src/lower/lowering_context.rs (1)

643-649: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale doc comment contradicts what this PR actually ships.

The comment says mixin_funcs is a "Stub field added to satisfy in-tree references; full mixin support is a separate workstream," but per the PR objectives and stmt.rs, this field now drives real synthesized-class binding (typeof M, .name, instanceof, etc.). Leaving "stub"/"separate workstream" language in place will mislead future readers about the feature's completeness.

📝 Suggested doc fix
-    /// Maps mixin name → (param_name, class-expression's own name, captured
-    /// class AST). The class-expression name is `None` for the common
-    /// anonymous `return class extends B {…}` form and drives the synthesized
-    /// class's user-visible `.name` (issue `#5952`). Stub field added to satisfy
-    /// in-tree references; full mixin support is a separate workstream.
+    /// Maps mixin name → `MixinFn { param_name, class_expr_name, class_ast }`.
+    /// `class_expr_name` is `None` for the common anonymous
+    /// `return class extends B {…}` form and drives the synthesized class's
+    /// user-visible `.name` (issue `#5952`).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/lower/lowering_context.rs` around lines 643 - 649,
Update the documentation for the mixin_funcs field in LoweringContext to
describe its current role in real mixin support and synthesized-class binding,
including the class-expression name behavior. Remove the stale “stub field” and
“separate workstream” wording while preserving the mapping and anonymous-class
name details.
crates/perry-runtime/src/object/class_registry/class_meta.rs (1)

33-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a focused unit test for the new empty-name registration → prototype-identity behavior. Both files change runtime semantics for anonymous class registrations (issue #5952), but no Rust unit test directly registers a zero-length name and asserts its prototype identity is preserved.

  • crates/perry-runtime/src/object/class_registry/class_meta.rs#L33-L55: add/extend a test that calls js_register_class_name with name_len == 0 and asserts class_name_for_id returns Some("".to_string()) rather than None.
  • crates/perry-runtime/src/builtins/formatting/prototype_equality.rs#L78-L93: extend unregistered_shape_id_normalizes_to_plain_object_prototype (or add a sibling test) to register a class with an empty name and assert prototypes_differ treats its instances as prototype-distinct from a plain object/unregistered shape, mirroring the test_gap_5952_mixin_factory_binding.ts gap fixture at the Rust unit-test level.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/class_registry/class_meta.rs` around lines 33
- 55, Add focused Rust unit coverage at both affected sites: in
crates/perry-runtime/src/object/class_registry/class_meta.rs lines 33-55, add a
test that calls js_register_class_name with class_id nonzero, a valid pointer,
and name_len 0, then verifies class_name_for_id returns Some("");in
crates/perry-runtime/src/builtins/formatting/prototype_equality.rs lines 78-93,
extend unregistered_shape_id_normalizes_to_plain_object_prototype or add a
sibling test to register an empty-name class and verify prototypes_differ
distinguishes its instances from a plain object or unregistered shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-hir/src/lower/lowering_context.rs`:
- Around line 643-649: Update the documentation for the mixin_funcs field in
LoweringContext to describe its current role in real mixin support and
synthesized-class binding, including the class-expression name behavior. Remove
the stale “stub field” and “separate workstream” wording while preserving the
mapping and anonymous-class name details.

In `@crates/perry-runtime/src/object/class_registry/class_meta.rs`:
- Around line 33-55: Add focused Rust unit coverage at both affected sites: in
crates/perry-runtime/src/object/class_registry/class_meta.rs lines 33-55, add a
test that calls js_register_class_name with class_id nonzero, a valid pointer,
and name_len 0, then verifies class_name_for_id returns Some("");in
crates/perry-runtime/src/builtins/formatting/prototype_equality.rs lines 78-93,
extend unregistered_shape_id_normalizes_to_plain_object_prototype or add a
sibling test to register an empty-name class and verify prototypes_differ
distinguishes its instances from a plain object or unregistered shape.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d972cfd6-c5a1-4924-805e-e16346ca5d55

📥 Commits

Reviewing files that changed from the base of the PR and between 448970d and 2285c4b.

📒 Files selected for processing (7)
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower/mod.rs
  • crates/perry-hir/src/lower/pre_scan.rs
  • crates/perry-hir/src/lower/stmt.rs
  • crates/perry-runtime/src/builtins/formatting/prototype_equality.rs
  • crates/perry-runtime/src/object/class_registry/class_meta.rs
  • test-files/test_gap_5952_mixin_factory_binding.ts

@proggeramlug proggeramlug merged commit 4325228 into main Jul 13, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/5952-mixin-factory-binding branch July 13, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant