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
+19 -20
View File
@@ -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}