From ec5950da0693afddc8c0fa92c22e353d47d69584 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:27:29 +0000 Subject: [PATCH] dom: deliver standalone event target listeners --- crates/obscura-js/js/bootstrap.js | 98 +++++++++++++++++++++++- crates/obscura-js/src/runtime.rs | 56 ++++++++++++++ render-repros/checks.json | 3 + render-repros/eventtarget-lifecycle.html | 41 ++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 render-repros/eventtarget-lifecycle.html diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index bcf25de..1397e85 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -753,6 +753,94 @@ function _shallowCloneNode(node) { return el; } +// EventTarget listener state belongs to the JS wrapper rather than the backing +// DOM node. This is also what makes `new EventTarget()` and subclasses used by +// framework schedulers work: those targets deliberately have no native node id. +const _eventTargetListeners = new WeakMap(); +function _eventCapture(options) { + return typeof options === "boolean" ? options : !!(options && options.capture); +} +function _eventTargetAdd(target, type, callback, options) { + if (callback == null) return; + const isFunction = typeof callback === "function"; + if (!isFunction && typeof callback.handleEvent !== "function") return; + type = String(type); + const capture = _eventCapture(options); + const signal = options && typeof options === "object" ? options.signal : null; + if (signal && signal.aborted) return; + let byType = _eventTargetListeners.get(target); + if (!byType) { + byType = new Map(); + _eventTargetListeners.set(target, byType); + } + let listeners = byType.get(type); + if (!listeners) { + listeners = []; + byType.set(type, listeners); + } + if (listeners.some((entry) => entry.callback === callback && entry.capture === capture)) return; + const entry = { + callback, + capture, + once: !!(options && typeof options === "object" && options.once), + passive: !!(options && typeof options === "object" && options.passive), + signal, + abortHandler: null, + }; + listeners.push(entry); + if (signal && typeof signal.addEventListener === "function") { + entry.abortHandler = () => _eventTargetRemove(target, type, callback, capture); + signal.addEventListener("abort", entry.abortHandler, { once: true }); + } +} +function _eventTargetRemove(target, type, callback, options) { + const byType = _eventTargetListeners.get(target); + if (!byType) return; + type = String(type); + const listeners = byType.get(type); + if (!listeners) return; + const capture = _eventCapture(options); + for (let i = 0; i < listeners.length; i++) { + const entry = listeners[i]; + if (entry.callback !== callback || entry.capture !== capture) continue; + listeners.splice(i, 1); + if (entry.signal && entry.abortHandler && typeof entry.signal.removeEventListener === "function") { + entry.signal.removeEventListener("abort", entry.abortHandler); + } + break; + } + if (listeners.length === 0) byType.delete(type); + if (byType.size === 0) _eventTargetListeners.delete(target); +} +function _eventTargetDispatch(target, event) { + if (!event || typeof event.type === "undefined") { + throw new TypeError("Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'."); + } + if (String(event.type) === "") { + throw new DOMException("The event's type was not specified.", "InvalidStateError"); + } + if (!event.target) event.target = target; + event.currentTarget = target; + event.eventPhase = 2; + const listeners = (_eventTargetListeners.get(target)?.get(String(event.type)) || []).slice(); + for (const entry of listeners) { + const current = _eventTargetListeners.get(target)?.get(String(event.type)); + if (!current || !current.includes(entry)) continue; + if (entry.once) _eventTargetRemove(target, event.type, entry.callback, entry.capture); + const callback = entry.callback; + try { + if (typeof callback === "function") callback.call(target, event); + else callback.handleEvent.call(callback, event); + } catch (error) { + console.error(error); + } + if (event._immediatePropagationStopped) break; + } + event.currentTarget = null; + event.eventPhase = 0; + return !event.defaultPrevented; +} + class Node { static ELEMENT_NODE = 1; static ATTRIBUTE_NODE = 2; @@ -1033,7 +1121,15 @@ class Node { return true; } isSameNode(other) { return other && this._nid === other._nid; } - addEventListener() {} removeEventListener() {} dispatchEvent() { return true; } + addEventListener(type, callback, options) { + _eventTargetAdd(this, type, callback, options); + } + removeEventListener(type, callback, options) { + _eventTargetRemove(this, type, callback, options); + } + dispatchEvent(event) { + return _eventTargetDispatch(this, event); + } } class CharacterData extends Node { get data() { diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 12b5df3..7c58f30 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -4457,4 +4457,60 @@ mod tests { .unwrap(); assert_eq!(result, serde_json::json!(["menu", "true"])); } + + /// Framework schedulers commonly subclass EventTarget for their own + /// lifecycle events. These targets have no backing DOM node, but must + /// still deliver callbacks (including object, once, and signal listeners). + #[test] + fn standalone_event_target_delivers_framework_lifecycle_events() { + let mut rt = setup_runtime("
"); + let result = rt + .evaluate( + r#" + const TypedEventTarget = class extends EventTarget {}; + const target = new TypedEventTarget(); + const calls = []; + const removed = () => calls.push("removed"); + const controller = new AbortController(); + const node = { id: "canvas-ref" }; + + target.addEventListener("insert", (event) => calls.push(event.node.id)); + target.addEventListener("insert", { handleEvent() { calls.push("object"); } }); + target.addEventListener("insert", () => calls.push("once"), { once: true }); + target.addEventListener("insert", removed); + target.removeEventListener("insert", removed); + target.addEventListener("insert", () => calls.push("aborted"), { + signal: controller.signal, + }); + controller.abort(); + + const first = new Event("insert", { cancelable: true }); + first.node = node; + const firstResult = target.dispatchEvent(first); + const second = new Event("insert"); + second.node = node; + const secondResult = target.dispatchEvent(second); + return [ + target instanceof EventTarget, + calls, + firstResult, + secondResult, + first.target === target, + first.currentTarget === null, + ]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([ + true, + ["canvas-ref", "object", "once", "canvas-ref", "object"], + true, + true, + true, + true + ]) + ); + } } diff --git a/render-repros/checks.json b/render-repros/checks.json index f085560..f991a67 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -53,6 +53,9 @@ {"name": "max padding uses actual viewport", "color": "#c2255c", "x": 60, "y": 240, "width": 20, "height": 40}, {"name": "round quantizes nested custom property math", "color": "#2f9e44", "x": 0, "y": 300, "width": 250, "height": 40} ], + "eventtarget-lifecycle": [ + {"name": "framework lifecycle EventTarget completes", "color": "#18723a", "x": 0, "y": 0, "width": 420, "height": 120} + ], "flex-whitespace-items": [ {"name": "direct flex sidebar starts at container edge", "color": "#1971c2", "x": 0, "y": 0, "width": 225, "height": 180}, {"name": "direct flex body keeps its intended inset", "color": "#2b8a3e", "x": 225, "y": 0, "width": 675, "height": 200}, diff --git a/render-repros/eventtarget-lifecycle.html b/render-repros/eventtarget-lifecycle.html new file mode 100644 index 0000000..382a6a1 --- /dev/null +++ b/render-repros/eventtarget-lifecycle.html @@ -0,0 +1,41 @@ + + + + +