diff --git a/packages/app/e2e/mobile/native-text-reflow/README.md b/packages/app/e2e/mobile/native-text-reflow/README.md new file mode 100644 index 0000000000..b92666cc58 --- /dev/null +++ b/packages/app/e2e/mobile/native-text-reflow/README.md @@ -0,0 +1,26 @@ +# Native iOS text geometry regression + +Run on the Namespace Mac with a fresh simulator development build. Use the PID +returned by `xcrun simctl launch sh.paseo.debug`. +Attach only to your task's app, while no device replay is running: + +```sh +PASEO_NATIVE_TEXT_REFLOW_PID=12345 \ +PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \ +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 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. + +This covers native container resizing and selection retention. It does not cover +Fabric event delivery, chat row height, reader anchoring, or the on-screen copy +menu. Those require the real chat journey: read an older multiline response, +open/close Explorer, resize/rotate, inspect complete paragraphs and bullets, +retain expanded/collapsed details, and check a stationary reader's position. +Accessibility exposes full text even when glyphs are clipped, so a text wait +alone does not verify that journey. diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.py b/packages/app/e2e/mobile/native-text-reflow/ios.py new file mode 100644 index 0000000000..7a4cbd5125 --- /dev/null +++ b/packages/app/e2e/mobile/native-text-reflow/ios.py @@ -0,0 +1,55 @@ +import lldb + + +def __lldb_init_module(debugger, _internal_dict): + debugger.HandleCommand("expression -l objc++ -- @import UIKit") + frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame() + options = lldb.SBExpressionOptions() + options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus) + result = frame.EvaluateExpression( + r'''({ + UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init]; + auto hostFrame = host.frame; + hostFrame.size.width = 500; + hostFrame.size.height = 200; + host.frame = hostFrame; + UIView *container = (UIView *)[host performSelector:@selector(contentView)]; + container.frame = host.bounds; + UITextView *text = nil; + for (UIView *child in host.subviews) { + if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child; + for (UIView *nested in child.subviews) { + if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested; + } + } + text.frame = container.bounds; + text.font = [UIFont systemFontOfSize:16]; + text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection."; + auto range = text.selectedRange; + range.location = 12; + range.length = 9; + text.selectedRange = range; + auto frame = container.frame; + frame.size.width = 240; + container.frame = frame; + [container layoutIfNeeded]; + CGFloat narrow = text.frame.size.width; + auto selection = text.selectedRange; + frame.size.width = 500; + container.frame = frame; + [container layoutIfNeeded]; + CGFloat wide = text.frame.size.width; + BOOL ok = text != nil && narrow == 240 && wide == 500 && + selection.location == text.selectedRange.location && + selection.length == text.selectedRange.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]; + })''', + options, + ) + if result.GetError().Fail(): + raise RuntimeError(result.GetError().GetCString()) + print(result.GetObjectDescription()) diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.sh b/packages/app/e2e/mobile/native-text-reflow/ios.sh new file mode 100644 index 0000000000..b4e9a07a22 --- /dev/null +++ b/packages/app/e2e/mobile/native-text-reflow/ios.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}" +: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}" + +if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then + echo "Expected a running PaseoDebug simulator app" >&2 + exit 1 +fi + +probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \ + -o "command script import $probe_dir/ios.py" \ + -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" diff --git a/patches/react-native-uitextview+2.2.0.patch b/patches/react-native-uitextview+2.2.0.patch new file mode 100644 index 0000000000..f64f09ebc7 --- /dev/null +++ b/patches/react-native-uitextview+2.2.0.patch @@ -0,0 +1,72 @@ +diff --git a/node_modules/react-native-uitextview/ios/RNUITextView.mm b/node_modules/react-native-uitextview/ios/RNUITextView.mm +index d0c31ed..df8a233 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; + RNUITextViewShadowNode::ConcreteState::Shared _state; + UITapGestureRecognizer * _outsideTapRecognizer; + BOOL _suppressSelectionChange; ++ CGRect _lastTextLayoutFrame; + } + + + (ComponentDescriptorProvider)componentDescriptorProvider +@@ -47,7 +48,8 @@ using namespace facebook::react; + _textView.delegate = self; + // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. + _textView.layoutManager.usesFontLeading = NO; +- [self addSubview:_textView]; ++ _textView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; ++ [_view addSubview:_textView]; + + 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; + _textView.attributedText = nil; ++ _lastTextLayoutFrame = CGRectZero; + } + + - (void)layoutSubviews + { + [super layoutSubviews]; +- // _textView's frame is assigned inside drawRect, which only fires when +- // state changes. Trigger a redraw whenever the host frame moves out from +- // under it (rotation, parent relayout) so the text view resizes and +- // onTextLayout re-fires with the new line wrapping. +- if (!CGRectEqualToRect(_textView.frame, _view.frame)) { ++ // Autoresizing updates UIKit immediately; report the new line layout when ++ // the content container's bounds change. ++ if (!CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds)) { + [self setNeedsDisplay]; + } + } +@@ -124,8 +125,9 @@ 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]; +- const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); +- if (!textChanged && !frameChanged) { ++ const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.bounds); ++ const BOOL layoutChanged = !CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds); ++ if (!textChanged && !frameChanged && !layoutChanged) { + return; + } + if (textChanged) { +@@ -142,7 +144,7 @@ using namespace facebook::react; + _suppressSelectionChange = NO; + } + if (frameChanged) { +- _textView.frame = _view.frame; ++ _textView.frame = _view.bounds; + } + + __block std::vector lines; +@@ -162,6 +164,7 @@ using namespace facebook::react; + } + }]; + ++ _lastTextLayoutFrame = _view.bounds; + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onTextLayout(facebook::react::RNUITextViewEventEmitter::OnTextLayout{static_cast(self.tag), lines}); diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 06d8892c60..4235127a63 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -12,6 +12,10 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-markdown-display", patchPrefix: "react-native-markdown-display+", }, + { + nodeModulesPath: "node_modules/react-native-uitextview", + patchPrefix: "react-native-uitextview+", + }, // Remove after react-native-unistyles ships // https://github.com/jpudysz/react-native-unistyles/pull/1203. {