fix(mobile): CJK input loss — IME state machine, focus routing, and Android InputConnection recovery

Three independent root causes of intermittent Chinese character loss
(English was unaffected because it bypasses the composition path):

1. input-cjk.js state machine: stuck _composing when compositionend never
   fires (WeChat/Sogou IMEs) silently swallowed all input; the deferred
   compositionend flush could reset the textarea mid-next-composition
   (cancels the live IME composition on iOS); the 100ms keydown-echo
   window discarded ANY input regardless of content.

2. Focus stealing: session-select / SSE-reconnect paths call
   terminal.focus() (15+ call sites), landing focus on xterm's hidden
   textarea; with the CJK onData gate active, everything typed there was
   swallowed. Fix: focus router in initTerminal routes ALL
   terminal.focus() calls to the CJK field while it is visible, plus a
   self-healing onData gate that reclaims focus when it swallows input.

3. Android InputConnection wedge (9-key IMEs + Chromium): the keyboard
   composes in its own UI but delivers zero DOM events. Fix: skip
   redundant textarea value/selection writes (they race IME session
   setup), and re-tapping the focused empty field forces a blur→focus
   cycle that restarts the input session.

Diagnostics: input-cjk.js now traces every IME event/flush decision into
the crash-diag breadcrumbs; /api/crash-diag stores beacons per page-load
id (iOS PWA reloads no longer wipe the trail, concurrent clients no
longer clobber each other) and flushes on visibilitychange.

Tests: test/input-cjk.test.ts (vm-sandbox, 9 cases incl. regression
guards for all three root causes).
This commit is contained in:
Teigen
2026-07-07 17:52:10 +08:00
parent 1fa88cd187
commit 443b85c18e
5 changed files with 455 additions and 19 deletions
+32 -8
View File
@@ -61,6 +61,10 @@
const _crashDiag = {
_entries: [],
_maxEntries: 50,
// Per-page-load id: the server keys beacons by it, so a reload (fresh id)
// archives the previous page's trail instead of overwriting it, and
// concurrent clients (desktop + phone) don't clobber each other.
_pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
log(msg) {
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
this._entries.push(entry);
@@ -69,22 +73,36 @@ const _crashDiag = {
}
};
// Log previous crash breadcrumbs on startup
// Log previous crash breadcrumbs on startup, and re-beacon them under a
// distinct page id — an iOS PWA reload wipes the in-memory trail, and without
// this the fresh page's first beacon would be all the server ever sees.
try {
const prev = localStorage.getItem('codeman-crash-diag');
if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
if (prev) {
console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
}
} catch {}
_crashDiag.log('PAGE LOAD');
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
setInterval(() => {
function _crashDiagBeacon() {
try {
localStorage.setItem('codeman-crash-heartbeat', String(Date.now()));
if (_crashDiag._entries.length > 0) {
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') }));
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
}
} catch {}
}
setInterval(() => {
try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); } catch {}
_crashDiagBeacon();
}, 2000);
// iOS suspends JS the instant the app is backgrounded — entries logged since
// the last 2s tick would sit unsent until (if ever) the page resumes. Flush
// immediately on hide so a repro right before an app switch is never lost.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') _crashDiagBeacon();
});
window.addEventListener('error', (e) => {
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
@@ -3390,9 +3408,15 @@ class CodemanApp {
// Close WebSocket for previous session (new one opens after buffer load)
this._disconnectWs();
// Clear CJK textarea to prevent sending stale text to the wrong session
const cjkEl = document.getElementById('cjkInput');
if (cjkEl) cjkEl.value = '';
// Clear CJK input to prevent sending stale text to the wrong session.
// Must go through CjkInput.clear() — a raw value wipe leaves the module's
// pending flush timers armed and drops the phantom char it relies on.
if (typeof CjkInput !== 'undefined') {
CjkInput.clear();
} else {
const cjkEl = document.getElementById('cjkInput');
if (cjkEl) cjkEl.value = '';
}
// Clean up flicker filter state when switching sessions
this._clearTimer('flickerFilterTimeout');