From df6f4703949ab09c98a6ee4b870cacdaef6f3bf9 Mon Sep 17 00:00:00 2001 From: Thiago Vinhas Date: Wed, 13 May 2026 14:48:14 -0500 Subject: [PATCH 1/2] fix(macos): three Fabric focus regressions (blur, sendAccessibilityEvent, VirtualView) --- .../View/RCTViewComponentView.mm | 8 ++++- .../RCTVirtualViewComponentView.mm | 32 +++++++++++++++++++ .../Fabric/Mounting/RCTMountingManager.mm | 14 +++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 0dd52234048f..24605ddb6e65 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -1762,7 +1762,13 @@ - (void)focus - (void)blur { - [[self window] resignFirstResponder]; + // `NSWindow` does not implement `resignFirstResponder`; only NSResponder + // subclasses inherit it. Calling the selector on the window is a silent + // no-op, leaving programmatic `ref.blur()` from JS without effect. The + // correct AppKit equivalent is `makeFirstResponder:nil`, which clears + // the current first responder. Mirrors the working pattern at + // RCTTextInputComponentView.mm:995-997. + [[self window] makeFirstResponder:nil]; } - (BOOL)needsPanelToBecomeKey diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm index 20f44da228d1..cf9e81904344 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm @@ -171,6 +171,38 @@ - (NSArray *)accessibilityChildren [self _unhideIfNeeded]; return [super accessibilityChildren]; } + +- (BOOL)acceptsFirstResponder +{ + // Mirror of the iOS `focusItemsInRect:` hook for keyboard / Tab + // navigation. AppKit queries `acceptsFirstResponder` while walking + // the responder chain to compute the next Tab target. If this view + // is hidden via the `hideOffscreenVirtualViewsOnIOS` optimization, + // we unhide here so a subsequent Tab landing actually finds a real + // view to focus. + // + // Caveat: a fully `hidden=YES` view is excluded from AppKit's + // responder chain at the parent level, so `acceptsFirstResponder` + // will not be queried until the view becomes visible. This override + // therefore only catches the "visible but flagged offscreen" case + // where `_unhideIfNeeded` is a fast no-op anyway. The complete fix + // for Tab-into-fully-hidden views requires switching the hiding + // strategy from `self.hidden = YES` to something that keeps the + // view in the responder chain (e.g. zero-alpha / out-of-bounds + // frame) — out of scope here; tracked as a follow-up. + [self _unhideIfNeeded]; + return [super acceptsFirstResponder]; +} + +- (id)accessibilityHitTest:(NSPoint)point +{ + // VoiceOver cursor / pointer-hover-with-VoiceOver path. Same + // rationale as `accessibilityChildren` above: a hit-test that + // would land on a flagged-offscreen virtual view should unhide + // it so the AX tree has a real element to return. + [self _unhideIfNeeded]; + return [super accessibilityHitTest:point]; +} #endif // macOS] - (void)prepareForRecycle diff --git a/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm b/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm index 9f5114766e72..97ef1102f9b1 100644 --- a/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm +++ b/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm @@ -346,7 +346,19 @@ - (void)synchronouslyDispatchAccessbilityEventOnUIThread:(ReactTag)reactTag even RCTUIView *componentView = [_componentViewRegistry findComponentViewWithTag:reactTag]; // [macOS] #if !TARGET_OS_OSX // [macOS] UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, componentView); -#endif // [macOS] +#else // [macOS + // Move first-responder to the component view and post the + // AppKit-equivalent accessibility notification so VoiceOver picks + // up the focus change. Without this branch the Fabric path for + // `AccessibilityInfo.setAccessibilityFocus(reactTag)` silently + // no-ops on macOS — the old-arch path in + // RCTAccessibilityManager.mm:setAccessibilityFocus: does the + // equivalent work, which we mirror here. + if (componentView != nil) { + [[componentView window] makeFirstResponder:componentView]; + NSAccessibilityPostNotification(componentView, NSAccessibilityFocusedUIElementChangedNotification); + } +#endif // macOS] } } From 06227dff3f4479e1a4dd209180483c82d4c8eb1a Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 7 Jul 2026 14:30:29 -0700 Subject: [PATCH 2/2] fix(macos): guard View blur() and trim inline comments Follow-up refinements on top of Thiago Vinhas's original fix: - RCTViewComponentView.blur(): only clear the window's first responder when this view actually holds it (guard on == self), matching iOS [self resignFirstResponder] semantics and avoiding stealing focus from an unrelated view. - Trim the verbose inline comments from all three changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../View/RCTViewComponentView.mm | 10 +++------- .../RCTVirtualViewComponentView.mm | 20 ------------------- .../Fabric/Mounting/RCTMountingManager.mm | 7 ------- 3 files changed, 3 insertions(+), 34 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 24605ddb6e65..6e20d03f6438 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -1762,13 +1762,9 @@ - (void)focus - (void)blur { - // `NSWindow` does not implement `resignFirstResponder`; only NSResponder - // subclasses inherit it. Calling the selector on the window is a silent - // no-op, leaving programmatic `ref.blur()` from JS without effect. The - // correct AppKit equivalent is `makeFirstResponder:nil`, which clears - // the current first responder. Mirrors the working pattern at - // RCTTextInputComponentView.mm:995-997. - [[self window] makeFirstResponder:nil]; + if ([[self window] firstResponder] == self) { + [[self window] makeFirstResponder:nil]; + } } - (BOOL)needsPanelToBecomeKey diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm index cf9e81904344..0a81e6391571 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm @@ -174,32 +174,12 @@ - (NSArray *)accessibilityChildren - (BOOL)acceptsFirstResponder { - // Mirror of the iOS `focusItemsInRect:` hook for keyboard / Tab - // navigation. AppKit queries `acceptsFirstResponder` while walking - // the responder chain to compute the next Tab target. If this view - // is hidden via the `hideOffscreenVirtualViewsOnIOS` optimization, - // we unhide here so a subsequent Tab landing actually finds a real - // view to focus. - // - // Caveat: a fully `hidden=YES` view is excluded from AppKit's - // responder chain at the parent level, so `acceptsFirstResponder` - // will not be queried until the view becomes visible. This override - // therefore only catches the "visible but flagged offscreen" case - // where `_unhideIfNeeded` is a fast no-op anyway. The complete fix - // for Tab-into-fully-hidden views requires switching the hiding - // strategy from `self.hidden = YES` to something that keeps the - // view in the responder chain (e.g. zero-alpha / out-of-bounds - // frame) — out of scope here; tracked as a follow-up. [self _unhideIfNeeded]; return [super acceptsFirstResponder]; } - (id)accessibilityHitTest:(NSPoint)point { - // VoiceOver cursor / pointer-hover-with-VoiceOver path. Same - // rationale as `accessibilityChildren` above: a hit-test that - // would land on a flagged-offscreen virtual view should unhide - // it so the AX tree has a real element to return. [self _unhideIfNeeded]; return [super accessibilityHitTest:point]; } diff --git a/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm b/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm index 97ef1102f9b1..506768a6c1b5 100644 --- a/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm +++ b/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm @@ -347,13 +347,6 @@ - (void)synchronouslyDispatchAccessbilityEventOnUIThread:(ReactTag)reactTag even #if !TARGET_OS_OSX // [macOS] UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, componentView); #else // [macOS - // Move first-responder to the component view and post the - // AppKit-equivalent accessibility notification so VoiceOver picks - // up the focus change. Without this branch the Fabric path for - // `AccessibilityInfo.setAccessibilityFocus(reactTag)` silently - // no-ops on macOS — the old-arch path in - // RCTAccessibilityManager.mm:setAccessibilityFocus: does the - // equivalent work, which we mirror here. if (componentView != nil) { [[componentView window] makeFirstResponder:componentView]; NSAccessibilityPostNotification(componentView, NSAccessibilityFocusedUIElementChangedNotification);