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
When RCTTextInputComponentView writes JS's text into a focused field, UIKit delivers the keys queued behind -setAttributedText: inside the write; -textInputDidChange ignores them (_comingFromJS) and the write replaces them. The previous commit skips writes that change nothing. The ones left are real changes and lose keys the same way: a nested <Text> restyled on every keystroke, and onChangeText transforms applied through setTextAndSelection. Keep the keys instead: - A key that goes through -textInputShouldChangeText:inRange: during a write is recorded: -textInputDidChange records the field's text right after it. Other changes reported during the write are the write's own (a UITextView reports the text JS wrote), so they are not recorded. - After the write (-_setAttributedStringFromJS: from updateState:, and setTextAndSelection), the edit is put back on top of JS's text (RCTTextInputTextToRestore: where it was made if JS changed only attributes; an insertion at the same distance from the end if JS changed the characters) with -replaceRange:withText:, so it takes the typing attributes and is an undoable edit, and is reported to JS as typing (onChange, then onSelectionChange for the caret). - The restored text is cut to maxLength without splitting a composed character sequence (RCTTextInputTextWithinLength, now shared with -textInputShouldChangeText:inRange:). - UIKit can deliver more queued keys inside the restore's edit, before it applies. The caret is put after the range the edit replaces (the typing attributes still come from the text before that range), so they land after the restored keys, stay in the field and are reported with it; their maxLength check counts the restore. The selection moves past them, so the next key follows them. A key delivered there that edits text before the end of that range (a backspace) is dropped: applied, it would delete text the edit then replaces, and the edit would replace other text. - The selection is mapped through the edit (RCTTextInputOffsetAfterEdit), so setTextAndSelection keeps the selection JS asked for, and a caret at the start of a range the user typed over moves past the replacement. - Nothing is put back while text is marked (IME composition), so a composition is never committed as plain text. - Keys UIKit delivers after the assignment (a text view, or a text field with autocorrection) are already in the field: they are only reported, and the caret, which the write had put back before them, goes after them again. Tests: RCTTextInputComponentViewTests (backed text field and text view that deliver keys or a composition inside -setAttributedText:; keys go through -textInputShouldChangeText:inRange:), RCTTextInputUtilsTest. RNTester: TextInput > "Typing round trip" gains nested <Text>, transform, multiline append and maxLength formatter fields. 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:
Second half of the fix for #58904, stacked on #59197: this branch contains #59197's commit, so review only the second commit (
7ce6cd3bbc) here, or merge #59197 first.Root cause. When
RCTTextInputComponentViewwrites JS's text into the focused field, UIKit delivers the keystrokes queued behind that-setAttributedText:inside it.-textInputDidChangeignores them because_comingFromJSis set, and the write then replaces them. #59197 skips the writes that change nothing. The writes that remain are real changes, and they lose keys the same way:<Text>child: the typed character arrives in the field's default style, the state comes back with the child's style, and the restyle is written on every keystroke;onChangeTexttransforming the text (upper-casing, stripping characters): the controlledvaluediffers from the field, and TextInput applies it with thesetTextAndSelectioncommand, which writes it the same way;Fix. Keep the keys the write would drop:
-textInputShouldChangeText:inRange:during a write marks the write, and the change UIKit reports right after it is recorded. Only those changes are recorded: a multilineRCTUITextViewalso reports the write itself (throughtextViewDidChangeSelection:), and that is JS's text, not typing.-_setAttributedStringFromJS:fromupdateState:oldState:, and the end ofsetTextAndSelection),-_restoreTextTypedDuringUpdateFromJS:writtenText:puts that edit back on top of JS's text and reports it through-textInputDidChange, then-textInputDidChangeSelection, so JS receives every key as typing (onChange) and the caret it moved (onSelectionChange), and can still change or reject the keys (the transform runs again on the restored keys). A key is kept even when the text it left equals the text JS wrote (the user typesdwhile JS appendsd: both are kept).RCTTextInputTextToRestore(new,RCTTextInputUtils): if JS changed only attributes, the edit applies where it was made; if JS changed the characters, only an insertion is put back, at the same distance from the end of the text. A replacement or deletion made during a write that changed the characters is not replayed: JS's text wins, as before. The edit range (RCTTextInputChangedRange) never splits a composed character sequence; it reads both strings throughCFStringInlineBufferrather than copying them. The new helpers are declared inRCTTextInputUtils.h, next toRCTCopyBackedTextInput, the other helper of this view that is unit-tested through that header.-replaceRange:withText:, as typing does: the keys take the typing attributes a caret before them gets (a nested<Text>style; typed over a selection, the text before the selection), and the insertion is an edit the user can undo. Those typing attributes come from-_updateTypingAttributesSampledAt:, which-_updateTypingAttributesnow calls too, so restored keys and typed keys follow one attribute policy.-replaceRange:withText:, before it applies. The restore puts the caret after the range it replaces, so those keys land after the restored ones, stay in the field (only that range is replaced) and are reported with it; the selection then moves past them too, so the next key follows them. An edit delivered there that starts before the end of that range is dropped: a backspace, or an autocorrection or predictive replacement of the text before the caret. Applied, it would change text the restore then replaces, and the restore would replace other text (see the limitation below). A dropped correction leaves the word as typed; no key is lost.RCTTextInputOffsetAfterEdit). ForsetTextAndSelectionthat is the selection JS asked for: a caret JS put before the typed keys stays there; a caret at the insertion point, or at the start of a range the user typed over, moves past the keys. The end of a selection is mapped the same way, so a JS selection that ends at the start of a range the user typed over grows over the restored keys.Two guards protect this PR's own re-insertion; unit tests cover both, and the app runs needed the first (keys delivered inside the restore, below):
maxLength: UIKit checked the restored keys against the text before the write, so they are cut to the length left, without splitting a composed character sequence (RCTTextInputTextWithinLength, now also used by-textInputShouldChangeText:inRange:, so both apply the same rule). Keys UIKit delivers inside the restore's own edit (step 5) are checked counting the length the restore is adding (without that,123456789into amaxLength={5}formatter ended at12-345in 1 of 5 launches).-replaceRange:withText:(step 5) is committed by that call: UIKit'sreplaceRangecommits marked text (a unit probe:abc,dtyped during a restyle,かcomposed inside the restore's edit, ends asabcdか, unmarked). It needs a composition key queued behind a key that was itself queued behind a write.Changelog:
[IOS] [FIXED] - Fix TextInput losing keys typed while it applies a style or a transformed value from JS (nested
<Text>,onChangeTexttransforms) on the New ArchitectureTest Plan:
RNTester. TextInput > "Typing round trip" gains nested
<Text>(single line and multiline), upper-casing and slash-stripping fields, a multiline controlled field with an "Append ' def'" button, and amaxLength={5}field that formats digits as12-34. Release build on a Simulator: typeprivate/rn-device-testquickly into each fresh text field; every field must hold all of it (upper-cased, or without slashes) and match JS's copy below. In the multiline field typeabcand press the button: it must readabc defonce. Type123456789quickly into the formatted field: it must read12-34and never exceed 5 characters.Unit tests (React-RCTFabric test spec; how to run it locally is in #59197):
99 of 99 pass on this branch (
RCTAttributedTextUtilsTest34,RCTTextInputComponentViewTests41,RCTTextInputUtilsTest24).RCTTextInputComponentViewTests(33 new cases, on the fixture from fix(ios): don't write a TextInput's own text back into the focused field (#58904) #59197: a realRCTTextInputComponentViewin a window, whose backedRCTUITextFieldorRCTUITextViewdelivers keys (a backspace among them), or an IME composition, inside-setAttributedText:before or after the assignment, or keys inside-replaceRange:withText:; keys go through-textInputShouldChangeText:inRange:, soonKeyPressandmaxLengthrun as for real typing; a subclass records what the view reports to JS):onChangethenonSelectionChange), transforms (single line and multiline), or sets a controlled value; a key equal to the text JS appends (single line and multiline:abcdd); JS's text left alone when no key was typed; keys delivered after the assignment not duplicated, caret after them, reported; JS's selection kept;setTextAndSelectionand through a state (_setAttributedStringFromJS:), and single line;maxLength: restored keys stop at the limit, fill it exactly, and never split an emoji; keys delivered inside the restore respect it; typing respects it;setTextAndSelection); inside the restore of a key typed over a selection (abcXY; the restored key takes the style of the text before the selection) and of a backspace (abce); a backspace delivered there is dropped and leaves the other text and the caret right (after an insertion:abcd; in the middle of the text, inside the restore of a backspace:abde); that range is guarded only while the restore's edit applies, so the next key typed inside it is kept (XYZd);RCTTextInputUtilsTest:RCTTextInputChangedRange(insertion, middle insertion, replacement, deletion, repeated character, emoji sharing a surrogate, combining mark, flag, skin tone, and 20,000 random edits over composed sequences rebuilding the new text),RCTTextInputTextToRestore(restyle, backspace, middle insertion, upper-casing, transform in the middle, JS shortening the text, no replay of a replacement, emoji after a flag, and 20,000 random attribute-only writes),RCTTextInputTextWithinLength,RCTTextInputOffsetAfterEdit(including a caret at the start of a range typed over).Each part fails tests when disabled (number of tests): recording any change during a write instead of a key's (7: the multiline duplication cases, and a multiline transform loses the keys), no key recorded at all (19), no restore after a state (14) or after
setTextAndSelection(5), no "arrived after the assignment" check (1), the caret not put back after keys that arrived after the assignment (1), JS's selection not mapped (7), not inserting as an undoable edit (7), noonSelectionChangeafter a restore (3, and 1 for keys that arrived after the assignment), the restore's own edit reported to JS as well (2), nomaxLengthcut (3), restore length not counted for keys inside the restore (1), no marked-text check (2); and for keys delivered inside the restore: the replaced range selected instead of the caret after it (2), those keys not counted in the selection (6), typing attributes taken at the caret instead of before the replaced range (1), a backspace there not dropped (2), the guarded range not cleared after the restore (1: the next key typed inside it is dropped,XYd), a caret at the start of a range typed over not moved past it (2). The restore and-_updateTypingAttributesshare one attribute policy: with the policy changed to drop the foreground color, the restored keys' style tests fail (2); with the same change and the restore sampling the attributes itself, none do (restored keys would silently keep the old policy).Main, PR 1 and PR 1 + PR 2, 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 and driven by a local UI test; the committed "Typing round trip" example covers the main ones (second table). PR 1's column is one run ona91468ffe6, and the PR 1 + PR 2 column one run on this head (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; PR 1 adds its first three fields, this PR the rest.)Every case except undo is exact with both PRs (5/5), including the multiline JS append (
abc+def, not duplicated),maxLength={5}with a formatter (12-34, never longer than 5;main0/5), and a key typed while JS appends the same character (main0/5 single line). No case is worse thanmain. In PR 1's column the nested<Text>rows, transforms and typing in the middle of a nested input still lose keys (the write is a real change there); with this PR they are all 5/5.Undo (
hello, a pause,world, undo) shows the text after the undo. Uncontrolled and controlled inputs undo the whole typing run, as a plainUITextFielddoes. In the nested<Text>input the first key is typed with the input's default attributes and JS restyles it. UIKit's-setAttributedText:on a focused field empties its undo stack (removeAllActions; a plainUITextFieldorUITextViewdoes the same when an app setsattributedTextwhile it is focused), so on every build the typing before that write cannot be undone, and one undo removes only the keys typed after it: onmainthat leavesh; with this PRhel×5, and a second undo leavesh. The difference is the keys UIKit delivered during the write: this PR puts them back with-replaceRange:withText:, which UIKit keeps as its own undo step instead of joining it to the typing that follows (reproduced in a unit run:h, a restyle witheltyped during it, thenlo world: undo giveshel, thenh).-insertText:would join them (undo givesh, as onmain), but it inserts at the caret, so a key UIKit delivers inside the restore (as it did inside the replace in 1 of 5 launches of themaxLengthrow) would land before the restored keys instead of after them. With the range-based edit and the caret after the range it replaces, such a key lands after the restored keys, and the next key after it. Undoing past the restyle needs a restyle that leaves the undo stack alone, which changes how every restyle is written and is outside this PR:-replaceRange:withAttributedText:with undo registration disabled keeps the earlier typing but still splits it into two undo steps; only a text view can restyle in place (through its text storage) and keep one. Autocorrect: the Simulator does not autocorrect XCUITest input; the rows check that no key is lost and no key moves (an earlier revision of this PR left the caret before a key UIKit delivered after the write, givingtehcatin 2 of 5; fixed, and covered by a unit test). IME composition was driven withsetMarkedText/unmarkText(no Japanese keyboard in the Simulator), with a nested<Text>restyled every 250 ms during composition. Dictation and predictive-text picks were not exercised. The JS-appends-drows pass withabcddef(bothds), or withabcdefwhen the user'sdreached JS before JS's append was written (JS's append then never applies, by React Native's event-count rule).Known limitation. An edit that UIKit delivers inside the restore's own edit (step 5) and that starts before the end of the range the restore replaces is dropped. For an autocorrection or predictive replacement that means the correction is not applied and no key is lost. For a backspace, one backspace is lost:
abc, thendtyped during a restyle and a backspace delivered inside the edit that putsdback, leavesabcdinstead ofabc. Applied, that backspace would delete the character before the caret, which is text the edit is about to replace, and the edit would then replace other text (on the unguarded code:abdwith the caret at 0 in that case, and in the middle ofabcde, two backspaces gaveabe, deletingdinstead ofb). Keeping the backspace would need a second edit after the restore (another undo step, and another point where UIKit can deliver keys). It needs two keys queued behind one write, the second inside the restore; the app runs never produced it (keys inside the restore were seen once in 5 launches of themaxLengthrow, and that was a character). Unit tests pin the dropped backspace.Long text: 30 keys into a 9,990-character multiline input take the same time on all three builds, dominated by XCUITest.
RCTTextInputChangedRangeruns only when keys arrive during a write.Supporting evidence (not
main). On 0.81.5 (an app carrying both PRs as a patch, regenerated from this branch's head): a lab with controlled set/clear/restyle, transforms and length limits: all 7 summaries exact (controlled set/clear 55/55, set 10/10, upper-casing 20/20, slash-stripping 20/20,maxLength10/10 and 10/10, and the first-character case/private/rn-device-testinto an empty field 20/20).Why there is no Maestro flow: see #59197 (Maestro's
inputTextwaits for each key, so no key arrives during a write; the flows pass on unpatchedmain).Scope. iOS Fabric only. Android: the reproducer 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. Semantically the two fit together:
-_setAttributedString:andsetTextAndSelectionreturn early). Then no write happens, no key is delivered inside one, and this PR has nothing to restore.maxLengthafter composition in-textInputDidChange, with a write wrapped in_comingFromJS. That write is not a JS update for this PR (_textBeforeUpdateFromJSis nil), so nothing is recorded or restored for it.-_updateTypingAttributes. This PR moves the sampling in that method into-_updateTypingAttributesSampledAt:, which the restore calls too: Fix iOS TextInput IME composition issues for CJK languages #56082's stripping goes into that helper, and restored keys then get the same stripped attributes as typed ones.Order: #59197, then this PR, then #56082 (or #56082 first, then these). Whichever lands second rebases with textual conflicts in
textInputShouldChangeText:inRange:(both touch themaxLengthblock: keep #56082's!markedTextRangecondition aroundRCTTextInputTextWithinLength),textInputDidChange(keep this PR's_comingFromJSbranch first, then #56082's deferred work),-_updateTypingAttributes(put #56082's stripping into-_updateTypingAttributesSampledAt:) andsetTextAndSelection(keep #56082'smarkedTextRangeguards around the write; this PR's_beginUpdateFromJS/restore wrap the whole command).Related: #58904 (with #59197), #33947 (fast typing vs clearing: keys delivered while a clear is written are kept on top of the empty text; not tested against that issue's reproducer), #47252 / #38155 (validation in
onChangeText; JS still decides, and now sees every key), #37502 (two styled<Text>children), #56082 (above), #56658 (abandoned).🤖 Generated with Claude Code