Skip to content

perry-codegen: cross-module class method dispatch on dynamic-typed receiver — obj.method() undefined when obj is class-instance via assigned property #493

Closed

Description

@proggeramlug

Background

After v0.5.586 closed #485 (cross-module subclass field initializers + arrow class fields installed in parent ctor), `new Hono()` instances correctly carry `get`, `post`, `fetch`, `use`, `on`, `routes`, `_basePath`. The next blocker for hono compile (#421) is that `app.get('/', cb)` doesn't actually register a route — `app.routes.length` stays 0 even though the arrow-field method gets called.

Repro

import { Hono } from 'hono';
const app = new Hono();
console.log('router:', typeof app.router);            // 'object'
console.log('router.add:', typeof app.router?.add);   // 'undefined'  ← bug
app.get('/', (c: any) => c.text('hi'));
console.log('routes:', app.routes.length);            // 0

Why

In hono, the route-registration path is:

// hono.js (subclass)
constructor(options = {}) {
  super(options);
  this.router = options.router ?? new SmartRouter({...});
}

// hono-base.js (parent — runs in cross-module ctor as of v0.5.586)
this[method] = (args1, ...args) => {
  if (typeof args1 === "string") this.#path = args1;
  else this.#addRoute(method, this.#path, args1);
  args.forEach((handler) => this.#addRoute(method, this.#path, handler));
};

#addRoute(method, path, handler) {
  // ...
  this.router.add(method, path, [handler, r]);   // ← undefined call
  this.routes.push(r);
}

`SmartRouter` is yet another class with a regular `add(method, path, handler) { ... }` declaration (not an arrow class field). Perry's class-method dispatch keys on the receiver's known class — but `app.router` was assigned dynamically inside hono.js's constructor (`this.router = options.router ?? new SmartRouter(...)`), and the static type of `app.router` is unknown at the call site in hono-base.js (where `#addRoute` lives). Without static type info, perry's `PropertyGet` falls through to dynamic property lookup, which doesn't find the prototype method and returns `undefined`.

Same root cause as #91 but in a different shape: instead of `map.get(key).method()`, this is `this.fieldAssignedToInstance.method()` where the assignment crosses module boundaries.

Likely fix

Two approaches:

  1. Codegen-time class-instance tracking — when `this.x = new SmartRouter(...)` is lowered, record `(this_class, x) → SmartRouter` in the class's field type map. The cross-module `obj.x.method()` site then sees `this.router` typed as `SmartRouter` and dispatches to `SmartRouter::add`.

  2. Runtime dispatch fallback for prototype methods — when `obj.method` returns undefined and `obj` has a class id stamped in its header, walk the class's method registry by name. Slower but works without compile-time type info.

`SmartRouter` itself probably has further dispatch issues (it has its own `add` and `match` methods, also a #routes private field, and the constructor takes `{routers: [...]}` with destructuring — verified lowering of those is needed too).

Why it matters

Blocks #421 (hono compile end-to-end). Probably also blocks #420 (drizzle's `db.query.users.findMany()` — same dynamic-receiver method dispatch shape).

Activity

  1. proggeramlug commented on May 6, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in v0.5.589. Three coordinated changes: (1) new runtime CLOSURE_REST_REGISTRY side-table mapping func_ptr → fixed_arity, populated at module init via js_register_closure_rest, consulted in js_closure_callN to bundle trailing args into a rest array via dispatch_rest_bundled; (2) codegen emits the registration calls in emit_string_pool for every closure body whose HIR has is_rest; (3) the static-bundling path in lower_call.rs::LocalGet was removed to avoid double-bundling. Plus js_native_call_method's array dispatch tower learned forEach. Parity: 209 pass / 3 known fails (was 204 / 8). End-to-end: app.get('/', cb) correctly increments app.routes.length to 1 — only the path field reads as [object Object] instead of / is left, which is a separate followup. Note: this fix also unblocks part of #421 (hono compile end-to-end) by reducing the gap to one final mergePath/Object.assign issue. The original #493 root cause analysis (cross-module class method dispatch on dynamic receiver) was wrong — the actual culprit was the rest-param-bundling-on-dynamic-dispatch path.

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