Repository: electron/electron
Pull request: fix: [a11y] fire AXMenuOpened event when ARIA menu is added to DOM

#### Description of Change

Fixes upstream bug https://issues.chromium.org/issues/40794596

This PR attempts to fix a long-standing bug with screen readers, where AXMenuOpened is not fired when an element with a role of "menu" is created and added to the DOM. If the DOM element exists and is toggled to be visible (e.g. the HTML hidden attribute is toggled), the AXMenuOpened _is_ fired! However, it's not fired for newly created elements.

I'm also going to attempt to upstream this patch, but given the original solution was reverted twice due to issues with Gmail's menus, I'd like to land this in Electron so apps can benefit while we try to land upstream.

This patch can be removed when the upstream CL lands.

#### Testing:

Since this fix needs the Accessibility Inspector (or a similar tool on Windows/Linux) to validate it, I haven't yet figured out a good automated test. You can verify the fix with this Fiddle: https://gist.github.com/81c2e1de58350893627d736b3b038b91

Steps to Repro:

1. Open the MacOS Accessibility Inspector
2. Open the Notifications window of the Accessibility Inspector by going to the menubar and choosing "Window > Show Notifications"
3. Run the Fiddle gist, and attach the Accessibility Inspector node to the running window
4. Hit "Show Menu" - see the AXMenuOpened is fired
5. Hit "Hide Menu" and then "Toggle Menu" - see the AXMenuOpened is fired
6. In Fiddle, switch to a current version of Electron (e.g. 42.0.0-alpha.1) and repeat steps 4 & 5. You will see that AXMenuOpened is not fired when clicking "Show Menu"

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included
- [x] I have built and tested this PR
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [ ] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Fixed an accessibility issue where the AXMenuOpened event was not fired on menu creation.


Changed files:
filename: patches/chromium/.patches
patch: @@ -148,3 +148,4 @@ feat_plumb_node_integration_in_worker_through_workersettings.patch
 fix_restore_sdk_inputs_cross-toolchain_deps_for_macos.patch
 fix_use_fresh_lazynow_for_onendworkitemimpl_after_didruntask.patch
 fix_pulseaudio_stream_and_icon_names.patch
+fix_fire_menu_popup_start_for_dynamically_created_aria_menus.patch

filename: patches/chromium/fix_fire_menu_popup_start_for_dynamically_created_aria_menus.patch
patch: @@ -0,0 +1,95 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Keeley Hammond <khammond@slack-corp.com>
+Date: Thu, 19 Mar 2026 00:34:37 -0700
+Subject: fix: fire MENU_POPUP_START for dynamically created 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 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.
+
+MENU_POPUP_END for deleted menus is already handled by
+AXTreeManager::OnNodeWillBeDeleted, which fires the event directly
+on the menu node before destruction.
+
+The change is behind the DynamicMenuPopupEvents feature flag, disabled
+by default, to allow stabilization before enabling by default. Enable
+with --enable-features=DynamicMenuPopupEvents.
+
+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..c87c47f34a5f47e9cb7cec04d703335a57f250cd 100644
+--- a/ui/accessibility/ax_event_generator.cc
++++ b/ui/accessibility/ax_event_generator.cc
+@@ -4,6 +4,7 @@
+ 
+ #include "ui/accessibility/ax_event_generator.h"
+ 
++#include "base/feature_list.h"
+ #include "base/no_destructor.h"
+ #include "ui/accessibility/ax_enums.mojom.h"
+ #include "ui/accessibility/ax_event.h"
+@@ -12,6 +13,12 @@
+ 
+ namespace ui {
+ 
++// Feature flag for firing MENU_POPUP_START for dynamically created ARIA menus.
++// Disabled by default to allow stabilization before enabling globally.
++BASE_FEATURE(kDynamicMenuPopupEvents,
++             "DynamicMenuPopupEvents",
++             base::FEATURE_DISABLED_BY_DEFAULT);
++
+ namespace {
+ 
+ bool HasEvent(const std::set<AXEventGenerator::EventParams>& node_events,
+@@ -1011,12 +1018,31 @@ 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 (base::FeatureList::IsEnabled(kDynamicMenuPopupEvents) &&
++        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;
++        }
++      }
++    }
+   }
+ 
+   FireActiveDescendantEvents();