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.
Written up from the debugging sessions that fixed the IME commit
corruption, the scroll slowdown, the mid-word caps, and the
distant-commit clobbering: the three-model mental model, the
invariants, the stock-gioui structural behaviors the design works
around (post-commit selection dedup, restartInput shadowing,
focus-gated selection commands, no acks), the log signatures, the
test-layer coverage table, and the operational pitfalls hit along
the way.