Skip to content

Fix Multiline TextInput with a fixed height scrolls to the bottom when prepending new lines to the text - #38679

Closed
fabOnReact wants to merge 26 commits into
react:mainfrom
fabOnReact:fix-input-field-scrolls-up
Closed

fabOnReact wants to merge 26 commits into
react:mainfrom
fabOnReact:fix-input-field-scrolls-up

Conversation

@fabOnReact

@fabOnReact fabOnReact commented Jul 29, 2023 •

Copy link
Copy Markdown
Contributor

Summary:

Please re-state the problem that we are trying to solve in this issue.

Multiline TextInput with a fixed height will scroll to the bottom of the screen when prepending new lines to the text.

What is the root cause of that problem?

The issue is caused by iOS UITextView:

  • The cursor moves to the end of the text when prepending new lines.
  • Moving the cursor to the end of the text triggers the scroll to the bottom.

The behavior was reproduced on an iOS App (without react-native).
The example included below implements a Component RCTUITextView based on UITextView, which modifies the UITextView attributedText with the textViewDidChange callback (source code available in this comment).

Adding a new line on top of the UITextView on iOS results in:
Issue 1) The cursor moves to the end of TextInput text
Issue 2) The TextInput scrolls to the bottom

Reproducing the issue on an iOS App without react-native

2023-06-17.18-16-16.mp4

Issue 1) is already fixed in react-native, which restores the previous cursor position (on Fabric with _setAttributedString) after changing the text.
Issue 2) needs to be fixed in react-native.

What changes do you think we should make in order to solve the problem?

Setting the correct TextInput scroll position after re-setting the cursor.

Changelog:

[IOS] [FIXED] - Fix Multiline TextInput with a fixed height scrolls to the bottom when changing AttributedText

Test Plan:

Fabric (reproduces on controlled/not controlled TextInput example):

Before After
Simulator.Screen.Recording.-.iPhone.14.Pro.-.2023-07-29.at.11.57.04.mp4
Simulator.Screen.Recording.-.iPhone.14.Pro.-.2023-07-29.at.11.55.11.mp4

Paper (reproduces only on controlled TextInput example):

function TextInputExample() {
  const [text, setText] = React.useState('');

  return (
    <View style={{marginTop: 200}}>
      <TextInput
        style={{height: 50, backgroundColor: 'white'}}
        multiline={true}
        value={text}
        onChangeText={text => {
          setText(text);
        }}
      />
    </View>
  );
}
Before After
Simulator.Screen.Recording.-.iPhone.14.Pro.-.2023-07-29.at.13.05.48.mp4
Simulator.Screen.Recording.-.iPhone.14.Pro.-.2023-07-29.at.13.09.48.mp4

@facebook-github-bot facebook-github-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 Jul 29, 2023
@analysis-bot

analysis-bot commented Jul 29, 2023 •

Copy link
Copy Markdown
Platform Engine Arch Size (bytes) Diff
android hermes arm64-v8a 17,885,642 -1
android hermes armeabi-v7a n/a --
android hermes x86 n/a --
android hermes x86_64 n/a --
android jsc arm64-v8a 21,240,040 +1
android jsc armeabi-v7a n/a --
android jsc x86 n/a --
android jsc x86_64 n/a --

Base commit: 475a156
Branch: main

@fabOnReact fabOnReact changed the title Fix input field scrolls up when tap enter in 1st line of text Fix iOS TextInput scrolls up when tap on 1st line of text Jul 29, 2023
@fabOnReact fabOnReact changed the title Fix iOS TextInput scrolls up when tap on 1st line of text Fix Multiline TextInput with a fixed height scrolls to the bottom when prepending new lines to the text Jul 29, 2023
@fabOnReact
fabOnReact marked this pull request as ready for review July 29, 2023 07:33
@facebook-github-bot facebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Jul 29, 2023
@NickGerleman

Copy link
Copy Markdown
Contributor

Would this also prevent scroll when adding to the end of the list?

@fabOnReact
fabOnReact marked this pull request as draft August 17, 2023 11:48
@fabOnReact

Copy link
Copy Markdown
Contributor Author

Thanks for the code review. I moved the PR to draft. I'm working on a solution for this issue.

