Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e791ab3df7 test(ios): run the native text probe on the main thread 2026-10-09 22:41:11 +02:00
Mohamed BoudraandOxxx f521abfb24 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>
2026-10-09 22:24:28 +02:00
Mohamed Boudra b89ebbede6 fix(ios): restore native text reflow for evaluation 2026-10-09 22:24:28 +02:00
5 changed files with 217 additions and 0 deletions
@@ -0,0 +1,31 @@
# Native iOS text geometry regression
Run on the Namespace Mac with a fresh simulator development build. Use the PID
returned by `xcrun simctl launch <your-task-simulator-UDID> 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 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.
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.
@@ -0,0 +1,76 @@
import lldb
def __lldb_init_module(debugger, _internal_dict):
process = debugger.GetSelectedTarget().GetProcess()
main_threads = [thread for thread in process if thread.GetQueueName() == "com.apple.main-thread"]
if len(main_threads) != 1 or not process.SetSelectedThread(main_threads[0]):
raise RuntimeError("Could not select the UIKit main thread")
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
frame = process.GetSelectedThread().GetSelectedFrame()
options = lldb.SBExpressionOptions()
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
is_main_thread = frame.EvaluateExpression("(BOOL)[NSThread isMainThread]", options)
if is_main_thread.GetError().Fail() or is_main_thread.GetValueAsUnsigned() != 1:
raise RuntimeError("Refusing to run UIKit probe outside the main thread")
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;
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 &&
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 recycled=%.0f,%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide, reusedWider, reusedSame,
(unsigned long)keptSelection.location,
(unsigned long)keptSelection.length];
})''',
options,
)
if result.GetError().Fail():
raise RuntimeError(result.GetError().GetCString())
print(result.GetObjectDescription())
@@ -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 recycled=530,530 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
@@ -0,0 +1,88 @@
diff --git a/node_modules/react-native-uitextview/ios/RNUITextView.mm b/node_modules/react-native-uitextview/ios/RNUITextView.mm
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;
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:)];
@@ -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;
}
- (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,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];
- 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;
}
+ // 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) {
// 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) {
- _textView.frame = _view.frame;
- }
__block std::vector<std::string> lines;
const int maxLines = props.numberOfLines;
@@ -162,6 +169,7 @@ using namespace facebook::react;
}
}];
+ _lastTextLayoutFrame = _view.bounds;
if (_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::RNUITextViewEventEmitter>(_eventEmitter)
->onTextLayout(facebook::react::RNUITextViewEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines});
+4
View File
@@ -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.
{