Skip to content

fix(macos): three Fabric focus regressions (blur, sendAccessibilityEvent, VirtualView)#2959

Closed
tvinhas wants to merge 1 commit into
microsoft:0.83-mergefrom
tvinhas:fix-macos-fabric-focus-regressions
Closed

fix(macos): three Fabric focus regressions (blur, sendAccessibilityEvent, VirtualView)#2959
tvinhas wants to merge 1 commit into
microsoft:0.83-mergefrom
tvinhas:fix-macos-fabric-focus-regressions

Conversation

@tvinhas

@tvinhas tvinhas commented May 13, 2026

Copy link
Copy Markdown

Summary

Three independent focus / first-responder regressions on the macOS Fabric path, surfaced while validating the "Verify focus changes" item on the Road to 0.83 tracking issue (#2901). Each one is small, localized, and mirrors an
existing working pattern elsewhere in the codebase.


1. RCTViewComponentView.mmblur() calls a method that doesn't exist on NSWindow

React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

// Before
- (void)blur
{
  [[self window] resignFirstResponder];
}

// After
- (void)blur
{
  [[self window] makeFirstResponder:nil];
}

NSWindow does not implement resignFirstResponder — only NSResponder subclasses inherit it. Calling that selector on the window is a silent no-op (Objective-C runtime swallows it), so programmatic ref.blur() from JS leaves focus stuck on
the view. The correct AppKit equivalent is makeFirstResponder:nil, which clears the current first responder. This mirrors the working pattern already in RCTTextInputComponentView.mm:995-997 for TextInput.blur().

2. RCTMountingManager.mm — Fabric sendAccessibilityEvent("focus") is a silent no-op on macOS

React/Fabric/Mounting/RCTMountingManager.mm, synchronouslyDispatchAccessbilityEventOnUIThread:

// Before
if ([@"focus" isEqualToString:eventType]) {
  RCTUIView<RCTComponentViewProtocol> *componentView = [_componentViewRegistry findComponentViewWithTag:reactTag];
#if !TARGET_OS_OSX
  UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, componentView);
#endif
  // ↑ macOS branch is empty
}

// After
if ([@"focus" isEqualToString:eventType]) {
  RCTUIView<RCTComponentViewProtocol> *componentView = [_componentViewRegistry findComponentViewWithTag:reactTag];
#if !TARGET_OS_OSX
  UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, componentView);
#else
  if (componentView != nil) {
    [[componentView window] makeFirstResponder:componentView];
    NSAccessibilityPostNotification(componentView, NSAccessibilityFocusedUIElementChangedNotification);
  }
#endif
}

The Fabric path for AccessibilityInfo.setAccessibilityFocus(reactTag) was an unconditional no-op on macOS because the #if !TARGET_OS_OSX guard left no #else branch. The fix mirrors the working old-arch path in
RCTAccessibilityManager.mm:setAccessibilityFocus: (makeFirstResponder: + NSAccessibilityPostNotification(NSAccessibilityFocusedUIElementChangedNotification)) which has been correct for years.

3. RCTVirtualViewComponentView.mm — macOS keyboard-nav hooks for the hideOffscreenVirtualViewsOnIOS optimization

React/Fabric/Mounting/ComponentViews/VirtualView/RCTVirtualViewComponentView.mm

The iOS branch wires accessibilityElementCount and focusItemsInRect: to trigger _unhideIfNeeded when keyboard navigation or VoiceOver reaches a flagged-offscreen view. The macOS branch only had accessibilityChildren (VoiceOver path);
keyboard / Tab navigation had no hook.

Added two AppKit hooks in the macOS branch:

- (BOOL)acceptsFirstResponder
{
  [self _unhideIfNeeded];
  return [super acceptsFirstResponder];
}

- (id)accessibilityHitTest:(NSPoint)point
{
  [self _unhideIfNeeded];
  return [super accessibilityHitTest:point];
}

