Skip to content

fix(ios): center TextInput text when lineHeight > fontSize on Fabric - #56487

Open
janicduplessis wants to merge 2 commits into
react:mainfrom
janicduplessis:fix/ios-textinput-lineheight-baseline-offset
Open

janicduplessis wants to merge 2 commits into
react:mainfrom
janicduplessis:fix/ios-textinput-lineheight-baseline-offset

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On iOS, when a TextInput has lineHeight > fontSize, UIKit anchors typed text and the placeholder to the bottom of the paragraph line box, and sizes the single-line caret to the full line-box height instead of the font height.

Before After

fontSize: 16, lineHeight: 80. iOS 26 simulator, Fabric.

Why

The fix splits two ways because UIKit's draw paths don't all honor the same attributes:

Surface Honors NSBaselineOffsetAttributeName?
Multi-line typed text (UITextView) yes
Placeholder (single + multi, UILabel) yes
Single-line typed text + caret (UITextField / UIFieldEditor) no
  • Multi-line typed text + placeholder: apply a baseline offset to re-center the glyph. Multi-line typed text reuses the existing RCTApplyBaselineOffset helper that <Text> already uses, so per-fragment fonts (nested <Text> with different fontSize) are handled correctly.
  • Single-line typed text + caret: zero paragraphStyle.minimumLineHeight / maximumLineHeight per range on the attributed string handed to UIKit. UITextField then renders at the font's natural line height and its built-in vertical centering positions the glyph in the bounds; the caret rect — derived from the same line box — shrinks to match. Other paragraphStyle fields (alignment, indent) are preserved per range. The shadow node measures from TextInputState, so _updateState re-inflates stripped line heights from defaultTextAttributes before pushing — without that step the cell would shrink to the font's natural height after the first keystroke.

The typed-text fix lives in RCTTextInputComponentView._setAttributedString: (single Fabric entry point, preserves the _textOf:equals: early-return). The placeholder fix lives in _placeholderTextAttributes of each backing view.

Performance

When lineHeight ≤ font.lineHeight (common case): zero allocations — guarded out before any mutation.

Changelog:

[IOS] [FIXED] - Center typed TextInput text, placeholder, and single-line caret when lineHeight > fontSize.

Test Plan

RN Tester → TextInput → lineHeight baseline. Verified on iOS 26 simulator (iPhone 16 Pro / iPhone 17 Pro Max) on Fabric, including nested <Text> mixed-font cases. Confirmed cell height stays stable across edits.

Related: #38359, #39145, #53092.

@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 Apr 17, 2026
@facebook-github-tools facebook-github-tools Bot added the Contributor A React Native contributor. label Apr 17, 2026
@janicduplessis
janicduplessis force-pushed the fix/ios-textinput-lineheight-baseline-offset branch 4 times, most recently from 606f1d7 to 4e79973 Compare April 18, 2026 17:06
@janicduplessis
janicduplessis force-pushed the fix/ios-textinput-lineheight-baseline-offset branch from 4e79973 to bf72714 Compare April 18, 2026 18:13
@thanhnd1o2

Copy link
Copy Markdown

Looking for it

@janicduplessis
janicduplessis force-pushed the fix/ios-textinput-lineheight-baseline-offset branch 2 times, most recently from 9bcb81f to c73a582 Compare May 4, 2026 18:54
@janicduplessis
janicduplessis marked this pull request as ready for review May 4, 2026 20:31
@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 May 4, 2026
@janicduplessis
janicduplessis force-pushed the fix/ios-textinput-lineheight-baseline-offset branch 2 times, most recently from acf8672 to e4b8473 Compare May 5, 2026 15:57
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown

Warning

JavaScript API change detected

This PR commits an update to ReactNativeApi.d.ts, indicating a change to React Native's public JavaScript API.

  • Please include a clear changelog message.
  • This change will be subject to additional review.

This change was flagged as: POTENTIALLY_BREAKING

@janicduplessis
janicduplessis force-pushed the fix/ios-textinput-lineheight-baseline-offset branch 2 times, most recently from 99c3b8b to 156b724 Compare May 5, 2026 20:09
…ht > fontSize

On iOS, when a TextInput's lineHeight exceeds its font's line height, UIKit
anchors glyphs to the bottom of the attributed-string line box instead of
centering them within it. The same misalignment affects the placeholder.
On single-line UITextField the caret is also sized to the full line box.

This patch fixes all three surfaces. The approach varies by UIKit
rendering path:

UITextView (multi-line) typed text — honors NSBaselineOffsetAttributeName.
  Call RCTApplyBaselineOffset in RCTTextInputComponentView._setAttributedString:
  to inject the offset. Re-seed NSParagraphStyleAttributeName from
  defaultTextAttributes on ranges missing it (or carrying a zero-line-height
  stub), because UIKit's typingAttributes drops the paragraph style between
  keystrokes — without the re-seed the helper sees maximumLineHeight == 0 and
  bails for typed content.

