fix(ios): keep recycled text views at their container width

Apply the minimal candidate from #6364 and cover recycled geometry in the native probe. Document that the probe does not reproduce mounted Fabric clipping.

Co-authored-by: Oxxx <103127477+omcdowell@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-10-09 22:24:28 +02:00
co-authored by Oxxx
parent b89ebbede6
commit f521abfb24
4 changed files with 52 additions and 17 deletions
@@ -13,6 +13,11 @@ bash packages/app/e2e/mobile/native-text-reflow/ios.sh
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
and resizes its content container from 500 to 240 and back to 500 points. It
asserts the inner frame follows both widths and the same selection survives.
It then recycles the view the way Fabric's pool does and remounts it 30 points
wider and at the same size; the inner frame must still match the container.
A zeroed frame on recycle comes back 30 or 0 points wide. This assertion checks
the intermediate recycled geometry; it does not reproduce visible clipping in a
mounted Fabric paragraph or exercise the subsequent `drawRect` call.
It uses public UIView/UITextView operations, without mocking React Native.
LLDB detaches after the probe. An assertion failure or evaluation error fails the
shell command; the log distinguishes them.
@@ -39,14 +39,28 @@ def __lldb_init_module(debugger, _internal_dict):
container.frame = frame;
[container layoutIfNeeded];
CGFloat wide = text.frame.size.width;
auto keptSelection = text.selectedRange;
// Fabric recycles the view: the container keeps its size in the pool,
// and the next paragraph mounts it 30 points wider (a list-item paragraph
// reused by a full-width one) or at the same size.
[host performSelector:@selector(prepareForRecycle)];
frame.size.width = 530;
container.frame = frame;
[container layoutIfNeeded];
CGFloat reusedWider = text.frame.size.width;
[host performSelector:@selector(prepareForRecycle)];
container.frame = frame;
[container layoutIfNeeded];
CGFloat reusedSame = text.frame.size.width;
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
selection.location == text.selectedRange.location &&
selection.length == text.selectedRange.length &&
reusedWider == 530 && reusedSame == 530 &&
selection.location == keptSelection.location &&
selection.length == keptSelection.length &&
selection.location == 12 && selection.length == 9;
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide,
(unsigned long)text.selectedRange.location,
(unsigned long)text.selectedRange.length];
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f recycled=%.0f,%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide, reusedWider, reusedSame,
(unsigned long)keptSelection.location,
(unsigned long)keptSelection.length];
})''',
options,
)
@@ -15,4 +15,4 @@ xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 recycled=530,530 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
+26 -10
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/react-native-uitextview/ios/RNUITextView.mm b/node_modules/react-native-uitextview/ios/RNUITextView.mm
index d0c31ed..df8a233 100644
index d0c31ed..d1605ea 100644
--- a/node_modules/react-native-uitextview/ios/RNUITextView.mm
+++ b/node_modules/react-native-uitextview/ios/RNUITextView.mm
@@ -22,6 +22,7 @@ using namespace facebook::react;
@@ -20,9 +20,17 @@ index d0c31ed..df8a233 100644
const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(handleLongPressIfNecessary:)];
@@ -94,16 +96,15 @@ using namespace facebook::react;
// Reset the frame to zero so that when it properly lays out on the next use
_textView.frame = CGRectZero;
@@ -91,19 +93,21 @@ using namespace facebook::react;
[super prepareForRecycle];
_state.reset();
- // Reset the frame to zero so that when it properly lays out on the next use
- _textView.frame = CGRectZero;
+ // Keep the text view on the content container's bounds. The container keeps
+ // its size while the view waits in the recycle pool, and autoresizing only
+ // applies the size delta on reuse, so a zeroed text view would come back as
+ // wide as that delta (zero, or a two-word column).
+ _textView.frame = _view.bounds;
_textView.attributedText = nil;
+ _lastTextLayoutFrame = CGRectZero;
}
@@ -41,7 +49,7 @@ index d0c31ed..df8a233 100644
[self setNeedsDisplay];
}
}
@@ -124,8 +125,9 @@ using namespace facebook::react;
@@ -124,10 +128,16 @@ using namespace facebook::react;
// entirely when nothing actually changed so a JS-side state update made in
// response to onSelectionChange doesn't deselect what the user is selecting.
const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
@@ -52,17 +60,25 @@ index d0c31ed..df8a233 100644
+ if (!textChanged && !frameChanged && !layoutChanged) {
return;
}
+ // Size the text view before handing it the text, so UIKit lays the text out
+ // at the container width instead of a stale width.
+ if (frameChanged) {
+ _textView.frame = _view.bounds;
+ }
if (textChanged) {
@@ -142,7 +144,7 @@ using namespace facebook::react;
// Reassigning attributedText clears any active selection. Save it and
// restore after, while suppressing the synthetic textViewDidChangeSelection
@@ -141,9 +151,6 @@ using namespace facebook::react;
}
_suppressSelectionChange = NO;
}
if (frameChanged) {
- if (frameChanged) {
- _textView.frame = _view.frame;
+ _textView.frame = _view.bounds;
}
- }
__block std::vector<std::string> lines;
@@ -162,6 +164,7 @@ using namespace facebook::react;
const int maxLines = props.numberOfLines;
@@ -162,6 +169,7 @@ using namespace facebook::react;
}
}];