Skip to content

Commit 7dc77e2

Browse files
imharjotSBoudrias
andcommitted
fix(search): prevent submissions while the prompt is loading
Consolidate the regression tests into two flows using withResolver, exposed from @inquirer/type so all packages can use it until Node 22 becomes the minimum supported version. Co-authored-by: Simon Boudrias <admin@simonboudrias.com>
1 parent 73b5eab commit 7dc77e2

6 files changed

Lines changed: 86 additions & 24 deletions

File tree

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import * as readline from 'node:readline';
22
import { AsyncResource } from 'node:async_hooks';
3-
import { type Prompt } from '@inquirer/type';
3+
import { type Prompt, withResolver } from '@inquirer/type';
44
import MuteStream from 'mute-stream';
55
import { onExit as onSignalExit } from 'signal-exit';
66
import ScreenManager from './screen-manager.ts';
7-
import { PromisePolyfill } from './promise-polyfill.ts';
87
import { type InquirerReadline } from '@inquirer/type';
98
import { withHooks, effectScheduler } from './hook-engine.ts';
109
import { AbortPromptError, CancelPromptError, ExitPromptError } from './errors.ts';
@@ -98,7 +97,7 @@ export function createPrompt<Value, Config>(
9897
output.mute();
9998
const screen = new ScreenManager(rl);
10099

101-
const { promise, resolve, reject } = PromisePolyfill.withResolver<Value>();
100+
const { promise, resolve, reject } = withResolver<Value>();
102101

103102
return withHooks(rl, (cycle) => {
104103
// Clear hook effects synchronously before the prompt settles: readline

‎packages/core/src/lib/promise-polyfill.ts‎

Lines changed: 0 additions & 19 deletions
This file was deleted.

‎packages/search/search.test.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, it, expect, expectTypeOf } from 'vitest';
1+
import { describe, it, expect, expectTypeOf, vi } from 'vitest';
22
import { render } from '@inquirer/testing';
3+
import { withResolver } from '@inquirer/type';
34
import search, { Separator } from './src/index.ts';
45

56
// Array of all countries names as string
@@ -39,6 +40,52 @@ function getListSearch(
3940
}
4041

4142
describe('search prompt', () => {
43+
it('does not submit stale choices and keeps the search term while a search is pending', async () => {
44+
const { promise: resultsPromise, resolve: resolveResults } =
45+
withResolver<Array<string>>();
46+
const terms: Array<string | undefined> = [];
47+
const { answer, events, nextRender } = await render(search, {
48+
message: 'Select a Canadian province',
49+
source: (term: string | undefined) => {
50+
terms.push(term);
51+
return term ? resultsPromise : ['Alberta'];
52+
},
53+
});
54+
55+
// A first enter while the search for "Que" is still pending must not
56+
// submit any stale choice.
57+
events.type('Que');
58+
await nextRender();
59+
events.keypress('enter');
60+
61+
// The search term must survive the enter keypress.
62+
events.type('bec');
63+
await nextRender();
64+
65+
resolveResults(['Quebec']);
66+
await nextRender();
67+
events.keypress('enter');
68+
await expect(answer).resolves.toEqual('Quebec');
69+
expect(terms.at(-1)).toBe('Quebec');
70+
});
71+
72+
it('does not start another validation while one is pending', async () => {
73+
const { promise: validation, resolve: resolveValidation } = withResolver<boolean>();
74+
const validate = vi.fn(() => validation);
75+
const { answer, events } = await render(search, {
76+
message: 'Select a Canadian province',
77+
source: () => ['Quebec'],
78+
validate,
79+
});
80+
81+
events.keypress('enter');
82+
events.keypress('enter');
83+
resolveValidation(true);
84+
85+
await expect(answer).resolves.toEqual('Quebec');
86+
expect(validate).toHaveBeenCalledOnce();
87+
});
88+
4289
it('allows to search', async () => {
4390
const { answer, events, getScreen, nextRender } = await render(search, {
4491
message: 'Select a Canadian province',

‎packages/search/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export default createPrompt(
190190

191191
useKeypress(async (key, rl) => {
192192
if (isEnterKey(key)) {
193-
if (selectedChoice) {
193+
if (selectedChoice && status === 'idle') {
194194
setStatus('loading');
195195
const isValid = await validate(selectedChoice.value);
196196
setStatus('idle');

‎packages/type/src/utils.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { test, expect } from 'vitest';
2+
import { withResolver } from './utils.ts';
3+
4+
test('withResolver resolves the returned promise', async () => {
5+
const { promise, resolve } = withResolver<string>();
6+
resolve('foo');
7+
await expect(promise).resolves.toBe('foo');
8+
});
9+
10+
test('withResolver rejects the returned promise', async () => {
11+
const { promise, reject } = withResolver<string>();
12+
reject(new Error('bar'));
13+
await expect(promise).rejects.toThrow('bar');
14+
});

‎packages/type/src/utils.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,24 @@ export type _Pick<O extends object, K extends Key> = __Pick<O, keyof O & K>;
5959
export type Pick<O extends object, K extends Key> = O extends unknown
6060
? _Pick<O, K>
6161
: never;
62+
63+
/**
64+
* Create a promise and get access to its `resolve` and `reject` functions.
65+
*
66+
* Mirrors `Promise.withResolvers()`, available starting from Node 22.
67+
* TODO: Remove once Node 22 becomes the minimum supported version.
68+
*/
69+
export function withResolver<T>(): {
70+
promise: Promise<T>;
71+
resolve: (value: T) => void;
72+
reject: (error: unknown) => void;
73+
} {
74+
let resolve: (value: T) => void;
75+
let reject: (error: unknown) => void;
76+
const promise = new Promise<T>((res, rej) => {
77+
resolve = res;
78+
reject = rej;
79+
});
80+
81+
return { promise, resolve: resolve!, reject: reject! };
82+
}

0 commit comments

Comments
 (0)