Skip to content

fix(ios): improve focus requests and focus-state handling - #2

Open
SynurDevelopers wants to merge 9 commits into
mainfrom
fix/ios-focus-path
Open

SynurDevelopers wants to merge 9 commits into
mainfrom
fix/ios-focus-path

Conversation

@SynurDevelopers

Copy link
Copy Markdown
Owner

Review mirror: This PR mirrors ArturKalach/react-native-external-keyboard#126 so repository-scoped review tools can inspect the same change inside the SynurDevelopers fork. Both PRs use main at f0b0a72 as the base and fix/ios-focus-path as the head. Any commit pushed to that head branch updates both PRs.

Tracks upstream issue #125.

Summary

This PR improves physical-keyboard focus on iOS, especially during navigation, native view attachment, and Fabric recycling.

It routes focus requests through a more reliable focus environment, retains requests until views are ready, and fixes related ownership, focus-locking, ordering, and event-state problems found while investigating upstream issue #125.

The final diff changes 17 files under ios/. It does not change the JavaScript API, TypeScript source, codegen specifications, or Android implementation.

The shared native paths used by Fabric and the Legacy Bridge were updated. Local native tests covered Fabric; the Legacy Bridge configuration was not compiled or run.

What changed

Focus requests and autofocus

  • Added RNCEKVKeyboardFocusService as the shared entry point for programmatic keyboard focus.
  • Attached targets now use their own window’s root controller before falling back to the key-window root or supplied controller.
  • Keyboard and VoiceOver focus requests made before attachment are retained and replayed when the view joins a window.
  • The text-input wrapper waits for its controller, superview, window, and native child before submitting a focus request.
  • The main request path and text-input wrapper clear their own stored controller preference during detach and recycling.
  • Removed the duplicate autofocus branch during initial attachment.
  • Deferred autofocus is invalidated when Fabric recycles a view.
  • If deferred autofocus runs after the view detaches, the next attachment can retry it.

Ownership and ordering

  • Changed the controller’s preferred focus target to a weak reference.
  • Changed five delegate-to-view back-pointers to weak references.
  • Changed ordered-group entry and exit boundaries to weak references.
  • Stored focus targets and group boundaries are rejected when they are no longer attached.
  • Emptying an ordered relationship now clears its cached entry and exit boundaries.
  • Directional guides now follow nearest-wrapper ownership, so a nested wrapper does not enable its parent’s guides.
  • Entering an orderIndex group reports the redirected movement as handled, preventing UIKit from performing a second movement.

Focus locking and focus state

  • FocusTrap now avoids repeating focus side effects when unchanged props are reapplied.
  • Inactive and disabled traps no longer request keyboard or VoiceOver focus.
  • Fabric applies the disabled state before activating a trap, preventing the common compound update from briefly stealing focus.
  • An active trap requests focus when it joins a window.
  • Native focus-state processing no longer depends on an onFocusChange listener.
  • JavaScript event emission remains gated by the listener flag.
  • enableContextMenu can therefore follow native focus without requiring onFocusChange.
  • The main view and text-input wrapper now use the same wrapper-level focus state machine. Moving between descendants no longer emits repeated focus events, and blur can still be reported after the focused child is deallocated.

Known limitations

Several uncommon lifecycle cases remain:

  • If a text-input request is made after the wrapper attaches but before its native child is inserted, adding the child does not trigger replay. The request waits until the wrapper attaches again.
  • A keyboard request is marked as consumed before UIKit performs its asynchronous focus update. Detaching during that interval can still discard the request.
  • An attached VoiceOver request does not recheck attachment or recycling state inside its asynchronous notification block.
  • Pending requests have no expiry. A pending request that is not cleared by a later successful call can replay after a future attachment.
  • Attached imperative requests use the target’s window, but windowless direct callers and the separate focus-memory path can still use the global RCTKeyWindow().
  • A controller preference is cleared during the main detach and recycle paths, but it is not consumed immediately after a successful focus update. Direct cross-window reparenting or other callers can therefore leave an old preference behind.
  • Direct native and Legacy compound FocusTrap prop updates can still pass through a briefly active state. The public JavaScript FocusTrap avoids most of these transitions by rendering a plain View while inactive.
  • Ordered-group boundaries are checked for attachment but not for current adjacency or recycled component identity.
  • Direction-only focus links without an orderId can still leave subscribers registered after unmount.

