From 1447dd702ac8d40d506ec3569ed6f6b4adf0ac9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 21:37:28 +0200 Subject: [PATCH 1/3] fix: support keepsMenuPresented on Fabric Co-authored-by: codex --- ios/NewArch/MenuView.mm | 24 +++++++++++++++ ios/Shared/MenuViewImplementation.swift | 30 +++++++++++++++++++ .../UIMenuNativeComponent.ts | 19 ++++++++++++ 3 files changed, 73 insertions(+) diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm index a54e619e..066b0125 100644 --- a/ios/NewArch/MenuView.mm +++ b/ios/NewArch/MenuView.mm @@ -105,6 +105,27 @@ - (void)onOpenMenu { NSMutableArray *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()]; if (action.subactions.size() > 0) { for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) { + NSMutableArray *subSubactionsArray = + [NSMutableArray arrayWithCapacity:subaction.subactions.size()]; + for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) { + NSDictionary *subSubactionDict = @{ + @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()], + @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()], + @"titleColor": @(subSubaction.titleColor), + @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()], + @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()], + @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()], + @"imageColor": @(subSubaction.imageColor), + @"displayInline": @(subSubaction.displayInline), + @"attributes": @{ + @"destructive": @(subSubaction.attributes.destructive), + @"disabled": @(subSubaction.attributes.disabled), + @"hidden": @(subSubaction.attributes.hidden), + @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented), + }, + }; + [subSubactionsArray addObject:subSubactionDict]; + } NSDictionary *subactionDict = @{ @"id": [NSString stringWithUTF8String:subaction.id.c_str()], @"title": [NSString stringWithUTF8String:subaction.title.c_str()], @@ -118,7 +139,9 @@ - (void)onOpenMenu { @"destructive": @(subaction.attributes.destructive), @"disabled": @(subaction.attributes.disabled), @"hidden": @(subaction.attributes.hidden), + @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented), }, + @"subactions": subSubactionsArray, }; [subactionsArray addObject:subactionDict]; } @@ -138,6 +161,7 @@ - (void)onOpenMenu { @"destructive": @(action.attributes.destructive), @"disabled": @(action.attributes.disabled), @"hidden": @(action.attributes.hidden), + @"keepsMenuPresented": @(action.attributes.keepsMenuPresented), }, @"subactions": subactionsArray, }; diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift index 5c4e0da4..1fa4e2ef 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift @@ -66,11 +66,23 @@ public class MenuViewImplementation: UIButton { return self.menu } } + + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + isMenuPresented = true + } public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { sendMenuClose() + isMenuPresented = false + if pendingMenu != nil { + pendingMenu = nil + self.setup() + } } + private var isMenuPresented = false + private var pendingMenu: UIMenu? + func setup () { let menu = UIMenu(title: _title, identifier: nil, @@ -86,10 +98,28 @@ public class MenuViewImplementation: UIButton { } } + if isMenuPresented { + pendingMenu = menu + self.refreshPresentedMenu(menu) + return + } + self.menu = menu self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress } + private func refreshPresentedMenu(_ menu: UIMenu) { + var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction } + if let interaction = self.contextMenuInteraction { + candidates.append(interaction) + } + + var visited: Set = [] + for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted { + interaction.updateVisibleMenu { _ in menu } + } + } + public override func reactSetFrame(_ frame: CGRect) { super.reactSetFrame(frame); } diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts index fc929a39..9409036f 100644 --- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts +++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts @@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati types here, to avoid issues while `pod install` takes place. */ +type SubSubAction = { + id?: string; + title: string; + titleColor?: Int32; + subtitle?: string; + state?: string; + image?: string; + imageColor?: Int32; + displayInline?: boolean; + attributes?: { + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; + keepsMenuPresented?: boolean; + }; +}; type SubAction = { id?: string; title: string; @@ -26,7 +42,9 @@ type SubAction = { destructive?: boolean; disabled?: boolean; hidden?: boolean; + keepsMenuPresented?: boolean; }; + subactions?: Array; }; type MenuAction = { id?: string; @@ -41,6 +59,7 @@ type MenuAction = { destructive?: boolean; disabled?: boolean; hidden?: boolean; + keepsMenuPresented?: boolean; }; subactions?: Array; }; From 94405062cdbb82214a338b22e0cb67aa78303d6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 22:00:56 +0200 Subject: [PATCH 2/3] fix: preserve button menu presentation chrome Co-authored-by: codex --- ios/Shared/MenuViewImplementation.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift index 1fa4e2ef..6cfec685 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift @@ -59,17 +59,27 @@ public class MenuViewImplementation: UIButton { self.setup() } + // Presentation is tracked from the two delegate methods the class already + // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping) + // shadows UIButton's own implementation and degrades the button-anchored + // presentation into generic context-menu chrome — an empty header row with + // a dismiss chevron appears above the actions. public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + // Flush updates deferred by the presented-guard before the action + // provider snapshots self.menu (covers a stuck flag from an + // interaction that never ended cleanly). + if pendingMenu != nil { + pendingMenu = nil + isMenuPresented = false + self.setup() + } + isMenuPresented = true sendMenuOpen() return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self = self else { return nil } return self.menu } } - - public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { - isMenuPresented = true - } public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { sendMenuClose() From 7b71e40b6f054bab8c8054b4bdb26dc398dc6a7e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 22:30:48 +0200 Subject: [PATCH 3/3] fix: refresh the visible menu level Co-authored-by: codex --- ios/Shared/MenuViewImplementation.swift | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift index 6cfec685..e8311084 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift @@ -126,8 +126,35 @@ public class MenuViewImplementation: UIButton { var visited: Set = [] for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted { - interaction.updateVisibleMenu { _ in menu } + // The block receives whichever menu level is currently on screen — + // the navigated submenu when the user picked inside one, not the + // root. Swap in the matching node from the rebuilt tree (stable + // identifiers from the JS action ids) so that level updates in + // place; returning an unrelated menu instead makes UIKit render it + // as navigation into a foreign menu, with a stale or blank + // expanded-submenu header row above the actions. The root carries + // an auto-generated identifier that never matches, so it falls + // through to a children-only replacement. + interaction.updateVisibleMenu { [weak self] visibleMenu in + guard let self = self else { return visibleMenu } + if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) { + return replacement + } + return visibleMenu.replacingChildren(menu.children) + } + } + } + + private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? { + if menu.identifier == identifier { + return menu + } + for element in menu.children { + if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) { + return match + } } + return nil } public override func reactSetFrame(_ frame: CGRect) {