fix(app): unstick iOS image picker and land reusable Maestro primitives
On iOS, tapping "+" → "Add image" in the Composer left an invisible backdrop trapping touches after the native photo picker dismissed. Root cause: the dropdown menu's onSelect fired while the RN Modal was still completing UIKit dismissal, so PHPicker presented over a dismissing Modal that never cleanly released. DropdownMenu now defers the selected item's onSelect until Modal onDismiss (UIKit-level completion) plus a short buffer on iOS, then invokes it. Same DropdownMenu instance — no per-call opt-in. Along the way, the Maestro repro surfaced a controlled-TextInput character-drop race: fast inputText on a controlled input drops characters mid-type. Direct-host and pair-link inputs are now ref-backed uncontrolled inputs with stable testIDs. New reusable E2E primitives under packages/app/maestro/: - flows/land-in-chat.yaml — clearState + direct connect to 127.0.0.1:6767, lands in the composer. Compose on this for any composer-level fixture. - image-picker-repro.yaml — regression for the zombie-backdrop bug. docs/MOBILE_TESTING.md gains the patterns: reach-the-composer via land-in-chat, uncontrolled inputs for Maestro-typed fields, and the dropdown → native-presenter dismissal timing on iOS. Existing relay-pairing and relay-then-direct fixtures updated to use stable testIDs instead of ambiguous text / point-based taps.
This commit is contained in:
@@ -247,8 +247,9 @@ export function AdaptiveModalSheet({
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||
<View style={styles.headerTitleGroup}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
|
||||
@@ -71,6 +71,7 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityLabel="Direct connection"
|
||||
testID="add-host-method-direct"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
@@ -93,6 +94,7 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityLabel="Paste pairing link"
|
||||
testID="add-host-method-pair-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
|
||||
@@ -155,29 +155,34 @@ export function AddHostModal({
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const hostInputRef = useRef<TextInput>(null);
|
||||
const endpointRawRef = useRef("");
|
||||
|
||||
const [endpointRaw, setEndpointRaw] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
endpointRawRef.current = "";
|
||||
hostInputRef.current?.clear();
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setEndpointRaw("");
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}, [isSaving, onClose]);
|
||||
}, [isSaving, clearInput, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setEndpointRaw("");
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, onCancel, onClose]);
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
const raw = endpointRaw.trim();
|
||||
const raw = endpointRawRef.current.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage("Host is required");
|
||||
return;
|
||||
@@ -240,16 +245,7 @@ export function AddHostModal({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
daemons,
|
||||
endpointRaw,
|
||||
handleClose,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
targetServerId,
|
||||
upsertDirectConnection,
|
||||
]);
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -264,8 +260,12 @@ export function AddHostModal({
|
||||
<Text style={styles.label}>Host</Text>
|
||||
<AdaptiveTextInput
|
||||
ref={hostInputRef}
|
||||
value={endpointRaw}
|
||||
onChangeText={setEndpointRaw}
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel="direct-host-input"
|
||||
onChangeText={(next) => {
|
||||
endpointRawRef.current = next;
|
||||
}}
|
||||
placeholder="hostname:port"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
@@ -289,6 +289,7 @@ export function AddHostModal({
|
||||
onPress={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
|
||||
testID="direct-host-submit"
|
||||
>
|
||||
{isSaving ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Alert, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
@@ -69,27 +69,33 @@ export function PairLinkModal({
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [offerUrl, setOfferUrl] = useState("");
|
||||
const offerUrlRef = useRef("");
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
offerUrlRef.current = "";
|
||||
inputRef.current?.clear();
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setOfferUrl("");
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}, [isSaving, onClose]);
|
||||
}, [isSaving, clearInput, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setOfferUrl("");
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, onCancel, onClose]);
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
const raw = offerUrl.trim();
|
||||
const raw = offerUrlRef.current.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
||||
return;
|
||||
@@ -159,16 +165,7 @@ export function PairLinkModal({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
daemons,
|
||||
handleClose,
|
||||
isMobile,
|
||||
isSaving,
|
||||
offerUrl,
|
||||
onSaved,
|
||||
targetServerId,
|
||||
upsertDaemonFromOfferUrl,
|
||||
]);
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -182,11 +179,13 @@ export function PairLinkModal({
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Pairing link</Text>
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
testID="pair-link-input"
|
||||
nativeID="pair-link-input"
|
||||
accessibilityLabel="pair-link-input"
|
||||
value={offerUrl}
|
||||
onChangeText={setOfferUrl}
|
||||
onChangeText={(next) => {
|
||||
offerUrlRef.current = next;
|
||||
}}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
|
||||
@@ -46,6 +46,8 @@ interface Rect {
|
||||
type DropdownMenuContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
|
||||
flushPendingSelect: () => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
};
|
||||
|
||||
@@ -167,19 +169,56 @@ export function DropdownMenu({
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const pendingSelectRef = useRef<(() => void) | null>(null);
|
||||
const [isOpen, setIsOpen] = useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const flushPendingSelect = useCallback(() => {
|
||||
const pendingSelect = pendingSelectRef.current;
|
||||
pendingSelectRef.current = null;
|
||||
if (!pendingSelect) return;
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
// Native presenters such as PHPicker can hang if launched while an RN
|
||||
// Modal is still completing dismissal on UIKit's side.
|
||||
setTimeout(pendingSelect, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSelect();
|
||||
}, []);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
|
||||
if (!closeOnSelect) {
|
||||
onSelect?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
pendingSelectRef.current = onSelect ?? null;
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
onSelect?.();
|
||||
},
|
||||
[setIsOpen],
|
||||
);
|
||||
|
||||
const value = useMemo<DropdownMenuContextValue>(
|
||||
() => ({
|
||||
open: isOpen,
|
||||
setOpen: setIsOpen,
|
||||
selectItem,
|
||||
flushPendingSelect,
|
||||
triggerRef,
|
||||
}),
|
||||
[isOpen, setIsOpen],
|
||||
[flushPendingSelect, isOpen, selectItem, setIsOpen],
|
||||
);
|
||||
|
||||
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
|
||||
@@ -266,7 +305,8 @@ export function DropdownMenuContent({
|
||||
horizontalPadding?: number;
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
|
||||
const { open, setOpen, triggerRef, flushPendingSelect } =
|
||||
useDropdownMenuContext("DropdownMenuContent");
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const [closing, setClosing] = useState(false);
|
||||
@@ -288,6 +328,12 @@ export function DropdownMenuContent({
|
||||
}
|
||||
}, [open, modalVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && !modalVisible) {
|
||||
flushPendingSelect();
|
||||
}
|
||||
}, [flushPendingSelect, modalVisible, open]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
@@ -377,6 +423,7 @@ export function DropdownMenuContent({
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onDismiss={flushPendingSelect}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
@@ -501,7 +548,7 @@ export function DropdownMenuItem({
|
||||
tooltip?: string;
|
||||
}>): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
|
||||
const { selectItem } = useDropdownMenuContext("DropdownMenuItem");
|
||||
|
||||
// Derive state from status prop (preferred) or legacy loading prop
|
||||
const isPending = status === "pending" || loading;
|
||||
@@ -539,10 +586,7 @@ export function DropdownMenuItem({
|
||||
disabled={isDisabled}
|
||||
onPress={() => {
|
||||
if (isDisabled) return;
|
||||
if (closeOnSelect) {
|
||||
setOpen(false);
|
||||
}
|
||||
onSelect?.();
|
||||
selectItem(onSelect, closeOnSelect);
|
||||
}}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.item,
|
||||
|
||||
Reference in New Issue
Block a user