Validation

Completed:

  • Confirmed that the final diff contains only 17 iOS library files.
  • Confirmed that git diff --check passes.
  • Ran 97 native XCTest tests successfully on an iOS Simulator under Fabric.
  • Ran 69 targeted mutation checks; all 69 caused the tests to fail as expected.
  • Reviewed the final implementation against the twelve findings in upstream issue #125 and the subsequent local review findings.

The temporary test infrastructure was removed from the final diff in 8ef3723 to keep the PR focused on the library changes. The test history, commit references, and restoration instructions are documented in this PR comment.

Not completed:

  • No device testing.
  • No end-to-end test that drives UIKit’s live focus engine or observes a physical focus ring.
  • The Legacy Bridge configuration was not compiled or run.

Synur Developer added 8 commits August 25, 2026 17:21
…try on attach

UIKit honors setNeedsFocusUpdate only when the environment contains the
currently focused item, so requests on nearest-ancestor controllers were
silently dropped (nested controllers, react-native-screens). All focus
requests now go through RNCEKVKeyboardFocusService, preferring the key
window root. Imperative focus() on a detached view records a pending
request replayed on window attach. Also collapses the duplicated
autofocus branches in didMoveToWindow, revalidates the deferred autofocus
block against a generation counter after recycling, and enables the
directional order guides via a descendant check instead of a first-subview
pointer comparison. The text-input wrapper additionally gains the
hasOnFocusChanged emission gate ahead of the base-class gate removal in
the focus-event commit; the interim tree is double-gated with unchanged
behavior.
All focus delegates held plain-strong back-pointers to their host views
while the views strongly own the delegates, making every keyboard view
immortal; the back-pointers are now zeroing-weak. The view controller's
custom focus view association is stored through a weak holder, skipped
and self-cleared when off-window, so controllers no longer retain
unmounted subtrees or steer later focus updates to stale views. Group
entry/exit boundary views latched in the order-linking singleton are now
weak.
FocusTrap now diffs forceLock/lockDisabled and requests focus only when
the lock becomes active, so prop commits and disable transitions no
longer steal keyboard or VoiceOver focus. Tabbing into an ordered group
suppresses UIKit's default move instead of double-focusing, and latched
entry/exit boundaries are revalidated against the window before use. The
native focus handler chain (context-menu registration) no longer depends
on a JS listener being attached — only event emission is gated. Blur is
emitted even after the tracked focused child deallocates, and moves
between descendants of one wrapper no longer emit duplicate focus events.
Hydrates the dormant ExternalKeyboardExampleTests target with unit
tests covering focus change events, focus delegates, keyboard focus
service, lock view, and retain cycles.

Adds a setup script to enable coverage on the test run and updates
the Podfile/project to wire the target into the example workspace.
… window root

- Readiness for imperative keyboard focus is now window-based: the request
  base parks when the controller or window is missing, and the text-input
  wrapper additionally parks until its native child exists; both replay from
  didMoveToWindow.
- RNCEKVKeyboardFocusService resolves the routing controller from the target
  view's own window root first, then the key-window root, then the supplied
  fallback, and returns the controller it routed to.
- Detach and cleanReferences now clear the controller's preferred-focus
  target when it still points at the view's own request, so recycled or
  navigated-away views cannot be revived as stale preferred targets.
- screenReaderFocus gets the same park-and-replay as keyboard focus, so both
  halves of the JS focus() call survive a pre-attach request.
- A same-generation autofocus that is skipped while detached returns its
  attempt, so the next attach retries instead of losing autofocus.
- The text-input wrapper now inherits the focus delegate's tracked focus
  state machine (single focus per wrapper-level entry, blur after the tracked
  child deallocates) instead of the descendant-only checks.
