Skip to content

Fix off-center caret and text in a recycled TextInput on iOS - #59190

Closed
jkasprzyk17 wants to merge 1 commit into
react:mainfrom
jkasprzyk17:fix/textinput-recycled-caret-offset
Closed

jkasprzyk17 wants to merge 1 commit into
react:mainfrom
jkasprzyk17:fix/textinput-recycled-caret-offset

Conversation

@jkasprzyk17

@jkasprzyk17 jkasprzyk17 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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

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.

RNTesterPlayground.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'},
});

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}
before-after

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:

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.

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

@cipolleschi cipolleschi left a comment

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.

THanks for the fix

@meta-codesync

meta-codesync Bot commented Oct 19, 2026

Copy link
Copy Markdown

@cipolleschi has imported this pull request. If you are a Meta employee, you can view this in D126072650.

@meta-codesync meta-codesync Bot closed this in e10d6ba Oct 20, 2026
@react-native-bot

Copy link
Copy Markdown
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?

@react-native-bot react-native-bot added the Merged This PR has been merged. label Oct 20, 2026
@meta-codesync

meta-codesync Bot commented Oct 20, 2026

Copy link
Copy Markdown

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

[iOS] TextInput caret and typed text drawn above centre when its view is reused from a larger-font TextInput that was given a value

3 participants