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 { afterEach, describe, expect, it } from "vitest";
import { ComposerTextInput } from "./text-input.web";
import type { ComposerTextInputHandle } from "./text-input-types";
interface MountedInput {
root: Root;
container: HTMLDivElement;
textarea: HTMLTextAreaElement;
inputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
}
interface TextRecorder {
@@ -16,7 +18,10 @@ interface TextRecorder {
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");
document.body.appendChild(container);
const root = createRoot(container);
@@ -24,6 +29,7 @@ function mountInput(onChangeText: (text: string) => void): MountedInput {
act(() => {
root.render(
<ComposerTextInput
ref={inputRef}
text=""
multiline={true}
onChangeText={onChangeText}
@@ -37,7 +43,7 @@ function mountInput(onChangeText: (text: string) => void): MountedInput {
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);
return mounted;
}
@@ -147,16 +153,44 @@ describe("ComposerTextInput web IME composition", () => {
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 mounted = mountInput((text) => changes.push(text));
act(() => {
dispatchComposition(mounted.textarea, "compositionstart");
typeFromIme(mounted.textarea, "你好");
typeFromIme(mounted.textarea, "ㅎ");
typeFromIme(mounted.textarea, "하");
typeFromIme(mounted.textarea, "한");
});
expect(changes).toEqual([]);
act(() => {
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 {
value?: string;
setSelectionRange?: (start: number, end: number) => void;
addEventListener: (type: "compositionend", listener: EventListener) => void;
removeEventListener: (type: "compositionend", listener: EventListener) => void;
addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void;
removeEventListener: (
type: "compositionstart" | "compositionend",
listener: EventListener,
) => void;
}
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
@@ -21,6 +24,7 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
) {
const inputRef = useRef<TextInput | null>(null);
const textRef = useRef(text);
const isComposingRef = useRef(false);
const onChangeTextRef = useRef(onChangeText);
onChangeTextRef.current = onChangeText;
@@ -28,7 +32,11 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
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;
@@ -36,14 +44,17 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
}
};
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);
@@ -54,7 +65,14 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
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) => {
textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null;