Skip to content

Commit fd40b43

Browse files
committed
fix(@inquirer/core): discard keystrokes buffered before prompt creation
Defer the first render cycle by one setImmediate tick for proper Readable streams (like process.stdin) so that OS-level buffered data flows through readline harmlessly before any keypress handlers are registered. Pre-mute the output to suppress stale keystroke echoing during the drain period. Old-style streams (like MuteStream) have no OS-level buffering, so the render cycle starts immediately as before. Closes #1303
1 parent fd001c1 commit fd40b43

2 files changed

Lines changed: 95 additions & 32 deletions

File tree

packages/core/core.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EventEmitter } from 'node:stream';
1+
import { EventEmitter, PassThrough } from 'node:stream';
22
import { describe, it, expect, vi } from 'vitest';
33
import { render } from '@inquirer/testing';
44
import { stripVTControlCharacters } from 'node:util';
@@ -576,6 +576,41 @@ it('allow aborting the prompt using signals', async () => {
576576
await expect(answer).rejects.toThrow(AbortPromptError);
577577
});
578578

579+
it('should ignore keypresses buffered before prompt creation', async () => {
580+
const prompt = createPrompt(
581+
(_config: { message: string }, done: (value: string) => void) => {
582+
useKeypress((key: KeypressEvent) => {
583+
if (isEnterKey(key)) {
584+
done('submitted');
585+
}
586+
});
587+
588+
return 'Question?';
589+
},
590+
);
591+
592+
// Use PassThrough which has proper stream buffering (like real process.stdin)
593+
const input = new PassThrough();
594+
const output = new PassThrough();
595+
output.write = () => true; // suppress output
596+
597+
// Buffer an Enter keypress BEFORE the prompt exists
598+
input.write('\r');
599+
600+
const answer = prompt({ message: 'test' }, { input, output });
601+
602+
// The buffered Enter should NOT resolve the prompt
603+
const result = await Promise.race([
604+
answer.then(() => 'resolved'),
605+
new Promise((resolve) => setTimeout(() => resolve('pending'), 200)),
606+
]);
607+
expect(result).toBe('pending');
608+
609+
// A real Enter AFTER creation should still work
610+
input.write('\r');
611+
await expect(answer).resolves.toBe('submitted');
612+
});
613+
579614
it('fail on aborted signals', async () => {
580615
const Prompt = (config: { message: string }, done: (value: string) => void) => {
581616
useKeypress((key: KeypressEvent) => {

packages/core/src/lib/create-prompt.ts

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { type InquirerReadline } from '@inquirer/type';
99
import { withHooks, effectScheduler } from './hook-engine.ts';
1010
import { AbortPromptError, CancelPromptError, ExitPromptError } from './errors.ts';
1111

12+
// Capture the real setImmediate at module load time so it works even when test
13+
// frameworks mock timers with vi.useFakeTimers() or similar.
14+
const nativeSetImmediate = globalThis.setImmediate;
15+
1216
type ViewFunction<Value, Config> = (
1317
config: Prettify<Config>,
1418
done: (value: Value) => void,
@@ -49,6 +53,11 @@ export function createPrompt<Value, Config>(
4953
const output = new MuteStream();
5054
output.pipe(context.output ?? process.stdout);
5155

56+
// Pre-mute the output so that readline doesn't echo stale keystrokes
57+
// to the terminal before the first render. ScreenManager will unmute/mute
58+
// the output around each render call as needed.
59+
output.mute();
60+
5261
const rl = readline.createInterface({
5362
terminal: true,
5463
input,
@@ -85,14 +94,6 @@ export function createPrompt<Value, Config>(
8594
rl.on('SIGINT', sigint);
8695
cleanups.add(() => rl.removeListener('SIGINT', sigint));
8796

88-
// Re-renders only happen when the state change; but the readline cursor could change position
89-
// and that also requires a re-render (and a manual one because we mute the streams).
90-
// We set the listener after the initial workLoop to avoid a double render if render triggered
91-
// by a state change sets the cursor to the right position.
92-
const checkCursorPos = () => screen.checkCursorPos();
93-
rl.input.on('keypress', checkCursorPos);
94-
cleanups.add(() => rl.input.removeListener('keypress', checkCursorPos));
95-
9697
return withHooks(rl, (cycle) => {
9798
// The close event triggers immediately when the user press ctrl+c. SignalExit on the other hand
9899
// triggers after the process is done (which happens after timeouts are done triggering.)
@@ -101,30 +102,57 @@ export function createPrompt<Value, Config>(
101102
rl.on('close', hooksCleanup);
102103
cleanups.add(() => rl.removeListener('close', hooksCleanup));
103104

104-
cycle(() => {
105-
try {
106-
const nextView = view(config, (value) => {
107-
setImmediate(() => resolve(value));
108-
});
109-
110-
// Typescript won't allow this, but not all users rely on typescript.
111-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
112-
if (nextView === undefined) {
113-
const callerFilename = callSites[1]?.getFileName();
114-
throw new Error(
115-
`Prompt functions must return a string.\n at ${callerFilename}`,
116-
);
105+
const startCycle = () => {
106+
// Re-renders only happen when the state change; but the readline cursor could
107+
// change position and that also requires a re-render (and a manual one because
108+
// we mute the streams). We set the listener after the initial workLoop to avoid
109+
// a double render if render triggered by a state change sets the cursor to the
110+
// right position.
111+
const checkCursorPos = () => screen.checkCursorPos();
112+
rl.input.on('keypress', checkCursorPos);
113+
cleanups.add(() => rl.input.removeListener('keypress', checkCursorPos));
114+
115+
cycle(() => {
116+
try {
117+
const nextView = view(config, (value) => {
118+
setImmediate(() => resolve(value));
119+
});
120+
121+
// Typescript won't allow this, but not all users rely on typescript.
122+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
123+
if (nextView === undefined) {
124+
const callerFilename = callSites[1]?.getFileName();
125+
throw new Error(
126+
`Prompt functions must return a string.\n at ${callerFilename}`,
127+
);
128+
}
129+
130+
const [content, bottomContent] =
131+
typeof nextView === 'string' ? [nextView] : nextView;
132+
screen.render(content, bottomContent);
133+
134+
effectScheduler.run();
135+
} catch (error: unknown) {
136+
reject(error);
117137
}
118-
119-
const [content, bottomContent] =
120-
typeof nextView === 'string' ? [nextView] : nextView;
121-
screen.render(content, bottomContent);
122-
123-
effectScheduler.run();
124-
} catch (error: unknown) {
125-
reject(error);
126-
}
127-
});
138+
});
139+
};
140+
141+
// Proper Readable streams (like process.stdin) may have OS-level buffered
142+
// data that arrives in the poll phase when readline resumes the stream.
143+
// Deferring the first render by one setImmediate tick (check phase, after
144+
// poll) lets that stale data flow through readline harmlessly—no keypress
145+
// handlers are registered yet and the output is muted, so the stale
146+
// keystrokes are silently discarded.
147+
// Old-style streams (like MuteStream) have no such buffering, so the
148+
// render cycle starts immediately.
149+
//
150+
// @see https://github.com/SBoudrias/Inquirer.js/issues/1303
151+
if ('readableFlowing' in input) {
152+
nativeSetImmediate(startCycle);
153+
} else {
154+
startCycle();
155+
}
128156

129157
return Object.assign(
130158
promise

0 commit comments

Comments
 (0)