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:
co-authored by
Mohamed Boudra
parent
2347242cdf
commit
ba91e3ba7c
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user