Repository navigation
Commit e10d6ba
Fix off-center caret and text in a recycled TextInput on iOS (#59190)
Summary:
Fixes #59184.
On iOS, a single-line `TextInput` can 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 a `TextInput` with 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. `UITextField` answers 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 through `prepareForRecycle`. 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 `UITextField` that 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, because `UITextView` has no separate field editor and #38679 relies on the restore and scroll there. The `selection` prop is still applied as before.
Both ways the bug shows up go through `_setAttributedString:`:
- **Value from state on mount:** on `Insert`, `updateState:` runs before `updateLayoutMetrics:`, so a recycled view still has the previous owner's frame when it gets the new value.
- **Controlled value from JS:** `setTextAndSelection` sets the value while the field isn't focused. JS passes `-1` when there's no `selection` prop, 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 `TextInput` drawn above center while focused, after its view was reused from a `TextInput` with a larger font
Pull Request resolved: #59190
Test 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 `textInputDidBeginEditing` that prints the field editor's frame, `contentSize` and `contentOffset`. The log is not part of this change.
<details>
<summary>RNTesterPlayground.js</summary>
```js
function SingleLineField({autoFocus}) {
return (
<View style={styles.field}>
<TextInput style={styles.singleLine} autoFocus={autoFocus} placeholder="Type here" />
<View pointerEvents="none" style={styles.centerLine} />
</View>
);
}
function ControlledPrice() {
const [value, setValue] = useState('');
return (
<View>
<TextInput value={value} style={styles.price} />
<Button title="Set 32 pt value" onPress={() => setValue('2 000 Kč')} />
</View>
);
}
function Playground() {
const [step, setStep] = useState('none');
return (
<View style={styles.container}>
<Text>Reference, mounted once:</Text>
<SingleLineField />
<Button title="1. 17 pt" onPress={() => setStep('field')} />
<Button title="2. 32 pt value" onPress={() => setStep('price')} />
<Button title="2b. 32 pt controlled" onPress={() => setStep('priceControlled')} />
<Button title="3. 17 pt reused" onPress={() => setStep('reused')} />
{step === 'field' && <SingleLineField key="field" />}
{step === 'price' && <TextInput key="price" value="2 000 Kč" style={styles.price} />}
{step === 'priceControlled' && <ControlledPrice key="priceControlled" />}
{step === 'reused' && <SingleLineField key="reused" autoFocus />}
</View>
);
}
const styles = StyleSheet.create({
container: {padding: 10, gap: 12},
field: {height: 44, borderWidth: 1, borderColor: '#ccc', borderRadius: 8},
singleLine: {height: 42, paddingHorizontal: 16, fontSize: 17},
centerLine: {position: 'absolute', left: 0, right: 0, top: 20.5, height: 1, backgroundColor: 'red'},
price: {height: 40, fontSize: 32, fontWeight: '800', textAlign: 'center'},
});
```
</details>
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.
| Run | Before | After |
| --- | --- | --- |
| 1 → 2 → 3 (value from state) | `contentOffset {0, 4.67}`, `contentSize {348, 42}` | `contentOffset {0, 0}` |
| 1 → 2b → set value → 3 (controlled value) | `contentOffset {0, 5.67}`, `contentSize {348, 42}` | `contentOffset {0, 0}` |
<img width="2452" height="795" alt="before-after" src="https://github.com/user-attachments/assets/f3307829-ed8f-4ace-bfbd-0be336b047b2" />
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:
- **Focused field, value changed from JS:** a controlled field that uppercases its text in `onChangeText`, with the caret at index 2. Typing "z" and "y" gave `ABZYCDEF` with selection 4-4, so the caret is still restored while editing.
- **Unfocused field, value changed from JS:** caret at index 2, blur, prepend "X" from JS, then `focus()`. The caret is at the end of the text, with and 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 **
node scripts/clang-format.js --check \
packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
# no output, exit code 0
```
I didn't add a unit test. `React/Tests` isn'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 `selection` prop, `clear()`, or `setSelection()`) still goes through `setSelectedTextRange:`. 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.
Reviewed By: Abbondanzo
Differential Revision: D126072650
Pulled By: cipolleschi
fbshipit-source-id: 68c540fd4a9ed327e0e38fba34d88db4fc0ca7ad1 parent fa2baa2 commit e10d6ba
1 file changed
Lines changed: 19 additions & 13 deletions
File tree
- packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
796 | 796 | | |
797 | 797 | | |
798 | 798 | | |
799 | | - | |
800 | | - | |
801 | | - | |
802 | | - | |
803 | | - | |
804 | | - | |
805 | | - | |
806 | | - | |
807 | | - | |
808 | | - | |
809 | | - | |
810 | | - | |
811 | | - | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
812 | 818 | | |
813 | 819 | | |
814 | 820 | | |
| |||
0 commit comments