fix(app): preserve IME composition across text fields (#3517)

* fix(app): preserve IME composition in form inputs

Form model publication was replaying changing default text into the native or DOM editing surface. Keep the editing surface authoritative, defer web publication until composition commits, and reserve resetKey changes for explicit text replacement.

* refactor(app): centralize text editing authority

Route every app text field, including the composer and plugin runtime, through one platform-owned editing primitive. Its type and runtime reject controlled value replay, its command handle owns programmatic replacement, and lint prevents raw React Native TextInput imports outside the boundary.

* fix(app): restore composer placeholder styling
This commit is contained in:
Mohamed Boudra
2026-08-18 11:32:49 +00:00
committed by GitHub
parent cd2b6bca9d
commit e2e97e4854
39 changed files with 575 additions and 390 deletions
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -26,6 +26,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProjectEditSheet } from "@/components/project-edit-sheet";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -1063,7 +1064,7 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
<TextInput
testID="script-edit-name"
accessibilityLabel={t("settings.project.scripts.nameAccessibility")}
value={script.name}
initialValue={script.name}
onChangeText={handleNameChange}
onBlur={handleNameBlur}
placeholder="dev"
@@ -1082,7 +1083,7 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
testID="script-edit-command"
accessibilityLabel={t("settings.project.scripts.commandAccessibility")}
multiline
value={script.commandText}
initialValue={script.commandText}
onChangeText={handleCommandChange}
onBlur={handleCommandBlur}
placeholder="npm run dev"
+2 -2
View File
@@ -5,10 +5,10 @@ import {
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -448,7 +448,7 @@ function GeneralSection({
</Text>
</View>
<TextInput
value={terminalScrollbackValue}
initialValue={terminalScrollbackValue}
onChangeText={handleTerminalScrollbackChangeText}
onBlur={commitTerminalScrollback}
onSubmitEditing={commitTerminalScrollback}
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, TextInput, View, type PressableStateCallbackType } from "react-native";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
import {
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import {
MAX_CODE_FONT_SIZE,
MAX_UI_BASE_FONT_SIZE,
@@ -332,7 +333,7 @@ function FontFamilyRow({
<Text style={settingsStyles.rowHint}>{hint}</Text>
</View>
<TextInput
value={draft}
initialValue={draft}
onChangeText={onChangeDraft}
onBlur={handleCommit}
onSubmitEditing={handleCommit}
@@ -372,7 +373,7 @@ function FontSizeRow({
</View>
<View style={styles.sizeField}>
<TextInput
value={draft}
initialValue={draft}
onChangeText={onChangeDraft}
onBlur={onCommit}
onSubmitEditing={onCommit}
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
export interface ProfileDraft {
name: string;
@@ -41,9 +42,9 @@ export function TerminalProfileEditModal({
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const nameInputRef = useRef<TextInput>(null);
const commandInputRef = useRef<TextInput>(null);
const argsInputRef = useRef<TextInput>(null);
const nameInputRef = useRef<EditingTextInputHandle>(null);
const commandInputRef = useRef<EditingTextInputHandle>(null);
const argsInputRef = useRef<EditingTextInputHandle>(null);
const handleNameChange = useCallback((value: string) => {
setName(value);