Compare commits

...

1 Commits

Author SHA1 Message Date
Keeley Hammond
4c09531cde fix: fire AXMenuOpened event when a visible ARIA menu instance is added to the DOM 2026-03-06 18:16:43 -08:00
2 changed files with 138 additions and 0 deletions

View File

@@ -147,3 +147,4 @@ refactor_allow_customizing_config_in_freedesktopsecretkeyprovider.patch
fix_wayland_test_crash_on_teardown.patch
fix_set_correct_app_id_on_linux.patch
fix_pass_trigger_for_global_shortcuts_on_wayland.patch
fix_fire_menu_popup_start_end_for_dynamically_created_removed_aria.patch

View File

@@ -0,0 +1,137 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Keeley Hammond <khammond@slack-corp.com>
Date: Fri, 6 Mar 2026 16:59:59 -0800
Subject: fix: fire MENU_POPUP_START/END for dynamically created/removed ARIA
menus
When an ARIA menu element is dynamically created (e.g. via appendChild)
rather than being shown by toggling visibility, the AXMenuOpened event
was not fired. The OnIgnoredChanged path handles the visibility toggle
case, but OnNodeCreated/OnAtomicUpdateFinished did not fire
MENU_POPUP_START for newly created menu nodes.
Previous attempts to fix this (crbug.com/1254875) were reverted because
they fired the event too eagerly in OnNodeCreated (before the tree was
fully formed) and without filtering, causing regressions with screen
readers on pages that misused role="menu".
This fix addresses both issues:
1. Fires MENU_POPUP_START in OnAtomicUpdateFinished (after the tree
update is complete) rather than in OnNodeCreated.
2. Only fires if the menu has at least one menuitem child, filtering
out false positives from misused role="menu" elements.
For MENU_POPUP_END, when a menu is dynamically removed from the DOM,
the event is fired on the parent node since events on deleted nodes
are erased before processing.
This patch can be removed when a CL containing the fix is accepted
into Chromium.
Bug: 40794596
diff --git a/ui/accessibility/ax_event_generator.cc b/ui/accessibility/ax_event_generator.cc
index 8fe1cacc274c543e6a5f13bb9b3712639f8bbda5..0eb8326175dce95d7387a4302ca966c75d67ac47 100644
--- a/ui/accessibility/ax_event_generator.cc
+++ b/ui/accessibility/ax_event_generator.cc
@@ -953,6 +953,7 @@ void AXEventGenerator::OnSubtreeWillBeDeleted(AXTree* tree, AXNode* node) {
DCHECK_EQ(tree_, tree);
FireValueInTextFieldChangedEventIfNecessary(tree, node);
FireLiveRegionEvents(node, /* removal */ true);
+ FireMenuPopupEndForDeletedMenus(node);
}
void AXEventGenerator::OnNodeWillBeReparented(AXTree* tree, AXNode* node) {
@@ -1011,14 +1012,41 @@ void AXEventGenerator::OnAtomicUpdateFinished(
/*new_value*/ true);
}
- if (IsAlert(change.node->GetRole()))
+ if (IsAlert(change.node->GetRole())) {
AddEvent(change.node, Event::ALERT);
- else if (change.node->data().IsActiveLiveRegionRoot())
+ } else if (change.node->data().IsActiveLiveRegionRoot()) {
AddEvent(change.node, Event::LIVE_REGION_CREATED);
- else if (change.node->data().IsContainedInActiveLiveRegion())
+ } else if (change.node->data().IsContainedInActiveLiveRegion()) {
FireLiveRegionEvents(change.node, /* is_removal */ false);
+ }
+
+ // Fire MENU_POPUP_START when a menu is dynamically created (e.g. via
+ // appendChild). The OnIgnoredChanged path handles menus that already exist
+ // in the DOM and are shown/hidden. This handles the case where the menu
+ // element itself is created on the fly.
+ // Only fire if the menu has at least one menuitem child, to avoid false
+ // positives from elements that misuse role="menu".
+ if (change.node->GetRole() == ax::mojom::Role::kMenu &&
+ !change.node->IsInvisibleOrIgnored()) {
+ for (auto iter = change.node->UnignoredChildrenBegin();
+ iter != change.node->UnignoredChildrenEnd(); ++iter) {
+ if (IsMenuItem(iter->GetRole())) {
+ AddEvent(change.node, Event::MENU_POPUP_START);
+ break;
+ }
+ }
+ }
}
+ // Fire MENU_POPUP_END for menus that were dynamically removed. These are
+ // fired on the parent node because the menu node itself has been deleted.
+ for (AXNodeID parent_id : pending_menu_popup_end_node_ids_) {
+ if (AXNode* parent = tree_->GetFromId(parent_id)) {
+ AddEvent(parent, Event::MENU_POPUP_END);
+ }
+ }
+ pending_menu_popup_end_node_ids_.clear();
+
FireActiveDescendantEvents();
nodes_to_suppress_parent_changed_on_.clear();
@@ -1069,6 +1097,23 @@ void AXEventGenerator::FireLiveRegionEvents(AXNode* node, bool is_removal) {
}
}
+void AXEventGenerator::FireMenuPopupEndForDeletedMenus(AXNode* node) {
+ // When a menu is dynamically removed from the DOM, we cannot add
+ // MENU_POPUP_END on the menu node itself because OnNodeDeleted will erase
+ // its events before they are processed. Instead, record the parent node ID
+ // so we can fire MENU_POPUP_END on the parent in OnAtomicUpdateFinished.
+ if (node->GetRole() == ax::mojom::Role::kMenu &&
+ !node->IsInvisibleOrIgnored()) {
+ if (AXNode* parent = node->GetUnignoredParent()) {
+ pending_menu_popup_end_node_ids_.push_back(parent->id());
+ }
+ return;
+ }
+ for (const auto& child : node->children()) {
+ FireMenuPopupEndForDeletedMenus(child.get());
+ }
+}
+
void AXEventGenerator::FireActiveDescendantEvents() {
for (AXNode* node : active_descendant_changed_) {
AXNode* descendant = tree_->GetFromId(
diff --git a/ui/accessibility/ax_event_generator.h b/ui/accessibility/ax_event_generator.h
index e4d05a3671b5ee02ec4e28517cafe639f4cd7607..84ce10a492dc69aa062f7c9323c44c05c02add50 100644
--- a/ui/accessibility/ax_event_generator.h
+++ b/ui/accessibility/ax_event_generator.h
@@ -328,6 +328,7 @@ class AX_EXPORT AXEventGenerator : public AXTreeObserver {
bool IsRemovalRelevantInLiveRegion(AXNode* node);
void FireLiveRegionEvents(AXNode* node, bool is_removal);
+ void FireMenuPopupEndForDeletedMenus(AXNode* node);
void FireActiveDescendantEvents();
// If the given target node is inside a text field and the node's modification
// could affect the field's value, generates an `VALUE_IN_TEXT_FIELD_CHANGED`
@@ -372,6 +373,11 @@ class AX_EXPORT AXEventGenerator : public AXTreeObserver {
// Registered events for a given node.
std::map<Event, std::set<AXNodeID>> registered_event_to_node_ids_;
+ // Parent node IDs where MENU_POPUP_END should fire because a menu child
+ // was dynamically removed from the tree. We store the parent IDs because
+ // events on deleted nodes get erased before they can be processed.
+ std::vector<AXNodeID> pending_menu_popup_end_node_ids_;
+
// Please make sure that this ScopedObservation is always declared last in
// order to prevent any use-after-free.
base::ScopedObservation<AXTree, AXTreeObserver> tree_event_observation_{this};