fix(terminal): #541 landing fixes

A composition that ends in the same task as an Enter keydown was sent
twice. The keydown settled the pending edit (sending the composed word
and setting _dataAlreadySent), then xterm's own keydown finalized the
composition synchronously through _finalizeComposition(false), which
ignores _dataAlreadySent and sent the word again. settleEdit() now takes
the keydown event and, while xterm has a composition in flight
(_isSendingComposition), leaves the text to xterm for any key that makes
it finalize synchronously. On 229, CapsLock and the modifiers xterm keeps
the composition on its async path, which honours _dataAlreadySent, so the
edit still applies there. The waiting timers are cleared before that
early return, so a timer cannot fire after Enter's textarea clear and
send a run of DELs.

The guard sits in settleEdit(), not in applyEdit() as the bot proposed.
In applyEdit() it would also silence the timer path, where xterm always
finalizes asynchronously and skips _dataAlreadySent, so a non-composing
character typed just before a composition (the x in xword) would be lost
where master and the PR head both deliver it.

Two unit tests pin it, both measured: one fails without the guard
(the Enter keydown sends 'ab word' instead of 'ab '), and one fails with
the guard moved into applyEdit() (the timer path sends 'ab ' instead of
'ab xword'; a 229 settle must also still send the edit).

The xterm private-API guard test now also checks the bundle still ships
_isSendingComposition, and names it in its failure message and comment.

CLAUDE.md: the surviving #441 sentence said a keydown decides before
xterm's 229 rescue has run and that Enter's clear makes the pending diff
emit nothing. Neither holds any more (the edit diff is settled first, and
master already sent one DEL there), so it now says the edit diff is
settled first at that keydown. The PR's sentence notes the composition
exception.

The PR's own changeset is removed; its text goes into the single
combined release changeset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 05:46:56 +02:00
parent af4e3e6ed7
commit d7140c32b4
5 changed files with 61 additions and 14 deletions
+41
View File
@@ -571,6 +571,47 @@ describe('edit-based sync of the helper textarea (autocorrect replacements)', ()
expect(h.sent.join('')).toBe('o');
});
// compositionend and the Enter keydown in one task: xterm's keydown then finalizes the
// composition SYNCHRONOUSLY via `_finalizeComposition(false)`, which ignores `_dataAlreadySent`,
// so the settle must leave that text to xterm or it is sent twice.
it('does not resend a composition xterm finalizes itself at the Enter keydown', () => {
const h = editSyncHarness();
const controller = h.create(true);
typeKeys(h, 'ab ');
h.keydown();
h.edit('ab word');
(h.helper as any)._isSendingComposition = true;
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
h.flush();
expect(h.sent.join('')).toBe('ab ');
});
// On the timer path (and at a 229 keydown) xterm finalizes the composition ASYNCHRONOUSLY and
// skips `_dataAlreadySent`, so the edit must still be applied there: guarding it would drop the
// non-composing `x` typed before the composition.
it('still applies the edit while xterm finalizes a composition asynchronously', () => {
const h = editSyncHarness();
h.create(true);
typeKeys(h, 'ab ');
h.keydown();
h.edit('ab xword');
(h.helper as any)._isSendingComposition = true;
h.flush();
expect(h.sent.join('')).toBe('ab xword');
expect(h.helper._dataAlreadySent).toBe('xword');
const k = editSyncHarness();
const controller = k.create(true);
typeKeys(k, 'ab ');
k.keydown();
k.edit('ab xword');
(k.helper as any)._isSendingComposition = true;
controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229 });
k.flush();
expect(k.sent.join('')).toBe('ab xword');
expect(k.helper._dataAlreadySent).toBe('xword');
});
it('control: xterm alone duplicates the line when the keyboard autocorrects', () => {
const h = editSyncHarness();
h.create(false);
+8 -6
View File
@@ -49,18 +49,19 @@ describe('xterm private-API dependency guard', () => {
'xterm moved off the verified version — re-verify _kickRenderer in a real browser ' +
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame) AND the ' +
'CompositionHelper fields installEditSync() uses (terminal-keycode229-recovery.js: ' +
'_handleAnyTextareaChanges, _coreService, _isComposing, _dataAlreadySent), then update ' +
'VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' +
'_handleAnyTextareaChanges, _coreService, _isComposing, _isSendingComposition, _dataAlreadySent), ' +
'then update VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' +
'degrades to a silent no-op and the freeze it heals comes back unnoticed.'
).toBe(VERIFIED_XTERM_VERSION);
});
// terminal-keycode229-recovery.js swaps in an edit-based replacement for xterm's
// CompositionHelper._handleAnyTextareaChanges (Android autocorrect = delete + insert, which xterm's
// append-only diff duplicates). It reaches `_compositionHelper`, `_coreService`, `_isComposing`
// and `_dataAlreadySent`; if xterm renames any of them the install quietly falls back to xterm's own
// handler and the duplication returns. Property names survive minification, so a string check on
// the shipped bundle catches a rename on upgrade.
// append-only diff duplicates). It reaches `_compositionHelper`, `_coreService`, `_isComposing`,
// `_isSendingComposition` and `_dataAlreadySent`; if xterm renames any of them the install quietly
// falls back to xterm's own handler and the duplication returns (or, for `_isSendingComposition`, a
// composition xterm finalizes at an Enter keydown is sent twice). Property names survive
// minification, so a string check on the shipped bundle catches a rename on upgrade.
it('still ships the composition-helper fields the edit-based 229 sync depends on', () => {
const bundle = readFileSync(resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
for (const name of [
@@ -68,6 +69,7 @@ describe('xterm private-API dependency guard', () => {
'_compositionHelper',
'_coreService',
'_isComposing',
'_isSendingComposition',
'_dataAlreadySent',
]) {
expect(