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:
parent
357402cf29
commit
2c3c9793b0
4 changed files with 108 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue