Gboard sends Enter as a key event, not a commit, so the IME's model
lags the buffer by exactly one edit and its next commit is anchored at
the pre-edit caret. The drift guard snapped such commits to the new
caret, duplicating the just-typed word (2026-09-19 phone incident:
'a<enter>a' became 'A' + 'AaAa').
- state.go: track one pending non-IME content edit (imeStaleEditByte,
armed by noteNonIMEEdit on Enter/backspace/delete/cut/paste, consumed
by the next IME commit or key edit, cleared by a tap). The guard
accepts a small (<= 2 rune) TEXT commit ending at or before the edit
position as-is (IME STALE-OK): everything before the edit is
byte-identical in both models. Empty-text commits and commits ending
past the edit still snap to the caret.
- render.go + state.go + main.go: arm the pre-commit flush hold with the
drained frame's own EditSeq. The hold was armed with the last drawn
frame's seq while the frame's TextField.EditSeq was never set (always
0), so every frame matched and the hold released only via its
8-frame timeout - silently swallowing the resync pushes (and all IME
sync) that heal the desync. EditorLayout now ships EditSeq with the
frame's TextField; FlushIME releases on the next higher seq.
- e2e (ime_key_edit_test.go): reproduce the incident (same-batch and
next-batch variants) and pin the guard edges (deletions and
past-the-edit commits still snap).
- ime.md: document the key-event desync invariant and the hold.