From 14c22e08635e2d23c405abf8193c30fdf607c4aa Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 26 Mar 2026 17:42:31 -0400 Subject: [PATCH] Defer Ink teardown in prompt components --- .../ui/components/AutocompletePrompt.test.tsx | 15 +++++- .../node/ui/components/AutocompletePrompt.tsx | 7 +-- .../DangerousConfirmationPrompt.test.tsx | 25 +++++++-- .../DangerousConfirmationPrompt.tsx | 5 +- .../node/ui/components/SelectPrompt.test.tsx | 36 +++++++++++-- .../node/ui/components/SelectPrompt.tsx | 6 +-- .../node/ui/components/TextPrompt.test.tsx | 51 ++++++++++++++++--- .../private/node/ui/components/TextPrompt.tsx | 5 +- .../ui/hooks/use-deferred-unmount.test.tsx | 43 ++++++++++++++++ .../node/ui/hooks/use-deferred-unmount.ts | 17 +++++++ 10 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.test.tsx create mode 100644 packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.ts diff --git a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx index 7c449a9d710..30a90cc9024 100644 --- a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx @@ -6,6 +6,7 @@ import { sendInputAndWaitForContent, waitForInputsToBeReady, render, + waitForContent, } from '../../testing/ui.js' import {Stdout} from '../../ui.js' import {AbortController} from '../../../../public/node/abort.js' @@ -117,8 +118,13 @@ describe('AutocompletePrompt', async () => { await waitForInputsToBeReady() await sendInputAndWaitForChange(renderInstance, ARROW_DOWN) - await sendInputAndWaitForChange(renderInstance, ENTER) + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(getLastFrameAfterUnmount(renderInstance)).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? ✔ second @@ -520,8 +526,13 @@ describe('AutocompletePrompt', async () => { " `) - await sendInputAndWaitForChange(renderInstance, ENTER) + const renderPromise = renderInstance.waitUntilExit() + renderInstance.stdin.write(ENTER) + + expect(renderPromise.isFulfilled()).toBe(false) + await waitForContent(renderInstance, '✔') + await renderPromise expect(getLastFrameAfterUnmount(renderInstance)).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? ✔ fifth diff --git a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx index 80bf98d3ed2..03080fc0c22 100644 --- a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx @@ -5,10 +5,11 @@ import {InfoMessageProps} from './Prompts/InfoMessage.js' import {Message, PromptLayout} from './Prompts/PromptLayout.js' import {throttle} from '../../../../public/common/function.js' import {AbortSignal} from '../../../../public/node/abort.js' +import useDeferredUnmount from '../hooks/use-deferred-unmount.js' import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {ReactElement, useCallback, useEffect, useRef, useState} from 'react' -import {Box, useApp} from 'ink' +import {Box} from 'ink' export interface SearchResults { data: SelectItem[] @@ -42,7 +43,7 @@ function AutocompletePrompt({ infoMessage, groupOrder, }: React.PropsWithChildren>): ReactElement | null { - const {exit: unmountInk} = useApp() + const unmountInk = useDeferredUnmount() const [searchTerm, setSearchTerm] = useState('') const [searchResults, setSearchResults] = useState[]>(choices) const canSearch = choices.length > MIN_NUMBER_OF_ITEMS_FOR_SEARCH @@ -72,8 +73,8 @@ function AutocompletePrompt({ useEffect(() => { if (promptState === PromptState.Submitted && answer) { setSearchTerm('') - unmountInk() onSubmit(answer.value) + unmountInk() } }, [answer, onSubmit, promptState, unmountInk]) diff --git a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.test.tsx index b5eb3fc0d7e..1059875b5f2 100644 --- a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.test.tsx @@ -1,5 +1,11 @@ import {DangerousConfirmationPrompt} from './DangerousConfirmationPrompt.js' -import {getLastFrameAfterUnmount, sendInputAndWaitForChange, waitForInputsToBeReady, render} from '../../testing/ui.js' +import { + getLastFrameAfterUnmount, + sendInputAndWaitForChange, + waitForContent, + waitForInputsToBeReady, + render, +} from '../../testing/ui.js' import {unstyled} from '../../../../public/node/output.js' import React from 'react' @@ -61,7 +67,13 @@ describe('DangerousConfirmationPrompt', () => { await waitForInputsToBeReady() await sendInputAndWaitForChange(renderInstance, 'yes') - await sendInputAndWaitForChange(renderInstance, ENTER) + + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(onSubmit).toHaveBeenCalledWith(true) expect(unstyled(getLastFrameAfterUnmount(renderInstance)!)).toMatchInlineSnapshot(` "? Test question: @@ -112,15 +124,18 @@ describe('DangerousConfirmationPrompt', () => { , ) await waitForInputsToBeReady() - const promise = renderInstance.waitUntilExit() - await sendInputAndWaitForChange(renderInstance, ESC) + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✘', () => renderInstance.stdin.write(ESC)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(unstyled(getLastFrameAfterUnmount(renderInstance)!)).toMatchInlineSnapshot(` "? Test question: ✘ Cancelled " `) - await expect(promise).resolves.toEqual(undefined) expect(onSubmit).toHaveBeenCalledWith(false) }) }) diff --git a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx index d877dd98440..4ba1a2feadc 100644 --- a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx @@ -6,10 +6,11 @@ import useLayout from '../hooks/use-layout.js' import {messageWithPunctuation} from '../utilities.js' import {AbortSignal} from '../../../../public/node/abort.js' import useAbortSignal from '../hooks/use-abort-signal.js' +import useDeferredUnmount from '../hooks/use-deferred-unmount.js' import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {FunctionComponent, useCallback, useEffect, useState} from 'react' -import {Box, useApp, useInput, Text} from 'ink' +import {Box, useInput, Text} from 'ink' import figures from 'figures' export interface DangerousConfirmationPromptProps { @@ -38,7 +39,7 @@ const DangerousConfirmationPrompt: FunctionComponent({ initialAnswer: '', }) - const {exit: unmountInk} = useApp() + const unmountInk = useDeferredUnmount() const [error, setError] = useState | undefined>(undefined) const color = promptState === PromptState.Error ? 'red' : 'cyan' const underline = new Array(oneThird - 3).fill('▔') diff --git a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx index 0f3d4164a9e..bb9a246b8d6 100644 --- a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx @@ -1,5 +1,11 @@ import {SelectPrompt} from './SelectPrompt.js' -import {getLastFrameAfterUnmount, sendInputAndWaitForChange, waitForInputsToBeReady, render} from '../../testing/ui.js' +import { + getLastFrameAfterUnmount, + sendInputAndWaitForChange, + waitForContent, + waitForInputsToBeReady, + render, +} from '../../testing/ui.js' import {unstyled} from '../../../../public/node/output.js' import {Stdout} from '../../ui.js' import {AbortController} from '../../../../public/node/abort.js' @@ -53,8 +59,13 @@ describe('SelectPrompt', async () => { await waitForInputsToBeReady() await sendInputAndWaitForChange(renderInstance, ARROW_DOWN) - await sendInputAndWaitForChange(renderInstance, ENTER) + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(getLastFrameAfterUnmount(renderInstance)).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? ✔ second @@ -258,8 +269,13 @@ describe('SelectPrompt', async () => { `) await waitForInputsToBeReady() - await sendInputAndWaitForChange(renderInstance, ENTER) + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(getLastFrameAfterUnmount(renderInstance)).toMatchInlineSnapshot(` "? Test question? ✔ b @@ -288,8 +304,13 @@ describe('SelectPrompt', async () => { `) await waitForInputsToBeReady() - await sendInputAndWaitForChange(renderInstance, ENTER) + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(getLastFrameAfterUnmount(renderInstance)).toMatchInlineSnapshot(` "? Test question? ✔ a @@ -318,8 +339,13 @@ describe('SelectPrompt', async () => { `) await waitForInputsToBeReady() - await sendInputAndWaitForChange(renderInstance, 'b') + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write('b')) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(getLastFrameAfterUnmount(renderInstance)).toMatchInlineSnapshot(` "? Test question? ✔ b diff --git a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx index 8e1606efb37..b6722cd303a 100644 --- a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx @@ -3,10 +3,10 @@ import {InfoTableProps} from './Prompts/InfoTable.js' import {InfoMessageProps} from './Prompts/InfoMessage.js' import {Message, PromptLayout} from './Prompts/PromptLayout.js' import {AbortSignal} from '../../../../public/node/abort.js' +import useDeferredUnmount from '../hooks/use-deferred-unmount.js' import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {ReactElement, useCallback, useEffect} from 'react' -import {useApp} from 'ink' export interface SelectPromptProps { message: Message @@ -32,7 +32,7 @@ function SelectPrompt({ if (choices.length === 0) { throw new Error('SelectPrompt requires at least one choice') } - const {exit: unmountInk} = useApp() + const unmountInk = useDeferredUnmount() const {promptState, setPromptState, answer, setAnswer} = usePrompt | undefined>({ initialAnswer: undefined, }) @@ -47,8 +47,8 @@ function SelectPrompt({ useEffect(() => { if (promptState === PromptState.Submitted && answer) { - unmountInk() onSubmit(answer.value) + unmountInk() } }, [answer, onSubmit, promptState, unmountInk]) diff --git a/packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx index 939cec7006e..b3b74c3ec3a 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx @@ -1,5 +1,11 @@ import {TextPrompt} from './TextPrompt.js' -import {getLastFrameAfterUnmount, sendInputAndWaitForChange, waitForInputsToBeReady, render} from '../../testing/ui.js' +import { + getLastFrameAfterUnmount, + sendInputAndWaitForChange, + waitForContent, + waitForInputsToBeReady, + render, +} from '../../testing/ui.js' import {unstyled} from '../../../../public/node/output.js' import {AbortController} from '../../../../public/node/abort.js' import colors from '../../../../public/node/colors.js' @@ -72,7 +78,13 @@ describe('TextPrompt', () => { await waitForInputsToBeReady() await sendInputAndWaitForChange(renderInstance, 'A') - await sendInputAndWaitForChange(renderInstance, ENTER) + + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(onSubmit).toHaveBeenCalledWith('A') expect(unstyled(getLastFrameAfterUnmount(renderInstance)!)).toMatchInlineSnapshot(` "? Test question: @@ -86,7 +98,13 @@ describe('TextPrompt', () => { const renderInstance = render() await waitForInputsToBeReady() - await sendInputAndWaitForChange(renderInstance, ENTER) + + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(onSubmit).toHaveBeenCalledWith('A') expect(unstyled(getLastFrameAfterUnmount(renderInstance)!)).toMatchInlineSnapshot(` "? Test question: @@ -102,7 +120,13 @@ describe('TextPrompt', () => { ) await waitForInputsToBeReady() - await sendInputAndWaitForChange(renderInstance, ENTER) + + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(onSubmit).toHaveBeenCalledWith('') expect(unstyled(getLastFrameAfterUnmount(renderInstance)!)).toMatchInlineSnapshot(` "? Test question: @@ -124,7 +148,13 @@ describe('TextPrompt', () => { ) await waitForInputsToBeReady() - await sendInputAndWaitForChange(renderInstance, ENTER) + + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise expect(onSubmit).toHaveBeenCalledWith('A') expect(unstyled(getLastFrameAfterUnmount(renderInstance)!)).toMatchInlineSnapshot(` "? Test question: @@ -151,7 +181,8 @@ describe('TextPrompt', () => { }) test("masking the input if it's a password", async () => { - const renderInstance = render( {}} message="Test question" password />) + const onSubmit = vi.fn() + const renderInstance = render() await waitForInputsToBeReady() await sendInputAndWaitForChange(renderInstance, 'ABC') @@ -162,7 +193,13 @@ describe('TextPrompt', () => { " `) - await sendInputAndWaitForChange(renderInstance, ENTER) + const renderPromise = renderInstance.waitUntilExit() + await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) + + expect(renderPromise.isFulfilled()).toBe(false) + + await renderPromise + expect(onSubmit).toHaveBeenCalledWith('ABC') expect(unstyled(getLastFrameAfterUnmount(renderInstance)!)).toMatchInlineSnapshot(` "? Test question: ✔ *** diff --git a/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx index d9d94e586da..be06f4fab67 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx @@ -5,10 +5,11 @@ import useLayout from '../hooks/use-layout.js' import {messageWithPunctuation} from '../utilities.js' import {AbortSignal} from '../../../../public/node/abort.js' import useAbortSignal from '../hooks/use-abort-signal.js' +import useDeferredUnmount from '../hooks/use-deferred-unmount.js' import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {FunctionComponent, useCallback, useEffect, useState} from 'react' -import {Box, useApp, useInput, Text} from 'ink' +import {Box, useInput, Text} from 'ink' import figures from 'figures' export interface TextPromptProps { @@ -60,7 +61,7 @@ const TextPrompt: FunctionComponent = ({ const answerOrDefault = answer.length > 0 ? answer : defaultValue const displayEmptyValue = answerOrDefault === '' const displayedAnswer = displayEmptyValue ? emptyDisplayedValue : answerOrDefault - const {exit: unmountInk} = useApp() + const unmountInk = useDeferredUnmount() const [error, setError] = useState(undefined) const color = promptState === PromptState.Error ? 'red' : 'cyan' const underline = new Array(oneThird - 3).fill('▔') diff --git a/packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.test.tsx b/packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.test.tsx new file mode 100644 index 00000000000..d6d35eac709 --- /dev/null +++ b/packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.test.tsx @@ -0,0 +1,43 @@ +import useDeferredUnmount from './use-deferred-unmount.js' +import {render, waitFor} from '../../testing/ui.js' +import {describe, expect, test, vi} from 'vitest' + +import React, {useEffect} from 'react' + +function DeferredUnmountHarness({ + onReady, +}: { + onReady: (deferredUnmount: ReturnType) => void +}) { + const deferredUnmount = useDeferredUnmount() + + useEffect(() => { + onReady(deferredUnmount) + }, [deferredUnmount, onReady]) + + return null +} + +describe('useDeferredUnmount', () => { + test('defers exit asynchronously and forwards the provided error', async () => { + const onReady = vi.fn() + const renderInstance = render() + + await waitFor( + () => {}, + () => onReady.mock.calls.length > 0, + ) + + const deferredUnmount = onReady.mock.lastCall?.[0] + const error = new Error('deferred failure') + const renderPromise = renderInstance.waitUntilExit() + + deferredUnmount(error) + + expect(renderPromise.isPending()).toBe(true) + expect(renderPromise.isFulfilled()).toBe(false) + expect(renderPromise.isRejected()).toBe(false) + + await expect(renderPromise).rejects.toThrowError(error) + }) +}) diff --git a/packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.ts b/packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.ts new file mode 100644 index 00000000000..21d703a8429 --- /dev/null +++ b/packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.ts @@ -0,0 +1,17 @@ +import {useApp} from 'ink' +import {useCallback} from 'react' + +export default function useDeferredUnmount() { + const {exit: unmountInk} = useApp() + + return useCallback( + (error?: Parameters[0]) => { + // Defer unmounting to the next setImmediate so React 19 can flush + // batched state updates before the tree is torn down. React 19's + // scheduler also uses setImmediate in Node.js, so the submitted + // prompt frame renders before this callback fires. + setImmediate(() => unmountInk(error)) + }, + [unmountInk], + ) +}