From f521abfb245454840d1affbcbfcf76a4b45fe19b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 9 Oct 2026 22:24:22 +0200 Subject: [PATCH] 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> --- .../e2e/mobile/native-text-reflow/README.md | 5 +++ .../app/e2e/mobile/native-text-reflow/ios.py | 26 ++++++++++---- .../app/e2e/mobile/native-text-reflow/ios.sh | 2 +- patches/react-native-uitextview+2.2.0.patch | 36 +++++++++++++------ 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/packages/app/e2e/mobile/native-text-reflow/README.md b/packages/app/e2e/mobile/native-text-reflow/README.md index b92666cc58..655d9485e8 100644 --- a/packages/app/e2e/mobile/native-text-reflow/README.md +++ b/packages/app/e2e/mobile/native-text-reflow/README.md @@ -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. diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.py b/packages/app/e2e/mobile/native-text-reflow/ios.py index 7a4cbd5125..33846be3d5 100644 --- a/packages/app/e2e/mobile/native-text-reflow/ios.py +++ b/packages/app/e2e/mobile/native-text-reflow/ios.py @@ -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, ) diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.sh b/packages/app/e2e/mobile/native-text-reflow/ios.sh index b4e9a07a22..6080fc722c 100644 --- a/packages/app/e2e/mobile/native-text-reflow/ios.sh +++ b/packages/app/e2e/mobile/native-text-reflow/ios.sh @@ -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" diff --git a/patches/react-native-uitextview+2.2.0.patch b/patches/react-native-uitextview+2.2.0.patch index f64f09ebc7..f0d8f4713c 100644 --- a/patches/react-native-uitextview+2.2.0.patch +++ b/patches/react-native-uitextview+2.2.0.patch @@ -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 lines; -@@ -162,6 +164,7 @@ using namespace facebook::react; + const int maxLines = props.numberOfLines; +@@ -162,6 +169,7 @@ using namespace facebook::react; } }];