From 46b01f9f96bc4f5a6e4af558e1f90f0b7046c389 Mon Sep 17 00:00:00 2001 From: Hao Hong Date: Sun, 1 Nov 2026 21:55:03 -0800 Subject: [PATCH] Fix multiline TextInput placeholder missing or clipped on iOS when it changes without a resize RCTUITextView sizes its placeholder label only in layoutSubviews, and _updatePlaceholder asks for no layout pass. When the placeholder (or the font or text attributes it is drawn with) changes while the view keeps its size, the label keeps its previous frame: zero high after an empty placeholder, one line high after a one-line placeholder. This happens when Fabric reuses a recycled RCTTextInputComponentView for a multiline input of the same size, and when the placeholder of a mounted fixed-size multiline input changes. _updatePlaceholder now calls setNeedsLayout. --- .../Text/TextInput/Multiline/RCTUITextView.mm | 3 ++ .../React/Tests/Text/RCTUITextViewTests.mm | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 packages/react-native/React/Tests/Text/RCTUITextViewTests.mm diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index eef87f87f632..b9243ae21022 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -396,6 +396,9 @@ - (void)_updatePlaceholder _placeholderView.attributedText = [[NSAttributedString alloc] initWithString:_placeholder ?: @"" attributes:[self _placeholderTextAttributes]]; [self _invalidatePlaceholderVisibility]; + // `layoutSubviews` sizes the placeholder label to fit its text, and the new text may need a different size. Nothing + // else lays the label out again when this view keeps its size (also after it is recycled). + [self setNeedsLayout]; } - (NSDictionary *)_placeholderTextAttributes diff --git a/packages/react-native/React/Tests/Text/RCTUITextViewTests.mm b/packages/react-native/React/Tests/Text/RCTUITextViewTests.mm new file mode 100644 index 000000000000..1068ff9415cb --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTUITextViewTests.mm @@ -0,0 +1,51 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import + +static UILabel *RCTPlaceholderLabel(RCTUITextView *textView) +{ + for (UIView *subview in textView.subviews) { + if ([subview isKindOfClass:[UILabel class]]) { + return (UILabel *)subview; + } + } + return nil; +} + +@interface RCTUITextViewTests : XCTestCase +@end + +@implementation RCTUITextViewTests + +- (void)testPlaceholderIsLaidOutWhenSetOnALaidOutView +{ + RCTUITextView *textView = [[RCTUITextView alloc] initWithFrame:CGRectMake(0, 0, 340, 122)]; + [textView layoutIfNeeded]; + + textView.placeholder = @"Type 5 characters to show length helper"; + [textView layoutIfNeeded]; + + XCTAssertNotNil(RCTPlaceholderLabel(textView)); + XCTAssertGreaterThan(CGRectGetHeight(RCTPlaceholderLabel(textView).frame), 0); +} + +- (void)testPlaceholderIsLaidOutWhenItGetsLongerOnALaidOutView +{ + RCTUITextView *textView = [[RCTUITextView alloc] initWithFrame:CGRectMake(0, 0, 340, 122)]; + textView.placeholder = @"Short placeholder"; + [textView layoutIfNeeded]; + CGFloat oneLineHeight = CGRectGetHeight(RCTPlaceholderLabel(textView).frame); + + textView.placeholder = @"Tell us why you're interested in this position and what you would bring to the team"; + [textView layoutIfNeeded]; + + XCTAssertGreaterThan(CGRectGetHeight(RCTPlaceholderLabel(textView).frame), oneLineHeight); +} + +@end