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 CompositionEvent,
|
||||||
type FC,
|
type FC,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
|
|
@ -442,12 +443,28 @@ function useImeComposerInputHandlers() {
|
||||||
[setComposerText, setCompositionState],
|
[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 {
|
return {
|
||||||
inputProps: {
|
inputProps: {
|
||||||
onCompositionStart,
|
onCompositionStart,
|
||||||
onCompositionUpdate,
|
onCompositionUpdate,
|
||||||
onCompositionEnd,
|
onCompositionEnd,
|
||||||
onChange,
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
},
|
},
|
||||||
isComposing,
|
isComposing,
|
||||||
isComposingRef: composingRef,
|
isComposingRef: composingRef,
|
||||||
|
|
|
||||||
|
|
@ -717,8 +717,14 @@ export function SharedComposer({
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
|
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
|
||||||
// Don't hijack it. See issue #5318.
|
// Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck
|
||||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
// 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) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,65 @@ with sync_playwright() as p:
|
||||||
info("compositionend watchdog recovery PASS")
|
info("compositionend watchdog recovery PASS")
|
||||||
clear()
|
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,
|
# 7. Final state. The change-password redirect emits benign 401 noise,
|
||||||
# so we filter via is_benign_* and only fail on real errors.
|
# so we filter via is_benign_* and only fail on real errors.
|
||||||
shoot("07-final")
|
shoot("07-final")
|
||||||
|
|
@ -505,7 +564,7 @@ with sync_playwright() as p:
|
||||||
info(
|
info(
|
||||||
f"DONE: ascii=OK paste={len(I18N_SAMPLES)}/{len(I18N_SAMPLES)} "
|
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"
|
f"compositionend_watchdog=OK keydown_repin=OK"
|
||||||
)
|
)
|
||||||
_watchdog.cancel()
|
_watchdog.cancel()
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
|
||||||
|
|
@ -96,3 +96,26 @@ def test_compare_composer_has_stuck_compositionend_watchdog():
|
||||||
assert (
|
assert (
|
||||||
"onCompositionUpdate" in src
|
"onCompositionUpdate" in src
|
||||||
), "compare composer is missing onCompositionUpdate wiring"
|
), "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