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 { 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user