…roup endpoint cleanup

- Both lock-view request guards now reject any inactive or disabled state
  (matching onAccessibilityFocusChanged:), and the Fabric prop diff applies
  lockDisabled before forceLock so a compound activate-and-disable commit
  never passes through a momentarily-active state. didMoveToWindow doubles
  as the attach replay for an active trap whose early request had no
  controller.
- Directional-guide enablement uses nearest-wrapper ownership: a nested
  order-group wrapper (or a focused wrapper itself) owns its focus, so a
  parent's guides no longer activate for a nested wrapper's focus.
- RNCEKVOrderRelationship.clear also nils its entry/exit endpoints, so
  emptying a group releases its cached boundaries on the unlink path.
The branch was developed and validated with a full XCTest suite (97 unit
tests over the changed focus-path methods, plus coverage and mutation
scoring). It lives in history: 6d0b46a adds the suite and test target,
1bfc9c9 and 94115dc extend it alongside the fixes they verify. Since the
project has no existing native test infrastructure, this commit removes
the suite, the test-target project changes, and the Podfile/Podfile.lock
edits from the PR tip to keep the reviewable diff limited to the library
sources. Revert this commit to restore the complete, passing test setup.
@SynurDevelopers SynurDevelopers self-assigned this Aug 26, 2026
@SynurDevelopers

Copy link
Copy Markdown
Owner Author

@greptile please review.

@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Greptile Summary

The PR revises iOS keyboard-focus routing and lifecycle handling without changing the JavaScript API or Android implementation.

  • Routes attached focus requests through the target window’s controller and retains eligible pre-attachment requests.
  • Updates focus ownership, ordered navigation, FocusTrap transitions, and wrapper-level focus-state handling.
  • Uses weak references and detach/recycle cleanup to reduce stale focus targets.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains in the eligible follow-up review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm Adds pending keyboard and accessibility request replay, cancellable deferred autofocus, and detach/recycle cleanup for routed focus preferences.
ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm Aligns text-input focus routing and focus-state handling with the shared wrapper infrastructure while retaining requests until required attachment state exists.
ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm Restricts focus side effects to active traps, orders Fabric prop application, and retries active focus after attachment.
ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm Tracks wrapper-level focus across descendant transitions and applies nearest-wrapper ownership.
ios/Services/RNCEKVKeyboardFocusService.mm Centralizes focus routing through the target window’s root controller with key-window and supplied-controller fallbacks.
ios/Extensions/UIViewController+RNCEKVExternalKeyboard.mm Stores preferred focus targets weakly and rejects detached or deallocated targets.
ios/Delegates/RNCEKVFocusSequenceDelegate/RNCEKVFocusSequenceDelegate.mm Uses the shared focus service, rejects detached group boundaries, and marks redirected group entry as handled.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant View as Focusable View
    participant Service as KeyboardFocusService
    participant Controller as Window Root Controller
    participant UIKit as UIKit Focus Engine
    Caller->>View: focus request
    alt View is not ready
        View->>View: retain pending request
        UIKit-->>View: didMoveToWindow
        View->>View: replay request
    end
    View->>Service: focus(target, fallback)
    Service->>Controller: rncekvFocusView(target)
    Controller->>UIKit: setNeedsFocusUpdate / updateFocusIfNeeded
    UIKit-->>View: didUpdateFocusInContext
    View->>View: update native wrapper focus state
Loading

Reviews (3): Last reviewed commit: "fix: clear stale pending focus request a..." | Re-trigger Greptile

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Focus routing documentation/behavior mismatch and stale “pending focus” flags can cause incorrect routing or unexpected request replay across attachments.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Improves iOS physical-keyboard focus reliability across lifecycle edges (attachment, navigation, Fabric recycling) by centralizing focus routing, retaining focus requests until views are ready, and tightening focus-state / ordering / locking behavior without changing the JS/TS public API.

