Files
paseo/packages/app/src/composer/input/text-input.web.browser.test.tsx
T
Mohamed Boudra 1622da40d9 Keep the mobile terminal keyboard open while typing (#4469)
* fix(app): keep terminal keyboard focus stable

Clearing the terminal buffer remounted the shared native input, triggering an IME hide/show cycle. Keep text replacement in place and make the composer explicitly reset its editor to preserve multiline layout clearing.

* test(app): verify terminal keyboard continuity on Android

Replace the new JSDOM regression with a real Android harness. It detects focused native input replacement and IME hide/show requests through terminal toolbar controls, and fails with the previous remount behavior.
2026-09-08 09:48:48 +02:00

211 lines
6.1 KiB
TypeScript

import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { EditingTextInput as ComposerTextInput } from "@/components/ui/text-input/text-input.web";
import type { EditingTextInputHandle as ComposerTextInputHandle } from "@/components/ui/text-input";
interface MountedInput {
root: Root;
container: HTMLDivElement;
textarea: HTMLTextAreaElement;
inputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
}
interface TextRecorder {
changes: string[];
onChangeText: (text: string) => void;
}
const mountedInputs: 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);
act(() => {
root.render(
<ComposerTextInput
ref={inputRef}
initialValue=""
multiline={true}
onChangeText={onChangeText}
testID="composer-input"
/>,
);
});
const textarea = container.querySelector("textarea");
if (!textarea) {
throw new Error("Composer text input did not render a textarea");
}
const mounted = { root, container, textarea, inputRef };
mountedInputs.push(mounted);
return mounted;
}
function dispatchComposition(
textarea: HTMLTextAreaElement,
type: "compositionstart" | "compositionend",
) {
textarea.dispatchEvent(new CompositionEvent(type, { bubbles: true }));
}
function typeFromIme(textarea: HTMLTextAreaElement, text: string): void {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
if (!valueSetter) {
throw new Error("HTML textarea value setter is unavailable");
}
valueSetter.call(textarea, text);
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
}
function ignoreTextChange(): void {}
function createTextRecorder(): TextRecorder {
const changes: string[] = [];
return {
changes,
onChangeText: (text) => changes.push(text),
};
}
afterEach(() => {
for (const mounted of mountedInputs.splice(0)) {
act(() => mounted.root.unmount());
mounted.container.remove();
}
});
describe("ComposerTextInput web IME composition", () => {
it("resets the composer text while preserving focus", () => {
const mounted = mountInput(ignoreTextChange);
act(() => {
mounted.inputRef.current?.focus();
typeFromIme(mounted.textarea, "line one\nline two");
});
act(() => mounted.inputRef.current?.reset());
expect(mounted.textarea.value).toBe("");
expect(mounted.inputRef.current?.getText()).toBe("");
expect(document.activeElement).toBe(mounted.textarea);
});
it("keeps locally typed text when its parent rerenders with a stale value", () => {
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
act(() => {
typeFromIme(mounted.textarea, "locally typed");
mounted.root.render(
<ComposerTextInput
initialValue=""
multiline={true}
onChangeText={recorder.onChangeText}
placeholder="rerender with stale publication"
testID="composer-input"
/>,
);
});
expect(mounted.textarea.value).toBe("locally typed");
expect(recorder.changes).toEqual(["locally typed"]);
});
it("keeps the DOM-owned candidate during composition and reports the committed text", () => {
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
act(() => {
dispatchComposition(mounted.textarea, "compositionstart");
mounted.textarea.value = "你好";
mounted.root.render(
<ComposerTextInput
initialValue=""
multiline={true}
onChangeText={recorder.onChangeText}
placeholder="rerender while composing"
testID="composer-input"
/>,
);
});
expect(mounted.textarea.value).toBe("你好");
act(() => {
dispatchComposition(mounted.textarea, "compositionend");
});
expect(recorder.changes).toEqual(["你好"]);
});
it("keeps the latest candidate across consecutive compositions", () => {
const mounted = mountInput(ignoreTextChange);
act(() => {
dispatchComposition(mounted.textarea, "compositionstart");
mounted.textarea.value = "你";
dispatchComposition(mounted.textarea, "compositionend");
dispatchComposition(mounted.textarea, "compositionstart");
mounted.textarea.value = "你好";
mounted.root.render(
<ComposerTextInput
initialValue="你"
multiline={true}
onChangeText={ignoreTextChange}
placeholder="second composition"
testID="composer-input"
/>,
);
});
expect(mounted.textarea.value).toBe("你好");
});
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, "한");
});
expect(changes).toEqual([]);
act(() => {
dispatchComposition(mounted.textarea, "compositionend");
});
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("");
});
});