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
+11 -5
View File
@@ -2,7 +2,6 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Text,
TextInput,
useWindowDimensions,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
@@ -59,8 +58,15 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import { ComposerTextInput } from "./text-input";
import type { ComposerTextInputHandle } from "./text-input-types";
import {
EditingTextInput,
type EditingTextInputHandle as ComposerTextInputHandle,
type EditingTextInputProps,
} from "@/components/ui/text-input";
const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.surface4,
}));
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
@@ -622,7 +628,7 @@ interface ComposerTextSurfaceProps {
readOnly: boolean;
value: string;
textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: React.ComponentProps<typeof TextInput>["style"];
textInputStyle: EditingTextInputProps["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
placeholder: string;
accessibilityLabel: string;
@@ -662,7 +668,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
<ComposerTextInput
ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET}
text={props.value}
initialValue={props.value}
onChangeText={props.onChangeText}
placeholder={props.placeholder}
accessibilityLabel={props.accessibilityLabel}
@@ -1,20 +0,0 @@
import type { TextInputProps } from "react-native";
import type { NativePastedFile } from "@/composer/native-pasted-image";
export interface ComposerTextInputHandle {
focus: () => void;
blur: () => void;
getText: () => string;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
getNativeRef?: () => unknown;
}
export interface ComposerTextInputProps extends Omit<
TextInputProps,
"defaultValue" | "onChangeText" | "value"
> {
text: string;
onChangeText: (text: string) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
onPasteError?: (message: string) => void;
}
-1
View File
@@ -1 +0,0 @@
export * from "./text-input.native";
@@ -1,72 +0,0 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import PasteInput, {
type PastedFile,
type PasteTextInputInstance,
} from "@mattermost/react-native-paste-input";
import { withUnistyles } from "react-native-unistyles";
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
type NativeInputInstance = PasteTextInputInstance & {
blur: () => void;
focus: () => void;
setNativeProps: (props: { text: string }) => void;
};
const ThemedPasteInput = withUnistyles(PasteInput, (theme) => ({
placeholderTextColor: theme.colors.surface4,
}));
export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>(
function ComposerTextInputNative(
{ text, onChangeText, onPasteImages, onPasteError, ...props },
ref,
) {
const inputRef = useRef<NativeInputInstance | null>(null);
const textRef = useRef(text);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
inputRef.current?.setNativeProps({ text: nextText });
if (selection) {
inputRef.current?.setSelection(selection.start, selection.end);
}
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}));
const handleChangeText = useCallback(
(nextText: string) => {
textRef.current = nextText;
onChangeText(nextText);
},
[onChangeText],
);
const handlePaste = useCallback(
(error: string | null | undefined, files: PastedFile[]) => {
if (error) {
onPasteError?.(error);
return;
}
if (files.length > 0) {
onPasteImages?.(files);
}
},
[onPasteError, onPasteImages],
);
return (
<ThemedPasteInput
{...props}
ref={inputRef}
defaultValue={text}
onChangeText={handleChangeText}
onPaste={handlePaste}
/>
);
},
);
@@ -1,8 +1,8 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { ComposerTextInput } from "./text-input.web";
import type { ComposerTextInputHandle } from "./text-input-types";
import { EditingTextInput as ComposerTextInput } from "@/components/ui/text-input/text-input.web";
import type { EditingTextInputHandle as ComposerTextInputHandle } from "@/components/ui/text-input";
interface MountedInput {
root: Root;
@@ -30,7 +30,7 @@ function mountInput(
root.render(
<ComposerTextInput
ref={inputRef}
text=""
initialValue=""
multiline={true}
onChangeText={onChangeText}
testID="composer-input"
@@ -90,7 +90,7 @@ describe("ComposerTextInput web IME composition", () => {
typeFromIme(mounted.textarea, "locally typed");
mounted.root.render(
<ComposerTextInput
text=""
initialValue=""
multiline={true}
onChangeText={recorder.onChangeText}
placeholder="rerender with stale publication"
@@ -112,7 +112,7 @@ describe("ComposerTextInput web IME composition", () => {
mounted.textarea.value = "你好";
mounted.root.render(
<ComposerTextInput
text=""
initialValue=""
multiline={true}
onChangeText={recorder.onChangeText}
placeholder="rerender while composing"
@@ -141,7 +141,7 @@ describe("ComposerTextInput web IME composition", () => {
mounted.textarea.value = "你好";
mounted.root.render(
<ComposerTextInput
text="你"
initialValue="你"
multiline={true}
onChangeText={ignoreTextChange}
placeholder="second composition"
@@ -1,98 +0,0 @@
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { TextInput } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
interface WebTextInputElement extends TextInput {
value?: string;
setSelectionRange?: (start: number, end: number) => void;
addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void;
removeEventListener: (
type: "compositionstart" | "compositionend",
listener: EventListener,
) => void;
}
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.surface4,
}));
export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>(
function ComposerTextInputWeb(
{ text, onChangeText, onPasteImages: _, onPasteError: __, ...props },
ref,
) {
const inputRef = useRef<TextInput | null>(null);
const textRef = useRef(text);
const isComposingRef = useRef(false);
const onChangeTextRef = useRef(onChangeText);
onChangeTextRef.current = onChangeText;
useEffect(() => {
const input = inputRef.current as WebTextInputElement | null;
if (!input) return;
const startComposition = () => {
isComposingRef.current = true;
};
const endComposition = () => {
isComposingRef.current = false;
const nextText = input.value ?? "";
if (nextText !== textRef.current) {
textRef.current = nextText;
onChangeTextRef.current(nextText);
}
};
input.addEventListener("compositionstart", startComposition);
input.addEventListener("compositionend", endComposition);
return () => {
input.removeEventListener("compositionstart", startComposition);
input.removeEventListener("compositionend", endComposition);
};
}, []);
const handleChangeText = useCallback(
(nextText: string) => {
if (isComposingRef.current) return;
if (nextText === textRef.current) return;
textRef.current = nextText;
onChangeText(nextText);
},
[onChangeText],
);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
getText: () => {
const input = inputRef.current as WebTextInputElement | null;
const nextText = input?.value ?? textRef.current;
if (nextText !== textRef.current) {
textRef.current = nextText;
}
return nextText;
},
replaceText: (nextText, selection) => {
textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) {
input.value = nextText;
}
if (selection && typeof input?.setSelectionRange === "function") {
input.setSelectionRange(selection.start, selection.end);
}
},
getNativeRef: () => inputRef.current,
}));
return (
<ThemedTextInput
{...props}
ref={inputRef}
defaultValue={text}
onChangeText={handleChangeText}
/>
);
},
);