studio/chat: release stuck IME flag when compositionend never fires

Chrome on Windows talking to a WSL-hosted Studio (issue #5546) fires
compositionstart + compositionupdate but no compositionend after the
IME commits. The earlier hardening in #5327 cleared the stale flag on
the next non-composing input event, which never arrives in this
sequence, so composingRef stays true forever and the Send button stays
disabled even though the committed CJK text is already in the textarea.

Add a watchdog in both useImeComposerInputHandlers (main + edit
composer) and SharedComposer (compare mode) that runs the same reset
the missing compositionend would have done. The timer is rearmed on
every compositionupdate and on every non-composing input so it only
fires when the IME pipeline has actually gone quiet — normal candidate
selection keeps it alive, the WSL stuck case lets it expire.

Extends the existing IME Playwright smoke with a stuck-compositionend
repro and adds a static guard so the watchdog can't be removed without
the regression tests catching it.
This commit is contained in:
upadhyayashwin 2026-05-18 17:19:37 +05:30
commit ec2d407217
4 changed files with 173 additions and 5 deletions

View file

@ -353,16 +353,58 @@ function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Fallback timeout for stuck IME composition. When Chrome on Windows talks
// to a WSL-hosted Studio (issue #5546), `compositionend` never fires after
// the candidate is committed, so `composingRef` stays true and Send stays
// disabled. Every compositionupdate / non-composing input resets the timer;
// only a true gap-after-commit lets it fire. 2500ms is well above a normal
// candidate-window pause but short enough to recover before the user
// notices the Send button is stuck.
const IME_STUCK_TIMEOUT_MS = 2500;
function useImeComposerInputHandlers() {
const aui = useAui();
const composingRef = useRef(false);
const [isComposing, setIsComposing] = useState(false);
const stuckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setCompositionState = useCallback((next: boolean) => {
composingRef.current = next;
setIsComposing(next);
const clearStuckTimer = useCallback(() => {
if (stuckTimerRef.current) {
clearTimeout(stuckTimerRef.current);
stuckTimerRef.current = null;
}
}, []);
const setCompositionState = useCallback(
(next: boolean) => {
composingRef.current = next;
setIsComposing(next);
clearStuckTimer();
if (next) {
stuckTimerRef.current = setTimeout(() => {
stuckTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
},
[clearStuckTimer],
);
const refreshStuckTimer = useCallback(() => {
if (!composingRef.current) {
return;
}
clearStuckTimer();
stuckTimerRef.current = setTimeout(() => {
stuckTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}, [clearStuckTimer]);
useEffect(() => clearStuckTimer, [clearStuckTimer]);
const setComposerText = useCallback(
(value: string) => {
const composer = aui.composer();
@ -380,6 +422,10 @@ function useImeComposerInputHandlers() {
setCompositionState(true);
}, [setCompositionState]);
const onCompositionUpdate = useCallback(() => {
refreshStuckTimer();
}, [refreshStuckTimer]);
const onCompositionEnd = useCallback(
(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
@ -399,6 +445,7 @@ function useImeComposerInputHandlers() {
return {
inputProps: {
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onChange,
},

View file

@ -68,6 +68,11 @@ function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Mirrors the threshold in thread.tsx — see the comment there. Chrome on
// Windows-over-WSL (issue #5546) never fires `compositionend` after the
// IME commit, so the compose flag would otherwise stay true forever.
const IME_STUCK_TIMEOUT_MS = 2500;
function fileToBase64DataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -284,6 +289,7 @@ export function SharedComposer({
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null);
@ -474,11 +480,40 @@ export function SharedComposer({
setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []);
function clearStuckImeTimer() {
if (stuckImeTimerRef.current) {
clearTimeout(stuckImeTimerRef.current);
stuckImeTimerRef.current = null;
}
}
function setCompositionState(next: boolean) {
composingRef.current = next;
setIsComposing(next);
clearStuckImeTimer();
if (next) {
stuckImeTimerRef.current = setTimeout(() => {
stuckImeTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
}
function refreshStuckImeTimer() {
if (!composingRef.current) {
return;
}
clearStuckImeTimer();
stuckImeTimerRef.current = setTimeout(() => {
stuckImeTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
useEffect(() => () => clearStuckImeTimer(), []);
async function send() {
if (composingRef.current) return;
const msg = text.trim();
@ -753,6 +788,9 @@ export function SharedComposer({
onCompositionStart={() => {
setCompositionState(true);
}}
onCompositionUpdate={() => {
refreshStuckImeTimer();
}}
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
setText(e.currentTarget.value);

View file

@ -3,12 +3,16 @@
"""Studio chat composer IME + multilingual regression smoke.
Covers two surfaces:
Covers three surfaces:
A. Stuck IME composition (issue #5318 / PR #5327): duplicate
compositionstart with no compositionend left isComposing=true,
dropping all subsequent keystrokes including ASCII.
B. Multilingual paste round-trip across 31 scripts -- guards the
controlled-textarea / React state plumbing against Unicode mangling.
C. Stuck compositionend (issue #5546): Chrome on Windows over WSL
fires compositionstart + compositionupdate but never compositionend,
wedging Send disabled after the IME commits. Verifies the
watchdog in useImeComposerInputHandlers releases the flag.
Model-free; the bug surface is the composer, not inference.
@ -424,6 +428,57 @@ with sync_playwright() as p:
info("stuck-composition recovery PASS")
clear()
# 6b. WSL + Windows Chrome repro for issue #5546: Chrome never emits
# compositionend after the IME commit, so the watchdog has to
# release the composing flag on its own once the events go silent.
# This dispatches a realistic "compose, commit, then nothing"
# sequence — no compositionend, no follow-up keystrokes — and
# waits for the Send button to come back enabled.
step("BUG REPRO: stuck compositionend recovery (issue #5546)")
clear()
composer.click()
composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:''}));
el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'你好'}));
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
).set;
setter.call(el, el.value + '你好');
el.dispatchEvent(new InputEvent('input', {
bubbles:true, inputType:'insertCompositionText',
data:'你好', isComposing:true,
}));
// Deliberately omit compositionend that is the WSL/Chrome
// bug surface. The watchdog in useImeComposerInputHandlers
// should reset isComposing after IME_STUCK_TIMEOUT_MS.
}"""
)
send_btn_5546 = page.locator('button[aria-label="Send message"]')
if send_btn_5546.count() == 0:
soft_fail("Send button not found for #5546 repro")
else:
# Watchdog is 2500ms; allow generous slack for slow CI.
try:
expect(send_btn_5546).not_to_be_disabled(timeout = 8_000)
info("Send button enabled after compositionend never fired")
except Exception:
shoot("06b-compositionend-watchdog-FAIL")
fail(
"Send button stayed disabled with no compositionend — "
"watchdog did not release the composing flag (issue #5546)."
)
after_value = read_value()
if "你好" not in after_value:
soft_fail(
f"compositionend-watchdog repro lost committed text: {after_value!r}"
)
shoot("06b-compositionend-watchdog")
info("compositionend watchdog recovery PASS")
clear()
# 7. Final state. The change-password redirect emits benign 401 noise,
# so we filter via is_benign_* and only fail on real errors.
shoot("07-final")
@ -451,7 +506,8 @@ with sync_playwright() as p:
info(
f"DONE: ascii=OK paste={len(I18N_SAMPLES)}/{len(I18N_SAMPLES)} "
f"normal_composition=OK stuck_recovery=OK"
f"normal_composition=OK stuck_recovery=OK "
f"compositionend_watchdog=OK"
)
_watchdog.cancel()
browser.close()

View file

@ -71,3 +71,30 @@ def test_ime_playwright_script_does_not_read_studio_old_pw():
"STUDIO_OLD_PW" not in code_only
), "IME Playwright script still references dead STUDIO_OLD_PW env var"
assert 'os.environ["STUDIO_NEW_PW"]' in code_only
def test_main_composer_has_stuck_compositionend_watchdog():
"""Issue #5546: Chrome on Windows over WSL never emits compositionend
after the IME commit. The composer keeps a watchdog that releases the
composing flag once events go silent; without it Send stays disabled
forever and CJK input is effectively dropped."""
src = THREAD_TSX.read_text()
assert "IME_STUCK_TIMEOUT_MS" in src, (
"main composer is missing the stuck-compositionend watchdog "
"(issue #5546)"
)
assert "onCompositionUpdate" in src, (
"main composer is missing onCompositionUpdate wiring; the "
"watchdog only resets while the IME is actively emitting events"
)
def test_compare_composer_has_stuck_compositionend_watchdog():
src = SHARED_TSX.read_text()
assert "IME_STUCK_TIMEOUT_MS" in src, (
"compare composer is missing the stuck-compositionend watchdog "
"(issue #5546)"
)
assert "onCompositionUpdate" in src, (
"compare composer is missing onCompositionUpdate wiring"
)