Skip to content

fix(ios): don't write a TextInput's own text back into the focused field (#58904) - #59197

Open
mjerris wants to merge 1 commit into
react:mainfrom
mjerris:fix/textinput-skip-noop-writes
Open

mjerris wants to merge 1 commit into
react:mainfrom
mjerris:fix/textinput-skip-noop-writes

Conversation

@mjerris

@mjerris mjerris commented Oct 19, 2026 •

Copy link
Copy Markdown

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, letterSpacing or Dynamic Type, right-to-left text, emoji, an IME commit, a maxLength formatter, and typing after autocorrect, after a controlled selection or 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; -textInputDidChange ignores them because _comingFromJS is set, and the write then replaces them. The keys are inserted and immediately overwritten (they still fire onKeyPress).

Many of these writes change nothing visible. RCTIsAttributedStringEffectivelySame reports a difference because UIKit decorates the text a field shows with attributes the state does not carry:

  • a controlled value equal 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);
  • the first character of an empty field gets the input's default text attributes (background color, event emitter) after it was sent, so the state that echoes it no longer compares equal;
  • a single-line field (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 takes singleLine: 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 with NO), 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 a lineBreakModeIOS that JS changes 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 the insensitive attributes): typed text can carry either paragraph style, and with both accepted this PR never writes where main does 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 kept private/rn-device-test in 5 of 10 launches (the others read pvate/rn-device-test), and 3 of 5 on 0.87.1. No commit between 0.87.1 and main changes 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 on main does 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, letterSpacing or 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 type private/rn-device-test as one fast burst (XCUITest typeText, 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. On main, the controlled fields lose characters; with this PR all three fields kept every key in 5 of 5 launches (XCUITest typeText, same build as the table below).

Unit tests, in the React-RCTFabric test spec:

  • RCTAttributedTextUtilsTest: 10 new cases for RCTIsAttributedStringEffectivelySame (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 through updateState:oldState: into a real RCTTextInputComponentView): the backed text field delivers keys inside -setAttributedText: as UIKit does, and its keys go through textInputShouldChangeText: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 with main's view); neither is a state without a paragraph style over typed text that has UIKit's initial typing paragraph style (main does 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 (RCTAttributedTextUtilsTest 34, RCTTextInputComponentViewTests 8, RCTTextInputUtilsTest 2); 26 of 26 on main. 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: the lineBreakModeIOS change 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 accepting main's comparison (the initial typing paragraph style).

To run them: the test spec is not built by OSS CI. Locally, add :testspecs => ['Tests'] to React-RCTFabric in scripts/cocoapods/fabric.rb, limit test_spec.source_files in React-RCTFabric.podspec to Tests/Text/{RCTTextInput,RCTAttributedTextUtils}*.mm (other files under Tests/ do not compile under CocoaPods), bundle exec pod install in packages/rn-tester, then xcodebuild 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). 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; 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 plain UITextField undoes the whole typing run (""), and with this PR the controlled input does the same (on main the 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), onChangeText transforms, 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 where main does 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 (main accepted 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 on main, 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 on main, 0/5 here; emoji: 0/5 and 2/5). Autocorrect: the Simulator did not autocorrect XCUITest input (teh cat stays); the row checks that no key is lost. The multiline JS-append row passes on main already; it is there because an earlier revision of the follow-up duplicated the appended text. The JS-appends-d rows 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's inputText sends 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:, the defaultTextAttributes setters).

  • Fix iOS TextInput IME composition issues for CJK languages #56082 strips the no-op NSShadow, the transparent NSBackgroundColor and the event emitter from the attributes it gives UIKit (typingAttributes, and -[RCTUITextField setDefaultTextAttributes:] before super). Typed text then lacks them. This PR treats the input's defaultTextAttributes as insensitive, and defaultTextAttributes still 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.
  • Fix iOS TextInput IME composition issues for CJK languages #56082 skips -_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 markedTextRange guards 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 attributedText reassignment 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

…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
@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