Skip to content

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

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

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

Conversation

@SynurDevelopers

@SynurDevelopers SynurDevelopers commented Aug 25, 2026

Copy link
Copy Markdown

Addresses #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 #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 iOS: focus() can silently fail during navigation or view attachment #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 3 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.
@SynurDevelopers SynurDevelopers changed the title fix(ios): resolve silent focus() failures and focus-path defects fix(ios): improve focus requests and focus-state handling Aug 26, 2026
Synur Developer added 4 commits August 26, 2026 11:58
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

Copy link
Copy Markdown
Author

Why the test suite is not in the final diff

The repository does not currently include a runnable iOS unit-test suite for this focus path. To validate these changes, I temporarily configured the existing XCTest target and added 97 native tests.

All 97 tests passed on an iOS Simulator under Fabric before I removed the test setup. I also ran 69 targeted mutation checks; all 69 caused the tests to fail as expected.

The test setup required changes to the Xcode project, Podfile, Podfile.lock, and a setup script. That infrastructure would have made this PR much larger and harder to review. Commit 8ef3723 therefore removes the test setup and restores the example project files to their upstream state, leaving only the iOS library changes in the final diff.

The tests remain available in the branch history:

  • 6d0b46a adds the test suite and supporting setup.
  • 1bfc9c9 and 94115dc add tests for the later review fixes.
  • Reverting 8ef3723 restores the complete 97-test setup.

These tests cover the native focus-path logic. They do not exercise UIKit’s live focus engine, observe a physical focus ring, or cover the Legacy Bridge configuration. No device testing was performed.

@SynurDevelopers

Copy link
Copy Markdown
Author

Suggested manual testing

These checks are intended for an iOS app that already uses react-native-external-keyboard. They focus on behavior that unit tests cannot verify, including the visible focus ring, VoiceOver, navigation timing, and UIKit focus movement.

Test this PR in an existing app

  1. Temporarily point the app's dependency at this PR commit:

    "react-native-external-keyboard": "github:SynurDevelopers/react-native-external-keyboard#f0cb812"
  2. Install dependencies with the app's usual package manager, then reinstall the iOS pods:

    cd ios
    bundle exec pod install
  3. In Xcode, use Product → Clean Build Folder. Remove the existing app from the test device or Simulator, then build and install it again. A Metro reload is not enough because this PR changes native iOS code.

  4. Run the checks on:

    • a physical iPhone or iPad with a Bluetooth or USB keyboard; and
    • the iOS Simulator with I/O → Keyboard → Connect Hardware Keyboard enabled.

    Run the VoiceOver checks on a physical device when possible. If the host app supports both React Native architectures, repeat the core checks under Fabric and the Legacy Bridge.

Core checks

  • Focus immediately after navigation: Focus an element on screen A, navigate to screen B, and call ref.current?.focus() from the destination screen's mount effect without a timer. The destination target should receive the focus ring immediately.
  • Keyboard-only focus: Repeat the navigation test with keyboardFocus(). It should behave the same way and should not require setTimeout.
  • VoiceOver focus: Enable VoiceOver and repeat the first test. The keyboard focus ring and VoiceOver cursor should both move to the destination target.
  • Autofocus during navigation: Push and pop a screen containing one autoFocus target several times, including rapid navigation. Focus should move once per mount, with no flicker, crash, or later jump to a recycled view.
  • FocusTrap updates: Put focus on a child inside an active FocusTrap, then trigger unrelated renders and style updates. Focus should remain on the child. Disabling the trap should not steal keyboard or VoiceOver focus, and Tab should then be able to leave the trap.
  • Directional links: Verify orderLeft, orderRight, orderUp, and orderDown. In a nested wrapper, the focused child should use its own navigation behavior rather than the parent wrapper's links.
  • Ordered groups: Tab into an orderIndex group whose logical order differs from its visual order. Focus should land directly on the first logical item without flicker or an extra focus event. Tab out, return, then unmount and remount the group to confirm its boundaries remain usable.
  • Focus events: Move focus between two descendants of the same wrapper, then leave the wrapper. Expect one focus event on entry, no duplicate event between descendants, and one blur event on exit. Repeat with KeyboardExtendedInput.
  • Context menu without a focus listener: Use enableContextMenu without registering onFocusChange. The context menu should still open when the view is focused.
  • Detach and reattach: Request focus before a conditionally rendered subtree attaches, then show it. Focus should arrive after attachment. Hide and show the subtree again and confirm that focus does not repeatedly return without a new request.

Optional deeper checks

  • Memory: In Instruments, repeatedly open and close a screen containing several keyboard-focus views. The old views and delegates should return to their previous live-instance count.
  • Multiple windows: On iPadOS, open the app in two windows and request focus in the window that is not currently active. The request should stay within the target window's scene.
  • Regression sweep: Confirm ordinary Tab and Shift-Tab navigation, focus halos, key-down and key-up events, and focus restoration after leaving and returning to a screen.

When reporting results, please include the device or Simulator model, iOS version, React Native version, and whether the app used Fabric or the Legacy Bridge.

@ArturKalach

Copy link
Copy Markdown
Owner

Dear @SynurDevelopers

Thanks for putting together the PR. The changes make sense, and I'll try to research around the information you've provided, including the known limitations and the suggested manual test checklist.

This falls outside my current focus for the moment, so I won't be able to get to it right away. But I'll return to this as I have some spare time.

@SynurDevelopers

Copy link
Copy Markdown
Author

Scenario Lab demo app

A runnable manual-test harness for this PR is available in SynurDevelopers/react-native-external-keyboard#1.

The demo branch is stacked on fix/ios-focus-path and adds Scenario Lab to the example app. It includes 47 guided scenarios with instructions, expected results, Pass/Fail/Skip recording, persistent notes, and JSON or Markdown export. The demo does not change the iOS library implementation in this PR.

To run it:

git clone https://github.com/SynurDevelopers/react-native-external-keyboard.git
cd react-native-external-keyboard
git checkout demo/scenario-lab
yarn
cd example/ios
pod install
cd ../..
yarn example ios

Open Scenario Lab from the example app home screen. Device, Simulator, VoiceOver, and Instruments requirements are documented in the Scenario Lab guide.

…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.
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