fix: `forwardingTarget` infinite recursion (#1519)
## 📜 Description
Fixed infinite `forwardingTarget` recursion.
## 💡 Motivation and Context
<!-- Why is this change required? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here. -->
That crash happened because of two recursions.
### 1️⃣ `respondsToSelector` back-pointer cycle
The naive RNKC code does this:
```swift
let previous = textField.delegate // returns BKTextField, because real delegate is BK
composite.textFieldDelegate = previous
textField.delegate = composite
```
But because BK overrides `setDelegate`, this does not change the real
UIKit delegate slot. It sets BK’s inner delegate:
```sh
UIKit real delegate slot -> BKTextField
BKTextField.userDelegate -> RNKC composite
RNKC composite.textFieldDelegate -> BKTextField
```
Now the loop:
```sh
UIKit -> BKTextField
BKTextField forwards to RNKC composite
RNKC composite forwards to BKTextField
BKTextField forwards to RNKC composite
...
```
That is why it is not a chain. We accidentally inserted RNKC inside BK,
while also telling RNKC that BK is its inner delegate.
The intended chain needs force-setting the real UIKit delegate slot:
```swift
let previous = textField.delegate // BKTextField
composite.textFieldDelegate = previous
textField.setForceDelegate(composite) // writes UITextField._delegate directly
```
Then the chain is:
```sh
UIKit real delegate slot -> RNKC composite
RNKC composite.textFieldDelegate -> BKTextField
BKTextField.userDelegate -> original delegate A
```
The remaining problem: BK is not just a delegate proxy. It is also a
UITextField. So it responds to many UITextField methods that are not
delegate methods.
### 2️⃣ `keyboardInputChangedSelection` self-relay loop
Once the composite is the genuine delegate, `activeDelegate` is the
field itself. The field, being a `UITextField`, "responds" to internal
methods UIKit relays to its delegate (`keyboardInputChangedSelection`).
The composite forwarded those back to the field -> the field relayed
them to its delegate (the composite) -> ∞
```sh
UIKit -> [BKTextField keyboardInputChangedSelection:] // BK is a UITextField, it implements this
BKTextField relays to its delegate -> RNKC composite
RNKC composite does not implement it -> forwardingTarget -> activeDelegate (BKTextField)
UIKit runtime re-sends -> [BKTextField keyboardInputChangedSelection:]
BKTextField relays to its delegate -> RNKC composite
...
```
The key observation is that BK "responds" to a selector for **two
completely different reasons**, and they must be treated differently:
- 🅰️ BK is a `UITextField` (it has a real method implementation):
`keyboardInputChangedSelection` -> implemented by `UITextField` itself
-> DO NOT forward (loops)
- 🅱️ BK proxies to its inner delegate (no implementation, resolved via
`forwardInvocation`): `textFieldShouldReturn` -> handled by
BK.userDelegate (delegate A) -> MUST forward
The naive forwarder couldn't tell them apart - it forwarded anything
`activeDelegate.responds(to:)` returned `true` for, which includes every
`UITextField`/`UIResponder` method the field implements as an object.
The fix is a structural discriminator. When `activeDelegate` is the text
input itself, we forward **only the selectors it resolves dynamically**
(🅱️), never the ones it implements as a UIKit object (🅰️):
```swift
private func shouldForward(_ aSelector: Selector!, to delegate: AnyObject) -> Bool {
guard delegate.responds(to: aSelector) else {
return false
}
// Self-delegating forwarding field (e.g. BKForwardingTextField).
if delegate is UITextField || delegate is UITextView,
let delegateClass = type(of: delegate) as? NSObject.Type
{
// `instancesRespond(to:)` inspects the real method table only, so it is
// `false` exactly for selectors the field routes through its own
// `forwardInvocation:` (i.e. to its inner delegate).
return !delegateClass.instancesRespond(to: aSelector)
}
return true
}
```
`instancesRespond(to:)` checks the class's real method table - it does
**not** see methods resolved through `forwardInvocation:`. So it is
`true` for "the field's own UIKit methods" and `false` for "the inner
delegate's methods", which is precisely the line we need to draw.
Both `responds(to:)` and `forwardingTarget(for:)` go through it:
```swift
override func responds(to aSelector: Selector!) -> Bool {
if super.responds(to: aSelector) {
return true
}
guard let activeDelegate = activeDelegate else {
return false
}
return shouldForward(aSelector, to: activeDelegate)
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
if let activeDelegate = activeDelegate, shouldForward(aSelector, to: activeDelegate) {
return activeDelegate
}
return super.forwardingTarget(for: aSelector)
}
```
Now the loop is broken at the source. For
`keyboardInputChangedSelection:`, `shouldForward` returns `false`, so
the composite no longer claims to respond to it — BK's relay check fails
and BK simply doesn't relay:
```sh
UIKit -> [BKTextField keyboardInputChangedSelection:]
BKTextField asks: does my delegate respond? -> composite.responds(to:) -> false
BKTextField does NOT relay -> chain ends, no recursion
```
And genuine inner-delegate methods still flow through untouched:
```sh
UIKit real delegate slot -> RNKC composite
RNKC composite -> forwardingTarget -> BKTextField // shouldForward == true
BKTextField.forwardInvocation: -> original delegate A
```
This is **not** a hardcoded method blocklist (the one that I tried to
use in
https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1482
) - it is a runtime structural test, so it keeps "forward everything"
intact for whatever the inner delegate implements, now or in the future.
It also only engages for self-delegating fields: a normal input's
`activeDelegate` is an `RCTBackedText*DelegateAdapter` (a plain
`NSObject`, not a `UITextField`/`UITextView`), so the discriminator is
skipped entirely and `scrollViewDidScroll:` and friends forward exactly
as before.
<hr />
Closes
https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1482
https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1513
https://github.com/kirillzyusko/react-native-keyboard-controller/issues/896
https://github.com/kirillzyusko/react-native-keyboard-controller/issues/752
Potentially fixes
https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1481
## 📢 Changelog
<!-- High level overview of important changes -->
<!-- For example: fixed status bar manipulation; added new types
declarations; -->
<!-- If your changes don't affect one of platform/language below - then
remove this platform/language -->
### iOS
- revert
http://github.com/kirillzyusko/react-native-keyboard-controller/pull/900
- added `setForceDelegate` extension for `UITextField`
- rename `UITextView+DelegateManager` to `TextInput+DelegateManager`
since now it handles both `UITextView`/`UITextField`
- added `shouldForward` helper to composite delegate that uses virtual
table to check if call can be actually forwarded
- use `shouldForward` in `responds`/`forwardingTarget`
## 🤔 How Has This Been Tested?
Tested manually on iPhone 17 Pro (iOS 26.2). Tested repro + Square repro
+ example app.
## 📸 Screenshots (if appropriate):
|Square|Repro|
|-------|------|
|<video
src="https://github.com/user-attachments/assets/cb6bfecf-3fb0-4e14-8540-2294fa1fba30">|<video
src="https://github.com/user-attachments/assets/9f79c8cf-34a1-4620-9bdc-883e84e0a105">|
## 📝 Checklist
- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed K
Kirill Zyusko committed
f15535d09a5b10f6dedc1c9b7d13981dd26c355d
Parent: 918254a
Committed by GitHub <noreply@github.com>
on 6/27/2026, 10:14:17 AM