Merge PR #6229: fix(collab-web): guard composer submit against IME composition (@roboomp)

This commit is contained in:
can1357
2026-07-22 21:13:22 +02:00
3 changed files with 80 additions and 4 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Prevented IME composition (Korean/Japanese/Chinese) from duplicating the last character when pressing Enter to commit in the collab web composer. ([#6163](https://github.com/can1357/oh-my-pi/issues/6163))
## [17.0.1] - 2026-07-16
### Fixed
@@ -1,5 +1,5 @@
import { SendHorizontal, Square } from "lucide-react";
import type { KeyboardEvent, ReactNode } from "react";
import type { KeyboardEvent, ReactNode, RefObject } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import type { GuestClient, GuestSnapshot } from "../../lib/client";
@@ -21,6 +21,39 @@ function autosize(el: HTMLTextAreaElement | null): void {
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}
/**
* Decides whether an Enter keydown should commit the composer. Returns `false` while an IME
* composition is active so the keystroke confirms the composition instead of submitting.
* `nativeEvent.isComposing` covers most browsers; `composing` bridges WebKit, which fires the
* confirming Enter keydown *after* `compositionend`.
*/
export function shouldSubmitOnEnter(e: KeyboardEvent<HTMLTextAreaElement>, composing: boolean): boolean {
if (e.key !== "Enter" || e.shiftKey) return false;
return !(e.nativeEvent.isComposing || composing);
}
/**
* Tracks IME composition state via a ref the keydown handler reads synchronously. The
* `compositionend` reset is deferred a tick because WebKit dispatches the confirming Enter
* keydown after `compositionend`, when `nativeEvent.isComposing` is already `false`.
*/
function useCompositionGuard(): {
composingRef: RefObject<boolean>;
onCompositionStart(): void;
onCompositionEnd(): void;
} {
const composingRef = useRef(false);
const onCompositionStart = useCallback((): void => {
composingRef.current = true;
}, []);
const onCompositionEnd = useCallback((): void => {
setTimeout(() => {
composingRef.current = false;
}, 0);
}, []);
return { composingRef, onCompositionStart, onCompositionEnd };
}
interface AskEditorProps {
prefill: string | undefined;
onSubmit(value: string): void;
@@ -34,13 +67,14 @@ interface AskEditorProps {
function AskEditor({ prefill, onSubmit }: AskEditorProps): ReactNode {
const [draft, setDraft] = useState(prefill ?? "");
const taRef = useRef<HTMLTextAreaElement | null>(null);
const { composingRef, onCompositionStart, onCompositionEnd } = useCompositionGuard();
useLayoutEffect(() => {
autosize(taRef.current);
}, [draft]);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
if (shouldSubmitOnEnter(e, composingRef.current)) {
e.preventDefault();
onSubmit(draft);
}
@@ -54,6 +88,8 @@ function AskEditor({ prefill, onSubmit }: AskEditorProps): ReactNode {
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder="type your response…"
rows={1}
spellCheck={false}
@@ -75,6 +111,7 @@ function AskEditor({ prefill, onSubmit }: AskEditorProps): ReactNode {
export function Composer({ client, snapshot }: ComposerProps): ReactNode {
const [text, setText] = useState("");
const taRef = useRef<HTMLTextAreaElement | null>(null);
const { composingRef, onCompositionStart, onCompositionEnd } = useCompositionGuard();
const live = snapshot.phase === "live";
const readOnly = snapshot.readOnly;
@@ -96,7 +133,7 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
}, [client, live, readOnly, text]);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
if (shouldSubmitOnEnter(e, composingRef.current)) {
e.preventDefault();
send();
}
@@ -167,6 +204,8 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder={
readOnly
? "read-only session — watching only"
+34 -1
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from "bun:test";
import type { KeyboardEvent } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { GuestSnapshot } from "../src/lib/client";
import { GuestClient } from "../src/lib/client";
import { Composer } from "../src/components/shell/Composer";
import { Composer, shouldSubmitOnEnter } from "../src/components/shell/Composer";
import { encodeBase64Url } from "../src/lib/link";
const LINK = `roomroomroom1234#${encodeBase64Url(new Uint8Array(32))}`;
@@ -77,3 +78,35 @@ describe("Composer host UI requests", () => {
expect(submit.disabled).toBe(false);
});
});
type KeyEvt = KeyboardEvent<HTMLTextAreaElement>;
function keydown(key: string, opts: { shiftKey?: boolean; isComposing?: boolean } = {}): KeyEvt {
return {
key,
shiftKey: opts.shiftKey ?? false,
nativeEvent: { isComposing: opts.isComposing ?? false },
} as KeyEvt;
}
describe("shouldSubmitOnEnter IME guard", () => {
it("submits on a plain Enter with no composition", () => {
expect(shouldSubmitOnEnter(keydown("Enter"), false)).toBe(true);
});
it("does not submit while nativeEvent.isComposing is true", () => {
expect(shouldSubmitOnEnter(keydown("Enter", { isComposing: true }), false)).toBe(false);
});
it("does not submit while the WebKit composing ref is still set", () => {
expect(shouldSubmitOnEnter(keydown("Enter"), true)).toBe(false);
});
it("does not submit on Shift+Enter (newline)", () => {
expect(shouldSubmitOnEnter(keydown("Enter", { shiftKey: true }), false)).toBe(false);
});
it("ignores non-Enter keys", () => {
expect(shouldSubmitOnEnter(keydown("a"), false)).toBe(false);
});
});