@fabOnReact

Copy link
Copy Markdown
Contributor Author
Simulator.Screen.Recording.-.iPhone.14.Pro.-.2023-08-19.at.09.35.06.mp4

@fabOnReact

Copy link
Copy Markdown
Contributor Author

Thanks for the code-review @NickGerleman. I updated the PR and I included a video test in this comment.

@fabOnReact
fabOnReact marked this pull request as ready for review August 20, 2023 03:10
Comment on lines +597 to +606
BOOL previousScrollEnabled = _backedTextInputView.scrollEnabled;
NSInteger previousOffsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument
toPosition:selectedRange.start];
if (previousScrollEnabled && previousOffsetStart > 0 && attributedString.string.length >= previousOffsetStart) {
NSString *lastChar = [attributedString.string substringWithRange:NSMakeRange(previousOffsetStart - 1, 1)];
NSInteger previousOffsetFromEnd = oldTextLength - previousOffsetStart;
if (previousOffsetFromEnd > 0 && [lastChar isEqual:@"\n"]) {
_backedTextInputView.scrollEnabled = NO;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain this heuristic? Is it specific to adding newlines?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @NickGerleman

Could you explain this heuristic?

https://github.com/facebook/react-native/pull/38679/files#diff-65bdfd8888173755da0fd38dae8c70ba1561c11ec453e690195a2f327fa6b62bR603

Scrolling is temporarily disabled when prepending a new line character to the attributed string:

  • the cursor is not at the end of the string (previousOffsetFromEnd == 0)
  • the last typed character (lastChar) is a new line character

Is it specific to adding newlines?

Yes. It is specific to adding new lines.

It is iOS issue 652653, previously addressed with commit Maintain cursor position when the text changes in the text input.

  1. Adding a new line changes the text spans and paragraph alignment. The old attributed string may have three spans with different paragraph alignments. The new attributed string could have one span with paragraph alignment 4 (see the test case below for a breakdown).
  2. Changing the AttributedText (text) attributes (alignment) triggers a mutation of the attributed text (_textOf)
  3. Mutating the AttributedString causes the cursor to move to the end of the string
  4. Moving the cursor to the end of the string causes the TextView to scroll to the end

https://developer.apple.com/forums/thread/652653
https://stackoverflow.com/questions/34914948/how-to-stop-cursor-changing-position-when-i-setattributedtext-in-uitextview-dele

Reproducing the issue on an iOS App without react-native

2023-06-17.18-16-16.mp4

Sourcecode available at Expensify/App#19507 (comment).

Details of the changes in the AttributedString attributes (paragraph alignment) when adding new line

Before one of the AttributedString spans has NSParagraphStyle Alignment 0. The string text is "1\n\n2".

CLICK TO OPEN ATTRIBUTED STRING DETAILS

Printing description of oldAttributedString->attributes:
Length 5 (8 blocks, 3 used, block 0 is at 0)
 2 0x600002e70230 {
    NSBackgroundColor = "UIExtendedGrayColorSpace 0 0";
    NSColor = "UIExtendedGrayColorSpace 0 1";
    NSFont = "<UICTFont: 0x130457dc0> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}
 1 0x600002f9d5e0 {
    NSBackgroundColor = "UIExtendedGrayColorSpace 0 0";
    NSColor = "UIExtendedGrayColorSpace 0 1";
    NSFont = "<UICTFont: 0x130457dc0> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
    NSParagraphStyle = "Alignment 0, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}
 2 0x600002e70230 {
    NSBackgroundColor = "UIExtendedGrayColorSpace 0 0";
    NSColor = "UIExtendedGrayColorSpace 0 1";
    NSFont = "<UICTFont: 0x130457dc0> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}

After The AttributedString has only one span. The span has NSParagraphStyle Alignment 4. The string text is "1\n\n2".

CLICK TO OPEN ATTRIBUTED STRING DETAILS

Printing description of attributedString->attributes:
Length 5 (2 blocks, 1 used, block 0 is at 0)
 5 0x600002e70230 {
    NSBackgroundColor = "UIExtendedGrayColorSpace 0 0";
    NSColor = "UIExtendedGrayColorSpace 0 1";
    NSFont = "<UICTFont: 0x130457dc0> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
    NSParagraphStyle = "Alignment 4, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection 0, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
}

I included a video of the two examples at this link.

@facebook-github-bot

Copy link
Copy Markdown
Contributor

@NickGerleman has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@fabOnReact

Copy link
Copy Markdown
Contributor Author

Thanks @NickGerleman. Could you tell me what is the lint warning? I can fix it and push again the branch so it is easier to import.

@NickGerleman

Copy link
Copy Markdown
Contributor

It's a formatting issue that I can fix before landing, but it is waiting on a reviewer of the imported PR. The best expert for iOS TextInput has been out of office.

@fabOnReact
fabOnReact marked this pull request as ready for review February 19, 2024 03:17
@fabOnReact

Copy link
Copy Markdown
Contributor Author

Thanks @NickGerleman. I replied to your code-review and further improved the solution as by Meta requirement.
#38679 (comment)
#38679 (comment)

@NickGerleman

Copy link
Copy Markdown
Contributor

Thanks @NickGerleman. I replied to your code-review and further improved the solution as by Meta requirement. #38679 (comment) #38679 (comment)

I will take another look at this soon (still need to write the accompanying internal tests first).

@NickGerleman

Copy link
Copy Markdown
Contributor

Thanks @NickGerleman. I replied to your code-review and further improved the solution as by Meta requirement. #38679 (comment) #38679 (comment)

I will take another look at this soon (still need to write the accompanying internal tests first).

Finally cleared some other commitments, so writing those tests now.

@facebook-github-bot

Copy link
Copy Markdown
Contributor

@NickGerleman has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@facebook-github-bot facebook-github-bot added the Merged This PR has been merged. label Mar 28, 2024
@facebook-github-bot

Copy link
Copy Markdown
Contributor

@NickGerleman merged this pull request in f6badca.

Titozzz pushed a commit that referenced this pull request Jun 18, 2024
…n prepending new lines to the text (#38679)

Summary:
### Please re-state the problem that we are trying to solve in this issue.

Multiline TextInput with a fixed height will scroll to the bottom of the screen when prepending new lines to the text.

### What is the root cause of that problem?

The issue is caused by iOS UITextView:
- The cursor moves to the end of the text when prepending new lines.
- Moving the cursor to the end of the text triggers the scroll to the bottom.

The behavior was reproduced on an iOS App (without react-native).
The example included below implements a Component RCTUITextView based on UITextView, which modifies the UITextView attributedText with the textViewDidChange callback (source code available in this [comment](Expensify/App#19507 (comment))).

Adding a new line on top of the UITextView on iOS results in:
Issue 1) The cursor moves to the end of TextInput text
Issue 2) The TextInput scrolls to the bottom

<details><summary>Reproducing the issue on an iOS App without react-native</summary>
<p>

<video src="https://user-images.githubusercontent.com/24992535/246601549-99f480f3-ce80-4678-9378-f71c8aa67e17.mp4" width="900" />

</p>
</details>

Issue 1) is already fixed in react-native, which restores the previous cursor position (on Fabric with  [_setAttributedString](https://github.com/fabriziobertoglio1987/react-native/blob/71e7bbbc2cf21abacf7009e300f5bba737e20d17/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#L600-L610)) after changing the text.
Issue 2) needs to be fixed in react-native.

### What changes do you think we should make in order to solve the problem?

Setting the correct TextInput scroll position after re-setting the cursor.

## Changelog:

[IOS] [FIXED] - Fix Multiline TextInput with a fixed height scrolls to the bottom when changing AttributedText

Pull Request resolved: #38679

Test Plan:
Fabric (reproduces on controlled/not controlled TextInput example):

| Before    | After |
| ----------- | ----------- |
| <video src="https://github.com/facebook/react-native/assets/24992535/e06b31fe-407d-4897-b608-73e0cc0f224a" width="350" />      | <video src="https://github.com/facebook/react-native/assets/24992535/fa2eaa31-c616-43c5-9596-f84e7b70d80a" width="350" />       |

Paper (reproduces only on controlled TextInput example):

```javascript
function TextInputExample() {
  const [text, setText] = React.useState('');

  return (
    <View style={{marginTop: 200}}>
      <TextInput
        style={{height: 50, backgroundColor: 'white'}}
        multiline={true}
        value={text}
        onChangeText={text => {
          setText(text);
        }}
      />
    </View>
  );
}
```

| Before    | After |
| ----------- | ----------- |
| <video src="https://github.com/facebook/react-native/assets/24992535/6cb1f2de-717e-4dce-be0a-644f6a051c08" width="350" />      | <video src="https://github.com/facebook/react-native/assets/24992535/dee6edb6-76c6-48b0-b78f-99626235d30e" width="350" />       |

Reviewed By: sammy-SC, cipolleschi

Differential Revision: D48674090

Pulled By: NickGerleman

fbshipit-source-id: 349e7b0910e314ec94b45b68c38571fed41ef117
Titozzz pushed a commit that referenced this pull request Jun 18, 2024
…n prepending new lines to the text (#38679)

Summary:
### Please re-state the problem that we are trying to solve in this issue.

Multiline TextInput with a fixed height will scroll to the bottom of the screen when prepending new lines to the text.

### What is the root cause of that problem?

The issue is caused by iOS UITextView:
- The cursor moves to the end of the text when prepending new lines.
- Moving the cursor to the end of the text triggers the scroll to the bottom.

The behavior was reproduced on an iOS App (without react-native).
The example included below implements a Component RCTUITextView based on UITextView, which modifies the UITextView attributedText with the textViewDidChange callback (source code available in this [comment](Expensify/App#19507 (comment))).

Adding a new line on top of the UITextView on iOS results in:
Issue 1) The cursor moves to the end of TextInput text
Issue 2) The TextInput scrolls to the bottom

<details><summary>Reproducing the issue on an iOS App without react-native</summary>
<p>

<video src="https://user-images.githubusercontent.com/24992535/246601549-99f480f3-ce80-4678-9378-f71c8aa67e17.mp4" width="900" />

</p>
</details>

Issue 1) is already fixed in react-native, which restores the previous cursor position (on Fabric with  [_setAttributedString](https://github.com/fabriziobertoglio1987/react-native/blob/71e7bbbc2cf21abacf7009e300f5bba737e20d17/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#L600-L610)) after changing the text.
Issue 2) needs to be fixed in react-native.

### What changes do you think we should make in order to solve the problem?

Setting the correct TextInput scroll position after re-setting the cursor.

## Changelog:

[IOS] [FIXED] - Fix Multiline TextInput with a fixed height scrolls to the bottom when changing AttributedText

Pull Request resolved: #38679

Test Plan:
Fabric (reproduces on controlled/not controlled TextInput example):

| Before    | After |
| ----------- | ----------- |
| <video src="https://github.com/facebook/react-native/assets/24992535/e06b31fe-407d-4897-b608-73e0cc0f224a" width="350" />      | <video src="https://github.com/facebook/react-native/assets/24992535/fa2eaa31-c616-43c5-9596-f84e7b70d80a" width="350" />       |

Paper (reproduces only on controlled TextInput example):

```javascript
function TextInputExample() {
  const [text, setText] = React.useState('');

  return (
    <View style={{marginTop: 200}}>
      <TextInput
        style={{height: 50, backgroundColor: 'white'}}
        multiline={true}
        value={text}
        onChangeText={text => {
          setText(text);
        }}
      />
    </View>
  );
}
```

| Before    | After |
| ----------- | ----------- |
| <video src="https://github.com/facebook/react-native/assets/24992535/6cb1f2de-717e-4dce-be0a-644f6a051c08" width="350" />      | <video src="https://github.com/facebook/react-native/assets/24992535/dee6edb6-76c6-48b0-b78f-99626235d30e" width="350" />       |

Reviewed By: sammy-SC, cipolleschi

Differential Revision: D48674090

Pulled By: NickGerleman

fbshipit-source-id: 349e7b0910e314ec94b45b68c38571fed41ef117
This was referenced Jun 28, 2024
meta-codesync Bot pushed a commit that referenced this pull request Oct 20, 2026
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: 68c540fd4a9ed327e0e38fba34d88db4fc0ca7ad
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. Merged This PR has been merged. 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.

6 participants