
In late 2025, iOS 26 shipped a keyboard that couldn't keep up with fast typists. Type quickly and characters would go missing. The tap registered, the key animated, but the letter never landed in the field. Worse, the half-finished words poisoned autocorrect, which kept "fixing" text the user never typed. Apple patched it in iOS 26.4 in March 2026. \n React Native has its own version of this bug. It's older than iOS 26's, it's still in your app, and unlike Apple's, it isn't a bug. It's the framework working exactly as designed, losing a race it was built to lose. \n If you run a chat app, you've already seen it: the last letter of a fast-typed word vanishes, the cursor jumps to the start of the line, a masked or formatted field "stutters" and reverts. It only happens on the busy screen, never in your test harness. Here's the exact line of code that does it, and why it's there on purpose. The symptom The bug report always sounds like a hardware problem. "Sometimes my keyboard skips letters." "The cursor jumps when I type fast." It's intermittent, it's worse on older phones, and it never reproduces when you sit down to look at it. \n The tell is the context . Pull the input out of the chat screen, mount it on its own, and hammer it: flawless. Put it back above a list of thirty rendered messages, and the skips come back. The input is the same input. The only thing that changed is how much work happens between two keystrokes. \n That's the whole bug. To see why it matters, you have to look at who actually owns the text. The text isn’t yours A React Native <TextInput> feels like a controlled React component. You pass value , you get onChangeText , you assume the string in your state is the string on screen. It isn't. The string on screen lives in a native UITextField / UITextView . Your JavaScript value is a copy that arrives late and gets pushed back even later. The two are kept in sync across an asynchronous boundary, and "asynchronous" is doing a lot of work in that sentence. \n Every keystroke runs a full round trip: The number threaded through that loop, eventCount , is the entire ballgame. The handshake Native keeps a counter. Every time the user changes the text, it bumps it. From RCTTextInputComponentView.mm : - (void)_updateState { // ... _mostRecentEventCount += _comingFromJS ? 0 : 1; // bump only for real user input data.mostRecentEventCount = _mostRecentEventCount; _state->updateState(std::move(data)); } Every change event carries that count up to JavaScript. JS stashes it the instant it hears about a keystroke, in TextInput.js : const _onChange = (event) => { const currentText = event.nativeEvent.text; props.onChange && props.onChange(event); props.onChangeText && props.onChangeText(currentText); // your setState runs here setLastNativeText(currentText); setMostRecentEventCount(event.nativeEvent.eventCount); // remember which keystroke we saw }; Then, when your re-render produces a new `value`, JS pushes it back down. But it stamps the push with *the event count it last acknowledged* , not the current one. From the synchronization effect in TextInput.js : useLayoutEffect(() => { if (lastNativeText !== props.value && typeof props.value === 'string') { // value prop disagrees with what native last told us → push it down viewCommands.setTextAndSelection( inputRef.current, mostRecentEventCount, // the count from the keystroke we processed, maybe stale by now text, selection?.start ?? -1, selection?.end ?? -1, ); } }, [mostRecentEventCount, props.value, /* ... */]); And here is the line. The native command handler refuses any write whose stamp doesn't match the live counter, in RCTTextInputComponentView.mm : - (void)setTextAndSelection:(NSInteger)eventCount value:(NSString *__nullable)value start:(NSInteger)start end:(NSInteger)end { if (_mostRecentEventCount != eventCount) { return; // JS is behind. Drop the write. Keep what the user typed. } // ... only now do we apply the JS value } Read that as a sentence: if you typed anything in the time it took my JavaScript to round-trip, throw away JavaScript's answer. The user typed h-e-l-l-o and JS was still busy re-rendering when it finally tried to set the field to hel (eventCount 3); native was already at hello (eventCount 5); the counts don't match, the write is dropped, native keeps hello . \n That guard is the feature, not a bug. It's what stops a slow JS thread from clobbering live text and dropping characters. Without it, every laggy re-render would stomp the field back to a stale value. The React Native team added the event-count handshake precisely to make fast typing safe. \n So if the guard works, where do the missing characters actually come from? The blind spots The handshake protects one case perfectly: a plain controlled input where value is just an echo of what the user typed. The characters that still die live in the cases the handshake can't reason about. You transform the text. The moment onChangeText returns something other than what the user typed (strip non-digits, uppercase, insert phone-number dashes, trim), value legitimately disagrees with the native field, so JS has to push it down. But it pushes with a possibly stale eventCount , and the gate drops it. The result: the user sees their raw keystrokes, your formatting lands a beat late or not until they pause, and if your transform removes a character, that character appears and then vanishes. Every masked input, every "force uppercase," every currency field is standing on this trapdoor. The cursor restoration race. Applying a JS value isn't just a string assignment. Setting attributedText collapses the selection, so RN reconstructs the cursor by hand. _setAttributedString measures the old offset-from-end and re-derives a new caret position. When a value lands late and your fast typist has already moved on, the caret snaps to the reconstructed spot and the next few keystrokes insert in the wrong place. To the user, that reads as dropped or scrambled letters. Marked text: the real "ghost tap." This is the one that rhymes with iOS 26. While the keyboard is mid-composition (predictive text, autocorrect's underlined suggestion, Japanese/Chinese/Korean IME, dictation), UIKit holds a markedTextRange . Overwriting attributedText during that window kills the composition outright. RN knows this and deliberately bails out of the comparison, in _textOf:equals : BOOL shouldFallbackToBareTextComparison = [_backedTextInputView.textInputMode.primaryLanguage isEqualToString:@"dictation"] || [_backedTextInputView.textInputMode.primaryLanguage isEqualToString:@"ko-KR"] || _backedTextInputView.markedTextRange || // user is mid-composition; don't you dare reset _backedTextInputView.isSecureTextEntry || fontHasBeenUpdatedBySystem; The comment above it is unusually candid for framework code: "when the user is in the middle of inputting some text in Japanese/Chinese, there will be styling on the text that we should disregard… updating the attributed text while inputting Korean language will break input mechanism." The protection is real, but it's a list of known hazards. Any path that resets the field while a composition is live drops the in-flight character: a custom setNativeProps , an aggressive controlled update, a prop combination the list doesn't cover. That's the React Native ghost tap, and it shows up most in exactly the locales that compose the most. The old architecture, or bypassing the handshake. Pre-Fabric (Paper) text inputs resolved the same race with an eventLag = nativeEventCount - mostRecentEventCount check and ignored any update where eventLag > 0 . Same idea, blunter edges, and a lot of apps still run it. On either architecture, the second you set text imperatively through setNativeProps or a ref instead of letting the setTextAndSelection command carry the event count, you've stepped outside the handshake entirely. Now stale writes really do overwrite live text, and characters really do disappear. This was the canonical RN input bug for years, and it's still the easiest one to reintroduce by hand. Why your chat is the worst possible host None of this fires until the JS thread is slow enough to lose the race. A bare input never loses it. A chat screen loses it constantly, because every keystroke does this: onChangeText → setState → re-render the screen → reconcile thirty message rows → now the input's value is ready to push back down → and by now the user is two characters ahead. The busier the conversation, the longer the keystroke-to-reconcile gap, the more often eventCount has already advanced past whatever JS is trying to write. That's why it scales with message count, why it's worse on slow devices, and why it's invisible in isolation. In isolation, there's nothing between the keystrokes. The fixes, in order of how much they help Get the message list out of the keystroke's render path. This is 90% of the cure. Lift the input into its own component with its own state so typing re-renders the input, not the conversation. Wrap the list in React.memo with stable keys, and make sure the text state never flows into it. If a keystroke can't trigger a list reconcile, it can't lose the race Don't control what you don't need to control. If you only read the text on submit, use defaultValue and pull the value off a ref. An uncontrolled input never pushes a value back down, so there's no stale write to drop. Use onChangeText for side effects like enabling a Send button, not for mirroring every character into state. If you must transform, expect the stutter and design around it. Formatting in onChangeText is fighting the event-count gate by definition. Defer the consumer of the text (the expensive list) with useDeferredValue , keep the input's own value update synchronous and cheap, and validate on blur instead of stripping characters on the keystroke path. Never reset text mid-composition. Before any imperative text set, check for an active markedTextRange . Don't drive value changes from autocorrect or predictive events. Test in a CJK or dictation locale, where the bug is twice as loud and your QA probably isn't typing Korean. Stay on the New Architecture and stay inside the handshake. Let the setTextAndSelection command carry the event count. Every setNativeProps -based value hack trades the framework's race protection for a fresh source of dropped characters. The takeaway The mental model that breaks people is "my React state is the text." It isn't. The text is a native object that updates at the speed of a finger, and your value is a cache that updates at the speed of a render. React Native spends real effort keeping those two from corrupting each other: a counter, a handshake, and a guard that throws your own writes away. When characters go missing, the framework isn't failing. It's telling you that your keystroke path is doing too much work, and it would rather drop your stale answer than the user's live one. \n Apple shipped their fix in 26.4. Yours is making sure nothing expensive ever stands between two keystrokes. \n If you've debugged the marked-text variant in a CJK locale, or found a worse blind spot in the handshake, I'd love to hear it.
View original source — Hacker Noon ↗



