diff --git a/packages/core/core.test.mts b/packages/core/core.test.mts index e68256c71..69fa2f88a 100644 --- a/packages/core/core.test.mts +++ b/packages/core/core.test.mts @@ -467,6 +467,46 @@ describe('createPrompt()', () => { await expect(answer).resolves.toEqual('done'); expect(getScreen({ raw: true })).toEqual(ansiEscapes.eraseLines(1)); }); + + it('clear timeout when force closing', { timeout: 1000 }, async () => { + let exitSpy = vi.fn(); + const prompt = createPrompt( + (config: { message: string }, done: (value: string) => void) => { + const timeout = useRef(); + const cleaned = useRef(false); + useKeypress(() => { + if (cleaned.current) { + expect.unreachable('once cleaned up keypress should not be called'); + } + clearTimeout(timeout.current); + timeout.current = setTimeout(() => {}, 1000); + }); + + exitSpy = vi.fn(() => { + clearTimeout(timeout.current); + cleaned.current = true; + // We call done explicitly, as onSignalExit is not triggered in this case + // But, CTRL+C will trigger rl.close, which should call this effect + // This way we can have the promise resolve + done('closed'); + }); + + useEffect(() => exitSpy, []); + + return config.message; + }, + ); + + const { answer, events } = await render(prompt, { message: 'Question' }); + + // This triggers the timeout + events.keypress('a'); + // This closes the readline + events.keypress({ ctrl: true, name: 'c' }); + + await expect(answer).resolves.toBe('closed'); + expect(exitSpy).toHaveBeenCalledTimes(1); + }); }); it('allow cancelling the prompt multiple times', async () => { diff --git a/packages/core/src/lib/create-prompt.mts b/packages/core/src/lib/create-prompt.mts index 33b736809..7f66a9922 100644 --- a/packages/core/src/lib/create-prompt.mts +++ b/packages/core/src/lib/create-prompt.mts @@ -43,12 +43,16 @@ export function createPrompt(view: ViewFunction) { ); }); - const onExit = AsyncResource.bind(() => { + const hooksCleanup = AsyncResource.bind(() => { try { effectScheduler.clearAll(); } catch (error) { reject(error); } + }); + + function onExit() { + hooksCleanup(); if (context?.clearPromptOnDone) { screen.clean(); @@ -59,7 +63,8 @@ export function createPrompt(view: ViewFunction) { removeExitListener(); rl.input.removeListener('keypress', checkCursorPos); - }); + rl.removeListener('close', hooksCleanup); + } cancel = () => { onExit(); @@ -96,6 +101,11 @@ export function createPrompt(view: ViewFunction) { // We set the listener after the initial workLoop to avoid a double render if render triggered // by a state change sets the cursor to the right position. rl.input.on('keypress', checkCursorPos); + + // The close event triggers immediately when the user press ctrl+c. SignalExit on the other hand + // triggers after the process is done (which happens after timeouts are done triggering.) + // We triggers the hooks cleanup phase on rl `close` so active timeouts can be cleared. + rl.on('close', hooksCleanup); }); }); diff --git a/packages/core/src/lib/use-keypress.mts b/packages/core/src/lib/use-keypress.mts index 319d43b6c..7a4589ba9 100644 --- a/packages/core/src/lib/use-keypress.mts +++ b/packages/core/src/lib/use-keypress.mts @@ -11,12 +11,15 @@ export function useKeypress( signal.current = userHandler; useEffect((rl) => { + let ignore = false; const handler = withUpdates((_input: string, event: KeypressEvent) => { + if (ignore) return; signal.current(event, rl); }); rl.input.on('keypress', handler); return () => { + ignore = true; rl.input.removeListener('keypress', handler); }; }, []); diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index c0aa8b996..4ceeee784 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -6,6 +6,7 @@ import { usePagination, useRef, useMemo, + useEffect, isBackspaceKey, isEnterKey, isUpKey, @@ -149,6 +150,13 @@ export default createPrompt( } }); + useEffect( + () => () => { + clearTimeout(searchTimeoutRef.current); + }, + [], + ); + const message = theme.style.message(config.message); let helpTipTop = '';