SIGN IN SIGN UP

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