Skip to content

Commit e5c2def

Browse files
fix(SelectPrompt): fix fresh-start fast selection error
Targeted the startup-specific case directly in `src/components/SelectPrompt/SelectPrompt.tsx`: newly mounted selects render immediately, but they stay `isDisabled` for the first event-loop tick, then become interactive. The goal is to prevent a very fast buffered `Enter` from being consumed by a brand-new `Select` during fresh startup.
1 parent 2518ab1 commit e5c2def

2 files changed

Lines changed: 62 additions & 11 deletions

File tree

src/components/SelectPrompt/SelectPrompt.test.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,36 @@ const { mockOnChange } = vi.hoisted(() => ({
77
mockOnChange: vi.fn<(value: string) => void>(),
88
}));
99

10+
const { mockSelect } = vi.hoisted(() => ({
11+
mockSelect:
12+
vi.fn<
13+
(props: {
14+
isDisabled?: boolean;
15+
options: { label: string; value: string }[];
16+
defaultValue?: string;
17+
onChange?: (value: string) => void;
18+
}) => void
19+
>(),
20+
}));
21+
1022
vi.mock('@inkjs/ui', async () => {
1123
const { Text } = await import('ink');
1224
return {
13-
Select: ({
14-
options,
15-
onChange,
16-
defaultValue,
17-
}: {
25+
Select: (props: {
26+
isDisabled?: boolean;
1827
options: { label: string; value: string }[];
1928
defaultValue?: string;
2029
onChange?: (value: string) => void;
2130
}) => {
22-
mockOnChange.mockImplementation((value) => onChange?.(value));
31+
mockSelect(props);
32+
mockOnChange.mockImplementation((value) => props.onChange?.(value));
2333
return (
2434
<>
25-
{defaultValue ? <Text>{`default:${defaultValue}`}</Text> : null}
26-
{options.map(({ value, label }) => (
35+
<Text>{`disabled:${String(props.isDisabled ?? false)}`}</Text>
36+
{props.defaultValue ? (
37+
<Text>{`default:${props.defaultValue}`}</Text>
38+
) : null}
39+
{props.options.map(({ value, label }) => (
2740
<Text key={value}>{label}</Text>
2841
))}
2942
</>
@@ -55,6 +68,10 @@ describe('SelectPrompt', () => {
5568
expect(frame).toContain('Second option');
5669
});
5770

71+
beforeEach(() => {
72+
mockSelect.mockClear();
73+
});
74+
5875
it('calls onCancel when Escape is pressed', async () => {
5976
const onCancel = vi.fn();
6077
const { stdin } = render(
@@ -74,7 +91,7 @@ describe('SelectPrompt', () => {
7491
);
7592

7693
stdin.write('\x03');
77-
await time.tick(20);
94+
await time.tick(10);
7895

7996
expect(onCancel).toHaveBeenCalledTimes(1);
8097
});
@@ -85,7 +102,7 @@ describe('SelectPrompt', () => {
85102
);
86103

87104
stdin.write(KEY.ESCAPE);
88-
await time.tick(20);
105+
await time.tick(10);
89106
});
90107

91108
it('passes defaultValue through to Select', () => {
@@ -100,6 +117,17 @@ describe('SelectPrompt', () => {
100117
expect(lastFrame()).toContain('default:second');
101118
});
102119

120+
it('enables selection on the next tick after mount', async () => {
121+
render(<SelectPrompt options={options} onChange={vi.fn()} />);
122+
123+
expect(mockSelect).toHaveBeenCalled();
124+
expect(mockSelect.mock.calls[0]?.[0].isDisabled).toBe(true);
125+
126+
await time.tick(10);
127+
128+
expect(mockSelect.mock.calls.at(-1)?.[0].isDisabled).toBe(false);
129+
});
130+
103131
it('renders an optional borderStyle on the prompt container', () => {
104132
const { lastFrame } = render(
105133
<SelectPrompt options={options} onChange={vi.fn()} borderStyle="round">

src/components/SelectPrompt/SelectPrompt.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Select, type SelectProps } from '@inkjs/ui';
22
import { Box, type BoxProps, useInput } from 'ink';
3+
import { useEffect, useState } from 'react';
4+
5+
import { time } from '@/utils';
36

47
interface SelectPromptProps extends SelectProps {
58
borderStyle?: BoxProps['borderStyle'];
@@ -13,6 +16,23 @@ export function SelectPrompt({
1316
onCancel,
1417
...selectProps
1518
}: SelectPromptProps) {
19+
const [isInteractive, setIsInteractive] = useState(false);
20+
21+
useEffect(() => {
22+
let isMounted = true;
23+
24+
void time.tick().then(() => {
25+
// v8 ignore next
26+
if (isMounted) {
27+
setIsInteractive(true);
28+
}
29+
});
30+
31+
return () => {
32+
isMounted = false;
33+
};
34+
}, []);
35+
1636
useInput((input, key) => {
1737
if (key.escape || (key.ctrl && input === 'c')) {
1838
onCancel?.();
@@ -23,7 +43,10 @@ export function SelectPrompt({
2343
<Box borderStyle={borderStyle} flexDirection="column">
2444
{children}
2545

26-
<Select {...selectProps} />
46+
<Select
47+
{...selectProps}
48+
isDisabled={selectProps.isDisabled ?? !isInteractive}
49+
/>
2750
</Box>
2851
);
2952
}

0 commit comments

Comments
 (0)