Repository navigation
fix: restore original delegate only for inputs with substituted delegate - #1684
Merged
Merged
Conversation
Contributor
📊 Package size report
|
kirillzyusko
marked this pull request as ready for review
October 16, 2026 08:39
Contributor
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📜 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 perKeyboardProvider, 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(withmaxLength={3}, already full) is focused and we callB.focus(); A.focus(); B.focus()in one handler:B.focus():Agets its own delegate back, and installing the composite delegate onBis postponed;A.focus(): we "restore"B, but the composite still storesA's delegate, soB's delegate becomesA'sRCTBackedTextFieldDelegateAdapter;B.focus(): the postponed install runs onBand wraps the wrong delegate.From now on every keystroke in
Bis handled byA's component view:A'sonKeyPressfires,A'smaxLengthrejects the character, andB'sonChangeTextnever fires. EvenB'sonFocusis reported to JS asA's. TappingBagain doesn't help, because the next restore putsA's delegate back onBagain.How to reproduce ❓
I couldn't reproduce it with the navigation snippet from the issue, but this code reproduces it 100% of the time:
123into the first input.What is the fix? 🤔
In
substituteDelegateBackwe 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:
B.focus():A's delegate is our composite delegate, soAgets its own delegate back (same as before). Installing the composite delegate onBis postponed;A.focus(): we try to restoreB, butB's delegate is still its ownRCTBackedTextFieldDelegateAdapter(the composite delegate was never set on it), so we skip it andBkeeps its own delegate. Installing the composite delegate onAis postponed;B.focus(): we try to restoreA.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 onBis 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:B's own delegate in the composite delegate and sets the composite delegate onB;Balready has the composite delegate and do nothing.As a result,
Akeeps its own delegate, andBhas the composite delegate that forwards toB's own adapter. Keystrokes inBare handled byB(itsonKeyPress,maxLengthandonChangeText), and the library'sonChangeText/selection events keep working forB.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 lastB.focus(), so all of them targetB; none of them targetsA. 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 onBafter it already lost focus, but the blur handler is queued right after it and restoresB'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.
substituteDelegateskips inputs whose delegate is already aKCTextInputCompositeDelegate. So the first postponed install sets it onB, 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 isweakanyway, so there's no retain cycle either).Other options I considered:
AwhileBis focused);Closes #1588
📢 Changelog
iOS
🤔 How Has This Been Tested?
Tested manually on iPhone 17 Pro (iOS 26.2).
📸 Screenshots (if appropriate):
before.mp4
after.mp4
📝 Checklist