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:
parent
d774af2041
commit
ec2d407217
4 changed files with 173 additions and 5 deletions
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue