Repository: servo/servo
Pull request #36163: dom: Track "removed" event listener status
Author: login: tharkum
Created: 2025-03-26T08:17:59Z | Merged: 2025-03-29T00:31:56Z

The DOM event listener "removed" status should be supported to track the following situations (with immediate effect of listener removal):
- Removing a later event listener while an earlier listener for the same event is running
- Nested usage (recursively dispatch another event) of "once" listeners https://dom.spec.whatwg.org/#event-listener-removed

During event dispatching requires to clone event listeners list on "invoke" step https://dom.spec.whatwg.org/#concept-event-listener-invoke and the lowercase "event listener" concept in Servo is EventListenerEntry https://dom.spec.whatwg.org/#concept-event-listener

Bug: #25479, #25090

---
- [X] `./mach build -d` does not report any errors
- [X] `./mach test-tidy` does not report any errors
- [X] These changes fix #25479, #25090

- [X] There are tests for these changes
  tests/wpt/tests/dom/events/remove-all-listeners.html
  tests/wpt/tests/html/webappapis/dynamic-markup-insertion/opening-the-input-stream/event-listeners.window.js




Linked issue #25479: Removing a later event listener while an earlier listener for the same event is running doesn't stop it from being called
Servo currently has no "removed" flag for its event listeners, as specified in https://dom.spec.whatwg.org/#event-listener-removed . I would add it myself as part of #22783 cleanup, but there's a complicating factor: The removed flag lives on an "event listener", lowercase letters, not an EventListener, and Servo event targets currently expose a list of capital-EL Event Listeners to the dispatch algorithm. The lowercase concept is approximately Servo's struct EventListenerEntry, but it's not an exact mapping and code that returns EventListeners can't just be drop-in replaced with code that returns EventListenerEntries.
Linked issue comments: user: login: pshaughn
created_at: 2020-01-10T02:42:39Z
body: This is almost certainly a cause of other already-open issues, but I didn't happen to see any specific one that's definitely it.
author_association: CONTRIBUTOR

