mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e791ab3df7 | ||
|
|
f521abfb24 | ||
|
|
b89ebbede6 |
@@ -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});
|
||||||
@@ -12,6 +12,10 @@ const patchedPackages = [
|
|||||||
nodeModulesPath: "node_modules/react-native-markdown-display",
|
nodeModulesPath: "node_modules/react-native-markdown-display",
|
||||||
patchPrefix: "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
|
// Remove after react-native-unistyles ships
|
||||||
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
|
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user