dom: deliver standalone event target listeners

This commit is contained in:
SGavrl committed 2026-07-26 14:27:29 +00:00
1 parent ca551a996b
commit ec5950da06
4 files changed
+197 -1

No files matched your search

+97 -1
View File
@@ -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() {
+56
View File
@@ -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("<div></div>");
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
])
);
}
}
+3
View File
@@ -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},
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>EventTarget lifecycle</title>
<style>
body { margin: 0; font: 18px sans-serif; }
#result {
box-sizing: border-box;
width: 420px;
height: 120px;
padding: 32px;
background: #a22;
color: white;
}
#result.passed { background: #18723a; }
</style>
</head>
<body>
<div id="result">lifecycle pending</div>
<script type="module">
const result = document.getElementById("result");
class LifecycleTarget extends EventTarget {}
const lifecycle = new LifecycleTarget();
let insertedNode = null;
lifecycle.addEventListener("insert", (event) => {
insertedNode = event.node;
});
const event = new Event("insert");
event.node = result;
lifecycle.dispatchEvent(event);
if (insertedNode === result) {
result.textContent = "framework lifecycle completed";
result.className = "passed";
} else {
result.textContent = "framework lifecycle stalled";
}
</script>
</body>
</html>