Describe the bug
On iOS, a focused TextInput can end up unable to accept any text. The keyboard is up, the field is first responder, isEditable and isEnabled are both true, but every keystroke is discarded: nothing is inserted, and the visible field's onChangeText and onKeyPress never fire. It does not recover by tapping the field, by navigating back and forward, or by backgrounding the app.
The cause is the delegate substitution in FocusedInputObserver. The app-wide provider owns one FocusedInputObserver, which owns one KCTextInputCompositeDelegate reused across focused inputs, and that composite keeps one weak textFieldDelegate at a time, so when focus moves between inputs the composite can be left forwarding to a different input's RCTBackedTextFieldDelegateAdapter. From then on, textField(_:shouldChangeCharactersIn:replacementString:) on the visible field is answered by another component view's textInputShouldChangeText:, which rejects the character when that other view is at its maxLength.
Two things combine:
setupObservers() defers substituteDelegate() by a runloop tick (DispatchQueue.main.async), while removeObservers() calls substituteDelegateBack() synchronously.
substituteDelegateBack() restores delegate.activeDelegate, which is whatever was stored last, not the outgoing field's own delegate.
With autoFocus on consecutive screens, focus changes faster than the deferral, so the stored delegate and the field it is installed on can drift apart. In this reproduction the wiring was never repaired by tapping the field, navigating back and forward, or backgrounding the app.
Code snippet
Two screens in a native stack, each autofocusing a TextInput, the first one with a maxLength. KeyboardAvoidingView and KeyboardProvider both come from the library:
import { KeyboardAvoidingView, KeyboardProvider } from 'react-native-keyboard-controller';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<KeyboardProvider>
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="A" component={ScreenA} />
<Stack.Screen name="B" component={ScreenB} />
</Stack.Navigator>
</NavigationContainer>
</KeyboardProvider>
);
}
function ScreenA({ navigation }) {
const [value, setValue] = useState('');
return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<TextInput value={value} onChangeText={setValue} autoFocus maxLength={3} />
<Button title="Next" onPress={() => navigation.navigate('B')} />
</KeyboardAvoidingView>
);
}
function ScreenB() {
const [value, setValue] = useState('');
return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<TextInput value={value} onChangeText={setValue} autoFocus />
</KeyboardAvoidingView>
);
}
Repo for reproducing
I don't have a standalone repo yet. The snippet above is the reduced shape of what I hit in a real app, on iOS only. I can put a minimal repo together if that helps.
To Reproduce
- Open Screen A. Its input autofocuses.
- Type exactly 3 characters, so the field sits at its
maxLength.
- Tap Next without dismissing the keyboard.
- Screen B's input autofocuses and the keyboard stays up.
- Type. Nothing is inserted and
onChangeText never fires.
Leaving the first field below its maxLength (2 characters) makes Screen B work normally. maxLength is not the bug, it is just what makes the misdirected delegate reject deterministically instead of occasionally.
Expected behavior
Keystrokes on the focused input are evaluated by that input's own delegate, and onChangeText fires.
Screenshots
Not applicable, the field looks completely normal.
Smartphone (please complete the following information):
- Desktop OS: macOS 26
- Device: iPhone 17 simulator
- OS: iOS 26.3
- RN version: 0.81.5
- RN architecture: new (Fabric)
- JS engine: Hermes
- Library version: 1.18.5. The 1.22.3 source still defers
substituteDelegate() and restores via delegate.activeDelegate, so I expect it is affected too, though I have not run it.
Additional context
What I verified:
- Mounting
KeyboardProvider makes the second field dead, not mounting it makes the same flow work, mounting it again reproduces the failure.
lldb on the dead field: it is the window's first responder, isEditable and isEnabled are true, and its delegate is KCTextInputCompositeDelegate. Without the provider the delegate is RCTBackedTextFieldDelegateAdapter and the field works.
- Raising the
maxLength of Screen A's input, without touching Screen B at all, brings Screen B's input back to life. That is what pins the misdirection: B's keystrokes were being evaluated by A's component view.
- Setting an explicit
maxLength={99999} or editable={true} on Screen B's input changes nothing, because the decision is made by the other view's props.
enabled={false} is not a workaround: KeyboardControllerView.willMoveToSuperview: calls mount() regardless of that prop, so the observer runs anyway. The only way I found to opt out on iOS is not mounting the provider at all.
This looks related to #1119, another composite-delegate lifetime and forwarding failure during navigation, which was closed by adding doesNotRecognizeSelector handling and logging rather than by changing the shared-delegate design. The deferral in setupObservers() was introduced by #1027.
Happy to test a patch against the reproduction above.
Describe the bug
On iOS, a focused
TextInputcan end up unable to accept any text. The keyboard is up, the field is first responder,isEditableandisEnabledare both true, but every keystroke is discarded: nothing is inserted, and the visible field'sonChangeTextandonKeyPressnever fire. It does not recover by tapping the field, by navigating back and forward, or by backgrounding the app.The cause is the delegate substitution in
FocusedInputObserver. The app-wide provider owns oneFocusedInputObserver, which owns oneKCTextInputCompositeDelegatereused across focused inputs, and that composite keeps oneweak textFieldDelegateat a time, so when focus moves between inputs the composite can be left forwarding to a different input'sRCTBackedTextFieldDelegateAdapter. From then on,textField(_:shouldChangeCharactersIn:replacementString:)on the visible field is answered by another component view'stextInputShouldChangeText:, which rejects the character when that other view is at itsmaxLength.Two things combine:
setupObservers()deferssubstituteDelegate()by a runloop tick (DispatchQueue.main.async), whileremoveObservers()callssubstituteDelegateBack()synchronously.substituteDelegateBack()restoresdelegate.activeDelegate, which is whatever was stored last, not the outgoing field's own delegate.With
autoFocuson consecutive screens, focus changes faster than the deferral, so the stored delegate and the field it is installed on can drift apart. In this reproduction the wiring was never repaired by tapping the field, navigating back and forward, or backgrounding the app.Code snippet
Two screens in a native stack, each autofocusing a
TextInput, the first one with amaxLength.KeyboardAvoidingViewandKeyboardProviderboth come from the library:Repo for reproducing
I don't have a standalone repo yet. The snippet above is the reduced shape of what I hit in a real app, on iOS only. I can put a minimal repo together if that helps.
To Reproduce
maxLength.onChangeTextnever fires.Leaving the first field below its
maxLength(2 characters) makes Screen B work normally.maxLengthis not the bug, it is just what makes the misdirected delegate reject deterministically instead of occasionally.Expected behavior
Keystrokes on the focused input are evaluated by that input's own delegate, and
onChangeTextfires.Screenshots
Not applicable, the field looks completely normal.
Smartphone (please complete the following information):
substituteDelegate()and restores viadelegate.activeDelegate, so I expect it is affected too, though I have not run it.Additional context
What I verified:
KeyboardProvidermakes the second field dead, not mounting it makes the same flow work, mounting it again reproduces the failure.lldbon the dead field: it is the window's first responder,isEditableandisEnabledare true, and itsdelegateisKCTextInputCompositeDelegate. Without the provider the delegate isRCTBackedTextFieldDelegateAdapterand the field works.maxLengthof Screen A's input, without touching Screen B at all, brings Screen B's input back to life. That is what pins the misdirection: B's keystrokes were being evaluated by A's component view.maxLength={99999}oreditable={true}on Screen B's input changes nothing, because the decision is made by the other view's props.enabled={false}is not a workaround:KeyboardControllerView.willMoveToSuperview:callsmount()regardless of that prop, so the observer runs anyway. The only way I found to opt out on iOS is not mounting the provider at all.This looks related to #1119, another composite-delegate lifetime and forwarding failure during navigation, which was closed by adding
doesNotRecognizeSelectorhandling and logging rather than by changing the shared-delegate design. The deferral insetupObservers()was introduced by #1027.Happy to test a patch against the reproduction above.