Skip to content

fix(hir+runtime): #838 — JS-classic prototype-method dispatch (Class.prototype.m = fn) - #877

Merged
proggeramlug merged 1 commit into
mainfrom
fix/838-prototype-method-dispatch
May 16, 2026
Merged

fix(hir+runtime): #838 — JS-classic prototype-method dispatch (Class.prototype.m = fn)#877
proggeramlug merged 1 commit into
mainfrom
fix/838-prototype-method-dispatch

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Closes #838. Surfaced by the #805 npm sweep on dayjs and chalk:
(new Dayjs()).format came back undefined even though the compiled
package linked. dayjs (and the pre-ES6 npm tail more broadly) attaches
instance methods via Dayjs.prototype.format = function(){…} — and the
minified dayjs bundle uses the aliased shape var m = M.prototype; m.parse = ….
Both lowered through perry's generic PropertySet path onto a
prototype-proxy that nothing in dispatch consulted, so the assignment
was a silent no-op from the user's perspective.

Fix

HIR-lowering (crates/perry-hir/src/lower/expr_assign.rs) recognises
two assignment shapes — with TS-syntactic-noise unwrapping
(TsAs / TsNonNull / TsSatisfies / TsTypeAssertion / TsConstAssertion / Paren)
so (Dayjs.prototype as any).format = … is handled identically to the
bare form:

  • Direct: <ClassName>.prototype.<method> = <fn>member.obj
    is a MemberExpr of the form <Ident>.prototype where the ident
    resolves to a known class.
  • Aliased: <local>.<method> = <fn> where the local was previously
    initialised from <ClassName>.prototype. A new
    ctx.prototype_aliases: HashMap<LocalId, String> is populated by
    lower_var_decl when it sees let p = ClassName.prototype; (or
    var / const).

Both routes emit a new Expr::RegisterPrototypeMethod { class_name, method_name, value } HIR node. Codegen lowers it to
js_register_prototype_method(class_id, name_ptr, name_len, value).

Runtime (crates/perry-runtime/src/object.rs) adds:

  • CLASS_PROTOTYPE_METHODS: RwLock<Option<HashMap<u32, HashMap<String, u64>>>>
    side-table keyed by class_id.
  • A single-arm lookup_prototype_method(class_id, name) consulted by
    both dispatch hot paths after the regular vtable / proto-object
    walks miss:
    • js_object_get_field_by_name → returns the closure value for
      inst.method reads.
    • js_native_call_method → binds this = receiver via the existing
      IMPLICIT_THIS thread-local and routes through
      js_native_call_value for inst.method(args).
  • Parent-class chain is walked depth-capped at 32, so methods registered
    on a base class are reachable via subclass instances (mirrors
    Object.getPrototypeOf semantics).

Generic obj.prop = fn on non-prototype objects is unchanged — only
assignments the HIR identifies as prototype-method attachments emit
the new FFI, and only those participate in the new dispatch arm.

Why this layer

A pure-runtime fix (turn the existing PropertySet-on-prototype-proxy
path into a real per-class side-table) would also work but would
require runtime introspection of the prototype-proxy shape on every
property assignment, plus a class-id back-pointer threaded through.
Doing recognition at HIR keeps the runtime hot paths free of
speculative lookups.

Test plan

  • cargo build --release -p perry-runtime -p perry-stdlib -p perry clean
  • New regression test test-files/test_issue_838_prototype_methods.ts
    covers direct, aliased, and multi-method this-aware composition shapes
  • Byte-for-byte parity with node --experimental-strip-types on the new test
  • CI: lint, cargo-test, parity, compile-smoke, api-docs-drift, security-audit green

Out of scope (follow-up)

Chalk's exported callable-with-properties (chalk is callable AND has
.green / .red / …) is a separate shape — a Proxy-wrapped callable,
not a Class.prototype.X = … pattern — and stays gated on Proxy /
callable-object support in the compiled-package codegen.

Refs #793 (Node.js + TypeScript compatibility roadmap), #805 (npm sweep harness), #115 (port-npm-to-perry skill).

…prototype.m = fn)

Surfaced by the #805 npm sweep on dayjs / chalk: `(new Dayjs()).format`
returned undefined vs. Node's function. dayjs (and the rest of the
pre-ES6 npm tail) attaches instance methods via
`Dayjs.prototype.format = function(){…}` — and the minified bundle uses
the aliased shape `var m = M.prototype; m.parse = …`. Both lowered
through the generic PropertySet path onto a prototype-proxy that
nothing in dispatch consulted, so the assignment was a silent no-op.

HIR-lowering now recognises both shapes (with TS-syntactic-noise
unwrapping for `(X.prototype as any).m = fn`) and routes them to a new
`Expr::RegisterPrototypeMethod` node that codegen emits as a
`js_register_prototype_method(class_id, name, value)` call. Runtime
stores those into `CLASS_PROTOTYPE_METHODS` keyed by class_id; the
dispatch hot paths (`js_object_get_field_by_name`,
`js_native_call_method`) consult `lookup_prototype_method` after the
vtable / proto-object walks miss, binding `this` to the receiver via
the existing `IMPLICIT_THIS` thread-local. Parent-class chain is walked
(depth-capped at 32) so base-class methods reach subclass instances.

Validation: new test-files/test_issue_838_prototype_methods.ts covers
direct, aliased, and multi-method `this`-aware composition shapes —
byte-for-byte parity with `node --experimental-strip-types`. Chalk's
callable-with-properties (Proxy-wrapped) is a separate shape and stays
gated on Proxy support in the compiled-package codegen.

Refs #793 #805 #115. Closes #838.
@proggeramlug
proggeramlug merged commit 62c6668 into main May 16, 2026
9 checks passed
@proggeramlug
proggeramlug deleted the fix/838-prototype-method-dispatch branch May 16, 2026 19:12
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.

compiled-package instance methods not callable (dayjs.format, chalk.green) — prototype-method dispatch gap

1 participant