mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(app): reflow iPad chat text after pane resize (#5716)
The selectable UIKit text view was a sibling of the Fabric content container, so existing chat rows kept an old width after Explorer changed the available pane. Make the container own the text view and preserve layout notifications on bounds changes. Includes a native geometry regression.
This commit is contained in:
@@ -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 <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 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,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())
|
||||
@@ -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"
|
||||
@@ -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<std::string> lines;
|
||||
@@ -162,6 +164,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});
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user