diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 6402770cb..3f68d3aa3 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 9eceb1892..d28a1ee20 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,154 @@ 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)) +} + +/// 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 9206101b5..cfa363111 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,21 @@ 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`). + // 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_value_read(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 9c4f902e2..ba73f9742 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 55a5bc33f..e506ab34f 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 000000000..b87c75505 --- /dev/null +++ b/test-files/test_gap_6301_event_target_subclass.ts @@ -0,0 +1,169 @@ +// #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); + +// ── 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); +const ce = new CustomEvent("custom", { detail: { n: 42 } }); +console.log("custom event type:", ce.type, "detail.n:", (ce.detail as { n: number }).n);