From ba91e3ba7cd483468c5fdb72cbea41f00264edbf Mon Sep 17 00:00:00 2001 From: chulmin-dev Date: Tue, 18 Aug 2026 17:54:46 +0900 Subject: [PATCH] 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 --- .../input/text-input.web.browser.test.tsx | 44 ++++++++++++++++--- .../app/src/composer/input/text-input.web.tsx | 24 ++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 82fa0dc17..14783da48 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -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; } 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 = 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( 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(); + 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(""); }); }); diff --git a/packages/app/src/composer/input/text-input.web.tsx b/packages/app/src/composer/input/text-input.web.tsx index d6559dc2d..7c6bf63a8 100644 --- a/packages/app/src/composer/input/text-input.web.tsx +++ b/packages/app/src/composer/input/text-input.web.tsx @@ -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(null); const textRef = useRef(text); + const isComposingRef = useRef(false); const onChangeTextRef = useRef(onChangeText); onChangeTextRef.current = onChangeText; @@ -28,7 +32,11 @@ export const ComposerTextInput = forwardRef { + 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 { + 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 ({ 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;