Partial fix — fully solving Tab-into-fully-hidden-views on macOS requires changing the hide strategy from self.hidden = YES (which removes the view from AppKit's responder chain entirely, so acceptsFirstResponder is never queried) to
something that keeps it in the chain (zero-alpha layer, out-of-bounds frame, etc.). That's a deeper redesign than scoped here. The added hooks catch every case where the view IS visible but flagged offscreen, which is the common case
during scroll, and the comment in-file documents the deeper limitation as a follow-up.

---
Closes the "Verify focus changes" item on #2901 for the three concrete regressions identified. The VirtualView fix is intentionally partial and a follow-up issue can track the hide-strategy redesign separately.

Test Plan

- blur fix: code review against the working TextInput.blur() pattern at RCTTextInputComponentView.mm:995-997. Same call shape ([[<window>] makeFirstResponder:nil]) which has been correct on macOS since the file was added. The
resignFirstResponder line silently no-ops at runtime today; no regression possible from removing it.
- sendAccessibilityEvent fix: code review against the working old-arch path at RCTAccessibilityManager.mm:setAccessibilityFocus: (lines 422-426). Same call shape (makeFirstResponder: +
NSAccessibilityPostNotification(...FocusedUIElementChanged...)). The Fabric path was previously empty on macOS, so any behavior is strictly an improvement.
- VirtualView fix: code review — both new hooks delegate to _unhideIfNeeded and then [super ...]. _unhideIfNeeded is idempotent and fast-pathed when the view is not hidden. No behavior change for visible / non-hidden cases.
- pod install in packages/rn-tester on 0.83-merge succeeds with 86 deps / 85 installed (validated locally; build-system unchanged, this PR only touches .mm files).
- All three changes preserve existing iOS/visionOS behavior — every change is inside a #if TARGET_OS_OSX / #else branch or is a swap of one AppKit-only call for another.

Related

- #2901 — Road to 0.83 tracking issue (closes the "Verify focus changes" item for these three regressions; flags VirtualView hide-strategy redesign as a follow-up)
- RCTAccessibilityManager.mm (old-arch focus path, mirrored by fix #2)
- RCTTextInputComponentView.mm (working blur pattern, mirrored by fix #1)

@tvinhas tvinhas requested a review from a team as a code owner May 13, 2026 19:50
christopherwxyz added a commit to officialunofficial/react-native-macos that referenced this pull request Jun 6, 2026
…crosoft#2957 microsoft#2959 microsoft#2963 microsoft#2964)

Finishes the 0.83 stabilization on uno/desktop-0.85: codegen npmPackageName
constant, hermes-utils static_h/main branch selection by RCT_HERMES_V1_ENABLED,
three Fabric focus regressions (blur/sendAccessibilityEvent/VirtualView),
React-RCTUIKit.podspec in package files, TouchableBounce focusable +
AccessibilityInfo guards. RNTester-macOS unit tests remain green.
@Saadnajmi

Copy link
Copy Markdown
Collaborator

closing in favor of #3015 as this authors fork is still locked and can't be updated. Original commit cherry-picked to keep credit.

@Saadnajmi Saadnajmi closed this Jul 7, 2026
Saadnajmi added a commit that referenced this pull request Jul 8, 2026
…ent, VirtualView) (#3015)

## Summary

Supersedes #2959 (the original PR's source fork is disabled, so that
branch can no longer be updated). This carries the same three macOS
Fabric focus/first-responder fixes forward, with @tvinhas's original
commit cherry-picked to preserve authorship credit, plus one small
refinement.

The three regressions were surfaced while validating the "Verify focus
changes" item on #2901.

### 1. `RCTViewComponentView.blur()` was a no-op
`resignFirstResponder` on `NSWindow` doesn't clear the focused subview,
so `ref.blur()` from JS did nothing. Now clears the first responder via
`makeFirstResponder:nil`, **guarded on `self`** so it only clears focus
when this view actually holds it — matching iOS `[self
resignFirstResponder]` semantics and avoiding stealing focus from an
unrelated view.

### 2. Fabric `setAccessibilityFocus` was a silent no-op on macOS
`synchronouslyDispatchAccessbilityEventOnUIThread:` had a `#if
!TARGET_OS_OSX` guard with no `#else`, so
`AccessibilityInfo.setAccessibilityFocus(reactTag)` did nothing on the
Fabric path. Added the macOS branch (`makeFirstResponder:` +
`NSAccessibilityFocusedUIElementChangedNotification`), mirroring the
old-arch path in `RCTAccessibilityManager.mm`.

### 3. `RCTVirtualViewComponentView` keyboard-nav hooks
Added `acceptsFirstResponder` and `accessibilityHitTest:` to the macOS
branch so keyboard/Tab and VoiceOver navigation unhide flagged-offscreen
views under the `hideOffscreenVirtualViews` optimization. Partial by
design (a fully `hidden=YES` view is dropped from the responder chain —
full fix needs a hide-strategy redesign, tracked as follow-up).

## Difference from #2959
- Added the `if ([[self window] firstResponder] == self)` guard on
`blur()` for iOS-parity / to avoid focus-stealing.
- Trimmed the verbose inline comments.

## Test plan
- Builds clean: RNTester-macOS (`xcodebuild -scheme RNTester-macOS
-destination 'generic/platform=macOS'`) — **BUILD SUCCEEDED**.
- All three changes are macOS-gated (`#if TARGET_OS_OSX` / `#else`) and
preserve existing iOS/visionOS behavior.

## Credit
Original work by @tvinhas in #2959 (commit cherry-picked to retain
authorship).

Co-authored-by: Thiago Vinhas <thiago@vinhas.net>

---------

Co-authored-by: Thiago Vinhas <thiago@vinhas.net>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Saadnajmi added a commit that referenced this pull request Jul 8, 2026
…lityEvent, VirtualView) (#3016)

## Summary

Backport of #3015 to `0.81-stable`.

Three macOS-only Fabric focus / first-responder regressions:
1. **`RCTViewComponentView.blur()`** — was a no-op
(`resignFirstResponder` on `NSWindow` doesn't clear the focused
subview). Now clears via `makeFirstResponder:nil`, guarded on `self`.
2. **Fabric `setAccessibilityFocus`** — the macOS branch was empty;
added `makeFirstResponder:` +
`NSAccessibilityFocusedUIElementChangedNotification`.
3. **`RCTVirtualViewComponentView`** — added `acceptsFirstResponder` /
`accessibilityHitTest:` hooks so keyboard/VoiceOver navigation unhides
flagged-offscreen views.

All changes are macOS-gated; iOS/visionOS behavior is unchanged.

## Test Plan

Same as #3015 — builds clean (RNTester-macOS, BUILD SUCCEEDED); every
change is inside a `#if TARGET_OS_OSX` / `#else` branch.

## Credit

Original work by @tvinhas in #2959 (commit cherry-picked to retain
authorship). Supersedes #2959.

Co-authored-by: Thiago Vinhas <thiago@vinhas.net>

---------

Co-authored-by: Thiago Vinhas <thiago@vinhas.net>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants