Summary
On macOS, a menu item whose only job is to hold a submenu is turned into a normal command item. It gets an action and a target, so it highlights like a command instead of acting like a plain submenu parent.
Everything else in the menu is fine. Windows and Linux behave correctly. macOS is the only platform that does this.
Environment
- electrobun
2.0.1(macOS arm64). The same code is onmain. - Menu is set with
ApplicationMenu.setApplicationMenu(...).
Reproduce
ApplicationMenu.setApplicationMenu([
{
label: "View",
submenu: [
{
label: "Switch to", // no action, only a submenu
submenu: [
{ label: "Chat", action: "view-chat" },
{ label: "Skills", action: "view-skills" },
{ label: "Dashboard", action: "view-dashboard" },
],
},
],
},
]);Open the View menu. The "Switch to" item highlights like a command, even though it should just be a submenu parent.
Cause
Two places give the container a command.
package/src/sdks/main/core/ApplicationMenu.ts, line 53 and lines 55-57. The code only knows two cases: a role, or "everything else gets an action".
type: menuItem.type || "normal",
// application menus can either have an action or a role. not both.
...(menuItem.role ? { role: menuItem.role } : { action: actionWithDataId }),An item with only a submenu falls into the second case. So the JSON that reaches the native side looks like this:
{"label":"Switch to","type":"normal","action":"|EB|menuData_3|","enabled":true,"checked":false,"hidden":false,"submenu":[...]}The container now carries an action, and type says "normal". That also blocks the fallback in json_menu_parser.h, which only guesses "submenu" when type is empty.
package/src/native/macos/nativeWrapper.mm,createMenuFromConfig(v2.0.1: lines 1141-1142, 1180, 1202). The item is created with a fixed selector, and it gets a target whenever it has no role. The submenu is attached only afterwards.
menuItem = [[NSMenuItem alloc] initWithTitle:label ?: @""
action:@selector(menuItemClicked:)
keyEquivalent:@"";
...
} else {
menuItem.target = target;
}
...
if (submenuConfig) {
NSMenu *submenu = createMenuFromConfig(submenuConfig, target);
[menu setSubmenu:submenu forItem:menuItem];
}So the result is a real command item with a submenu attached. macOS never checks for type == "submenu"; the only type check in that function is type == "divider".
For comparison: Windows converts the item to MF_POPUP when a submenu exists (win/nativeWrapper.cpp:6637-6646), and Linux connects the click signal only when there is no submenu (linux/nativeWrapper.cpp:6201-6206).
Suggested fix
In ApplicationMenu.ts, do not send an action for an item that has a submenu:
+ const hasSubmenu = Array.isArray(menuItem.submenu) && menuItem.submenu.length > 0;
- const actionWithDataId = ffi.internal.serializeMenuAction(
- menuItem.action || "",
- menuItem.data,
- );
+ const actionWithDataId = hasSubmenu
+ ? undefined
+ : ffi.internal.serializeMenuAction(menuItem.action || "", menuItem.data);
- type: menuItem.type || "normal",
+ type: menuItem.type || (hasSubmenu ? "submenu" : "normal"),
...(menuItem.role
? { role: menuItem.role }
- : { action: actionWithDataId }),
+ : hasSubmenu ? {} : { action: actionWithDataId }),On macOS the change above is not enough on its own, because the item is still built with a command. That item should be created with nil instead of @selector(menuItemClicked:) when submenuConfig.count > 0, it should not get menuItem.target, and the submenu should be set directly with menuItem.submenu = ....
Small extra: serializeMenuAction("", undefined) never returns an empty string, and storeMenuData keeps its entry until the item is clicked. A container is never clicked, so every menu build leaks one entry.
Verified on main and v2.0.1: ApplicationMenu.ts is byte-identical in both (sha256 0f5879ee3ba09d35cad0caeb2445df47a1f4c26508863c614b86d71f2070b5fe).
Activity
0No activity yet.