Diff:
diff --git a/components/script/dom/event.rs b/components/script/dom/event.rs
index a9dd421b7e0c8..1347713f489e6 100644
--- a/components/script/dom/event.rs
+++ b/components/script/dom/event.rs
@@ -29,7 +29,7 @@ use crate::dom::bindings::reflector::{DomGlobal, Reflector, reflect_dom_object_w
 use crate::dom::bindings::root::{Dom, DomRoot, MutNullableDom};
 use crate::dom::bindings::str::DOMString;
 use crate::dom::element::Element;
-use crate::dom::eventtarget::{CompiledEventListener, EventTarget, ListenerPhase};
+use crate::dom::eventtarget::{EventListeners, EventTarget, ListenerPhase};
 use crate::dom::globalscope::GlobalScope;
 use crate::dom::htmlinputelement::InputActivationState;
 use crate::dom::htmlslotelement::HTMLSlotElement;
@@ -1151,10 +1151,7 @@ fn invoke(
     event.current_target.set(Some(&segment.invocation_target));
 
     // Step 6. Let listeners be a clone of event’s currentTarget attribute value’s event listener list.
-    let listeners =
-        segment
-            .invocation_target
-            .get_listeners_for(&event.type_(), Some(phase), can_gc);
+    let listeners = segment.invocation_target.get_listeners_for(&event.type_());
 
     // Step 7. Let invocationTargetInShadowTree be struct’s invocation-target-in-shadow-tree.
     let invocation_target_in_shadow_tree = segment.invocation_target_in_shadow_tree;
@@ -1207,8 +1204,8 @@ fn invoke(
 /// <https://dom.spec.whatwg.org/#concept-event-listener-inner-invoke>
 fn inner_invoke(
     event: &Event,
-    listeners: &[CompiledEventListener],
-    _phase: ListenerPhase,
+    listeners: &EventListeners,
+    phase: ListenerPhase,
     invocation_target_in_shadow_tree: bool,
     timeline_window: Option<&Window>,
     can_gc: CanGc,
@@ -1217,25 +1214,21 @@ fn inner_invoke(
     let mut found = false;
 
     // Step 2. For each listener in listeners, whose removed is false:
-    for listener in listeners {
-        // FIXME(#25479): We need an "if !listener.removed()" here,
-        // but there's a subtlety. Where Servo is currently using the
-        // CompiledEventListener, we really need something that maps to
-        // https://dom.spec.whatwg.org/#concept-event-listener
-        // which is not the same thing as the EventListener interface.
-        // script::dom::eventtarget::EventListenerEntry is the closest
-        // match we have, and is already holding the "once" flag,
-        // but it's not a drop-in replacement.
-
-        // Steps 2.1 and 2.3-2.4 are not done because `listeners` contain only the
-        // relevant ones for this invoke call during the dispatch algorithm.
-        // TODO: Step 2.1 If event’s type attribute value is not listener’s type, then continue.
+    for listener in listeners.iter() {
+        if listener.borrow().removed() {
+            continue;
+        }
+
+        // Step 2.1 If event’s type attribute value is not listener’s type, then continue.
 
         // Step 2.2. Set found to true.
         found = true;
 
-        // TODO Step 2.3 If phase is "capturing" and listener’s capture is false, then continue.
-        // TODO Step 2.4 If phase is "bubbling" and listener’s capture is true, then continue.
+        // Step 2.3 If phase is "capturing" and listener’s capture is false, then continue.
+        // Step 2.4 If phase is "bubbling" and listener’s capture is true, then continue.
+        if listener.borrow().phase() != phase {
+            continue;
+        }
 
         let event_target = event
             .GetCurrentTarget()
@@ -1243,12 +1236,20 @@ fn inner_invoke(
 
         // Step 2.5 If listener’s once is true, then remove an event listener given event’s currentTarget
         // attribute value and listener.
-        if let CompiledEventListener::Listener(event_listener) = listener {
-            event_target.remove_listener_if_once(&event.type_(), event_listener);
+        if listener.borrow().once() {
+            event_target.remove_listener(&event.type_(), listener);
         }
 
+        let Some(compiled_listener) =
+            listener
+                .borrow()
+                .get_compiled_listener(&event_target, &event.type_(), can_gc)
+        else {
+            continue;
+        };
+
         // Step 2.6 Let global be listener callback’s associated realm’s global object.
-        let global = listener.associated_global();
+        let global = compiled_listener.associated_global();
 
         // Step 2.7 Let currentEvent be undefined.
         let mut current_event = None;
@@ -1264,18 +1265,22 @@ fn inner_invoke(
         }
 
         // Step 2.9 If listener’s passive is true, then set event's in passive listener flag.
-        if let CompiledEventListener::Listener(event_listener) = listener {
-            event.set_in_passive_listener(event_target.is_passive(&event.type_(), event_listener));
-        }
+        event.set_in_passive_listener(event_target.is_passive(&event.type_(), listener));
 
-        // Step 2.10 If global is a Window object, then record timing info for event listener given event and listener.
+        // Step 2.10 If global is a Window object, then record timing info for event listener
+        // given event and listener.
         // Step 2.11 Call a user object’s operation with listener’s callback, "handleEvent", « event »,
         // and event’s currentTarget attribute value. If this throws an exception exception:
         //     Step 2.10.1 Report exception for listener’s callback’s corresponding JavaScript object’s
         //     associated realm’s global object.
         //     TODO Step 2.10.2 Set legacyOutputDidListenersThrowFlag if given.
         let marker = TimelineMarker::start("DOMEvent".to_owned());
-        listener.call_or_handle_event(&event_target, event, ExceptionHandling::Report, can_gc);
+        compiled_listener.call_or_handle_event(
+            &event_target,
+            event,
+            ExceptionHandling::Report,
+            can_gc,
+        );
         if let Some(window) = timeline_window {
             window.emit_timeline_marker(marker.end());
         }
diff --git a/components/script/dom/eventtarget.rs b/components/script/dom/eventtarget.rs
index 43e62b528d8af..ea76bbf2a8b42 100644
--- a/components/script/dom/eventtarget.rs
+++ b/components/script/dom/eventtarget.rs
@@ -307,11 +307,36 @@ impl CompiledEventListener {
 // (as distinct from https://dom.spec.whatwg.org/#callbackdef-eventlistener)
 #[derive(Clone, DenyPublicFields, JSTraceable, MallocSizeOf)]
 /// A listener in a collection of event listeners.
-struct EventListenerEntry {
+pub(crate) struct EventListenerEntry {
     phase: ListenerPhase,
     listener: EventListenerType,
     once: bool,
     passive: Option<bool>,
+    removed: bool,
+}
+
+impl EventListenerEntry {
+    pub(crate) fn phase(&self) -> ListenerPhase {
+        self.phase
+    }
+
+    pub(crate) fn once(&self) -> bool {
+        self.once
+    }
+
+    pub(crate) fn removed(&self) -> bool {
+        self.removed
+    }
+
+    /// <https://html.spec.whatwg.org/multipage/#getting-the-current-value-of-the-event-handler>
+    pub(crate) fn get_compiled_listener(
+        &self,
+        owner: &EventTarget,
+        ty: &Atom,
+        can_gc: CanGc,
+    ) -> Option<CompiledEventListener> {
+        self.listener.get_compiled_listener(owner, ty, can_gc)
+    }
 }
 
 impl std::cmp::PartialEq for EventListenerEntry {
@@ -320,19 +345,21 @@ impl std::cmp::PartialEq for EventListenerEntry {
     }
 }
 
-#[derive(JSTraceable, MallocSizeOf)]
+#[derive(Clone, JSTraceable, MallocSizeOf)]
 /// A mix of potentially uncompiled and compiled event listeners of the same type.
-struct EventListeners(Vec<EventListenerEntry>);
+pub(crate) struct EventListeners(
+    #[ignore_malloc_size_of = "Rc"] Vec<Rc<RefCell<EventListenerEntry>>>,
+);
 
 impl Deref for EventListeners {
-    type Target = Vec<EventListenerEntry>;
-    fn deref(&self) -> &Vec<EventListenerEntry> {
+    type Target = Vec<Rc<RefCell<EventListenerEntry>>>;
+    fn deref(&self) -> &Vec<Rc<RefCell<EventListenerEntry>>> {
         &self.0
     }
 }
 
 impl DerefMut for EventListeners {
-    fn deref_mut(&mut self) -> &mut Vec<EventListenerEntry> {
+    fn deref_mut(&mut self) -> &mut Vec<Rc<RefCell<EventListenerEntry>>> {
         &mut self.0
     }
 }
@@ -346,7 +373,7 @@ impl EventListeners {
         can_gc: CanGc,
     ) -> Option<CommonEventHandler> {
         for entry in &self.0 {
-            if let EventListenerType::Inline(ref inline) = entry.listener {
+            if let EventListenerType::Inline(ref inline) = entry.borrow().listener {
                 // Step 1.1-1.8 and Step 2
                 return get_compiled_handler(inline, owner, ty, can_gc);
             }
@@ -356,30 +383,7 @@ impl EventListeners {
         None
     }
 
-    // https://html.spec.whatwg.org/multipage/#getting-the-current-value-of-the-event-handler
-    fn get_listeners(
-        &self,
-        phase: Option<ListenerPhase>,
-        owner: &EventTarget,
-        ty: &Atom,
-        can_gc: CanGc,
-    ) -> Vec<CompiledEventListener> {
-        self.0
-            .iter()
-            .filter_map(|entry| {
-                if phase.is_none() || Some(entry.phase) == phase {
-                    // Step 1.1-1.8, 2
-                    entry.listener.get_compiled_listener(owner, ty, can_gc)
-                } else {
-                    None
-                }
-            })
-            .collect()
-    }
-
     fn has_listeners(&self) -> bool {
-        // TODO: add, and take into account, a 'removed' field?
-        // https://dom.spec.whatwg.org/#event-listener-removed
         !self.0.is_empty()
     }
 }
@@ -420,18 +424,11 @@ impl EventTarget {
         }
     }
 
-    pub(crate) fn get_listeners_for(
-        &self,
-        type_: &Atom,
-        specific_phase: Option<ListenerPhase>,
-        can_gc: CanGc,
-    ) -> Vec<CompiledEventListener> {
+    pub(crate) fn get_listeners_for(&self, type_: &Atom) -> EventListeners {
         self.handlers
             .borrow()
             .get(type_)
-            .map_or(vec![], |listeners| {
-                listeners.get_listeners(specific_phase, self, type_, can_gc)
-            })
+            .map_or(EventListeners(vec![]), |listeners| listeners.clone())
     }
 
     pub(crate) fn dispatch_event(&self, event: &Event, can_gc: CanGc) -> EventStatus {
@@ -439,7 +436,14 @@ impl EventTarget {
     }
 
     pub(crate) fn remove_all_listeners(&self) {
-        *self.handlers.borrow_mut() = Default::default();
+        let mut handlers = self.handlers.borrow_mut();
+        for (_, entries) in handlers.iter() {
+            entries
+                .iter()
+                .for_each(|entry| entry.borrow_mut().removed = true);
+        }
+
+        *handlers = Default::default();
     }
 
     /// <https://dom.spec.whatwg.org/#default-passive-value>
@@ -488,50 +492,49 @@ impl EventTarget {
 
         let idx = entries
             .iter()
-            .position(|entry| matches!(entry.listener, EventListenerType::Inline(_)));
+            .position(|entry| matches!(entry.borrow().listener, EventListenerType::Inline(_)));
 
         match idx {
             Some(idx) => match listener {
                 // Replace if there's something to replace with,
                 // but remove entirely if there isn't.
                 Some(listener) => {
-                    entries[idx].listener = EventListenerType::Inline(listener.into());
+                    entries[idx].borrow_mut().listener = EventListenerType::Inline(listener.into());
                 },
                 None => {
-                    entries.remove(idx);
+                    entries.remove(idx).borrow_mut().removed = true;
                 },
             },
             None => {
                 if let Some(listener) = listener {
-                    entries.push(EventListenerEntry {
+                    entries.push(Rc::new(RefCell::new(EventListenerEntry {
                         phase: ListenerPhase::Bubbling,
                         listener: EventListenerType::Inline(listener.into()),
                         once: false,
                         passive: None,
-                    });
+                        removed: false,
+                    })));
                 }
             },
         }
     }
 
-    pub(crate) fn remove_listener_if_once(&self, ty: &Atom, listener: &Rc<EventListener>) {
+    pub(crate) fn remove_listener(&self, ty: &Atom, entry: &Rc<RefCell<EventListenerEntry>>) {
         let mut handlers = self.handlers.borrow_mut();
 
-        let listener = EventListenerType::Additive(listener.clone());
         if let Some(entries) = handlers.get_mut(ty) {
-            entries.retain(|e| e.listener != listener || !e.once)
+            if let Some(position) = entries.iter().position(|e| *e == *entry) {
+                entries.remove(position).borrow_mut().removed = true;
+            }
         }
     }
 
     /// Determines the `passive` attribute of an associated event listener
-    pub(crate) fn is_passive(&self, ty: &Atom, listener: &Rc<EventListener>) -> bool {
-        let handlers = self.handlers.borrow();
-        let listener_instance = EventListenerType::Additive(listener.clone());
-
-        handlers
-            .get(ty)
-            .and_then(|entries| entries.iter().find(|e| e.listener == listener_instance))
-            .is_some_and(|entry| entry.passive.unwrap_or(self.default_passive_value(ty)))
+    pub(crate) fn is_passive(&self, ty: &Atom, listener: &Rc<RefCell<EventListenerEntry>>) -> bool {
+        listener
+            .borrow()
+            .passive
+            .unwrap_or(self.default_passive_value(ty))
     }
 
     fn get_inline_event_listener(&self, ty: &Atom, can_gc: CanGc) -> Option<CommonEventHandler> {
@@ -810,7 +813,7 @@ impl EventTarget {
             None => return,
         };
         let mut handlers = self.handlers.borrow_mut();
-        let entry = match handlers.entry(Atom::from(ty)) {
+        let entries = match handlers.entry(Atom::from(ty)) {
             Occupied(entry) => entry.into_mut(),
             Vacant(entry) => entry.insert(EventListeners(vec![])),
         };
@@ -820,14 +823,16 @@ impl EventTarget {
         } else {
             ListenerPhase::Bubbling
         };
-        let new_entry = EventListenerEntry {
+        let new_entry = Rc::new(RefCell::new(EventListenerEntry {
             phase,
             listener: EventListenerType::Additive(listener),
             once: options.once,
             passive: options.passive,
-        };
-        if !entry.contains(&new_entry) {
-            entry.push(new_entry);
+            removed: false,
+        }));
+
+        if !entries.contains(&new_entry) {
+            entries.push(new_entry);
         }
     }
 
@@ -842,21 +847,21 @@ impl EventTarget {
             return;
         };
         let mut handlers = self.handlers.borrow_mut();
-        let entry = handlers.get_mut(&Atom::from(ty));
-        if let Some(entry) = entry {
+        if let Some(entries) = handlers.get_mut(&Atom::from(ty)) {
             let phase = if options.capture {
                 ListenerPhase::Capturing
             } else {
                 ListenerPhase::Bubbling
             };
-            let old_entry = EventListenerEntry {
+            let old_entry = Rc::new(RefCell::new(EventListenerEntry {
                 phase,
                 listener: EventListenerType::Additive(listener.clone()),
                 once: false,
                 passive: None,
-            };
-            if let Some(position) = entry.iter().position(|e| *e == old_entry) {
-                entry.remove(position);
+                removed: false,
+            }));
+            if let Some(position) = entries.iter().position(|e| *e == old_entry) {
+                entries.remove(position).borrow_mut().removed = true;
             }
         }
     }
diff --git a/tests/wpt/meta/dom/events/remove-all-listeners.html.ini b/tests/wpt/meta/dom/events/remove-all-listeners.html.ini
deleted file mode 100644
index c5607b3918d79..0000000000000
--- a/tests/wpt/meta/dom/events/remove-all-listeners.html.ini
+++ /dev/null
@@ -1,6 +0,0 @@
-[remove-all-listeners.html]
-  [Removing all listeners and then adding a new one should work.]
-    expected: FAIL
-
-  [Nested usage of once listeners should work.]
-    expected: FAIL
diff --git a/tests/wpt/meta/html/webappapis/dynamic-markup-insertion/opening-the-input-stream/event-listeners.window.js.ini b/tests/wpt/meta/html/webappapis/dynamic-markup-insertion/opening-the-input-stream/event-listeners.window.js.ini
index a373f3385edfe..c00e2949bf506 100644
--- a/tests/wpt/meta/html/webappapis/dynamic-markup-insertion/opening-the-input-stream/event-listeners.window.js.ini
+++ b/tests/wpt/meta/html/webappapis/dynamic-markup-insertion/opening-the-input-stream/event-listeners.window.js.ini
@@ -10,6 +10,3 @@
 
   [Custom event listeners are to be removed from Window for an active but not fully active document]
     expected: FAIL
-
-  [Event listeners are to be removed with immediate effect]
-    expected: FAIL


Reviews:
user: login: xiaochengh
body: Thanks for working on this. Looks good in general!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T13:18:02Z
user: login: tharkum
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T13:56:44Z
user: login: tharkum
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T14:07:39Z
user: login: xiaochengh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T03:59:58Z
user: login: xiaochengh
body: Thanks! Just a minor comment about ref-counting and everything else looks good!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T04:00:47Z
user: login: tharkum
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T07:25:10Z
user: login: xiaochengh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T08:43:06Z
user: login: xiaochengh
body: LGTM.

Could a maintainer review this now?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T08:43:26Z
user: login: jdm
body: Thanks for fixing this!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-27T18:05:39Z
user: login: tharkum
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T06:21:43Z

Review comments:
diff_hunk: @@ -1217,80 +1214,87 @@ fn inner_invoke(
     let mut found = false;
 
     // Step 2. For each listener in listeners, whose removed is false:
-    for listener in listeners {
-        // FIXME(#25479): We need an "if !listener.removed()" here,
-        // but there's a subtlety. Where Servo is currently using the
-        // CompiledEventListener, we really need something that maps to
-        // https://dom.spec.whatwg.org/#concept-event-listener
-        // which is not the same thing as the EventListener interface.
-        // script::dom::eventtarget::EventListenerEntry is the closest
-        // match we have, and is already holding the "once" flag,
-        // but it's not a drop-in replacement.
-
-        // Steps 2.1 and 2.3-2.4 are not done because `listeners` contain only the
-        // relevant ones for this invoke call during the dispatch algorithm.
-        // TODO: Step 2.1 If event’s type attribute value is not listener’s type, then continue.
+    for listener in listeners.iter() {
+        if listener.borrow().removed() {
+            continue;
+        }
+
+        // Step 2.1 If event’s type attribute value is not listener’s type, then continue.
 
         // Step 2.2. Set found to true.
         found = true;
 
-        // TODO Step 2.3 If phase is "capturing" and listener’s capture is false, then continue.
-        // TODO Step 2.4 If phase is "bubbling" and listener’s capture is true, then continue.
+        // Step 2.3 If phase is "capturing" and listener’s capture is false, then continue.
+        // Step 2.4 If phase is "bubbling" and listener’s capture is true, then continue.
+        if listener.borrow().phase() != phase {
path: components/script/dom/event.rs
user: login: xiaochengh
body: This seems to have fixed a different bug. Is there any test case covering it?
created_at: 2025-03-26T13:12:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,19 +345,21 @@ impl std::cmp::PartialEq for EventListenerEntry {
     }
 }
 
-#[derive(JSTraceable, MallocSizeOf)]
+#[derive(Clone, JSTraceable, MallocSizeOf)]
 /// A mix of potentially uncompiled and compiled event listeners of the same type.
-struct EventListeners(Vec<EventListenerEntry>);
+pub(crate) struct EventListeners(
+    #[ignore_malloc_size_of = "Rc"] Vec<Rc<RefCell<EventListenerEntry>>>,
path: components/script/dom/eventtarget.rs
user: login: xiaochengh
body: What's the reason for making each `EventListenerEntry` ref-counted?
created_at: 2025-03-26T13:17:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1217,80 +1214,87 @@ fn inner_invoke(
     let mut found = false;
 
     // Step 2. For each listener in listeners, whose removed is false:
-    for listener in listeners {
-        // FIXME(#25479): We need an "if !listener.removed()" here,
-        // but there's a subtlety. Where Servo is currently using the
-        // CompiledEventListener, we really need something that maps to
-        // https://dom.spec.whatwg.org/#concept-event-listener
-        // which is not the same thing as the EventListener interface.
-        // script::dom::eventtarget::EventListenerEntry is the closest
-        // match we have, and is already holding the "once" flag,
-        // but it's not a drop-in replacement.
-
-        // Steps 2.1 and 2.3-2.4 are not done because `listeners` contain only the
-        // relevant ones for this invoke call during the dispatch algorithm.
-        // TODO: Step 2.1 If event’s type attribute value is not listener’s type, then continue.
+    for listener in listeners.iter() {
+        if listener.borrow().removed() {
+            continue;
+        }
+
+        // Step 2.1 If event’s type attribute value is not listener’s type, then continue.
 
         // Step 2.2. Set found to true.
         found = true;
 
-        // TODO Step 2.3 If phase is "capturing" and listener’s capture is false, then continue.
-        // TODO Step 2.4 If phase is "bubbling" and listener’s capture is true, then continue.
+        // Step 2.3 If phase is "capturing" and listener’s capture is false, then continue.
+        // Step 2.4 If phase is "bubbling" and listener’s capture is true, then continue.
+        if listener.borrow().phase() != phase {
path: components/script/dom/event.rs
user: login: tharkum
body: Previously this check was done in different function "EventListeners::get_listeners" then we are trying to get compiled listeners on "clone listeners" step.
(see https://github.com/servo/servo/pull/36163/files#diff-e1da7806199dccca486a36cf25d9fb9c5aaa515db4adb22751fe7d1246be4106L370)
created_at: 2025-03-26T13:56:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,19 +345,21 @@ impl std::cmp::PartialEq for EventListenerEntry {
     }
 }
 
-#[derive(JSTraceable, MallocSizeOf)]
+#[derive(Clone, JSTraceable, MallocSizeOf)]
 /// A mix of potentially uncompiled and compiled event listeners of the same type.
-struct EventListeners(Vec<EventListenerEntry>);
+pub(crate) struct EventListeners(
+    #[ignore_malloc_size_of = "Rc"] Vec<Rc<RefCell<EventListenerEntry>>>,
path: components/script/dom/eventtarget.rs
user: login: tharkum
body: During event dispatching requires to clone event listeners list
on "invoke" step https://dom.spec.whatwg.org/#concept-event-listener-invoke
and the lowercase "event listener" concept in Servo is EventListenerEntry
https://dom.spec.whatwg.org/#concept-event-listener

So we must have dedicated copy of EL list in event (on dispatching) because in event target this EL list could be changed (added new one or removed old) during callback processing in JS and at the same time we need to track "removed" status of ELs in copied list.
created_at: 2025-03-26T14:07:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1217,80 +1214,87 @@ fn inner_invoke(
     let mut found = false;
 
     // Step 2. For each listener in listeners, whose removed is false:
-    for listener in listeners {
-        // FIXME(#25479): We need an "if !listener.removed()" here,
-        // but there's a subtlety. Where Servo is currently using the
-        // CompiledEventListener, we really need something that maps to
-        // https://dom.spec.whatwg.org/#concept-event-listener
-        // which is not the same thing as the EventListener interface.
-        // script::dom::eventtarget::EventListenerEntry is the closest
-        // match we have, and is already holding the "once" flag,
-        // but it's not a drop-in replacement.
-
-        // Steps 2.1 and 2.3-2.4 are not done because `listeners` contain only the
-        // relevant ones for this invoke call during the dispatch algorithm.
-        // TODO: Step 2.1 If event’s type attribute value is not listener’s type, then continue.
+    for listener in listeners.iter() {
+        if listener.borrow().removed() {
+            continue;
+        }
+
+        // Step 2.1 If event’s type attribute value is not listener’s type, then continue.
 
         // Step 2.2. Set found to true.
         found = true;
 
-        // TODO Step 2.3 If phase is "capturing" and listener’s capture is false, then continue.
-        // TODO Step 2.4 If phase is "bubbling" and listener’s capture is true, then continue.
+        // Step 2.3 If phase is "capturing" and listener’s capture is false, then continue.
+        // Step 2.4 If phase is "bubbling" and listener’s capture is true, then continue.
+        if listener.borrow().phase() != phase {
path: components/script/dom/event.rs
user: login: xiaochengh
body: Ack.
created_at: 2025-03-27T03:46:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,19 +345,21 @@ impl std::cmp::PartialEq for EventListenerEntry {
     }
 }
 
-#[derive(JSTraceable, MallocSizeOf)]
+#[derive(Clone, JSTraceable, MallocSizeOf)]
 /// A mix of potentially uncompiled and compiled event listeners of the same type.
-struct EventListeners(Vec<EventListenerEntry>);
+pub(crate) struct EventListeners(
+    #[ignore_malloc_size_of = "Rc"] Vec<Rc<RefCell<EventListenerEntry>>>,
path: components/script/dom/eventtarget.rs
user: login: xiaochengh
body: I see, thanks!

Is it correct that the cloned list is only used temporarily during event invocation? I feel like in this case we can use something lighter than ref-counting, but the overhead of ref-counting might also be fine here.
created_at: 2025-03-27T03:59:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,19 +345,21 @@ impl std::cmp::PartialEq for EventListenerEntry {
     }
 }
 
-#[derive(JSTraceable, MallocSizeOf)]
+#[derive(Clone, JSTraceable, MallocSizeOf)]
 /// A mix of potentially uncompiled and compiled event listeners of the same type.
-struct EventListeners(Vec<EventListenerEntry>);
+pub(crate) struct EventListeners(
+    #[ignore_malloc_size_of = "Rc"] Vec<Rc<RefCell<EventListenerEntry>>>,
path: components/script/dom/eventtarget.rs
user: login: tharkum
body: Yes, it is correct - the cloned list is temporary on event invocation
(please see https://github.com/servo/servo/pull/36163/files#diff-ea6430e13386a6a7b2e4b04cda88357fe076367414e5d4e730d962e8251d8da4R1154)

Instead of rec-counting we can use the following approaches with Vec(EventListernerEntry)  but they have drawbacks:
1. To do deep clone of EL list, but need to track EL "removed" status via traversal of EL list in EventTarget (as example it was done in "remove_listetner_if_once" - https://github.com/servo/servo/pull/36163/files#diff-e1da7806199dccca486a36cf25d9fb9c5aaa515db4adb22751fe7d1246be4106L517), but what about ABA issue?
`... -> add EL X+Y -> event invocation: clone X+Y -> X callback (remove Y, add Y) -> Y callback ???`
Should we call Y callback in current event invocation? According to specification - no (https://dom.spec.whatwg.org/#concept-event-listener-invoke (step 6).
Of course to handle it properly we can add "fired event listener tracking" logic, but it will be harder to
understand and support it.
BTW, Chromium also has similar solution (GC-ed RegisterEventListener ~ Rc(EventListenerEntry))
https://chromium-review.googlesource.com/c/chromium/src/+/4623037/9/third_party/blink/renderer/core/dom/events/event_listener_map.h#47

2. To pass EL list via reference, but we will have mutable borrow issues later (like in https://github.com/servo/servo/pull/33965)


created_at: 2025-03-27T07:25:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,19 +345,21 @@ impl std::cmp::PartialEq for EventListenerEntry {
     }
 }
 
-#[derive(JSTraceable, MallocSizeOf)]
+#[derive(Clone, JSTraceable, MallocSizeOf)]
 /// A mix of potentially uncompiled and compiled event listeners of the same type.
-struct EventListeners(Vec<EventListenerEntry>);
+pub(crate) struct EventListeners(
+    #[ignore_malloc_size_of = "Rc"] Vec<Rc<RefCell<EventListenerEntry>>>,
path: components/script/dom/eventtarget.rs
user: login: xiaochengh
body: Ack. `Rc` does seem like the most straightforward approach here.

> BTW, Chromium also has similar solution (GC-ed RegisterEventListener ~ Rc(EventListenerEntry))

Chromium garbage-collects everything accessible from JS, which might be different here.
created_at: 2025-03-27T08:43:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -307,11 +307,36 @@ impl CompiledEventListener {
 // (as distinct from https://dom.spec.whatwg.org/#callbackdef-eventlistener)
 #[derive(Clone, DenyPublicFields, JSTraceable, MallocSizeOf)]
 /// A listener in a collection of event listeners.
-struct EventListenerEntry {
+pub(crate) struct EventListenerEntry {
     phase: ListenerPhase,
     listener: EventListenerType,
     once: bool,
     passive: Option<bool>,
+    removed: bool,
+}
+
+impl EventListenerEntry {
+    pub(crate) fn phase(&self) -> ListenerPhase {
+        self.phase
+    }
+
+    pub(crate) fn once(&self) -> bool {
+        self.once
+    }
+
+    pub(crate) fn removed(&self) -> bool {
+        self.removed
+    }
+
+    // https://html.spec.whatwg.org/multipage/#getting-the-current-value-of-the-event-handler
path: components/script/dom/eventtarget.rs
user: login: jdm
body: ```suggestion
    /// <https://html.spec.whatwg.org/multipage/#getting-the-current-value-of-the-event-handler>
```
created_at: 2025-03-27T18:01:52Z
author_association: MEMBER
diff_hunk: @@ -307,11 +307,36 @@ impl CompiledEventListener {
 // (as distinct from https://dom.spec.whatwg.org/#callbackdef-eventlistener)
 #[derive(Clone, DenyPublicFields, JSTraceable, MallocSizeOf)]
 /// A listener in a collection of event listeners.
-struct EventListenerEntry {
+pub(crate) struct EventListenerEntry {
     phase: ListenerPhase,
     listener: EventListenerType,
     once: bool,
     passive: Option<bool>,
+    removed: bool,
+}
+
+impl EventListenerEntry {
+    pub(crate) fn phase(&self) -> ListenerPhase {
+        self.phase
+    }
+
+    pub(crate) fn once(&self) -> bool {
+        self.once
+    }
+
+    pub(crate) fn removed(&self) -> bool {
+        self.removed
+    }
+
+    // https://html.spec.whatwg.org/multipage/#getting-the-current-value-of-the-event-handler
path: components/script/dom/eventtarget.rs
user: login: tharkum
body: I updated PR with your suggestion.
created_at: 2025-03-28T06:21:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: tharkum
created_at: 2025-03-26T08:33:55Z
body: cc @xiaochengh 
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-26T09:19:47Z
body: 🔨 Triggering try run ([#14079677163](https://github.com/servo/servo/actions/runs/14079677163)) for Linux (WPT)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-26T09:44:33Z
body: <span>Test results for linux-wpt from try job ([#14079677163](https://github.com/servo/servo/actions/runs/14079677163)):<div><details><summary>Flaky unexpected result (21)</summary><ul><li><span>OK `/_mozilla/mozilla/getBoundingClientRect.html` ([#18328](http://github.com/servo/servo/issues/18328))</span><ul><li><span>FAIL [expected PASS] subtest: `getBoundingClientRect 1` 
```
assert_equals: expected 62 but got 60.35
```
</span></li></ul></li><li><span>PASS [expected FAIL] `/css/compositing/mix-blend-mode/mix-blend-mode-video-sibling.html` ([#32849](http://github.com/servo/servo/issues/32849))</span></li><li><span>OK `/css/css-cascade/layer-font-face-override.html` ([#35935](http://github.com/servo/servo/issues/35935))</span><ul><li><span>PASS [expected FAIL] subtest: `@font-face override update with appended sheet 1`</span></li><li><span>PASS [expected FAIL] subtest: `@font-face override update with appended sheet 2`</span></li></ul></li><li><span>FAIL [expected PASS] `/css/css-overflow/line-clamp/line-clamp-021.tentative.html` ([#35008](http://github.com/servo/servo/issues/35008))</span></li><li><span>FAIL [expected PASS] `/css/css-sizing/dynamic-available-size-iframe.html` ([#31559](http://github.com/servo/servo/issues/31559))</span></li><li><span>OK `/css/css-values/cap-invalidation.html` ([#32757](http://github.com/servo/servo/issues/32757))</span><ul><li><span>FAIL [expected PASS] subtest: `CSS Values and Units Test: cap invalidation` 
```
uncaught exception: Error: assert_not_equals: expect the capital height of Ahem and sans-serif to be different got disallowed value 371.3333333333333
```
</span></li></ul></li><li><span>OK `/custom-elements/form-associated/form-disabled-callback.html`</span><ul><li><span>PASS [expected FAIL] subtest: `A disabled form-associated custom element should not submit an entry for it`</span></li></ul></li><li><span>OK `/encoding/legacy-mb-japanese/shift_jis/sjis-encode-form-sjis.html?1001-2000`</span><ul><li><span>FAIL [expected PASS] subtest: `U+528D 劍 %99%98` 
```
assert_equals: expected "%99%98" but got "%8F%59"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+5291 劑 %99%9D` 
```
assert_equals: expected "%99%9D" but got "%90%6D"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+5292 劒 %99%9A` 
```
assert_equals: expected "%99%9A" but got "%98%BC"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+5294 劔 %99%99` 
```
assert_equals: expected "%99%99" but got "%98%BA"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+529B 力 %97%CD` 
```
assert_equals: expected "%97%CD" but got "%98%BB"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+529C 劜 %FA%85` 
```
assert_equals: expected "%FA%85" but got "%8B%77"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+529F 功 %8C%F7` 
```
assert_equals: expected "%8C%F7" but got "%8D%A1"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+52A0 加 %89%C1` 
```
assert_equals: expected "%89%C1" but got "%89%EE"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+52A3 劣 %97%F2` 
```
assert_equals: expected "%97%F2" but got "%98%B9"
```
</span></li><li><span>FAIL [expected PASS] subtest: `U+52A6 劦 %FA%86` 
```
assert_equals: expected "%FA%86" but got "%98%B8"
```
</span></li><li><span>And 190 more unexpected results...</span></li></ul></li><li><span>OK `/encoding/legacy-mb-tchinese/big5/big5-encode-form-errors-misc.html?1-1000`</span><ul><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+21B ț %26%23539%3B` 
```
assert_equals: expected "%26%23539%3B" but got ""
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+21C Ȝ %26%23540%3B` 
```
assert_equals: expected (string) "%26%23540%3B" but got (undefined) undefined
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+21D ȝ %26%23541%3B` 
```
assert_equals: expected (string) "%26%23541%3B" but got (undefined) undefined
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+21E Ȟ %26%23542%3B` 
```
assert_equals: expected (string) "%26%23542%3B" but got (undefined) undefined
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+21F ȟ %26%23543%3B` 
```
assert_equals: expected (string) "%26%23543%3B" but got (undefined) undefined
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+220 Ƞ %26%23544%3B` 
```
assert_equals: expected (string) "%26%23544%3B" but got (undefined) undefined
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+221 ȡ %26%23545%3B` 
```
assert_equals: expected (string) "%26%23545%3B" but got (undefined) undefined
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+222 Ȣ %26%23546%3B` 
```
assert_equals: expected (string) "%26%23546%3B" but got (undefined) undefined
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+223 ȣ %26%23547%3B` 
```
assert_equals: expected (string) "%26%23547%3B" but got (undefined) undefined
```
</span></li><li><span>FAIL [expected PASS] subtest: `latin, greek, cyrillic, etc U+224 Ȥ %26%23548%3B` 
```
assert_equals: expected (string) "%26%23548%3B" but got (undefined) undefined
```
</span></li><li><span>And 390 more unexpected results...</span></li></ul></li><li><span>OK [expected ERROR] `/fetch/fetch-later/quota/same-origin-iframe/multiple-iframes.tentative.https.window.html` ([#35176](http://github.com/servo/servo/issues/35176))</span></li><li><span>OK `/html/browsers/browsing-the-web/navigating-across-documents/navigation-unload-same-origin.window.html` ([#29049](http://github.com/servo/servo/issues/29049))</span><ul><li><span>PASS [expected FAIL] subtest: `Same-origin navigation started from unload handler must be ignored`</span></li></ul></li><li><span>OK `/html/browsers/history/the-history-interface/traverse_the_history_5.html` ([#21383](http://github.com/servo/servo/issues/21383))</span><ul><li><span>FAIL [expected PASS] subtest: `Multiple history traversals, last would be aborted` 
```
assert_array_equals: Pages opened during history navigation expected property 1 to be 5 but got 3 (expected array [6, 5] got [6, 3])
```
</span></li></ul></li><li><span>OK `/html/browsers/windows/embedded-opener-remove-frame.html` ([#23867](http://github.com/servo/servo/issues/23867))</span><ul><li><span>PASS [expected FAIL] subtest: `opener of discarded auxiliary browsing context`</span></li></ul></li><li><span>PASS [expected FAIL] `/html/canvas/element/manual/drawing-text-to-the-canvas/canvas.2d.disconnected-font-size-math.html` ([#30063](http://github.com/servo/servo/issues/30063))</span></li><li><span>TIMEOUT [expected ERROR] `/html/canvas/element/manual/imagebitmap/createImageBitmap-premultiplyAlpha.html` ([#35881](http://github.com/servo/servo/issues/35881))</span></li><li><span>CRASH [expected TIMEOUT] `/html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_nonescaping-2.html` ([#22154](http://github.com/servo/servo/issues/22154))</span></li><li><span>OK [expected TIMEOUT] `/html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_nonescaping-3.html` ([#24066](http://github.com/servo/servo/issues/24066))</span><ul><li><span>FAIL [expected NOTRUN] subtest: `Check that popups from a sandboxed iframe do not escape the sandbox` 
```
assert_equals: It came from a sandboxed iframe expected "null" but got "http://web-platform.test:8000"
```
</span></li></ul></li><li><span>OK `/html/semantics/forms/form-submission-0/form-submit-iframe-then-location-navigate.html` ([#29634](http://github.com/servo/servo/issues/29634))</span><ul><li><span>PASS [expected FAIL] subtest: `Verifies that location navigations take precedence when following form submissions.`</span></li></ul></li><li><span>OK `/html/semantics/forms/form-submission-0/urlencoded2.window.html` ([#28687](http://github.com/servo/servo/issues/28687))</span><ul><li><span>PASS [expected FAIL] subtest: `application/x-www-form-urlencoded: Basic test (normal form)`</span></li></ul></li><li><span>OK `/html/semantics/scripting-1/the-script-element/execution-timing/077.html` ([#22139](http://github.com/servo/servo/issues/22139))</span><ul><li><span>FAIL [expected PASS] subtest: ` adding several types of scripts through the DOM and removing some of them confuses scheduler ` 
```
assert_array_equals: expected property 1 to be "Script #1 ran" but got "Script #3 ran" (expected array ["Script #2 ran", "Script #1 ran", "Script #3 ran", "Script #4 ran"] got ["Script #2 ran", "Script #3 ran", "Script #4 ran", "Script #1 ran"])
```
</span></li></ul></li><li><span>TIMEOUT [expected OK] `/webmessaging/without-ports/017.html` ([#24486](http://github.com/servo/servo/issues/24486))</span><ul><li><span>TIMEOUT [expected PASS] subtest: `origin of the script that invoked the method, about:blank` 
```
Test timed out
```
</span></li></ul></li></ul></details><details><summary>Stable unexpected results that are known to be intermittent (15)</summary><ul><li><span>OK `/FileAPI/url/url-with-fetch.any.html` ([#21517](http://github.com/servo/servo/issues/21517))</span><ul><li><span>PASS [expected FAIL] subtest: `Revoke blob URL after calling fetch, fetch should succeed`</span></li></ul></li><li><span>OK `/_webgl/conformance/textures/misc/texture-upload-size.html` ([#21770](http://github.com/servo/servo/issues/21770))</span><ul><li><span>FAIL [expected PASS] subtest: `WebGL test #44` 
```
assert_true: could not create image (SVG) expected true got false
```
</span></li><li><span>PASS [expected FAIL] subtest: `WebGL test #52`</span></li></ul></li><li><span>OK `/fetch/metadata/generated/css-font-face.https.sub.tentative.html` ([#32732](http://github.com/servo/servo/issues/32732))</span><ul><li><span>PASS [expected FAIL] subtest: `sec-fetch-dest`</span></li><li><span>PASS [expected FAIL] subtest: `sec-fetch-storage-access - Cross-site`</span></li></ul></li><li><span>OK `/html/browsers/browsing-the-web/navigating-across-documents/009.html` ([#24456](http://github.com/servo/servo/issues/24456))</span><ul><li><span>FAIL [expected PASS] subtest: `Link with onclick form submit to javascript url with document.write and href navigation ` 
```
assert_array_equals: expected property 1 to be "href" but got "click" (expected array ["write", "href"] got ["write", "click"])
```
</span></li></ul></li><li><span>OK `/html/browsers/browsing-the-web/navigating-across-documents/initial-empty-document/iframe-src-aboutblank-navigate-immediately.html` ([#29048](http://github.com/servo/servo/issues/29048))</span><ul><li><span>FAIL [expected PASS] subtest: `Navigating to a different document with form submission` 
```
assert_equals: expected "http://web-platform.test:8000/common/blank.html?1=" but got "about:blank"
```
</span></li></ul></li><li><span>OK `/html/browsers/browsing-the-web/navigating-across-documents/navigation-unload-same-origin-fragment.html` ([#20768](http://github.com/servo/servo/issues/20768))</span><ul><li><span>PASS [expected FAIL] subtest: `Tests that a fragment navigation in the unload handler will not block the initial navigation`</span></li></ul></li><li><span>OK [expected ERROR] `/html/canvas/element/manual/imagebitmap/createImageBitmap-colorSpaceConversion.html` ([#34151](http://github.com/servo/servo/issues/34151))</span></li><li><span>TIMEOUT [expected ERROR] `/html/canvas/element/manual/imagebitmap/createImageBitmap-transfer.html` ([#34119](http://github.com/servo/servo/issues/34119))</span></li><li><span>OK `/html/infrastructure/safe-passing-of-structured-data/structured-cloning-error-stack-optional.sub.window.html` ([#24131](http://github.com/servo/servo/issues/24131))</span><ul><li><span>PASS [expected FAIL] subtest: `web API-created TypeError (structuredClone())`</span></li><li><span>PASS [expected FAIL] subtest: `web API-created TypeError (worker)`</span></li><li><span>PASS [expected FAIL] subtest: `web API-created TypeError (cross-site iframe)`</span></li><li><span>PASS [expected FAIL] subtest: `web API-created TypeError (same-origin iframe)`</span></li></ul></li><li><span>OK [expected TIMEOUT] `/html/interaction/focus/the-autofocus-attribute/supported-elements.html` ([#24145](http://github.com/servo/servo/issues/24145))</span><ul><li><span>FAIL [expected NOTRUN] subtest: `Area element should support autofocus` 
```
assert_equals: expected Element node &lt;area autofocus="" href="/common/blank.html"&gt;&lt;/area&gt; but got Element node &lt;body&gt;
&lt;img src="/media/poster.png" usemap="#map"&gt;
&lt;map n...
```
</span></li></ul></li><li><span>TIMEOUT [expected OK] `/html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_escaping-2.html` ([#22667](http://github.com/servo/servo/issues/22667))</span></li><li><span>OK [expected TIMEOUT] `/html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_nonescaping-1.html` ([#24066](http://github.com/servo/servo/issues/24066))</span></li><li><span>PASS [expected TIMEOUT] `/html/semantics/embedded-content/the-img-element/image-loading-lazy-subframe-detached-crash.html` ([#31572](http://github.com/servo/servo/issues/31572))</span></li><li><span>OK `/html/semantics/forms/form-submission-0/text-plain.window.html` ([#28687](http://github.com/servo/servo/issues/28687))</span><ul><li><span>PASS [expected FAIL] subtest: `text/plain: 0x00 in value (formdata event)`</span></li></ul></li><li><span>OK `/navigation-timing/test-navigation-type-reload.html` ([#33334](http://github.com/servo/servo/issues/33334))</span><ul><li><span>PASS [expected FAIL] subtest: `Reload domContentLoadedEventStart &gt; Original domContentLoadedEventStart`</span></li><li><span>PASS [expected FAIL] subtest: `Reload domInteractive &gt; Original domInteractive`</span></li><li><span>PASS [expected FAIL] subtest: `Reload fetchStart &gt; Original fetchStart`</span></li></ul></li></ul></details></div></span>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-26T09:44:44Z
body: ✨ Try run ([#14079677163](https://github.com/servo/servo/actions/runs/14079677163)) succeeded.
author_association: NONE
user: login: tharkum
created_at: 2025-03-27T12:18:04Z
body: Reduced commit diffs by "let-else" (https://github.com/servo/servo/pull/36163/files#diff-ea6430e13386a6a7b2e4b04cda88357fe076367414e5d4e730d962e8251d8da4R1243)
author_association: CONTRIBUTOR