fix(app): defer composer updates during IME composition (#3462)

* fix(app): defer composer updates during IME composition

* fix(app): read live DOM value from web composer getText

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
chulmin-dev
2026-08-18 08:54:46 +00:00
committed by GitHub
co-authored by Mohamed Boudra
parent 2347242cdf
commit ba91e3ba7c
2 changed files with 60 additions and 8 deletions
@@ -2,11 +2,13 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { ComposerTextInput } from "./text-input.web"; import { ComposerTextInput } from "./text-input.web";
import type { ComposerTextInputHandle } from "./text-input-types";
interface MountedInput { interface MountedInput {
root: Root; root: Root;
container: HTMLDivElement; container: HTMLDivElement;
textarea: HTMLTextAreaElement; textarea: HTMLTextAreaElement;
inputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
} }
interface TextRecorder { interface TextRecorder {
@@ -16,7 +18,10 @@ interface TextRecorder {
const mountedInputs: MountedInput[] = []; const mountedInputs: MountedInput[] = [];
function mountInput(onChangeText: (text: string) => void): MountedInput { function mountInput(
onChangeText: (text: string) => void,
inputRef: React.MutableRefObject<ComposerTextInputHandle | null> = React.createRef(),
): MountedInput {
const container = document.createElement("div"); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
const root = createRoot(container); const root = createRoot(container);
@@ -24,6 +29,7 @@ function mountInput(onChangeText: (text: string) => void): MountedInput {
act(() => { act(() => {
root.render( root.render(
<ComposerTextInput <ComposerTextInput
ref={inputRef}
text="" text=""
multiline={true} multiline={true}
onChangeText={onChangeText} onChangeText={onChangeText}
@@ -37,7 +43,7 @@ function mountInput(onChangeText: (text: string) => void): MountedInput {
throw new Error("Composer text input did not render a textarea"); throw new Error("Composer text input did not render a textarea");
} }
const mounted = { root, container, textarea }; const mounted = { root, container, textarea, inputRef };
mountedInputs.push(mounted); mountedInputs.push(mounted);
return mounted; return mounted;
} }
@@ -147,16 +153,44 @@ describe("ComposerTextInput web IME composition", () => {
expect(mounted.textarea.value).toBe("你好"); expect(mounted.textarea.value).toBe("你好");
}); });
it("does not report committed text twice when the input event already reported it", () => { it("defers Korean IME input events until composition commits", () => {
const changes: string[] = []; const changes: string[] = [];
const mounted = mountInput((text) => changes.push(text)); const mounted = mountInput((text) => changes.push(text));
act(() => { act(() => {
dispatchComposition(mounted.textarea, "compositionstart"); dispatchComposition(mounted.textarea, "compositionstart");
typeFromIme(mounted.textarea, "你好"); typeFromIme(mounted.textarea, "ㅎ");
typeFromIme(mounted.textarea, "하");
typeFromIme(mounted.textarea, "한");
});
expect(changes).toEqual([]);
act(() => {
dispatchComposition(mounted.textarea, "compositionend"); dispatchComposition(mounted.textarea, "compositionend");
}); });
expect(changes).toEqual(["你好"]); expect(changes).toEqual(["한"]);
});
it("reads the live DOM value for send-path text extraction during composition", () => {
const inputRef = React.createRef<ComposerTextInputHandle>();
const mounted = mountInput(ignoreTextChange, inputRef);
let queuedText = "";
act(() => {
dispatchComposition(mounted.textarea, "compositionstart");
typeFromIme(mounted.textarea, "old한");
queuedText = mounted.inputRef.current?.getText() ?? "";
});
expect(queuedText).toBe("old한");
expect(mounted.textarea.value).toBe("old한");
act(() => {
mounted.inputRef.current?.replaceText("");
});
expect(mounted.textarea.value).toBe("");
}); });
}); });
@@ -6,8 +6,11 @@ import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-inp
interface WebTextInputElement extends TextInput { interface WebTextInputElement extends TextInput {
value?: string; value?: string;
setSelectionRange?: (start: number, end: number) => void; setSelectionRange?: (start: number, end: number) => void;
addEventListener: (type: "compositionend", listener: EventListener) => void; addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void;
removeEventListener: (type: "compositionend", listener: EventListener) => void; removeEventListener: (
type: "compositionstart" | "compositionend",
listener: EventListener,
) => void;
} }
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
@@ -21,6 +24,7 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
) { ) {
const inputRef = useRef<TextInput | null>(null); const inputRef = useRef<TextInput | null>(null);
const textRef = useRef(text); const textRef = useRef(text);
const isComposingRef = useRef(false);
const onChangeTextRef = useRef(onChangeText); const onChangeTextRef = useRef(onChangeText);
onChangeTextRef.current = onChangeText; onChangeTextRef.current = onChangeText;
@@ -28,7 +32,11 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
const input = inputRef.current as WebTextInputElement | null; const input = inputRef.current as WebTextInputElement | null;
if (!input) return; if (!input) return;
const startComposition = () => {
isComposingRef.current = true;
};
const endComposition = () => { const endComposition = () => {
isComposingRef.current = false;
const nextText = input.value ?? ""; const nextText = input.value ?? "";
if (nextText !== textRef.current) { if (nextText !== textRef.current) {
textRef.current = nextText; textRef.current = nextText;
@@ -36,14 +44,17 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
} }
}; };
input.addEventListener("compositionstart", startComposition);
input.addEventListener("compositionend", endComposition); input.addEventListener("compositionend", endComposition);
return () => { return () => {
input.removeEventListener("compositionstart", startComposition);
input.removeEventListener("compositionend", endComposition); input.removeEventListener("compositionend", endComposition);
}; };
}, []); }, []);
const handleChangeText = useCallback( const handleChangeText = useCallback(
(nextText: string) => { (nextText: string) => {
if (isComposingRef.current) return;
if (nextText === textRef.current) return; if (nextText === textRef.current) return;
textRef.current = nextText; textRef.current = nextText;
onChangeText(nextText); onChangeText(nextText);
@@ -54,7 +65,14 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(), focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(), blur: () => inputRef.current?.blur(),
getText: () => textRef.current, 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) => { replaceText: (nextText, selection) => {
textRef.current = nextText; textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null; const input = inputRef.current as WebTextInputElement | null;