mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
fix(terminal): forward the orphaned input event instead of replaying a guessed key
The previous shape guessed the character from `event.key` on keydown, re-emitted it, and then tried to suppress a late canonical copy with a 250 ms character-keyed dedupe. Review found three defects in that, all reproducible: the dedupe matched on the character alone with nothing scoping a candidate to the keydown that created it, so the same character typed twice inside the window had its second, real byte swallowed; anything whose committed text differed from `event.key` (Enter, IME punctuation) was delivered twice, because the dedupe could never match it; and the trigger ignored `key === 'Unidentified'`, which is what a soft keyboard reports, so it may never have fired where it was needed. The input event already carries the committed text in `ev.data` — exactly what xterm itself would have forwarded — so nothing has to be guessed. The controller now only decides WHETHER to forward, by asking whether xterm produced canonical data since the keydown that began the keystroke. No character-keyed matching survives, so the first two defects are structurally impossible rather than defended against, and nothing reads `key`/`keyCode`, so the third cannot recur. Three details are load-bearing and each has a test that fails without it: - The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event. `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a snapshot read at input time already contains that emission, reads it as silence, and delivers the character twice. - Our `input` listener is registered with `capture: true`. The target is visited twice in the event path, so a capture listener calling `stopPropagation()` stops later BUBBLE listeners on that same target; xterm's `cancel()` runs exactly in the branch where it handled the input, so on bubble we would never observe handled events, and whether we observed them at all would hang off `options.cancelEvents`. Measured in jsdom and headless chromium; the table is in the module header. - Enter is deliberately no longer special-cased. That mapping is what made the committed text differ from the re-emitted value in the first place. The scope is also narrower than the old name suggests, and the browser test now proves it rather than assuming it. For a keydown that reports keyCode 229 xterm ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()` diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it" there passes while xterm does all the work, so the browser tests assert WHO delivered the byte: zero canonical emissions for the genuinely orphaned case, exactly one delivery for the case xterm rescues itself. Also addresses review notes: the module gains an `@fileoverview` with `@dependency`/`@loadorder` and an entry in the load-order list and module inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its own. The keydown hook deliberately still runs for every key event rather than moving behind the 229 gate: gating it would reinstate exactly the blindness described above, and it is now a single counter assignment.
This commit is contained in:
@@ -218,90 +218,6 @@ describe('terminal Ctrl+C smart copy', () => {
|
||||
expect(res.data.join('')).not.toContain('\x16');
|
||||
});
|
||||
|
||||
it('recovers explicit keyCode 229 input once when the helper textarea never mutates', async () => {
|
||||
await setup('KEYCODE-229-RECOVERY', false);
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (window as any).app;
|
||||
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||
const originalSessionId = app.activeSessionId;
|
||||
const originalLocalEcho = app._localEchoEnabled;
|
||||
const originalSendInput = app._sendInputAsync;
|
||||
const originalPendingInput = app._pendingInput;
|
||||
const originalLastKeystrokeTime = app._lastKeystrokeTime;
|
||||
const sent: string[] = [];
|
||||
const dispatch229 = (key: string) => {
|
||||
for (const type of ['keydown', 'keyup']) {
|
||||
const event = new KeyboardEvent(type, {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperties(event, { keyCode: { value: 229 }, which: { value: 229 } });
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
app.activeSessionId = 'cod388-browser-regression';
|
||||
app._localEchoEnabled = false;
|
||||
app._pendingInput = '';
|
||||
app._lastKeystrokeTime = 0;
|
||||
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
|
||||
textarea.focus();
|
||||
|
||||
dispatch229('x');
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 30));
|
||||
const afterRecovery = [...sent];
|
||||
|
||||
// A browser that supplies its canonical input late must not duplicate
|
||||
// the character already recovered for this key token.
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('beforeinput', { data: 'x', inputType: 'insertText', bubbles: true, composed: true })
|
||||
);
|
||||
textarea.value = 'x';
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: 'x', inputType: 'insertText', bubbles: true, composed: true })
|
||||
);
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 0));
|
||||
const afterLateInput = [...sent];
|
||||
|
||||
dispatch229('Enter');
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 30));
|
||||
const final = [...sent];
|
||||
|
||||
// Two 229 candidates can overlap while the main thread is busy. A
|
||||
// canonical value for the first must resolve that candidate without
|
||||
// cancelling the second candidate's fallback.
|
||||
const overlapStart = sent.length;
|
||||
dispatch229('a');
|
||||
dispatch229('b');
|
||||
const busyUntil = performance.now() + 25;
|
||||
while (performance.now() < busyUntil) {
|
||||
// Deliberately hold the browser task so both xterm/fallback timers
|
||||
// remain queued while canonical input for `a` is prepared.
|
||||
}
|
||||
app.terminal._core.coreService.triggerDataEvent('a', true);
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 30));
|
||||
return { afterRecovery, afterLateInput, final, overlap: sent.slice(overlapStart) };
|
||||
} finally {
|
||||
app.activeSessionId = originalSessionId;
|
||||
app._localEchoEnabled = originalLocalEcho;
|
||||
app._sendInputAsync = originalSendInput;
|
||||
app._pendingInput = originalPendingInput;
|
||||
app._lastKeystrokeTime = originalLastKeystrokeTime;
|
||||
textarea.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
afterRecovery: ['x'],
|
||||
afterLateInput: ['x'],
|
||||
final: ['x', '\r'],
|
||||
overlap: ['a', 'b'],
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards full-width punctuation after a Chinese IME composition', async () => {
|
||||
await setup('IME-PUNCTUATION', false);
|
||||
const desktopChunks = await captureImeInput(page);
|
||||
|
||||
Reference in New Issue
Block a user