studio/chat: re-pin composing flag on IME keydown to close #5546 watchdog gap

The stuck-compositionend watchdog (PR #5551) releases composingRef after
2500 ms of IME silence so Send unwedges in the WSL+Chrome case. The same
release also fires during a long candidate-window pause in healthy IMEs,
which lets a subsequent IME-confirm Enter slip preedit text through
handleSubmit (main composer) or click-Send through send() (compare composer).

Add a keydown gate to both composers: when the browser still reports
nativeEvent.isComposing or keyCode 229, re-pin composingRef and cancel
any pending watchdog so the next form-submit / send() guard refuses.
The Send button stays visually enabled (avoids re-introducing the
stuck-UI bug) but the submit path is blocked until a real compositionend
or non-composing input arrives. Mirrors the existing isComposing guard
shape in shared-composer.onKeyDown.

Tests:
- tests/studio/test_composer_rtl_bidi_attribute.py: two new static
  guards asserting the keydown gate wiring in both composer files.
- tests/studio/playwright_chat_ime_i18n.py: new section 6c repro that
  fires the IME-confirm keydown after the watchdog has cleared, then
  triggers form.requestSubmit() and asserts the preedit text is not
  cleared (would indicate a leaked submit).

Verified across Chromium / Firefox / WebKit via a side-by-side pre-PR
vs post-PR simulation (54 scenarios, zero pageerror or console.error).
The #5546 stuck-end repro still passes (Send re-enables 2.5-3 s after
the silent commit) and the new keydown-repin probe confirms the submit
gate refuses on all three engines.
This commit is contained in:
Daniel Han 2026-05-18 13:03:32 +00:00
commit 2c3c9793b0
4 changed files with 108 additions and 3 deletions

View file

@ -80,6 +80,7 @@ import {
type CompositionEvent,
type FC,
type FormEvent,
type KeyboardEvent,
useCallback,
useEffect,
useRef,
@ -442,12 +443,28 @@ function useImeComposerInputHandlers() {
[setComposerText, setCompositionState],
);
// If the watchdog cleared the composing flags during a long candidate-window
// pause, a subsequent IME keypress (browser-side isComposing=true / IME
// keyCode 229) would otherwise reach handleSubmit with composingRef=false
// and submit the preedit text. Re-arm composingRef synchronously from the
// native event so the form-submit gate keeps blocking until compositionend.
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
clearStuckTimer();
}
},
[clearStuckTimer],
);
return {
inputProps: {
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onChange,
onKeyDown,
},
isComposing,
isComposingRef: composingRef,

View file

@ -717,8 +717,14 @@ export function SharedComposer({
function onKeyDown(e: KeyboardEvent) {
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
// Don't hijack it. See issue #5318.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
// Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck
// watchdog (#5546) cleared it during a long candidate-window pause; this
// keeps a follow-up click-Send from submitting preedit text.
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
clearStuckImeTimer();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy) {

View file

@ -477,6 +477,65 @@ with sync_playwright() as p:
info("compositionend watchdog recovery PASS")
clear()
# 6c. Watchdog-race repro: after the watchdog clears composingRef during a
# long candidate pause, a subsequent IME keydown (browser still sees
# isComposing=true / keyCode 229) must not slip preedit text through
# the form submit. The onKeyDown gate re-pins composingRef so the
# handleSubmit / blockSend guards keep refusing. The Send button stays
# visually enabled (watchdog has already cleared the React state); the
# refusal happens at form.requestSubmit() time, not at the button.
step("BUG REPRO: keydown re-pin after watchdog cleared composing (issue #5546 follow-up)")
clear()
composer.click()
composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new CompositionEvent('compositionstart', {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,
}));
}"""
)
send_btn_keydown = page.locator('button[aria-label="Send message"]')
# Wait past the watchdog so composingRef has cleared.
try:
expect(send_btn_keydown).not_to_be_disabled(timeout = 8_000)
except Exception:
soft_fail("watchdog did not clear before keydown re-pin test")
# Fire the IME-confirm Enter (keyCode 229, isComposing=true) then trigger
# the form submit synchronously. With the keydown gate, composingRef is
# re-pinned before handleSubmit runs and the submit is prevented; the
# textarea must still hold the preedit text.
submit_probe = composer.evaluate(
"""(el) => {
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown', {
bubbles:true, key:'Enter', code:'Enter', keyCode:229,
isComposing:true,
}));
const form = el.closest('form');
const before = el.value;
try { form && form.requestSubmit(); } catch (e) {}
return {before, after: el.value, cleared: before !== '' && el.value === ''};
}"""
)
if submit_probe.get("cleared"):
shoot("06c-keydown-repin-FAIL")
fail(
"Form submitted after an IME keydown -- preedit text leaked "
"through the watchdog gap (#5546 follow-up regression)."
)
info(f"Form submit refused after IME keydown; textarea retained {submit_probe.get('after')!r}")
shoot("06c-keydown-repin")
info("keydown re-pin gate 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")
@ -505,7 +564,7 @@ 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"compositionend_watchdog=OK"
f"compositionend_watchdog=OK keydown_repin=OK"
)
_watchdog.cancel()
browser.close()

View file

@ -96,3 +96,26 @@ def test_compare_composer_has_stuck_compositionend_watchdog():
assert (
"onCompositionUpdate" in src
), "compare composer is missing onCompositionUpdate wiring"
def test_main_composer_keydown_repins_composing_during_ime():
"""Issue #5546 watchdog can clear composingRef during a long candidate
pause; the IME keydown gate must re-pin it so a follow-up Enter does not
submit preedit text."""
src = THREAD_TSX.read_text()
assert "onKeyDown" in src, "main composer is missing onKeyDown IME gate"
assert "e.nativeEvent.isComposing" in src and "keyCode === 229" in src, (
"main composer keydown gate must check both nativeEvent.isComposing "
"and the IME keyCode 229 sentinel"
)
def test_compare_composer_keydown_repins_composing_during_ime():
"""Compare composer onKeyDown re-pins composingRef on IME keypress so a
follow-up click-Send during the watchdog window does not slip preedit
text through."""
src = SHARED_TSX.read_text()
assert "composingRef.current = true" in src, (
"compare composer keydown gate must re-pin composingRef when the "
"browser still considers the IME active"
)