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:
Mohamed Boudra
2026-04-17 14:07:15 +07:00
parent adf2f5921f
commit 9a8b01a738
11 changed files with 224 additions and 49 deletions
@@ -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,