UITextField (single-line) typed text — does NOT honor NSBaselineOffsetAttributeName.
  Per-range zero out paragraphStyle.minimumLineHeight / maximumLineHeight on
  the displayed attributedText so UITextField uses the font's natural line
  height; its built-in vertical centering then positions the glyph in the
  bounds, and the caret rect (sized from the same line box) shrinks to match.
  Other paragraph-style fields (alignment, indent) are preserved so nested
  <Text> styling survives. The shadow node measures from state, so undo the
  strip in _updateState before pushing — restore stripped line heights from
  defaultTextAttributes so the cell height stays at the configured lineHeight
  across edits.

Placeholder on both UITextField.attributedPlaceholder (UILabel draw) and
RCTUITextView._placeholderView — both honor NSBaselineOffsetAttributeName.
  Add the offset computation to _placeholderTextAttributes on both backing
  views so the placeholder is centered to match (single string, single set
  of attributes — a direct computation is equivalent to RCTApplyBaselineOffset).

Bug only manifests on Fabric. Paper has had a similar baseline-offset fix in
RCTUITextView.attributedText since 0.66 (see RCTUITextView setAttributedText:),
which never made it into the Fabric component view.
@janicduplessis
janicduplessis force-pushed the fix/ios-textinput-lineheight-baseline-offset branch from 156b724 to 3fbf76f Compare May 5, 2026 21:09
@yefim

yefim commented Jul 6, 2026

Copy link
Copy Markdown

Has there been any movement here? We rolled out Fabric recently and noticed that it's breaking all of our TextInput placeholders.

Screenshot 2026-07-06 at 1 51 09 PM (1) Screenshot 2026-07-06 at 4 22 44 PM

@janicduplessis

Copy link
Copy Markdown
Contributor Author

Haven’t had any feedback on this, I would recommend to avoid line height as a workaround.

@yefim

yefim commented Jul 29, 2026

Copy link
Copy Markdown

We ended up updating our TextInput wrapper to float a placeholder on top of the input.

function BaseTextInput() {
  // ...
  return (
    <View style={wrapperStyle}>
      {input}
      {hasPlaceholder && (
        <Animated.View
          style={[StyleSheet.absoluteFill, placeholderAnimatedStyle]}
          pointerEvents="none"
        >
          <Text style={placeholderStyle} numberOfLines={props.multiline ? undefined : 1}>
            {placeholder}
          </Text>
        </Animated.View>
      )}
    </View>
  );
}

@emmanuelchucks

Copy link
Copy Markdown

Tested this, and it fixes the issue in every case I tried. I didn't find any regressions.

Setup: current main (1c6e0c7) with this PR rebased on top. The only conflicts were the two RCTTextAttributes.h imports, which main has since removed. RNTester built from source, New Architecture, iOS 18.5 and iOS 27 simulators.

The test screen puts a Text, a TextInput with a value and an empty TextInput with a placeholder side by side, all with the same style (fontSize 16, paddingVertical 8). I measured how far the capital letters sit from the vertical centre of each box, in points; positive means too low. Text is within 0.33pt of centre in every case, so it's the reference. Each cell is typed text / placeholder:

main this PR
single-line, lineHeight 22 +2.83 / +1.50 +0.17 / +0.17
single-line, lineHeight 32 +12.83 / +6.50 +0.17 / +0.17
multiline, lineHeight 22 +1.50 / +1.17 +0.17 / -0.17
multiline, lineHeight 32 +6.50 / +6.17 +0.17 / -0.17
single-line, lineHeight 32, text size XXXL +18.83 / +9.50 0.00 / 0.00
  • Text renders exactly the same with and without this PR. Inputs with no lineHeight, or one smaller than the font's own, don't change.
  • Georgia gives the same results, and iOS 27 matches 18.5.
  • While typing, the text stays centred and the input keeps its height, also after typing an emoji. The caret is centred on the text: font height in single-line, line height in multiline.

Before (left) and after (right):

Before and after this PR

The caret with this PR:

Caret with this PR

I also tried the simpler fix first: adding the baseline offset to the input's text attributes, the way Text does with RCTApplyBaselineOffset. It fixes multiline and the placeholder, but single-line typed text only moves halfway (+6.50 at lineHeight 32). So handling UITextField separately, as this PR does, looks necessary.

We also backported this to our app on 0.88.0-rc.3 and built it from source. The chat input's text is centred without the manual offset we've been using, and typing an emoji doesn't change its height.

The test screen, if anyone wants to repeat this: https://gist.github.com/emmanuelchucks/40bc34ce0538ee6410b0455e4b5d4ad4

hsource added a commit to wanderlog/react-native that referenced this pull request Oct 21, 2026
…eact#56487)

This is a cherry-pick of a PR to fix TextInputs on iOS so that the text remains vertically-centered when a lineHeight is set. See react#56487 for more details

Co-authored-by: Cursor <cursoragent@cursor.com>
hsource added a commit to wanderlog/react-native that referenced this pull request Oct 21, 2026
…eact#56487)

This is a cherry-pick of a PR to fix TextInputs on iOS so that the text remains vertically-centered when a lineHeight is set. See react#56487 for more details

Co-authored-by: Cursor <cursoragent@cursor.com>
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

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. Contributor A React Native contributor. 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.

4 participants