Skip to content

A class with a computed-key member loses this.prototype inside every static method (named-binding read still works) #9369

Description

@proggeramlug

Repro

const K = "dyn" + "Key";                 // a computed, non-well-known key
class E {
  [K]() { return 1; }
  static probe() { return typeof this.prototype; }
}
console.log(E.probe(), typeof E.prototype);
node : object object
perry: undefined object

Inside the static method, this === E is true, typeof this is "function" and this.name is "E" — the receiver is right by every other measure. Only this.prototype answers undefined, and reading the very same property through the class's own name (E.prototype) in the very same function answers object.

Remove the [K]() member and this.prototype is an object again. A declaration-position control shows it is not about ordering — a static method before the computed member fails identically to one after it. Instance methods are unaffected.

Scope

Any class carrying a generic computed member loses this.prototype in every static method of that class.

class A {
  static before() { return typeof this.prototype; }   // undefined
  [K]() { return 1; }
  static after()  { return typeof this.prototype; }   // undefined
  method()        { return typeof this; }             // object — fine
}

Pre-existing: reproduces identically on 83754818e (#9242) and on current main.

Why it is worth fixing independently of #9341

This is the hole #9341 fell into. #9315 narrowed generic_computed_member_key so that well-known-symbol computed members ([Symbol.iterator] generator and non-generator, [Symbol.asyncIterator], [Symbol.toPrimitive], [util.inspect.custom]) stopped getting special lowering and became generic computed members — i.e. it routed the common cases onto the path this issue describes. axios's AxiosHeaders has a non-generator [Symbol.iterator]() and a static accessor() whose first act is let z = this.prototype, so cc --help started throwing Object.defineProperty called on non-object.

So reverting #9315 restores cc --help but leaves this in place, one plain computed key away from the next occurrence — and costs #9226's own-keys fix. Fixing this closes both.

Where it goes wrong

The class-ref .prototype read in crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs:

if name == "prototype" && class_id != 0 && is_class_id_registered(class_id) && !is_prototype_ref {
    let value = class_registry::class_decl_prototype_value(class_id);
    if value.to_bits() == TAG_UNDEFINED { return class_prototype_ref_value(class_id); }
    return value;
}

Reached with a recognised class ref this cannot return undefined — there is a synthetic fallback. So this in these static methods is not being recognised as a class ref on this path, the branch is skipped, and the read falls through to a lookup that finds nothing. The named-binding read still takes the class-ref path, which is exactly why the two disagree inside one function.

Found while bisecting #9341.

Activity

  1. proggeramlug commented on Sep 1, 2026

    @proggeramlug
    ContributorAuthor

    Measured findings, and two fixes that failed — recording so the next attempt starts here

    The receiver is a CLOSURE, not a class ref. Instrumenting the prototype read in js_object_get_field_by_name:

    read NaN-box bits tag
    E.prototype (named binding) 0x7ffe000000000001 0x7FFE — INT32 class ref, class_id 1 → resolves
    this.prototype (static method) 0x0000051ae5e38d78 0x0000 — a raw heap pointer
    control class, no computed member 0x7ffe000000000002 0x7FFE → resolves

    GC header of that pointer: (obj_type=4, gc_flags=2) — GC_TYPE_CLOSURE. The .prototype resolution is gated on (bits >> 48) == 0x7FFE, so it never fires for this, and the read falls through to a lookup that finds nothing. That is the whole "one function, two answers" symptom.

    Two failed fixes (do not repeat)

    1. An arm keyed on is_class_object_ptr — never fires. That predicate requires GC_TYPE_OBJECT plus shape kind Class; the receiver is a closure. js_object_get_class_id on it returns 0.
    2. Recovering class_id by matching ClosureHeader.func_ptr against CLASS_CONSTRUCTORS (class_id → func_ptr, registered by js_register_class_constructor) — the arm is reached, but the lookup misses: hdr=Some((4,2)) func_ptr=0x105186f84 cid=None. Adding an explicit constructor(){} does not help; both forms still fail.

    An unresolved observation worth chasing first

    With a two-class fixture, no diagnostic printed at all — that prototype read did not reach js_object_get_field_by_name. So the read path may vary with lowering. Locate where the read actually resolves before patching, e.g. PERRY_LLVM_KEEP_IR=1 and read the static method's emitted IR.

    The likely root, and why it may be bigger than .prototype

    crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs (~95–130) deliberately passes the INT32 class ref as this for Expr::ClassRef(_) — "this === ClassRef". A class with a computed member evidently stops lowering its binding as ClassRef and becomes a materialized closure, so this is a closure and every class-ref-keyed path changes behaviour, not only .prototype.

    If that holds, the durable fix is in lowering/dispatch rather than a runtime patch — and it plausibly also closes #9362, #9364, #9365 and #9366, all filed from the same investigation.

    Useful hooks for whoever takes it: js_register_class_computed_method(class_id, key, func_ptr, …) does carry the class_id, so it is a natural structural place to record a closure↔class link; class_decl_prototype_value(class_id) already returns the correct prototype (that is why the named-binding read works).

  2. added 3 commits that reference this issue on Sep 1, 2026
  3. proggeramlug commented on Sep 2, 2026

    @proggeramlug
    ContributorAuthor

    Closing: fixed in #9386 (a static body's this is the class, not an instance).

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