Skip to content

fix(runtime): EventTarget methods must live on the prototype so subclasses inherit them (#6301)#6311

Merged
proggeramlug merged 3 commits into
mainfrom
fix/6301-eventtarget-subclass
Jul 13, 2026
Merged

fix(runtime): EventTarget methods must live on the prototype so subclasses inherit them (#6301)#6311
proggeramlug merged 3 commits into
mainfrom
fix/6301-eventtarget-subclass

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Root cause

EventTarget's method surface never existed as values. addEventListener /
removeEventListener / dispatchEvent were only a compile-time lowering
(perry-codegen/src/lower_call/event_target.rs) that fires when the receiver's
static class name is literally EventTarget. Nothing lived on a prototype and
nothing was resolvable at runtime, which produced two symptoms:

  • on a plain new EventTarget(), t.addEventListener(...) worked as a call
    (the static lowering caught it) but typeof t.addEventListener was
    "undefined" — there was no property to read;
  • a class Bus extends EventTarget {} instance has the static class name Bus,
    so the lowering never fired and nothing was inherited at all:
    typeof b.dispatchEvent === "undefined", and calling it threw
    TypeError: value is not a function. Two-level (class B extends A extends EventTarget) was equally dead.

The EventTarget global also had no reserved class id, so
js_register_class_parent_dynamic — which resolves a builtin superclass by name
through global_builtin_constructor_class_id — left every extends EventTarget
subclass parentless in the class registry. There was no edge to inherit
across.

This is the actual root cause of #5931 ("Using cac with default command
resulting in error"): cac v7's CAC is class CAC extends EventTarget, and its
matched-command path calls this.dispatchEvent(new CustomEvent(...))
(cac/dist/index.js:543), so every matching command died with
TypeError: value is not a function. Verified directly — see below.

Fix

Resolve the methods off the receiver's class chain — the same mechanism
Event subclasses already use (is_event_instance) — and materialize them as
bound-method closures, which is the established shape for exactly this problem
(abort_signal_method_bind for AbortSignal, and the
value-read-materializes-a-bound-method work of #6281).

  • event_target.rs — reserve CLASS_ID_EVENT_TARGET (0xFFFF_2406).
    js_event_target_new stamps it on the instance header (mirroring
    construct_event's CLASS_ID_EVENT). class_chain_is_event_target walks the
    registered parent chain to it, and is_event_target accepts a receiver that is
    a target either by the legacy _eventTarget marker (the plain instance) or
    by class chain (a subclass at any depth). event_target_method_bind mints a
    1-capture bound-method closure per name, with the receiver stored as NaN-boxed
    bits so the GC's closure scan keeps it alive and relocates it.
  • object/instanceof.rs — map "EventTarget" to that id in
    global_builtin_constructor_class_id, so js_register_class_parent_dynamic
    wires the Subclass → EventTarget registry edge at class-definition time. This
    single line is what makes inheritance work at any depth: B extends A extends EventTarget reaches the base through the ordinary A → EventTarget edge, with
    no codegen chain-walking.
  • object/field_get_set/get_field_by_name_tail.rs — the value-read path
    (typeof b.dispatchEvent, const add = t.addEventListener). Placed
    deliberately last in the tail, so an own property and a real class-vtable
    method (a subclass that overrides dispatchEvent) are resolved earlier and
    keep winning.
  • object/native_call_method.rs — the dynamic-call path (a fused
    bus.dispatchEvent(ev) / this.addEventListener(...)). Placed immediately
    before the terminal "<m> is not a function" throw, so it can only ever turn a
    guaranteed failure into a working call; every own-field / vtable /
    prototype-chain lookup above it is untouched.
  • perry-codegen/src/expr/instance_misc1.rs — the literal-RHS instanceof
    id table, keeping x instanceof EventTarget consistent with the runtime id.

A subclass instance never runs js_event_target_new, so its hidden state
(marker + listener bag + max-listener count) is seeded lazily on first
listener/dispatch use (seed_event_target_state). That keeps the subclass
constructor lowering completely untouched — no super() arm, no implicit-ctor
arm, no new codegen path. Every heap value there is rooted in a
RuntimeHandleScope and re-read from its handle at each use, because both
js_object_alloc and key() (string interning) can allocate and GC-move the
receiver.

Before / after

class Bus extends EventTarget {}
const b = new Bus();
console.log(typeof b.dispatchEvent, typeof b.addEventListener);
const t = new EventTarget();
console.log(typeof t.addEventListener);
main @ 0960415ad this branch node
typeof b.dispatchEvent undefined function function
typeof b.addEventListener undefined function function
typeof t.addEventListener (plain base) undefined function function
b.dispatchEvent(new Event("x")) TypeError: value is not a function listener fires listener fires
class B extends A extends EventTarget undefined function function
b instanceof EventTarget false true true

cac / #5931 — verified end to end

npm i cac@7, perry.compilePackages: ["cac"], a default command plus a named
command, cli.parse([...]):

Verified

  • test-files/test_gap_6301_event_target_subclass.ts (new) — byte-identical
    to node --experimental-strip-types. Covers: method value-reads + typeof on
    the plain base and on a subclass; listeners actually firing through a subclass;
    two-level B extends A extends EventTarget; a subclass with its own
    constructor + fields calling this.dispatchEvent(...) (the cac shape); a
    detached .bind()-ed method value; removeEventListener actually removing;
    per-instance listener bags (no cross-instance leakage); a subclass override
    still winning over the inherited method; { once: true }; instanceof EventTarget from base, subclass and two-level subclass; and Event /
    CustomEvent construction + .detail unchanged.
  • Same fixture is byte-identical under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 (the lazy seeding and the bound-method captures
    survive a moving collection).
  • cargo test -p perry-runtime: 1264 passed, 0 failed (--test-threads=1).
    The parallel run shows one failure in
    url::node_compat::tests::path_to_file_url_posix_preserves_relative_trailing_slash
    — a cwd-dependent test that passes in isolation and on an unmodified
    origin/main worktree; unrelated to this change.
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh,
    scripts/gc_store_site_inventory.py and the addr-class audit all pass on the
    files this PR touches. (The addr-class gate is already red on clean main for
    crates/perry-runtime/src/map.rs — the pre-existing ratchet that fix(runtime): Map/BigInt bare-address branches must reject the handle bands (unblocks main) #6297
    addresses, not this branch.)
  • A/B'd the neighbouring event tests against an unmodified origin/main build:
    test_gap_events_import_4995 and test_issue_850_eventemitter diverge
    identically before and after — both are pre-existing EventEmitter gaps, not
    regressions from this PR.

Scope note

super.dispatchEvent(event) from inside a subclass override still does not reach
the base. That is a pre-existing, orthogonal limitation of super.<method>()
into any native base — class E extends EventEmitter { emit() { … super.emit(…) } } is worse today (the override does not even run) — and it wants
its own issue rather than a change to the super-call lowering here.

Fixes #6301
Fixes #5931

Summary by CodeRabbit

  • New Features
    • EventTarget now exposes addEventListener, removeEventListener, and dispatchEvent as real method values that work when inherited and accessed dynamically (including detached reads).
    • EventTarget is now correctly recognized by instanceof, covering plain instances and subclasses.
    • Listener state is initialized lazily for recognized EventTarget instances to support consistent per-instance behavior.
  • Bug Fixes
    • Correct behavior for multi-level inheritance, sibling subclasses, { once: true }, and listener removal.
    • Subclass dispatchEvent overrides take precedence over the inherited implementation.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 05f20668-0225-4fd5-a3b5-67285b3a0707

📥 Commits

Reviewing files that changed from the base of the PR and between 63aa5ad and f481f05.

📒 Files selected for processing (6)
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-runtime/src/event_target.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • test-files/test_gap_6301_event_target_subclass.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-runtime/src/event_target.rs

📝 Walkthrough

Walkthrough

EventTarget instances now carry a runtime class id, subclasses inherit EventTarget method values and dispatch behavior, listener state is seeded through class-chain recognition, and regression tests cover inheritance, overrides, binding, listeners, instanceof, and event construction.

Changes

EventTarget subclass support

Layer / File(s) Summary
EventTarget class identity and state
crates/perry-codegen/src/expr/instance_misc1.rs, crates/perry-runtime/src/event_target.rs, crates/perry-runtime/src/object/instanceof.rs
EventTarget receives a reserved class id; class-chain recognition, listener-state seeding, and base-instance allocation use that identity.
Bound method materialization and dispatch
crates/perry-runtime/src/event_target.rs, crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs, crates/perry-runtime/src/object/native_call_method.rs
EventTarget method names produce receiver-bound function values and support late native dispatch while preserving existing overrides.
Inheritance and listener behavior validation
test-files/test_gap_6301_event_target_subclass.ts
Tests cover inherited calls, multi-level subclasses, detached methods, listener lifecycle and isolation, overrides, once listeners, instanceof, construction forms, receiver shapes, and event construction.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JavaScript
  participant ObjectPropertyLookup
  participant EventTargetRuntime
  participant NativeMethodDispatch
  JavaScript->>ObjectPropertyLookup: Read inherited EventTarget method
  ObjectPropertyLookup->>EventTargetRuntime: Bind method to subclass receiver
  EventTargetRuntime-->>JavaScript: Return bound function
  JavaScript->>NativeMethodDispatch: Invoke inherited method
  NativeMethodDispatch->>EventTargetRuntime: Resolve receiver-bound method
  EventTargetRuntime-->>NativeMethodDispatch: Execute EventTarget operation
  NativeMethodDispatch-->>JavaScript: Return operation result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: EventTarget methods now inherit through subclasses.
Description check ✅ Passed The description covers the root cause, fix, related issues, and verification, though it does not use the template's exact section headings.
Linked Issues check ✅ Passed The code implements EventTarget inheritance, value reads, dynamic calls, instanceof mapping, and tests, addressing #6301 and #5931.
Out of Scope Changes check ✅ Passed The changes stay focused on EventTarget inheritance/runtime resolution and test coverage, with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/6301-eventtarget-subclass

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.

@proggeramlug proggeramlug force-pushed the fix/6301-eventtarget-subclass branch from 32e7b96 to 63aa5ad Compare July 12, 2026 18:46

@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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs`:
- Around line 1971-1988: Move the EventTarget fallback binding from the
tail-only block into the keyless branch of get_field_by_name_object_tail, after
existing own-property, override, and prototype checks. Reuse
is_event_target_method_name and event_target_method_bind for keyless class
instances so inherited addEventListener, removeEventListener, and dispatchEvent
resolve correctly while subclass overrides continue to win; retain the existing
tail fallback for keyed objects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b12fb1f6-19cc-43d2-8c71-4d8736a3de2d

📥 Commits

Reviewing files that changed from the base of the PR and between 32e7b96 and 63aa5ad.

📒 Files selected for processing (6)
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-runtime/src/event_target.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • test-files/test_gap_6301_event_target_subclass.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-runtime/src/event_target.rs

Comment on lines +1971 to +1988
// #6301: `EventTarget`'s method surface, read as a VALUE
// (`typeof b.dispatchEvent`, `const add = t.addEventListener`). The
// methods have no prototype home — they were only ever compile-time
// lowerings keyed on a receiver statically named `EventTarget` — so a
// plain `new EventTarget()` read them as `undefined` and a
// `class Bus extends EventTarget {}` instance inherited nothing.
// Materialize a bound method for a receiver that is an event target by
// marker (the plain instance) or by class chain (a subclass at any
// depth). Deliberately LAST in the tail: an own property and a real
// class-vtable method (a subclass that *overrides* `dispatchEvent`)
// are resolved earlier and keep winning.
if !key.is_null() && crate::event_target::is_event_target_method_name(key_bytes) {
if let Some(bound) =
crate::event_target::event_target_method_bind(obj as *mut ObjectHeader, key_bytes)
{
return JSValue::from_bits(bound.to_bits());
}
}

@coderabbitai coderabbitai Bot Jul 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move this fallback before the keyless early return.

For class instances with keys_array == NULL, get_field_by_name_object_tail returns at Line [1521] before reaching this block. Since EventTarget methods have no prototype home, the keyless path will still return undefined for inherited addEventListener, removeEventListener, and dispatchEvent.

Invoke the same binding logic in the keyless branch after existing override/prototype checks, preserving subclass overrides.

Proposed fix
@@ keyless `keys.is_null()` branch, before its final return
+        if !key.is_null()
+            && crate::event_target::is_event_target_method_name(key_bytes)
+        {
+            if let Some(bound) = crate::event_target::event_target_method_bind(
+                obj as *mut ObjectHeader,
+                key_bytes,
+            ) {
+                return JSValue::from_bits(bound.to_bits());
+            }
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// #6301: `EventTarget`'s method surface, read as a VALUE
// (`typeof b.dispatchEvent`, `const add = t.addEventListener`). The
// methods have no prototype home — they were only ever compile-time
// lowerings keyed on a receiver statically named `EventTarget` — so a
// plain `new EventTarget()` read them as `undefined` and a
// `class Bus extends EventTarget {}` instance inherited nothing.
// Materialize a bound method for a receiver that is an event target by
// marker (the plain instance) or by class chain (a subclass at any
// depth). Deliberately LAST in the tail: an own property and a real
// class-vtable method (a subclass that *overrides* `dispatchEvent`)
// are resolved earlier and keep winning.
if !key.is_null() && crate::event_target::is_event_target_method_name(key_bytes) {
if let Some(bound) =
crate::event_target::event_target_method_bind(obj as *mut ObjectHeader, key_bytes)
{
return JSValue::from_bits(bound.to_bits());
}
}
if !key.is_null()
&& crate::event_target::is_event_target_method_name(key_bytes)
{
if let Some(bound) = crate::event_target::event_target_method_bind(
obj as *mut ObjectHeader,
key_bytes,
) {
return JSValue::from_bits(bound.to_bits());
}
}
🤖 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/field_get_set/get_field_by_name_tail.rs`
around lines 1971 - 1988, Move the EventTarget fallback binding from the
tail-only block into the keyless branch of get_field_by_name_object_tail, after
existing own-property, override, and prototype checks. Reuse
is_event_target_method_name and event_target_method_bind for keyless class
instances so inherited addEventListener, removeEventListener, and dispatchEvent
resolve correctly while subclass overrides continue to win; retain the existing
tail fallback for keyed objects.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified empirically, and this one doesn't hold — the premise that a class instance can reach here with keys_array == NULL is false. Not moving the fallback, but I've documented the invariant and pinned it with fixtures so the next reader doesn't have to re-derive it.

Why a class X extends EventTarget instance never takes the keyless branch

Every class-instance allocator installs the shape-cached keys array unconditionally:

  • js_object_alloc_class_inline_keysset_object_keys_array(ptr, keys_array) (object/alloc.rs:240)
  • js_object_alloc_class_with_keysset_object_keys_array(ptr, keys_arr) (object/alloc.rs:402)
  • js_object_alloc_class_dynamic_parent — merges parent+own keys, else delegates to _with_keys (object/alloc.rs:436)

and the array they install is never NULL for a fieldless class — js_build_class_keys_array returns a zero-length array:

if field_count == 0 || packed_keys_len == 0 || packed_keys.is_null() {
    let arr = crate::array::js_array_alloc_with_length_longlived(0);   // <- empty, not null
    ...
    return arr;
}

(object/alloc.rs:284)

keys_array == NULL is the state of a freshly bump-allocated object (js_object_alloc, js_object_alloc_fast) before its first by-name store — i.e. Object.create(proto) results and bare js_new_function_construct instances. A class instance is never in that state.

Empirical check. I instrumented the tail with a temporary print at the keys.is_null() test and at the keyless dead-end return, and ran every shape I could think of that might produce an empty-shaped event target:

shape keys_array typeof x.dispatchEvent
class Bus extends EventTarget {} (no fields, no ctor) non-null (empty) function
subclass with a ctor that assigns nothing non-null (empty) function
two-level B extends A extends EventTarget non-null (empty) function
new Ctor() through a variable (dynamic new) non-null function
Reflect.construct(Bus, []) non-null function
const K = class extends EventTarget {} non-null function
class returned from a factory non-null function
subclass declared inside a function body non-null function
instance whose only own prop was deleted non-null function
Object.freeze(new Bus()) non-null function
Object.create(Bus.prototype) NULL function

The KEYLESS-DEAD-END probe never fired once — for any of these, for any of the three method names.

The only receiver that does reach the keyless branch is Object.create(Bus.prototype), and it already resolves correctly inside that branch: resolve_proto_chain_field_with_receiver (the first thing the keyless branch tries, well before its return undefined()) hops to the prototype object, which is itself a shaped, class-id-stamped receiver, and that hop reaches this fallback. I also checked it doesn't leak listeners onto the shared prototype — a listener added via an Object.created receiver does not fire on a sibling new Bus(). (Node actually throws Cannot read properties of undefined (reading 'get') on that shape, since there's no internal slot — so we're strictly more permissive there, not less.)

What I changed in response

Rather than move the block (which would put it ahead of the own-property and class-vtable lookups and break the subclass-override ordering this PR exists to get right), I:

  1. Documented the invariant on event_target::event_target_value_read, so the placement is justified where the mechanism lives.
  2. Added the empty-shape cases to test-files/test_gap_6301_event_target_subclass.ts — empty ctor, dynamic new, Reflect.construct, class expression, function-local subclass, deleted-own-prop, frozen instance. All byte-identical to node --experimental-strip-types, also under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1.

Thanks for the push on this — the invariant was load-bearing and undocumented, which is exactly what made the finding plausible.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
fd -t f alloc.rs -p crates/perry-runtime/src/object
echo "---"
rg -n "fn js_object_alloc_class_inline_keys|fn js_object_alloc_class_with_keys|fn js_object_alloc_class_dynamic_parent|fn js_build_class_keys_array" crates/perry-runtime/src/object/alloc.rs
echo "---"
rg -n -A15 "fn js_build_class_keys_array" crates/perry-runtime/src/object/alloc.rs
echo "---"
rg -n "set_object_keys_array" crates/perry-runtime/src/object/alloc.rs

Length of output: 5176


Confirms the claim: every class-instance allocator path (js_object_alloc_class_inline_keys, js_object_alloc_class_with_keys, js_object_alloc_class_dynamic_parent, and the two others found at lines 523/609/681/747) calls set_object_keys_array unconditionally, and js_build_class_keys_array returns an empty (non-null) array rather than null when field_count == 0. So the keyless branch is genuinely unreachable for class instances, matching the empirical results.

@proggeramlug Thanks for the thorough empirical verification — I checked the allocator call sites directly and confirm your analysis holds: js_object_alloc_class_inline_keys, js_object_alloc_class_with_keys, and js_object_alloc_class_dynamic_parent all call set_object_keys_array unconditionally, and js_build_class_keys_array returns an empty (non-null) ArrayHeader — not NULL — when field_count == 0. So keys_array == NULL genuinely can't occur for a class instance (fieldless or not), only for the pre-first-store bump-allocated shapes you identified.

Withdrawing the original suggestion — the current placement after the keys.is_null() early return is correct, and the ordering constraint (own-property/vtable-override resolution before the EventTarget fallback) is preserved. Appreciate the documentation and fixture coverage added for this invariant.

ヽ(・∀・)ノ


🧬 Code Graph Analysis Results

Relevant snippets

crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs

  • (around the tail’s keys_array null fast-path and EventTarget value read)
    • let keys = (*obj).keys_array;
    • if keys.is_null() { ... } block that handles keyless receivers (including prototype-chain resolution and prototype-method lookup/vtable getter walk) and returns JSValue::undefined() if nothing resolves.
    • Later (deliberately last in the tail), the EventTarget “method surface read as VALUE” hook:
      • if !key.is_null() { if let Some(bound) = crate::event_target::event_target_value_read(obj as *mut ObjectHeader, key_bytes) { ... } }
    • Includes the in-code ordering/invariant rationale: the EventTarget arm is after the keys_array.is_null() early return so subclass overrides and own-property precedence remain correct.

crates/perry-runtime/src/event_target.rs

  • [lines 1155-1160]
pub(crate) fn event_target_value_read(target: *mut ObjectHeader, name: &[u8]) -> Option<f64> {
    if !is_event_target_method_name(name) {
        return None;
    }
    event_target_method_bind(target, name)
}

crates/perry-runtime/src/object/class_registry/construct.rs

  • [lines 1827-1847] lookup_prototype_method(...) (used by the keyless branch for JS-classic prototype method reads)
pub(crate) fn lookup_prototype_method(class_id: u32, name: &str) -> Option<f64> {
    let guard = CLASS_PROTOTYPE_METHODS.read().ok()?;
    let map = guard.as_ref()?;
    let mut cid = class_id;
    let mut depth = 0usize;
    while depth < 32 {
        if let Some(per_class) = map.get(&cid) {
            if let Some(&bits) = per_class.get(name) {
                return Some(f64::from_bits(bits));
            }
        }
        match get_parent_class_id(cid) {
            Some(p) if p != 0 && p != cid => {
                cid = p;
                depth += 1;
            }
            _ => break,
        }
    }
    None
}

crates/perry-runtime/src/object/class_registry/parent_static.rs

  • [lines 1346-1349] get_parent_class_id(...) (used during class/vtable/prototype chain walking)
pub(crate) fn get_parent_class_id(class_id: u32) -> Option<u32> {
    let registry = CLASS_REGISTRY.read().unwrap();
    registry.as_ref().and_then(|r| r.get(&class_id).copied())
}

crates/perry-runtime/src/object/class_registry/parent_static.rs

  • [lines 1356-1377] lookup_class_method_in_chain(...) (method lookup across class parent chain; relevant to the keyless branch’s “fallback to class method in chain” logic)
pub fn lookup_class_method_in_chain(class_id: u32, name: &str) -> Option<(usize, u32, bool, bool)> {
    let registry = CLASS_VTABLE_REGISTRY.read().unwrap();
    let reg = registry.as_ref()?;
    let mut cur = class_id;
    for _ in 0..32 {
        if let Some(vt) = reg.get(&cur) {
            if let Some(entry) = vt.methods.get(name) {
                return Some((
                    entry.func_ptr,
                    entry.param_count,
                    entry.has_synthetic_arguments,
                    entry.has_rest,
                ));
            }
        }
        match get_parent_class_id(cur) {
            Some(pid) if pid != 0 => cur = pid,
            _ => return None,
        }
    }
    None
}

Ralph Küpper added 3 commits July 13, 2026 06:42
…asses inherit them (#6301)

`addEventListener` / `removeEventListener` / `dispatchEvent` existed only as a
compile-time lowering keyed on a receiver whose STATIC class name was literally
`EventTarget` (perry-codegen/src/lower_call/event_target.rs). Nothing lived on a
prototype and nothing was resolvable at runtime, so:

  * on a plain `new EventTarget()`, `t.addEventListener(...)` worked as a CALL
    but `typeof t.addEventListener` was `undefined` — there was no property;
  * a `class Bus extends EventTarget {}` instance has the static class name
    `Bus`, so the lowering never fired and it inherited NOTHING:
    `typeof b.dispatchEvent === "undefined"` and calling it threw
    `TypeError: value is not a function`. Two-level subclassing was equally dead.

The `EventTarget` global also had no reserved class id, so
`js_register_class_parent_dynamic` — which resolves a builtin superclass by name
through `global_builtin_constructor_class_id` — left every `extends EventTarget`
subclass parentless in the class registry. There was no edge to inherit across.

This is the root cause of #5931: cac v7's `CAC` is `class CAC extends
EventTarget` and its matched-command path calls `this.dispatchEvent(...)`, so
every matching command died with `TypeError: value is not a function`.

Resolve the methods off the receiver's CLASS CHAIN — the mechanism `Event`
subclasses already use (`is_event_instance`) — and materialize them as
bound-method closures, the established shape for this problem
(`abort_signal_method_bind`, and the value-read-materializes-a-bound-method work
of #6281):

  * event_target.rs: reserve CLASS_ID_EVENT_TARGET (0xFFFF_2406);
    `js_event_target_new` stamps it on the instance header (mirroring
    `construct_event`'s CLASS_ID_EVENT). `class_chain_is_event_target` walks the
    registered parent chain to it; `is_event_target` accepts a receiver that is a
    target either by the legacy `_eventTarget` marker (the plain instance) or by
    class chain (a subclass at any depth). `event_target_method_bind` mints a
    1-capture bound-method closure, receiver stored as NaN-boxed bits so the GC's
    closure scan keeps it alive and relocates it.
  * object/instanceof.rs: map "EventTarget" to that id in
    `global_builtin_constructor_class_id`, so the `Subclass -> EventTarget`
    registry edge is wired at class-definition time. This is what makes
    inheritance work at any depth — `B extends A extends EventTarget` reaches the
    base through the ordinary `A -> EventTarget` edge, with no codegen walking.
  * object/field_get_set/get_field_by_name_tail.rs: the VALUE-read path
    (`typeof b.dispatchEvent`). Deliberately LAST in the tail, so an own property
    and a real class-vtable method (a subclass that OVERRIDES `dispatchEvent`)
    are resolved earlier and keep winning.
  * object/native_call_method.rs: the dynamic-CALL path (a fused
    `bus.dispatchEvent(ev)`). Placed immediately before the terminal
    `"<m> is not a function"` throw, so it can only turn a guaranteed failure into
    a working call.
  * perry-codegen/src/expr/instance_misc1.rs: the literal-RHS `instanceof` id
    table, keeping `x instanceof EventTarget` consistent with the runtime id.

A subclass instance never runs `js_event_target_new`, so its hidden state
(marker + listener bag + max-listener count) is seeded lazily on first
listener/dispatch use (`seed_event_target_state`) — the subclass constructor
lowering stays completely untouched. Every heap value there is rooted in a
`RuntimeHandleScope` and re-read from its handle at each use, since both
`js_object_alloc` and `key()` (string interning) can allocate and GC-move the
receiver.

test-files/test_gap_6301_event_target_subclass.ts is byte-identical to
`node --experimental-strip-types` (also under PERRY_GC_FORCE_EVACUATE=1
PERRY_GC_VERIFY_EVACUATION=1) and covers method value-reads + typeof on base and
subclass, listeners firing through a subclass, two-level subclassing, a subclass
with its own ctor calling `this.dispatchEvent(...)` (the cac shape), a detached
bound method value, removeEventListener, per-instance listener bags, a subclass
override still winning, `{ once: true }`, `instanceof EventTarget`, and Event /
CustomEvent construction + `.detail` unchanged.

cac v7 with a default command now runs and is byte-identical to Node (it fails
with `TypeError: value is not a function` on main).

Fixes #6301
Fixes #5931
…alue-read fallback

Review follow-up on the placement of the #6301 value-read fallback in
`get_field_by_name_object_tail`: it sits after the `keys_array == null`
early return, which raised the question of whether a class instance with
no own properties would return `undefined` for an inherited
`addEventListener` / `removeEventListener` / `dispatchEvent`.

It cannot. Every class-instance allocator
(`js_object_alloc_class_inline_keys`, `js_object_alloc_class_with_keys`,
`js_object_alloc_class_dynamic_parent`) installs the shape-cached keys
array unconditionally, and `js_build_class_keys_array` returns a
zero-LENGTH array — never NULL — for a class that declares no fields. So
`class Bus extends EventTarget {}` lands on the shaped path with an empty
keys array and reaches the fallback; the keyless branch is only for
`Object.create(proto)` / bare `js_new_function_construct` receivers, which
resolve an inherited method through the prototype-object hop
(`resolve_proto_chain_field_with_receiver`) before reaching its dead end.

Document that invariant at the fallback, and pin it with fixture cases
whose own-key set is empty when the method is read: an empty constructor,
a dynamic `new` through a variable, `Reflect.construct`, a class
expression, a function-local subclass, an instance whose only own property
was deleted, and a frozen instance. All byte-identical to
`node --experimental-strip-types`.
…t_value_read

Keeps `get_field_by_name_tail.rs` under the 2000-line file-size cap (the
added rationale had pushed it to 2009) and puts the explanation next to
the mechanism it describes rather than at the call site.

No behaviour change: `event_target_value_read` is the existing name gate
plus `event_target_method_bind`, in the same order, at the same point in
the property walk.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Rebased on main + CodeRabbit review follow-up

Rebased onto main (7a3b48afd) — clean, no conflicts with the ten PRs that landed since branching (including #6322/#6316, which established the same "user's class chain wins, native is the fallback" ordering this PR relies on).

The "Major" finding — verified, and it does not hold

CodeRabbit flagged the value-read fallback as dead for class instances with keys_array == NULL, which return early at the keyless branch (~line 1521) before reaching it. I chased this empirically rather than taking it either way. The premise is false: a class instance can't have keys_array == NULL.

All three class-instance allocators (js_object_alloc_class_inline_keys, js_object_alloc_class_with_keys, js_object_alloc_class_dynamic_parent) install the shape-cached keys array unconditionally, and js_build_class_keys_array returns a zero-length array — never NULL — when the class declares no fields (object/alloc.rs:284). keys_array == NULL is the pre-first-store state of a bump-allocated object, which a class instance is never in.

I instrumented the tail with a temporary print at the keys.is_null() test and at the keyless dead-end, then ran every shape I could construct that might yield an empty-shaped event target: fieldless subclass, assign-nothing ctor, two-level subclass, dynamic new through a variable, Reflect.construct, class expression, factory-returned class, function-local subclass, deleted-own-prop instance, frozen instance, Object.create(Bus.prototype). The keyless-dead-end probe never fired once. Every shape reports function for all three methods, matching node. Full table + reasoning in the review thread.

The one receiver that does take the keyless branch is Object.create(Bus.prototype) — and it resolves correctly within that branch, via the resolve_proto_chain_field_with_receiver hop that runs well before its return undefined(). I also confirmed it doesn't leak listeners onto the shared prototype. (Node throws on that shape outright, so we're more permissive there, not less.)

Moving the block earlier, as suggested, would place it ahead of the own-property and class-vtable lookups and break the subclass-override ordering that is the whole point of this PR.

What changed in response

  1. Documented the invariant on the new event_target::event_target_value_read — the tail's call site now points at it. It was load-bearing and undocumented, which is exactly what made the finding plausible.
  2. Pinned it with fixtures: test_gap_6301_event_target_subclass.ts gains the empty-shape cases (empty ctor, dynamic new, Reflect.construct, class expression, function-local subclass, deleted-own-prop, frozen instance).
  3. Kept the tail under the file-size cap — the added rationale had pushed get_field_by_name_tail.rs to 2009 lines, so the body moved into event_target.rs (which has headroom) behind event_target_value_read. Behaviour-identical: same name gate, same bind, same point in the walk. get_field_by_name_tail.rs is now 1990 lines.

Verification

  • test-files/test_gap_6301_event_target_subclass.ts — byte-identical to node --experimental-strip-types, and byte-identical again under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1.
  • cac v7 + default command (Using cac with default command resulting in error #5931) — re-verified end to end on the rebased branch: a default [...files] command plus a named build <target> command, both dispatching through this.dispatchEvent(new CustomEvent(...)). Byte-identical to node.
  • cargo test -p perry-runtime1278 passed, 0 failed.
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh clean.
  • test_gap_events_import_4995 / test_issue_850_eventemitter still diverge exactly as they do on main — the pre-existing EventEmitter gaps already noted in the PR description, untouched by this branch.

One real bug found along the way — filed separately

While hunting for a shape that reaches the keyless path, I found an unrelated, pre-existing gap: an immediately-constructed class expression loses its builtin parent edge.

new (class extends EventTarget {})()   // instanceof EventTarget → false; addEventListener → undefined
new (class extends Event {})("tick")   // instanceof Event → false; .type → undefined   ← predates this PR

It hits Event (long-standing) exactly as it hits EventTarget, so it's a general ClassExprFresh-vs-builtin-parent registration hole, not something this PR introduced or should grow to cover — the const-bound form (const K = class extends EventTarget {}) works fine here. Filed as #6336.

@proggeramlug proggeramlug merged commit 81ab8e5 into main Jul 13, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/6301-eventtarget-subclass branch July 13, 2026 06:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant