fix(chat): defer composer value writeback during IME composition (Fixes #2527) (#2691)

* fix(chat): defer composer value writeback during IME composition

The controlled-writeback effect compared the value prop against the
CodeMirror document and, on mismatch, dispatched a wholesale replacement
with the caret forced to the end. While the browser composes (pinyin,
kana, hangul) the uncommitted text lives in the DOM, not in the document,
so the mismatch is expected and the dispatch interrupted the IME session
and jumped the cursor. Skip the writeback while the view is composing,
using CodeMirror's public compositionStarted getter; the composition
commits through its own pipeline and reports via onChange.

Fixes #2527

* fix(chat): preserve external composer writes during IME

* fix(chat): restore composition-wide writeback guard

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Serhii Dziupin
2026-08-17 14:24:39 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 7ef6441bf3
commit 1c76dbefe4
2 changed files with 32 additions and 0 deletions
@@ -344,6 +344,10 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
// Skip every controlled writeback while the browser is composing.
// A stale value echo can differ from CodeMirror's newer document,
// and replacing it would interrupt the IME session and move the caret.
if (view.compositionStarted) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
// An external rewrite (draft restore, history navigation,
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const composerEditorSource = readFileSync(
new URL('../ComposerEditor.tsx', import.meta.url),
'utf-8',
);
const writebackEffect = (): string => {
const start = composerEditorSource.indexOf('// Controlled value:');
expect(start).toBeGreaterThan(-1);
const end = composerEditorSource.indexOf('}, [value]);', start);
expect(end).toBeGreaterThan(start);
return composerEditorSource.slice(start, end);
};
describe('composer value writeback composition guard (issue #2527)', () => {
test('checks equality, then composition, before dispatching', () => {
const effect = writebackEffect();
const equalityCheck = effect.indexOf('if (current === value) return;');
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
const dispatch = effect.indexOf('view.dispatch({');
expect(equalityCheck).toBeGreaterThan(-1);
expect(compositionGuard).toBeGreaterThan(equalityCheck);
expect(dispatch).toBeGreaterThan(compositionGuard);
});
});