Skip to content

fix: restore original delegate only for inputs with substituted delegate - #1684

Merged
kirillzyusko merged 1 commit into
mainfrom
fix/avoid-deferred-delegate-restoration
Oct 16, 2026
Merged

kirillzyusko merged 1 commit into
mainfrom
fix/avoid-deferred-delegate-restoration

Conversation

@kirillzyusko

@kirillzyusko kirillzyusko commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

📜 Description

Restore original delegate only for inputs with substituted delegate.

💡 Motivation and Context

When an input gets focused, we replace its UIKit delegate with our KCTextInputCompositeDelegate. It intercepts selection/text changes and forwards everything else to the original delegate. When the focus moves away, we put the original delegate back. There is only one composite delegate per KeyboardProvider, and it stores the original delegate of only one input at a time.

Since #1027 we install the composite delegate one frame after the focus, so that other libraries that set their own delegate later (e.g. via decorator views) aren't overwritten. But we restore the original delegate synchronously. If the focus changes again before that deferred frame, we "restore" an input that never received the composite delegate. And because the composite still stores the delegate of the previous input, we assign another input's delegate to it.

For example, input A (with maxLength={3}, already full) is focused and we call B.focus(); A.focus(); B.focus() in one handler:

  1. B.focus(): A gets its own delegate back, and installing the composite delegate on B is postponed;
  2. A.focus(): we "restore" B, but the composite still stores A's delegate, so B's delegate becomes A's RCTBackedTextFieldDelegateAdapter;
  3. B.focus(): the postponed install runs on B and wraps the wrong delegate.

From now on every keystroke in B is handled by A's component view: A's onKeyPress fires, A's maxLength rejects the character, and B's onChangeText never fires. Even B's onFocus is reported to JS as A's. Tapping B again doesn't help, because the next restore puts A's delegate back on B again.

How to reproduce ❓

I couldn't reproduce it with the navigation snippet from the issue, but this code reproduces it 100% of the time:

<KeyboardAvoidingView behavior="padding" style={{ flex: 1, paddingTop: 80 }}>
  <TextInput ref={a} autoFocus maxLength={3} value={valueA} onChangeText={setA} />
  <TextInput ref={b} value={valueB} onChangeText={setB} />
  <Button
    title="Trigger"
    onPress={() => {
      b.current?.focus();
      a.current?.focus();
      b.current?.focus();
    }}
  />
</KeyboardAvoidingView>
  1. Type 123 into the first input.
  2. Press "Trigger".
  3. Type anything: the second input is focused, but nothing gets typed.

What is the fix? 🤔

In substituteDelegateBack we now restore the delegate only if the input's current delegate is our composite delegate. If it isn't, we never replaced it, so there is nothing to restore. With this check the composite delegate can be set on only one input at a time, and the delegate it stores always belongs to that input.

The postponed substitution from #1027 stays as is. In the normal flow (the deferred install already happened by the time the focus moves) nothing changes.

With the fix, the same sequence looks like this:

  1. B.focus(): A's delegate is our composite delegate, so A gets its own delegate back (same as before). Installing the composite delegate on B is postponed;
  2. A.focus(): we try to restore B, but B's delegate is still its own RCTBackedTextFieldDelegateAdapter (the composite delegate was never set on it), so we skip it and B keeps its own delegate. Installing the composite delegate on A is postponed;
  3. B.focus(): we try to restore A. A's delegate is also its own (the install from step 2 hasn't run yet), so we skip it too. Installing the composite delegate on B is postponed.

Then the postponed installs run. Each of them takes the input that is focused at that moment (B), not the one that was focused when it was scheduled:

  • the first one stores B's own delegate in the composite delegate and sets the composite delegate on B;
  • the other two see that B already has the composite delegate and do nothing.

As a result, A keeps its own delegate, and B has the composite delegate that forwards to B's own adapter. Keystrokes in B are handled by B (its onKeyPress, maxLength and onChangeText), and the library's onChangeText/selection events keep working for B.

Important

Can a postponed install set the composite delegate on an input that already lost focus?

No. The postponed block doesn't capture the input. It uses the input that is focused at the moment it runs (self.currentResponder). In the example above, all three postponed installs run after the last B.focus(), so all of them target B; none of them targets A. If nothing is focused by then (the input was blurred), the block does nothing.

The only exception is focusing and blurring an input in the same main-thread turn (e.g. B.focus(); B.blur()). There the install runs on B after it already lost focus, but the blur handler is queued right after it and restores B's own delegate. This PR doesn't change that behavior.

Warning

Won't the composite delegate be set twice and end up wrapping itself (infinite forwarding)?

No. substituteDelegate skips inputs whose delegate is already a KCTextInputCompositeDelegate. So the first postponed install sets it on B, and the next two do nothing. The composite delegate stores only a delegate that is not a composite delegate, and the restore assigns only that stored delegate. A self-reference can't happen (and the stored delegate is weak anyway, so there's no retain cycle either).

Other options I considered:

  • capture the input in the deferred closure. It doesn't help: the wrong delegate is assigned synchronously in step 2, before any deferred closure runs. On top of that, a stale closure would install the composite delegate on an input that is no longer focused (A while B is focused);
  • substitute the delegate synchronously again (revert fix: delay delegate substitution #1027). It brings back the problem with libraries that set their own delegate after the input is mounted;
  • store the original delegate per input (dictionary/associated objects). A bigger change, and the check above is enough.

Closes #1588

📢 Changelog

iOS

  • restore the original text input delegate only if our composite delegate was actually set on that input (add delegate comparison for substitution back);

🤔 How Has This Been Tested?

Tested manually on iPhone 17 Pro (iOS 26.2).

📸 Screenshots (if appropriate):

Before After
before.mp4
after.mp4

📝 Checklist

  • CI successfully passed
  • I added new mocks and corresponding unit-tests if library API was changed

@kirillzyusko kirillzyusko self-assigned this Oct 15, 2026
@kirillzyusko kirillzyusko added 🐛 bug Something isn't working 🍎 iOS iOS specific focused input 📝 Anything about focused input functionality labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📊 Package size report

Current size Target Size Difference
346755 bytes 346663 bytes 92 bytes 📈

@kirillzyusko
kirillzyusko marked this pull request as ready for review October 16, 2026 08:39
@github-actions

Copy link
Copy Markdown
Contributor
  1. Incorrect Delegate Restoration in substituteDelegateBack
    Why: The modified conditions in substituteDelegateBack may prevent proper restoration of the old delegate if the current delegate is not exactly the same instance, leading to incorrect behavior or crashes.
    Fix: Remove the additional conditions to always restore the old delegate when substituting back.

  2. Potential Delegate Reference Issue
    Why: The check textField.delegate === delegate could fail if the delegate instance doesn't match, causing delegates not to be properly restored and leading to runtime errors.
    Fix: Ensure that the delegate restoration isn't conditional on the current delegate instance, which might not always be the case.

@kirillzyusko
kirillzyusko merged commit e4d63d0 into main Oct 16, 2026
19 checks passed
@kirillzyusko
kirillzyusko deleted the fix/avoid-deferred-delegate-restoration branch October 16, 2026 08:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working focused input 📝 Anything about focused input functionality 🍎 iOS iOS specific

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: shared composite delegate forwards keystrokes to another input, leaving a focused TextInput unable to accept text

1 participant