Repository navigation
Conversation
…Fabric) On iOS with the New Architecture, typing fast into a TextInput can lose keys (react#58904). When a state comes back with the current event count, updateState:oldState: writes its text into the field if -_textOf:equals: reports a difference. Written into a focused field, the write drops keys: UIKit delivers the keys queued behind -setAttributedText: inside it, -textInputDidChange ignores them (_comingFromJS) and the write replaces them. Many of these writes change nothing the field shows, but RCTIsAttributedStringEffectivelySame reports a difference because UIKit decorates typed text: the input's default text attributes (background color, event emitter) on the first character, a paragraph style (UIKit's default one, or the one in its initial typing attributes, which has another line break strategy), and in a single-line UITextField its own line break mode and writing direction and no line height (`lineHeight`, `writingDirection`). A controlled `value` built from props carries none of them. - RCTIsAttributedStringEffectivelySame gets a `singleLine` argument (new 5-argument overload; the 4-argument function stays, unchanged): for a single-line field it does not compare the line break mode, writing direction and line height. A multiline input still compares the line break mode (a text view keeps the one in its text: a JS `lineBreakModeIOS` change is written). - -_textOf:equals: passes the input's defaultTextAttributes and UIKit's default paragraph style as insensitive attributes, and whether the input is single-line. It also accepts what it accepted before (_originalTypingAttributes as insensitive attributes), so it never writes where it did not before. Tests: RCTAttributedTextUtilsTest (comparison), and RCTTextInputComponentViewTests, which commits states through updateState:oldState: into a backed text input that delivers keys inside -setAttributedText: as UIKit does; its no-op cases lose the keys on main. RNTester: TextInput > "Typing round trip". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7ztWHg5Dnq1KeknJQnfex
This branch has not been deployed
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.
Summary:
On iOS with the New Architecture, typing quickly into a TextInput loses keys (#58904). This PR fixes the cases where the write that loses them changes nothing the field shows: the issue's first case (an uncontrolled input losing the keys typed right after its first character), controlled inputs, including with
lineHeight,letterSpacingor Dynamic Type, right-to-left text, emoji, an IME commit, amaxLengthformatter, and typing after autocorrect, after a controlledselectionor in the middle of the text. #59198 (stacked on this one) handles the writes that are real changes.Root cause. When a TextInput's state comes back to
-[RCTTextInputComponentView updateState:oldState:]with the current event count, the view writes the state's text into the field if-_textOf:equals:reports a difference. While the field is focused, UIKit delivers the keystrokes queued behind that-setAttributedText:inside it;-textInputDidChangeignores them because_comingFromJSis set, and the write then replaces them. The keys are inserted and immediately overwritten (they still fireonKeyPress).Many of these writes change nothing visible.
RCTIsAttributedStringEffectivelySamereports a difference because UIKit decorates the text a field shows with attributes the state does not carry:valueequal to what was typed is rebuilt from props without a paragraph style, while the typed text has one: UIKit's default paragraph style (with its own line break mode), or, after some writes, the one in the field's initial typing attributes (which has another line break strategy);UITextField) shows its text with the field's writing direction and no line height, whatever the paragraph style says (lineHeight,writingDirection), so such a state never compared equal and every keystroke was written back.Fix. The comparison the view already uses is extended, rather than adding a second one:
RCTIsAttributedStringEffectivelySame(RCTAttributedTextUtils.mm) gets a 5-argument overload that takessingleLine: for a single-line text field it does not compare the line break mode, writing direction and line height of paragraph styles, which the field sets itself in the text it shows. The 4-argument function keeps its signature, symbol and behaviour (it calls the new one withNO), so code built against a prebuilt React Native still links. A multiline input still compares the line break mode: a text view keeps the one in its text, so alineBreakModeIOSthat JS changes is written.-_textOf:equals:passes the input'sdefaultTextAttributesand UIKit's default paragraph style as insensitive attributes, and whether the input is single-line. It also accepts what it accepted before (_originalTypingAttributesas the insensitive attributes): typed text can carry either paragraph style, and with both accepted this PR never writes wheremaindoes not. Its only caller is the TextInput view.Text or attributes that JS changes still differ and are written as before, except, in a single-line input, the line break mode, writing direction and line height of the paragraph style: the text field lays its line out with its own and does not show them.
The original report. The issue's first case (an uncontrolled field losing the keys typed right after its first character) is not fixed on
main. The issue's reproducer (https://github.com/vdzerkalo/rn-58904-textinput-lost-key, React Native built from source, Release) still loses them on 0.88.0: its uncontrolled field keptprivate/rn-device-testin 5 of 10 launches (the others readpvate/rn-device-test), and 3 of 5 on 0.87.1. No commit between 0.87.1 andmainchanges the comparison, the typing attributes or the write path for this case. RNTester does not show it: there the first character already has the input's default attributes when it is sent, so the state that comes back is equal; in the reproducer it is sent with only UIKit's font and color and gets the default attributes afterwards, so the echo differs and is written into the field while keys are queued. With this PR the reproducer's uncontrolled field kept every key in 15 of 15 launches on 0.87.1 and 15 of 15 on 0.88.0. So the first-character case passing in RNTester onmaindoes not mean it is fixed there.Changelog:
[IOS] [FIXED] - Fix TextInput losing keys typed quickly on the New Architecture: after the first character of an empty input, and in controlled inputs (including with
lineHeight,letterSpacingor Dynamic Type)Test Plan:
RNTester. TextInput > "Typing round trip" (
rntester://example/TextInput/typingRoundTrip). Run a Release build on an iOS Simulator, tap an empty field and typeprivate/rn-device-testas one fast burst (XCUITesttypeText, or typing on the Mac keyboard with the hardware keyboard connected). The field and JS's copy below the fields must both hold the whole string. Onmain, the controlled fields lose characters; with this PR all three fields kept every key in 5 of 5 launches (XCUITesttypeText, same build as the table below).Unit tests, in the React-RCTFabric test spec:
RCTAttributedTextUtilsTest: 10 new cases forRCTIsAttributedStringEffectivelySame(UIKit's paragraph style on typed text, the input's default attributes on the first character, any line break mode in a single-line field, a single-line field without the line height, Hebrew in a single-line and a multiline field, letter spacing, emoji and combining marks; and as real changes: another line height in a multiline input, another alignment, a changed or removed attribute, other characters, another line break mode or UIKit's paragraph style in a multiline input), next to the existing ones, which still pass.RCTTextInputComponentViewTests(new; commits states throughupdateState:oldState:into a realRCTTextInputComponentView): the backed text field delivers keys inside-setAttributedText:as UIKit does, and its keys go throughtextInputShouldChangeText:inRange:. A controlled value equal to the typed text, the first character with the input's default attributes, and a single-line state with a line height are not written while keys arrive (all three lose the keys withmain's view); neither is a state without a paragraph style over typed text that has UIKit's initial typing paragraph style (maindoes not write it either); a multiline line-height or line-break-mode change, a restyle and other text are written.Results: 44 of 44 pass on this branch (
RCTAttributedTextUtilsTest34,RCTTextInputComponentViewTests8,RCTTextInputUtilsTest2); 26 of 26 onmain. Each part fails tests when disabled: comparing the line break mode in a single-line field (10 tests), ignoring it in a multiline input as well (3: thelineBreakModeIOSchange is not written), comparing the writing direction in a single-line field (Hebrew), comparing the line height in a single-line field (2), no default text attributes as insensitive (the first character), no UIKit default paragraph style (the controlled value and the first character), and not acceptingmain's comparison (the initial typing paragraph style).To run them: the test spec is not built by OSS CI. Locally, add
:testspecs => ['Tests']toReact-RCTFabricinscripts/cocoapods/fabric.rb, limittest_spec.source_filesinReact-RCTFabric.podspectoTests/Text/{RCTTextInput,RCTAttributedTextUtils}*.mm(other files underTests/do not compile under CocoaPods),bundle exec pod installinpackages/rn-tester, thenxcodebuild test -workspace RNTesterPods.xcworkspace -scheme React-RCTFabric-Unit-Tests -destination 'platform=iOS Simulator,name=iPhone 17'.Main, this PR and the follow-up, in RNTester. Release, arm64, no sanitizers (Xcode 27.1, iOS 27.1 SDK, iPhone 17 Simulator on the iOS 27.0 runtime). Each case is 5 fresh launches into an empty field, typed with XCUITest
typeText; a case passes when the field and JS's value are both exact. The cases are rendered by a local, uncommitted RNTester module (one input per screen) and driven by a local UI test; the committed "Typing round trip" example covers the main ones. PR 1's column is one run on this head (a91468ffe6), and the PR 1 + PR 2 column one run on the follow-up's head on top of it (7ce6cd3bbc). Themaincolumn was measured at27e77216ef.mainthen moved to51d3e67af8(no change to the files these PRs touch); there the rows the PRs fix plus a control row were re-run, 5 launches each, with the same pass/fail pattern: uncontrolled 5/5; controlled 1/5, controlledlineHeight1/5, Hebrew 1/5, nested<Text>0/5, upper-casing 0/5, slash-stripping 0/5, single-line JS append 1/5,maxLengthformatter 1/5, JS appendsd1/5.mainonChangeTextlineHeightnumber-pad, placeholder,lineHeight(burst28)lineHeightletterSpacing: 3textAlign: centertextAlign: rightwritingDirection: rtlallowFontScaling(Dynamic Type)secureTextEntry, controlledsecureTextEntry, uncontrolled<Text><Text>, multiline<Text>go: uncontrolled<Text>restyled every 250 msxyztyped right after: uncontrolled<Text>tehthencat: controlled<Text>onChangeTextupper-cases; caret to 5,x,yonChangeTextstrips/selectionprop moves the caret, then a keyXY: uncontrolled<Text>abc, then JS appendsdefabc, then JS appendsdefdtoabcwhiledefis typedmaxLength={5}, formatter12-34,123456789typedhello, pause,world, undo): uncontrolled""×5""×5""×5"h"×4,"he"""×5""×5<Text>"h"×5"h"×5"hel"×5UITextField(reference)""×5""×5""×5mainlineHeight)lineHeight<Text><Text>, multilineabcthen "Append ' def'"maxLength={5}formatter,123456789(
mainhas no "Typing round trip" example; this PR adds its first three fields, the follow-up the rest.)Undo (
hello, a pause,world, undo) shows the text after the undo: a plainUITextFieldundoes the whole typing run (""), and with this PR the controlled input does the same (onmainthe keys were already lost).Rows this PR does not fix: a nested
<Text>child (the restyle is a real change and is still written on every keystroke),onChangeTexttransforms, and typing in the middle of a nested input; they are the follow-up PR's (all 5/5 there). On the nested rows this PR never writes wheremaindoes not: typed text can carry either UIKit's default paragraph style or the one in the field's initial typing attributes, which has another line break strategy, and-_textOf:equals:accepts both (mainaccepted only the second). Without that, a state with no paragraph style compared unequal to text shown with the initial typing style after a nested restyle, and was written on every key (paste then type into a nested input: 5/5 onmain, 0/5). Both builds lose keys on the other nested rows, where the restyle is written on every key; which keys depends on when it lands (nested: 1/5 onmain, 0/5 here; emoji: 0/5 and 2/5). Autocorrect: the Simulator did not autocorrect XCUITest input (teh catstays); the row checks that no key is lost. The multiline JS-append row passes onmainalready; it is there because an earlier revision of the follow-up duplicated the appended text. The JS-appends-drows check that a key equal to text JS appends at the same moment is kept (the follow-up's).Why there is no Maestro flow. RNTester's E2E suite is Maestro. A flow that types into each field passes on unpatched
main: Maestro'sinputTextsends keys one at a time with a round trip per key, so no key is ever queued behind a write.Scope. iOS Fabric only. Android: the reproducer's fields on an API 35 emulator (0.87.1) kept every key once the harness waited for each field to be focused. The legacy architecture is removed on
main.Interaction with #56082
#56082 (open, CJK IME composition) changes the same file. The two are independent and either can merge first; the other then rebases with textual conflicts only (
-_textOf:equals:, thedefaultTextAttributessetters).NSShadow, the transparentNSBackgroundColorand the event emitter from the attributes it gives UIKit (typingAttributes, and-[RCTUITextField setDefaultTextAttributes:]beforesuper). Typed text then lacks them. This PR treats the input'sdefaultTextAttributesas insensitive, anddefaultTextAttributesstill returns the attributes React Native set, so a state that carries them and typed text that lacks them still compare equal: the skip keeps working.-_setAttributedString:while text is marked; this PR's comparison runs inside it, so during composition there is no write either way.Suggested order: this PR first (it is smaller and only touches the comparison); #56082 rebases by keeping its
markedTextRangeguards around the code this PR leaves in place.Related: #58904 (the first-character case and controlled inputs fixed here; restyles and transforms in the stacked follow-up PR), #58807 (Japanese composition interrupted by an
attributedTextreassignment that attribute differences trigger while the plain text is unchanged: the same kind of no-op write; not verified with Simeji), #56463 / #48497 / #56082 (CJK composition on Fabric), #56658 (abandoned attempt to widen the insensitive attributes), #37722, #27019.🤖 Generated with Claude Code