From 07a9b9f336b62531346fc25ef3ca84cff19c896f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 12 Jul 2026 16:53:15 +0200 Subject: [PATCH 1/3] fix(runtime): EventTarget methods must live on the prototype so subclasses inherit them (#6301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `" 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 --- .../perry-codegen/src/expr/instance_misc1.rs | 6 + crates/perry-runtime/src/event_target.rs | 222 +++++++++++++++++- .../field_get_set/get_field_by_name_tail.rs | 19 ++ crates/perry-runtime/src/object/instanceof.rs | 7 + .../src/object/native_call_method.rs | 27 +++ .../test_gap_6301_event_target_subclass.ts | 110 +++++++++ 6 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 test-files/test_gap_6301_event_target_subclass.ts diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 6402770cb6..3f68d3aa34 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -479,6 +479,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "Event" | "globalThis.Event" => 0xFFFF2403u32, "CustomEvent" | "globalThis.CustomEvent" => 0xFFFF2404u32, "DOMException" | "globalThis.DOMException" => 0xFFFF2405u32, + // #6301. Keep in sync with + // perry-runtime/src/event_target.rs::CLASS_ID_EVENT_TARGET. + // A plain `new EventTarget()` carries this id on its header; a + // `class X extends EventTarget` instance reaches it through the + // parent edge the class registry wires at definition time. + "EventTarget" | "globalThis.EventTarget" => 0xFFFF2406u32, // node:fs constructor exports. Keep these ids in sync with // perry-runtime/src/fs/mod.rs and instanceof.rs. "fs.Dir" => 0xFFFF0086u32, diff --git a/crates/perry-runtime/src/event_target.rs b/crates/perry-runtime/src/event_target.rs index 9eceb18923..7b6b2ccaee 100644 --- a/crates/perry-runtime/src/event_target.rs +++ b/crates/perry-runtime/src/event_target.rs @@ -17,6 +17,13 @@ use std::sync::{Mutex, OnceLock}; pub const CLASS_ID_EVENT: u32 = 0xFFFF_2403; pub const CLASS_ID_CUSTOM_EVENT: u32 = 0xFFFF_2404; pub const CLASS_ID_DOM_EXCEPTION: u32 = 0xFFFF_2405; +/// `EventTarget` base class. Stamped on `new EventTarget()` instances and used +/// as the PARENT class id of a user `class X extends EventTarget` (wired by +/// `js_register_class_parent_dynamic` via `global_builtin_constructor_class_id`). +/// Walking to it through the class chain is what lets a subclass instance be +/// recognized as an event target (#6301). Keep in sync with the reserved id in +/// perry-codegen/src/expr/instance_misc1.rs. +pub const CLASS_ID_EVENT_TARGET: u32 = 0xFFFF_2406; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; @@ -491,7 +498,35 @@ pub(crate) fn abort_dom_exception_value() -> f64 { crate::value::js_nanbox_pointer(err as i64) } -unsafe fn is_event_target(target: *const ObjectHeader) -> bool { +/// True for `EventTarget`'s own reserved class id and for any user class whose +/// registered parent chain reaches it — `class Bus extends EventTarget {}` and +/// `class B extends A extends EventTarget` alike. The edge is wired at class- +/// definition time by `js_register_class_parent_dynamic`, which resolves the +/// `EventTarget` global through `global_builtin_constructor_class_id` (#6301). +/// Mirrors `is_event_instance`'s walk for the `Event` base. +pub(crate) fn class_chain_is_event_target(class_id: u32) -> bool { + if class_id == 0 { + return false; + } + if class_id == CLASS_ID_EVENT_TARGET { + return true; + } + let mut cur = class_id; + for _ in 0..64 { + match crate::object::get_parent_class_id(cur) { + Some(parent) if parent != 0 && parent != cur => { + if parent == CLASS_ID_EVENT_TARGET { + return true; + } + cur = parent; + } + _ => return false, + } + } + false +} + +pub(crate) unsafe fn is_event_target(target: *const ObjectHeader) -> bool { if target.is_null() { return false; } @@ -507,16 +542,68 @@ unsafe fn is_event_target(target: *const ObjectHeader) -> bool { if (*gc_header).obj_type != crate::gc::GC_TYPE_OBJECT { return false; } + // A subclass instance carries its own class id and never ran + // `js_event_target_new`, so it has no `_eventTarget` marker field until + // `seed_event_target_state` lazily installs one. The class-chain edge is + // what identifies it (#6301). + if class_chain_is_event_target((*target).class_id) { + return true; + } let marker = js_object_get_field_by_name_f64(target, key(b"_eventTarget")); marker.to_bits() == JSValue::bool(true).bits() } +/// Install the hidden EventTarget state (marker + listener bag + max-listener +/// count) on an object that is an event target by class chain but has never +/// been seeded — i.e. a `class Bus extends EventTarget` instance, whose +/// constructor path allocates a plain subclass object rather than calling +/// `js_event_target_new`. Seeding on first listener/dispatch use keeps the +/// subclass ctor lowering untouched. +/// +/// `js_object_alloc` and `key` (which interns a string) both allocate and can +/// therefore GC-move `target`, so every heap value is rooted up front and +/// re-read from its handle at each use. The bag and all three key strings are +/// allocated BEFORE the first store on purpose: the inline form +/// `js_object_set_field_by_name(target, key(b"…"), …)` allocates the key +/// *after* Rust has already evaluated `target` (arguments evaluate +/// left-to-right), which would leave a stale receiver pointer if interning that +/// key triggered a collection. +unsafe fn seed_event_target_state(target: *mut ObjectHeader) -> *mut ObjectHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let target_handle = scope.root_raw_mut_ptr(target); + let bag_handle = scope.root_raw_mut_ptr(js_object_alloc(0, 0)); + let marker_key = scope.root_string_ptr(key(b"_eventTarget")); + let listeners_key = scope.root_string_ptr(key(b"_eventTargetListeners")); + let max_listeners_key = scope.root_string_ptr(key(b"_eventTargetMaxListeners")); + + js_object_set_field_by_name( + target_handle.get_raw_mut_ptr::(), + marker_key.get_raw_mut_ptr::(), + bool_value(true), + ); + js_object_set_field_by_name( + target_handle.get_raw_mut_ptr::(), + listeners_key.get_raw_mut_ptr::(), + boxed_ptr(bag_handle.get_raw_mut_ptr::()), + ); + js_object_set_field_by_name( + target_handle.get_raw_mut_ptr::(), + max_listeners_key.get_raw_mut_ptr::(), + 10.0, + ); + + bag_handle.get_raw_mut_ptr::() +} + unsafe fn listeners_bag(target: *mut ObjectHeader) -> Option<*mut ObjectHeader> { if !is_event_target(target) { return None; } let bag = js_object_get_field_by_name_f64(target, key(b"_eventTargetListeners")); - value_as_ptr::(bag) + if let Some(bag) = value_as_ptr::(bag) { + return Some(bag); + } + Some(seed_event_target_state(target)) } unsafe fn event_array( @@ -655,9 +742,16 @@ extern "C" fn event_target_abort_remove_listener( } /// `new EventTarget()`. +/// +/// The instance carries `CLASS_ID_EVENT_TARGET` on its header (mirroring +/// `construct_event`'s `CLASS_ID_EVENT`), so `t instanceof EventTarget` holds +/// for the base the same way it holds for a subclass through the registered +/// parent edge (#6301). The `_eventTarget` marker field stays: the Node +/// `events` helpers' target probe predates the class id, and a subclass +/// instance is seeded with the same marker on first use. #[no_mangle] pub extern "C" fn js_event_target_new() -> *mut ObjectHeader { - let target = js_object_alloc(0, 0); + let target = js_object_alloc(CLASS_ID_EVENT_TARGET, 0); let bag = js_object_alloc(0, 0); js_object_set_field_by_name( target, @@ -913,3 +1007,125 @@ pub unsafe extern "C" fn js_event_target_set_max_listeners( js_object_set_field_by_name(target, key(b"_eventTargetMaxListeners"), n); 1 } + +// ───────────────────────────────────────────────────────────────── +// #6301 — the EventTarget method surface as real function VALUES. +// +// `addEventListener` / `removeEventListener` / `dispatchEvent` used to exist +// only as compile-time lowerings keyed on a receiver whose static class name +// was literally `EventTarget` (`lower_call/event_target.rs`). Nothing lived on +// a prototype, so `typeof t.addEventListener` was `undefined` even on a plain +// `new EventTarget()`, and a `class Bus extends EventTarget {}` instance — +// whose static class name is `Bus` — inherited nothing at all and died with +// `TypeError: value is not a function` (the real root cause of #5931: cac v7's +// `class CAC extends EventTarget` calls `this.dispatchEvent(...)`). +// +// The methods are now resolved off the receiver's *class chain* — the same +// mechanism `Event` subclasses already use — and materialized as bound-method +// closures (the AbortSignal `abort_signal_method_bind` shape, and the +// value-read-materializes-a-bound-method shape of #6281). Both the value-read +// path (`object/field_get_set/get_field_by_name_tail.rs`) and the dynamic-call +// path (`object/native_call_method.rs`) consult `event_target_method_bind`, so +// a subclass instance at any depth reads them as functions AND calls them. +// ───────────────────────────────────────────────────────────────── + +/// The receiver a bound EventTarget method closure captured in slot 0 (stored +/// as NaN-boxed bits so the GC's closure scan keeps it alive and relocates it). +unsafe fn bound_event_target(closure: *const crate::closure::ClosureHeader) -> *mut ObjectHeader { + let bits = crate::closure::js_closure_get_capture_ptr(closure, 0) as u64; + crate::value::js_nanbox_get_pointer(f64::from_bits(bits)) as *mut ObjectHeader +} + +/// Shared body of the add/remove listener thunks. `string_from_value` can +/// allocate (a non-string `type` is coerced), so the receiver and the value +/// arguments are rooted across it and re-read afterwards. +unsafe fn bound_listener_call( + closure: *const crate::closure::ClosureHeader, + event_type: f64, + listener: f64, + options: f64, + add: bool, +) -> f64 { + let listener_value = JSValue::from_bits(listener.to_bits()); + if !listener_value.is_pointer() { + // Node ignores a nullish listener and rejects a non-object one; a + // non-pointer here is neither a closure nor a handler object, so there + // is nothing to register or remove. + return undefined_value(); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let target_handle = scope.root_raw_mut_ptr(bound_event_target(closure)); + let listener_handle = scope.root_nanbox_f64(listener); + let options_handle = scope.root_nanbox_f64(options); + + let name_handle = scope.root_string_ptr(string_from_value(event_type)); + + let target = target_handle.get_raw_mut_ptr::(); + let name = name_handle.get_raw_const_ptr::(); + let callback_ptr = crate::value::js_nanbox_get_pointer(listener_handle.get_nanbox_f64()); + let options = options_handle.get_nanbox_f64(); + if add { + js_event_target_add_event_listener_with_options(target, name, callback_ptr, options); + } else { + js_event_target_remove_event_listener_with_options(target, name, callback_ptr, options); + } + undefined_value() +} + +extern "C" fn event_target_add_event_listener_thunk( + closure: *const crate::closure::ClosureHeader, + event_type: f64, + listener: f64, + options: f64, +) -> f64 { + unsafe { bound_listener_call(closure, event_type, listener, options, true) } +} + +extern "C" fn event_target_remove_event_listener_thunk( + closure: *const crate::closure::ClosureHeader, + event_type: f64, + listener: f64, + options: f64, +) -> f64 { + unsafe { bound_listener_call(closure, event_type, listener, options, false) } +} + +extern "C" fn event_target_dispatch_event_thunk( + closure: *const crate::closure::ClosureHeader, + event: f64, +) -> f64 { + unsafe { js_event_target_dispatch_event(bound_event_target(closure), event) } +} + +/// The EventTarget method names, in the order Node's `EventTarget.prototype` +/// declares them. Callers gate on this before paying for the receiver probe. +pub(crate) fn is_event_target_method_name(name: &[u8]) -> bool { + matches!( + name, + b"addEventListener" | b"removeEventListener" | b"dispatchEvent" + ) +} + +/// Materialize a bound EventTarget method for `name` when `target` is an event +/// target (a plain `new EventTarget()` or a subclass instance at any depth). +/// Returns `None` for any other name or receiver, so an unknown property still +/// reads as `undefined` and a non-target object keeps its existing dispatch. +pub(crate) fn event_target_method_bind(target: *mut ObjectHeader, name: &[u8]) -> Option { + let (func, arity): (*const u8, u32) = match name { + b"addEventListener" => (event_target_add_event_listener_thunk as *const u8, 3), + b"removeEventListener" => (event_target_remove_event_listener_thunk as *const u8, 3), + b"dispatchEvent" => (event_target_dispatch_event_thunk as *const u8, 1), + _ => return None, + }; + if !unsafe { is_event_target(target) } { + return None; + } + crate::closure::js_register_closure_arity(func, arity); + // `js_closure_alloc` can GC-move the receiver — root it and re-read. + let scope = crate::gc::RuntimeHandleScope::new(); + let target_handle = scope.root_raw_mut_ptr(target); + let closure = crate::closure::js_closure_alloc(func, 1); + let target_bits = boxed_ptr(target_handle.get_raw_mut_ptr::()).to_bits(); + crate::closure::js_closure_set_capture_ptr(closure, 0, target_bits as i64); + Some(boxed_ptr(closure)) +} diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 9206101b5a..7ccbc1fb92 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1968,6 +1968,25 @@ pub(crate) fn get_field_by_name_object_tail( } } + // #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()); + } + } + // Key not found JSValue::undefined() } diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 9c4f902e26..ba73f97425 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -432,6 +432,13 @@ pub(crate) fn global_builtin_constructor_class_id(name: &str) -> u32 { "Event" => crate::event_target::CLASS_ID_EVENT, "CustomEvent" => crate::event_target::CLASS_ID_CUSTOM_EVENT, "DOMException" => crate::event_target::CLASS_ID_DOM_EXCEPTION, + // #6301: the `X → EventTarget` edge is what makes a user + // `class Bus extends EventTarget {}` instance resolve the inherited + // `addEventListener`/`removeEventListener`/`dispatchEvent` surface + // (see `event_target::class_chain_is_event_target`). Without an id + // here `js_register_class_parent_dynamic` left the subclass + // parentless and it inherited nothing. + "EventTarget" => crate::event_target::CLASS_ID_EVENT_TARGET, // TypedArray constructors used as runtime *values* (a dynamic // `x instanceof TA` where `TA` is a variable — e.g. test262's // `testWithTypedArrayConstructors`). Mirrors the per-kind synthetic diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 55a5bc33f4..e506ab34f4 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1720,6 +1720,33 @@ pub unsafe extern "C" fn js_native_call_method( } } + // #6301: `class Bus extends EventTarget {}` — a fused + // `bus.dispatchEvent(ev)` / `this.addEventListener(...)` call. The static + // lowering in `lower_call/event_target.rs` only fires for a receiver whose + // class name is literally `EventTarget`, so a subclass call landed here and + // threw " is not a function" (cac v7's `class CAC extends EventTarget` + // → #5931). Runs LAST, after every own-field / vtable / prototype-chain + // lookup above, so a subclass that OVERRIDES one of these names keeps its + // own method; only a genuine miss on a real event target reaches this. + if jsval.is_pointer() + && crate::event_target::is_event_target_method_name(method_name.as_bytes()) + { + // Re-read the receiver from its root handle instead of reusing the + // `object` snapshot taken at entry: the dispatch arms above allocate, so + // a moving collection may have relocated it (the same reason the args go + // through `refreshed_args()`). + let receiver = object_handle.get_nanbox_f64(); + let recv = (receiver.to_bits() & crate::value::POINTER_MASK) as *mut ObjectHeader; + if !recv.is_null() && !crate::value::addr_class::is_small_handle(recv as usize) { + if let Some(bound) = + crate::event_target::event_target_method_bind(recv, method_name.as_bytes()) + { + let args = refreshed_args(); + return crate::closure::js_native_call_value(bound, args.as_ptr(), args.len()); + } + } + } + crate::object::class_registry::report_dispatch_miss( "call-method (no method/field/proto match)", object, diff --git a/test-files/test_gap_6301_event_target_subclass.ts b/test-files/test_gap_6301_event_target_subclass.ts new file mode 100644 index 0000000000..e9e46f7927 --- /dev/null +++ b/test-files/test_gap_6301_event_target_subclass.ts @@ -0,0 +1,110 @@ +// #6301: `class X extends EventTarget` must inherit the EventTarget method +// surface. The methods used to exist only as a compile-time lowering keyed on a +// receiver whose static class name was literally `EventTarget`, so nothing was +// readable as a value (`typeof t.addEventListener` was `undefined` even on a +// plain `new EventTarget()`) and a subclass inherited nothing at all — +// `this.dispatchEvent(...)` threw `TypeError: value is not a function`. That is +// the root cause of #5931 (cac v7's `class CAC extends EventTarget`). + +// ── plain EventTarget: methods are readable as values ── +const plain = new EventTarget(); +console.log("plain typeof addEventListener:", typeof plain.addEventListener); +console.log("plain typeof removeEventListener:", typeof plain.removeEventListener); +console.log("plain typeof dispatchEvent:", typeof plain.dispatchEvent); +plain.addEventListener("ping", () => console.log("plain listener fired")); +console.log("plain dispatch returned:", plain.dispatchEvent(new Event("ping"))); + +// ── one-level subclass ── +class Bus extends EventTarget {} +const bus = new Bus(); +console.log("sub typeof addEventListener:", typeof bus.addEventListener); +console.log("sub typeof dispatchEvent:", typeof bus.dispatchEvent); +bus.addEventListener("go", (e: Event) => { + console.log("sub listener fired:", e.type); +}); +console.log("sub dispatch returned:", bus.dispatchEvent(new Event("go"))); + +// ── two-level subclass: B extends A extends EventTarget ── +class A extends EventTarget {} +class B extends A {} +const deep = new B(); +console.log("two-level typeof dispatchEvent:", typeof deep.dispatchEvent); +deep.addEventListener("deep", () => console.log("two-level listener fired")); +deep.dispatchEvent(new Event("deep")); + +// ── a subclass with its own constructor + state (the cac shape) ── +class Emitter extends EventTarget { + count: number; + constructor(start: number) { + super(); + this.count = start; + } + fire(name: string): void { + // `this.dispatchEvent(...)` — exactly what cac's matched-command path does. + this.dispatchEvent(new CustomEvent(name, { detail: { count: this.count } })); + } +} +const em = new Emitter(7); +em.addEventListener("tick", (e: Event) => { + const detail = (e as CustomEvent).detail as { count: number }; + console.log("ctor-subclass listener fired, detail.count:", detail.count); +}); +em.fire("tick"); +console.log("ctor-subclass field survived super():", em.count); + +// ── the method value is callable when detached from the read ── +const add = bus.addEventListener.bind(bus); +add("bound", () => console.log("bound-read listener fired")); +bus.dispatchEvent(new Event("bound")); + +// ── removeEventListener actually removes ── +const once = () => console.log("SHOULD NOT FIRE"); +deep.addEventListener("gone", once); +deep.removeEventListener("gone", once); +deep.dispatchEvent(new Event("gone")); +console.log("removed listener did not fire"); + +// ── listener bags are per-instance, not shared across subclass instances ── +const busA = new Bus(); +const busB = new Bus(); +busA.addEventListener("only-a", () => console.log("busA listener fired")); +busB.dispatchEvent(new Event("only-a")); +console.log("busB has no busA listener"); +busA.dispatchEvent(new Event("only-a")); + +// ── an override on the subclass still wins over the inherited method ── +// The inherited surface is resolved as a LAST resort, after every own-property +// and class-vtable lookup, so a subclass that redefines one of these names +// keeps its own method. (This override deliberately does not delegate, so the +// registered listener must not fire.) +class Loud extends EventTarget { + dispatchEvent(event: Event): boolean { + console.log("override ran for:", event.type); + return true; + } +} +const loud = new Loud(); +console.log("override-subclass inherits addEventListener:", typeof loud.addEventListener); +loud.addEventListener("x", () => console.log("SHOULD NOT FIRE")); +console.log("override returned:", loud.dispatchEvent(new Event("x"))); + +// ── options: { once: true } and event fields still work through a subclass ── +let onceCount = 0; +bus.addEventListener("tick-once", () => { + onceCount++; +}, { once: true }); +bus.dispatchEvent(new Event("tick-once")); +bus.dispatchEvent(new Event("tick-once")); +console.log("once listener call count:", onceCount); + +// ── instanceof reaches the EventTarget base from both the base and a subclass ── +console.log("plain instanceof EventTarget:", plain instanceof EventTarget); +console.log("sub instanceof EventTarget:", bus instanceof EventTarget); +console.log("two-level instanceof EventTarget:", deep instanceof EventTarget); +console.log("plain object instanceof EventTarget:", {} instanceof EventTarget); + +// ── Event / CustomEvent construction is untouched ── +const ev = new Event("basic"); +console.log("event type:", ev.type, "cancelable:", ev.cancelable); +const ce = new CustomEvent("custom", { detail: { n: 42 } }); +console.log("custom event type:", ce.type, "detail.n:", (ce.detail as { n: number }).n); From 6f7556098941d394544f8aff6cc1c6c97577a706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 06:51:27 +0200 Subject: [PATCH 2/3] test(runtime): pin the empty-shape invariant behind the EventTarget value-read fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .../field_get_set/get_field_by_name_tail.rs | 16 +++++ .../test_gap_6301_event_target_subclass.ts | 59 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 7ccbc1fb92..4ce54d6091 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1979,6 +1979,22 @@ pub(crate) fn get_field_by_name_object_tail( // 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. + // + // This sits AFTER the `keys_array.is_null()` early return above, and + // that is correct — a `class X extends EventTarget` instance never + // takes the keyless branch. 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 with no declared fields. + // So even `class Bus extends EventTarget {}` (no fields, no ctor) lands + // on this shaped path with an empty keys array, not on the keyless one. + // The keyless branch is for `Object.create(proto)` / bare + // `js_new_function_construct` receivers, which resolve an inherited + // EventTarget method through the prototype-object hop + // (`resolve_proto_chain_field_with_receiver`) before ever reaching its + // dead end. Covered by the empty-shape cases in + // `test-files/test_gap_6301_event_target_subclass.ts`. 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) diff --git a/test-files/test_gap_6301_event_target_subclass.ts b/test-files/test_gap_6301_event_target_subclass.ts index e9e46f7927..b87c755054 100644 --- a/test-files/test_gap_6301_event_target_subclass.ts +++ b/test-files/test_gap_6301_event_target_subclass.ts @@ -103,6 +103,65 @@ console.log("sub instanceof EventTarget:", bus instanceof EventTarget); console.log("two-level instanceof EventTarget:", deep instanceof EventTarget); console.log("plain object instanceof EventTarget:", {} instanceof EventTarget); +// ── EMPTY-SHAPE receivers: a subclass instance with no own properties at all ── +// The value-read fallback lives after the field-walk's `keys_array == null` +// early return, so these pin the invariant that makes that placement correct: +// a class instance ALWAYS carries a shape-cached keys array (a zero-LENGTH one +// when the class declares no fields), so it takes the shaped path, never the +// keyless one. Each of these has an empty own-key set at the moment the method +// is read. +class Empty extends EventTarget { + constructor() { + super(); // ctor that assigns nothing + } +} +const empty = new Empty(); +console.log("empty-ctor subclass:", typeof empty.addEventListener, typeof empty.dispatchEvent); + +// constructed through a variable (dynamic `new`) rather than a static class ref +const BusRef = Bus; +const viaRef = new BusRef(); +console.log("dynamic-new subclass:", typeof viaRef.addEventListener); + +// constructed via Reflect.construct +const viaReflect = Reflect.construct(Bus, []) as Bus; +console.log("Reflect.construct subclass:", typeof viaReflect.addEventListener); + +// a class *expression* bound to a const +const AnonBus = class extends EventTarget {}; +const anon = new AnonBus(); +anon.addEventListener("anon", () => console.log("class-expression listener fired")); +console.log("class-expression subclass:", typeof anon.dispatchEvent); +anon.dispatchEvent(new Event("anon")); + +// a subclass declared inside a function body +function makeLocal(): void { + class Local extends EventTarget {} + const local = new Local(); + console.log("fn-local subclass:", typeof local.addEventListener); + local.addEventListener("local", () => console.log("fn-local listener fired")); + local.dispatchEvent(new Event("local")); +} +makeLocal(); + +// an instance whose only own property has been deleted (own-key set now empty) +class Deletable extends EventTarget { + x?: number; + constructor() { + super(); + this.x = 1; + } +} +const del = new Deletable(); +delete del.x; +console.log("deleted-own-prop subclass:", typeof del.addEventListener, typeof del.dispatchEvent); +del.addEventListener("del", () => console.log("deleted-own-prop listener fired")); +del.dispatchEvent(new Event("del")); + +// a frozen no-field subclass instance +const frozen = Object.freeze(new Bus()); +console.log("frozen subclass:", typeof frozen.addEventListener, typeof frozen.dispatchEvent); + // ── Event / CustomEvent construction is untouched ── const ev = new Event("basic"); console.log("event type:", ev.type, "cancelable:", ev.cancelable); From f481f0507eaad48593828d77db07a530a826e655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 06:57:07 +0200 Subject: [PATCH 3/3] refactor(runtime): move the EventTarget value-read behind event_target_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. --- crates/perry-runtime/src/event_target.rs | 29 +++++++++++++++ .../field_get_set/get_field_by_name_tail.rs | 36 +++++-------------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/crates/perry-runtime/src/event_target.rs b/crates/perry-runtime/src/event_target.rs index 7b6b2ccaee..d28a1ee206 100644 --- a/crates/perry-runtime/src/event_target.rs +++ b/crates/perry-runtime/src/event_target.rs @@ -1129,3 +1129,32 @@ pub(crate) fn event_target_method_bind(target: *mut ObjectHeader, name: &[u8]) - crate::closure::js_closure_set_capture_ptr(closure, 0, target_bits as i64); Some(boxed_ptr(closure)) } + +/// Value-read entry point for the object field-get tail (#6301): resolve +/// `addEventListener` / `removeEventListener` / `dispatchEvent` off the +/// receiver's class chain when nothing earlier in the property walk claimed the +/// name. Gated on the name first, so a non-EventTarget read pays only a +/// length+compare, never the receiver probe. +/// +/// The call site sits AFTER the tail's `keys_array.is_null()` early return, and +/// that placement is correct: a `class X extends EventTarget` instance never +/// takes the keyless branch. 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` hands back a zero-LENGTH +/// array — never NULL — for a class that declares no fields. So even the +/// emptiest subclass (`class Bus extends EventTarget {}`, no fields, no ctor) +/// lands on the shaped path with an empty keys array and reaches this fallback. +/// +/// The keyless branch is for `Object.create(proto)` / bare +/// `js_new_function_construct` receivers, whose inherited methods resolve one +/// hop earlier through `resolve_proto_chain_field_with_receiver` (the prototype +/// object is itself a shaped, class-id-stamped receiver) — so they never reach +/// that branch's dead end either. The empty-shape cases in +/// `test-files/test_gap_6301_event_target_subclass.ts` pin this. +pub(crate) fn event_target_value_read(target: *mut ObjectHeader, name: &[u8]) -> Option { + if !is_event_target_method_name(name) { + return None; + } + event_target_method_bind(target, name) +} diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 4ce54d6091..cfa363111a 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1969,35 +1969,15 @@ pub(crate) fn get_field_by_name_object_tail( } // #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. - // - // This sits AFTER the `keys_array.is_null()` early return above, and - // that is correct — a `class X extends EventTarget` instance never - // takes the keyless branch. 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 with no declared fields. - // So even `class Bus extends EventTarget {}` (no fields, no ctor) lands - // on this shaped path with an empty keys array, not on the keyless one. - // The keyless branch is for `Object.create(proto)` / bare - // `js_new_function_construct` receivers, which resolve an inherited - // EventTarget method through the prototype-object hop - // (`resolve_proto_chain_field_with_receiver`) before ever reaching its - // dead end. Covered by the empty-shape cases in - // `test-files/test_gap_6301_event_target_subclass.ts`. - if !key.is_null() && crate::event_target::is_event_target_method_name(key_bytes) { + // (`typeof b.dispatchEvent`, `const add = t.addEventListener`). + // 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. See + // `event_target::event_target_value_read` for why placing it after the + // `keys_array.is_null()` early return above is correct. + if !key.is_null() { if let Some(bound) = - crate::event_target::event_target_method_bind(obj as *mut ObjectHeader, key_bytes) + crate::event_target::event_target_value_read(obj as *mut ObjectHeader, key_bytes) { return JSValue::from_bits(bound.to_bits()); }