Changes:

  • Introduces RNCEKVKeyboardFocusService and updates multiple call sites to route focus via the most appropriate controller (ideally the target view’s window root).
  • Adds “pending focus” replay behavior for focus requests made before a view is ready/attached; clears routed preferred-focus targets on detach/recycle to avoid stale controller state.
  • Converts multiple delegate back-pointers and order-group boundaries to weak references and adjusts ordering/focus-state logic to reduce duplicate focus side effects.
File summaries
File Description
ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm Retains/replays focus requests until controller/window/child are ready; clears routed preferred-focus on detach/recycle; gates JS focus events.
ios/Views/RNCEKVExternalKeyboardView/RNCEKVExternalKeyboardView.mm Gates JS focus-change emission by hasOnFocusChanged while preserving internal focus-state handling.
ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm Prevents repeated focus side effects on unchanged props; orders prop application to avoid transient focus stealing; routes focus via focus service.
ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm Adds pending request retention/replay, deferred autofocus invalidation via generation, and clears preferred-focus targets on detach.
ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm Adjusts “focused” detection to respect nearest wrapper ownership; routes focus via focus service.
ios/Views/Base/FocusChange/RNCEKVViewFocusChangeBase.mm Updates focus-change propagation so native focus-state updates don’t depend on a JS listener.
ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.mm Clears cached entry/exit boundaries when relationships are emptied.
ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.h Makes ordered-group entry/exit boundaries weak to avoid retaining stale views.
ios/Services/RNCEKVKeyboardFocusService.mm Adds focus:withFallback: and updates focus routing to prefer target view’s window/controller context.
ios/Services/RNCEKVKeyboardFocusService.h Documents new focus routing API that returns the controller used for routing.
ios/features/Halo/delegate/RNCEKVHaloDelegate.mm Converts delegate back-pointer to weak to avoid retain cycles/stale references.
ios/Extensions/UIViewController+RNCEKVExternalKeyboard.mm Stores preferred focus target via weak holder, purges detached targets, and updates swizzled preferred-focus environments.
ios/Extensions/UIViewController+RNCEKVExternalKeyboard.h Changes rncekvCustomFocusView contract to weak to prevent retaining focused views.
ios/Delegates/RNCEKVGroupIdentifierDelegate/RNCEKVGroupIdentifierDelegate.mm Converts delegate back-pointer to weak.
ios/Delegates/RNCEKVFocusSequenceDelegate/RNCEKVFocusSequenceDelegate.mm Routes focus via focus service; treats redirected movement as handled; clears detached order boundaries.
ios/Delegates/RNCEKVFocusLinkDelegate/RNCEKVFocusLinkDelegate.mm Converts delegate back-pointer to weak.
ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm Improves wrapper focus tracking so descendant changes don’t spam focus events and blur can be reported after target dealloc.
Review details

Suppressed comments (1)

ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm:74

  • Similar to _pendingFocusRequest, _pendingScreenReaderFocus is never cleared on the successful path when screenReaderFocus is called directly (not via didMoveToWindow). This can replay an old accessibility focus request on a future attachment.
- (void)screenReaderFocus {
  if (self.window == nil) {
    _pendingScreenReaderFocus = YES;
    return;
  }
  dispatch_async(dispatch_get_main_queue(), ^{
    UIView *focusView = [self getFocusTargetView];
    UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification,
                                    focusView);
  });
}
  • Files reviewed: 17/17 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ios/Services/RNCEKVKeyboardFocusService.mm
Comment thread ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm
…eviewed]

A focus request parked while the view was partially ready was never
cleared by a later successful focus, causing a stale replay on the next
window attach.
Copilot AI review requested due to automatic review settings August 27, 2026 00:15
@greptile-apps
greptile-apps Bot dismissed their stale review August 27, 2026 00:15

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@SynurDevelopers

Copy link
Copy Markdown
Owner Author

@greptile please review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes multiple interdependent iOS focus/attachment/recycling code paths (including preferred-focus routing and lifecycle replay) and the Legacy Bridge path was not compiled/run per the PR description.

Review details
  • Files reviewed: 17/17 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

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