Repository navigation
Fix off-center caret and text in a recycled TextInput on iOS - #59190
Closed
jkasprzyk17 wants to merge 1 commit into
Closed
jkasprzyk17 wants to merge 1 commit into
jkasprzyk17 wants to merge 1 commit into
Conversation
When text is set from JS, `_setAttributedString:` restored the caret even when the input was not focused. `UITextField` scrolls its field editor to the caret on every selection change. If the text is taller than the field, the editor scrolls, keeps that offset after the text fits again, and keeps it through view recycling. The next input that reuses the view then draws its caret and typed text too high while it is focused. Restore the caret only when a single-line input is first responder. UIKit places the caret when editing begins, so an input that is not being edited has no caret to keep. Multiline inputs are unchanged. Fixes react#59184
cipolleschi
approved these changes
Oct 19, 2026
|
@cipolleschi has imported this pull request. If you are a Meta employee, you can view this in D126072650. |
Collaborator
|
This pull request was successfully merged by @jkasprzyk17 in e10d6ba When will my fix make it into a release? | How to file a pick request? |
|
@cipolleschi merged this pull request in e10d6ba. |
meta-codesync Bot
pushed a commit
that referenced
this pull request
Oct 22, 2026
Summary: Fixes #59132. On iOS, a single-line `TextInput` with a `placeholder` and no `lineHeight` can draw its placeholder below the field's vertical centre. It happens when the field's native view was last used by a `TextInput` with a `lineHeight` (Fabric recycles `RCTTextInputComponentView`s), and when a `lineHeight` is removed from a mounted single-line field. Typed text is not affected. UITextField's placeholder label keeps the paragraph style of the previous `attributedPlaceholder` and applies it to a new placeholder that has none. `RCTNSTextAttributesFromTextAttributes` only adds an `NSParagraphStyle` when an alignment, writing direction, line break mode or strategy, or line height is set, so once the `lineHeight` is gone the new placeholder has no paragraph style, and the label keeps measuring and drawing it with the old line height. Setting `attributedPlaceholder` to `nil` first doesn't reset it. This change gives the placeholder a paragraph style of its own when the text attributes have none: an `NSMutableParagraphStyle` with `lineBreakMode = NSLineBreakByTruncatingTail`, which is what a fresh placeholder label uses. A paragraph style from the text attributes is used as before. In a right-to-left layout `RCTNSTextAttributesFromTextAttributes` always adds a paragraph style, so the new one only applies to left-to-right layouts without `textAlign`. In a plain UIKit test on the simulator, compared with a fresh `UITextField`, the placeholder renders to the same pixels (English and Hebrew text, with and without `semanticContentAttribute` forced to right-to-left), and `sizeWithAttributes:`, which `intrinsicContentSize` uses, returns the same size. The multiline `RCTUITextView` is unchanged: its placeholder is a plain `UILabel`, which doesn't keep the previous paragraph style. Related: #56487 changes `_placeholderTextAttributes` for fields whose `lineHeight` is larger than the font; this change is about fields without a `lineHeight`. #59184 (fixed in #59190) is another piece of state that outlives the recycling of `RCTTextInputComponentView`. #57748 and #58355 fixed other properties lost on the same single-line/multiline view swap. ## Changelog: [IOS] [FIXED] - Fix `TextInput` placeholder drawn below centre after its view is reused from a `TextInput` with `lineHeight` Pull Request resolved: #59195 Test Plan: Added `RCTUITextFieldTests` with two cases: - the placeholder loses a line height that was removed from the text attributes - a paragraph style from the text attributes is kept for the placeholder `React/Tests` isn't compiled by any open source target, so I added the file to the `RNTesterUnitTests` target locally (not part of this change) and ran it on an iPhone 17 Pro simulator, iOS 26.5: - without the fix: `testPlaceholderDropsLineHeightRemovedFromTextAttributes` fails (`((paragraphStyle) != nil) failed`), the other test passes - with the fix: `** TEST SUCCEEDED **`, both tests pass I tested manually in RNTester (Debug, new architecture, iPhone 17 Pro simulator, iOS 26.5) with this Playground code: <details> <summary>RNTesterPlayground.js</summary> ```js function Field({label, children}) { return ( <View style={styles.field}> <RNTesterText style={styles.label}>{label}</RNTesterText> <View style={styles.box}> {children} <View pointerEvents="none" style={styles.centreLine} /> </View> </View> ); } function Playground() { const [stage, setStage] = useState(0); const [lineHeight, setLineHeight] = useState(true); return ( <View style={styles.container}> <Field label="Reference (fresh view)"> <TextInput placeholder="Search…" style={styles.singleLine} /> </Field> <Button title="1. Mount multiline" onPress={() => setStage(1)} /> <Button title="2. Unmount it" onPress={() => setStage(2)} /> <Button title="3. Mount single-line" onPress={() => setStage(3)} /> {stage === 1 ? ( <TextInput key="multiline" multiline style={{fontSize: 17, lineHeight: 34, borderWidth: 1}} /> ) : null} {stage === 3 ? ( <Field label="Single-line (reused view)"> <TextInput key="singleline" placeholder="Search…" style={styles.singleLine} /> </Field> ) : null} <Button title={lineHeight ? 'Remove lineHeight' : 'Add lineHeight'} onPress={() => setLineHeight(value => !value)} /> <Field label={`Single-line, lineHeight ${lineHeight ? '34' : 'unset'}`}> <TextInput placeholder="Search…" style={[styles.singleLine, lineHeight ? {lineHeight: 34} : null]} /> </Field> </View> ); } const styles = StyleSheet.create({ box: {borderColor: '#888', borderWidth: StyleSheet.hairlineWidth}, centreLine: { backgroundColor: 'red', height: 1, left: 0, position: 'absolute', right: 0, top: 20.5, }, container: {gap: 8, padding: 10}, field: {gap: 4}, label: {fontSize: 12}, singleLine: {fontSize: 17, height: 42, paddingHorizontal: 12}, }); ``` </details> Steps: tap **1**, **2** and **3**. The single-line field mounted in step 3 reuses the view of the multiline field from step 1. Then tap **Remove lineHeight**. | Before | After | | --- | --- | | <img width="300" alt="59132-before" src="https://github.com/user-attachments/assets/34076c45-1736-4af2-98d4-396b57f82400" /> | <img width="300" alt="59132-after" src="https://github.com/user-attachments/assets/1c995d1c-9bfb-4efd-bbb1-c87a04e47d19" /> | Before: the placeholder of the reused field and the placeholder of the field whose `lineHeight` was removed sit below the red centre line. After: both are centred like the reference field. Tapping **Add lineHeight** again gives the field its `lineHeight` placeholder back, the same as without this change. Commands: ```sh yarn install yarn --cwd packages/react-native-codegen build cd packages/rn-tester pod install pod update hermes-engine --no-repo-update # Podfile.lock pinned an older Hermes cd ../.. xcodebuild -workspace packages/rn-tester/RNTesterPods.xcworkspace -scheme RNTester -configuration Debug \ -destination 'platform=iOS Simulator,id=<iPhone 17 Pro, iOS 26.5 simulator UDID>' build # ** BUILD SUCCEEDED ** xcodebuild test -workspace packages/rn-tester/RNTesterPods.xcworkspace -scheme RNTester -configuration Debug \ -destination 'platform=iOS Simulator,id=<iPhone 17 Pro, iOS 26.5 simulator UDID>' \ -only-testing:RNTesterUnitTests/RCTUITextFieldTests # with RCTUITextFieldTests.mm added to the target locally # ** TEST SUCCEEDED ** node scripts/clang-format.js --check \ packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm \ packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm # no output, exit code 0 ``` Reviewed By: christophpurrer Differential Revision: D126309241 Pulled By: cipolleschi fbshipit-source-id: 04ffa2930e3884f7a60d2dd9407d779ffc5f7775
jkasprzyk17
added a commit
to jkasprzyk17/portfolio
that referenced
this pull request
Oct 22, 2026
- Add react-native-better-pdf, @gmisoftware/react-native-pay and better-skills to Open Source, and the first two to the GMI role - Add the merged React Native core fix (react/react-native#59190) as a contribution; open PRs stay out until they land - Correct Miniliga's stack (ASP.NET Core, SignalR and PostgreSQL, not Node.js) and Łatwa Ustna's (AssemblyAI, Algolia, Sentry, PostHog) - Add Bachelor of Engineering in Applied Computer Science at Politechnika Krakowska - Replace em dashes in copy, page titles and comments
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:
Fixes #59184.
On iOS, a single-line
TextInputcan draw its caret and typed text a few points above its vertical center while it is focused. This happens when its native view was last used by aTextInputwith a larger font that was given a value, and it lasts for the life of the view.When text is set from JS,
_setAttributedString:restores the caret position, even when the field is not focused.UITextFieldanswers every selection change by scrolling its field editor to the caret. If the text is taller than the field, the editor scrolls even though nobody is editing it. In the issue's reproducer the 32 pt value leaves a vertical offset of 4.67 pt. The editor keeps that offset after the text fits again, and keeps it throughprepareForRecycle. The next field that gets the recycled view edits through that editor, so its text is drawn too high.This change restores the caret only when a single-line input is first responder. A
UITextFieldthat is not being edited has no caret to keep, because UIKit places the caret when editing begins. I checked this in RNTester: in a field that was blurred with the caret at index 2 and whose value then changed from JS,focus()puts the caret at the end, with and without this change. Multiline inputs keep the current behavior, becauseUITextViewhas no separate field editor and #38679 relies on the restore and scroll there. Theselectionprop is still applied as before.Both ways the bug shows up go through
_setAttributedString::Insert,updateState:runs beforeupdateLayoutMetrics:, so a recycled view still has the previous owner's frame when it gets the new value.setTextAndSelectionsets the value while the field isn't focused. JS passes-1when there's noselectionprop, so only_setAttributedString:changes the selection. This happens even with the correct frame when the text is taller than the field.Changelog:
[IOS] [FIXED] - Fix caret and text of a single-line
TextInputdrawn above center while focused, after its view was reused from aTextInputwith a larger fontTest Plan:
I reproduced the issue in RNTester with a port of the reproducer from the issue (Debug, new architecture, iPhone 17 Pro simulator, iOS 26.5). While testing, I added a temporary log in
textInputDidBeginEditingthat prints the field editor's frame,contentSizeandcontentOffset. The log is not part of this change.RNTesterPlayground.js
Steps: tap 1, then 2 (or 2b and "Set 32 pt value"), then 3, and type. The field from step 3 reuses the view of the 32 pt field. I relaunched the app before each run so the recycle pool was empty.
contentOffset {0, 4.67},contentSize {348, 42}contentOffset {0, 0}contentOffset {0, 5.67},contentSize {348, 42}contentOffset {0, 0}Before: the typed text sits above the red center line. After: it is centered, like the placeholder of the reference field.
I also checked that editing still works with this change:
onChangeText, with the caret at index 2. Typing "z" and "y" gaveABZYCDEFwith selection 4-4, so the caret is still restored while editing.focus(). The caret is at the end of the text, with and without this change.Commands:
I didn't add a unit test.
React/Testsisn't compiled by any open source target, and the field editor is private to UIKit and attached only while the field is editing. A test would need a window and a first responder to see the offset.Not covered: a selection that is set explicitly on a field that isn't focused (a non-empty
selectionprop,clear(), orsetSelection()) still goes throughsetSelectedTextRange:. On a large-font field it could still leave an offset behind. Those calls ask for a specific selection, so skipping them would change what they do, and I left them out of this PR.