Skip to content

fix(ios): keep the keys typed while TextInput applies text from JS (#58904) - #59198

Open
mjerris wants to merge 2 commits into
react:mainfrom
mjerris:fix/textinput-keep-keys-during-writes
Open

mjerris wants to merge 2 commits into
react:mainfrom
mjerris:fix/textinput-keep-keys-during-writes

Conversation

@mjerris

@mjerris mjerris commented Oct 19, 2026

Copy link
Copy Markdown

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 RCTTextInputComponentView writes JS's text into the focused field, UIKit delivers the keystrokes queued behind that -setAttributedText: inside it. -textInputDidChange ignores them because _comingFromJS is 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:

  • a TextInput whose text is a nested <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;
  • onChangeText transforming the text (upper-casing, stripping characters): the controlled value differs from the field, and TextInput applies it with the setTextAndSelection command, which writes it the same way;
  • typing in the middle of either.

Fix. Keep the keys the write would drop:

  1. A key that goes through -textInputShouldChangeText:inRange: during a write marks the write, and the change UIKit reports right after it is recorded. Only those changes are recorded: a multiline RCTUITextView also reports the write itself (through textViewDidChangeSelection:), and that is JS's text, not typing.
  2. After the write (-_setAttributedStringFromJS: from updateState:oldState:, and the end of setTextAndSelection), -_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 types d while JS appends d: both are kept).
  3. Where the edit goes is computed by 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 through CFStringInlineBuffer rather than copying them. The new helpers are declared in RCTTextInputUtils.h, next to RCTCopyBackedTextInput, the other helper of this view that is unit-tested through that header.
  4. The restore inserts with -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 -_updateTypingAttributes now calls too, so restored keys and typed keys follow one attribute policy.
  5. UIKit can deliver more queued keys inside the restore's own -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.
  6. Selection: the selection after the write is mapped through the restored edit (RCTTextInputOffsetAfterEdit). For setTextAndSelection that 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.
  7. Keys a text view delivers after the new text was already assigned are still in the field: they are only reported, not inserted again, and the caret, which the write had put back before them, goes after them.

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):

  1. 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, 123456789 into a maxLength={5} formatter ended at 12-345 in 1 of 5 launches).
  2. IME: if a key during the write left marked text, or text is marked when the write ends, nothing is restored or reported, so the restore never commits a composition as plain text. A composition UIKit starts inside the restore's own -replaceRange:withText: (step 5) is committed by that call: UIKit's replaceRange commits marked text (a unit probe: abc, d typed during a restyle, か composed inside the restore's edit, ends as abcdか, 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>, onChangeText transforms) on the New Architecture

Test 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 a maxLength={5} field that formats digits as 12-34. Release build on a Simulator: type private/rn-device-test quickly 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 type abc and press the button: it must read abc def once. Type 123456789 quickly into the formatted field: it must read 12-34 and 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 (RCTAttributedTextUtilsTest 34, RCTTextInputComponentViewTests 41, RCTTextInputUtilsTest 24).

  • 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 real RCTTextInputComponentView in a window, whose backed RCTUITextField or RCTUITextView delivers 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:, so onKeyPress and maxLength run as for real typing; a subclass records what the view reports to JS):
    • keys typed while JS restyles (they take JS's style, caret after them, reported as onChange then onSelectionChange), 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;
    • text JS inserts with no key typed is not duplicated: multiline focused and unfocused, through setTextAndSelection and 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;
    • a composition started during a write is not committed as plain text (the field holds JS's text, nothing marked); one that arrives after the write is left composing, not restored or reported;
    • keys UIKit delivers inside the restore's own edit: kept and reported once, with the caret after them, so the next key follows them (single line, multiline, and after 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);
    • restored keys are one undoable edit.
  • 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), no onSelectionChange after a restore (3, and 1 for keys that arrived after the assignment), the restore's own edit reported to JS as well (2), no maxLength cut (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 -_updateTypingAttributes share 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 on a91468ffe6, and the PR 1 + PR 2 column one run on this head (7ce6cd3bbc). The main column was measured at 27e77216ef. main then moved to 51d3e67af8 (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, controlled lineHeight 1/5, Hebrew 1/5, nested <Text> 0/5, upper-casing 0/5, slash-stripping 0/5, single-line JS append 1/5, maxLength formatter 1/5, JS appends d 1/5.

case main PR 1 PR 1 + PR 2
uncontrolled, onChangeText 5/5 5/5 5/5
uncontrolled, lineHeight 5/5 5/5 5/5
the issue's field: uncontrolled, number-pad, placeholder, lineHeight (burst 28) 5/5 5/5 5/5
controlled 1/5 5/5 5/5
controlled, lineHeight 0/5 5/5 5/5
controlled, letterSpacing: 3 0/5 5/5 5/5
controlled, textAlign: center 5/5 5/5 5/5
controlled, textAlign: right 5/5 5/5 5/5
controlled, Hebrew, writingDirection: rtl 0/5 5/5 5/5
controlled, allowFontScaling (Dynamic Type) 0/5 5/5 5/5
secureTextEntry, controlled 5/5 5/5 5/5
secureTextEntry, uncontrolled 5/5 5/5 5/5
nested <Text> 1/5 0/5 5/5
nested <Text>, multiline 5/5 5/5 5/5
emoji/ZWJ/flag/skin tone/combining mark, uncontrolled 5/5 5/5 5/5
same, controlled 0/5 5/5 5/5
same, nested <Text> 0/5 2/5 5/5
IME composition に→にほ→にほん, commit, then go: uncontrolled 5/5 5/5 5/5
same, controlled 1/5 5/5 5/5
same, nested <Text> restyled every 250 ms 4/5 5/5 5/5
paste, then xyz typed right after: uncontrolled 5/5 5/5 5/5
same, controlled 5/5 5/5 5/5
same, nested <Text> 5/5 5/5 5/5
autocorrect on, teh then cat: controlled 0/5 5/5 5/5
same, nested <Text> 0/5 0/5 5/5
onChangeText upper-cases; caret to 5, x, y 0/5 0/5 5/5
onChangeText strips / 0/5 0/5 5/5
controlled selection prop moves the caret, then a key 0/5 5/5 5/5
caret moved to 5, XY: uncontrolled 4/5 5/5 5/5
same, controlled 1/5 5/5 5/5
same, nested <Text> 0/5 0/5 5/5
multiline controlled, abc, then JS appends def 5/5 5/5 5/5
single-line controlled, abc, then JS appends def 2/5 5/5 5/5
controlled, JS appends d to abc while def is typed 0/5 5/5 5/5
same, multiline 5/5 5/5 5/5
maxLength={5}, formatter 12-34, 123456789 typed 0/5 5/5 5/5
multiline growing with its content 5/5 5/5 5/5
9,990-character multiline, 30 keys: uncontrolled 5/5 (0.78s) 5/5 (0.78s) 5/5 (0.77s)
same, controlled 5/5 (0.78s) 5/5 (0.78s) 5/5 (0.78s)
undo (hello, pause, world, undo): uncontrolled "" ×5 "" ×5 "" ×5
same, controlled "h" ×4, "he" "" ×5 "" ×5
same, nested <Text> "h" ×5 "h" ×5 "hel" ×5
same, plain UITextField (reference) "" ×5 "" ×5 "" ×5
"Typing round trip" field main PR 1 PR 1 + PR 2
uncontrolled (lineHeight) n/a 5/5 5/5
controlled n/a 5/5 5/5
controlled, lineHeight n/a 5/5 5/5
nested <Text> n/a n/a 5/5
nested <Text>, multiline n/a n/a 5/5
upper-casing n/a n/a 5/5
slash-stripping n/a n/a 5/5
multiline, abc then "Append ' def'" n/a n/a 5/5
maxLength={5} formatter, 123456789 n/a n/a 5/5

(main has 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; main 0/5), and a key typed while JS appends the same character (main 0/5 single line). No case is worse than main. 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 plain UITextField does. 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 plain UITextField or UITextView does the same when an app sets attributedText while 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: on main that leaves h; with this PR hel ×5, and a second undo leaves h. 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 with el typed during it, then lo world: undo gives hel, then h). -insertText: would join them (undo gives h, as on main), 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 the maxLength row) 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, giving tehcat in 2 of 5; fixed, and covered by a unit test). IME composition was driven with setMarkedText/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-d rows pass with abcddef (both ds), or with abcdef when the user's d reached 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, then d typed during a restyle and a backspace delivered inside the edit that puts d back, leaves abcd instead of abc. 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: abd with the caret at 0 in that case, and in the middle of abcde, two backspaces gave abe, deleting d instead of b). 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 the maxLength row, 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. RCTTextInputChangedRange runs 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, maxLength 10/10 and 10/10, and the first-character case /private/rn-device-test into an empty field 20/20).

Why there is no Maestro flow: see #59197 (Maestro's inputText waits for each key, so no key arrives during a write; the flows pass on unpatched main).

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:

Order: #59197, then this PR, then #56082 (or #56082 first, then these). Whichever lands second rebases with textual conflicts in textInputShouldChangeText:inRange: (both touch the maxLength block: keep #56082's !markedTextRange condition around RCTTextInputTextWithinLength), textInputDidChange (keep this PR's _comingFromJS branch first, then #56082's deferred work), -_updateTypingAttributes (put #56082's stripping into -_updateTypingAttributesSampledAt:) and setTextAndSelection (keep #56082's markedTextRange guards 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

mjerris and others added 2 commits October 19, 2026 08:53
…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
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 19, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 19, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant