Conversation
Differences in type declarationsWe detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:
New type declarationspackages/cli-kit/dist/private/node/ui/hooks/use-deferred-unmount.d.tsexport default function useDeferredUnmount(): (error?: Parameters<(errorOrResult?: Error | unknown) => void>[0]) => void;
Existing type declarationsWe found no diffs with existing type declarations |
Coverage report
Test suite run success3994 tests passing in 1529 suites. Report generated by 🧪jest coverage report action from 14c22e0 |
|
We detected some changes at Caution DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release. |
There was a problem hiding this comment.
Pull request overview
This PR addresses a React 19/Ink lifecycle race where prompt components could unmount Ink immediately on submit/cancel, potentially preventing the final “completed” frame from rendering before teardown.
Changes:
- Introduces a shared
useDeferredUnmounthook that defers Inkexit()viasetImmediate. - Updates
TextPrompt,SelectPrompt,AutocompletePrompt, andDangerousConfirmationPromptto use deferred unmount. - Strengthens prompt tests to verify the final
✔/✘completion frame is rendered before the prompt exit completes.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.ts | Adds a hook to defer Ink teardown to the next setImmediate. |
| packages/cli-kit/src/private/node/ui/hooks/use-deferred-unmount.test.tsx | Adds a unit test ensuring deferred exit forwards errors and is async. |
| packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx | Switches prompt teardown from useApp().exit to deferred unmount. |
| packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx | Updates submit tests to wait for the completion frame before exit. |
| packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx | Uses deferred unmount (and adjusts submit ordering). |
| packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx | Updates submit tests to wait for the completion frame before exit. |
| packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx | Uses deferred unmount for submit/cancel teardown. |
| packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.test.tsx | Updates submit/cancel tests to wait for completion frame before exit. |
| packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx | Uses deferred unmount after submit. |
| packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx | Updates submit tests to ensure completion frame is rendered before exit resolves. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const renderPromise = renderInstance.waitUntilExit() | ||
| await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) | ||
|
|
||
| expect(renderPromise.isFulfilled()).toBe(false) | ||
|
|
||
| await renderPromise |
There was a problem hiding this comment.
The assertion that renderPromise is still unresolved is checked after awaiting waitForContent. Since waitForContent yields via setImmediate/setTimeout(0) and the new deferred unmount also schedules exit via setImmediate, it’s possible for the prompt to exit before this assertion runs (while the last frame still contains ✔), making this check timing-sensitive. Prefer asserting ordering directly (ensure the ✔ frame is observed before waitUntilExit settles) rather than checking isFulfilled() after waitForContent completes.
| expect(renderPromise.isFulfilled()).toBe(false) | ||
|
|
There was a problem hiding this comment.
Same potential flakiness pattern: waitForContent yields through setImmediate, and the new unmount is also scheduled via setImmediate. By the time this assertion runs, waitUntilExit() may already have resolved even though the final frame was rendered, causing intermittent failures. Consider asserting that the final frame is observed before the exit promise settles (ordering check) rather than asserting isFulfilled() === false after waitForContent returns.
| expect(renderPromise.isFulfilled()).toBe(false) |
| const renderPromise = renderInstance.waitUntilExit() | ||
| await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) | ||
|
|
||
| expect(renderPromise.isFulfilled()).toBe(false) |
There was a problem hiding this comment.
This isFulfilled() assertion is performed after awaiting waitForContent, which can yield long enough for the deferred exit (scheduled with setImmediate) to run. That can make this check flaky even if the final ✔/✘ frame rendered correctly. Consider validating ordering (final frame observed before waitUntilExit settles) instead of checking isFulfilled() after waitForContent.
| const renderPromise = renderInstance.waitUntilExit() | |
| await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) | |
| expect(renderPromise.isFulfilled()).toBe(false) | |
| let exitSettled = false | |
| const renderPromise = renderInstance.waitUntilExit().then(() => { | |
| exitSettled = true | |
| }) | |
| await waitForContent(renderInstance, '✔', () => { | |
| expect(exitSettled).toBe(false) | |
| renderInstance.stdin.write(ENTER) | |
| }) |
| await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) | ||
|
|
||
| expect(renderPromise.isFulfilled()).toBe(false) | ||
|
|
There was a problem hiding this comment.
waitForContent yields via setImmediate, and useDeferredUnmount schedules exit via setImmediate as well. That means by the time waitForContent resolves, renderPromise may already be fulfilled while the last frame still includes ✔, causing this assertion to be timing-sensitive. Consider asserting the ordering between “✔ observed” and “exit resolved” directly (e.g., by racing those two conditions) instead of checking isFulfilled() after waitForContent completes.
| await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) | |
| expect(renderPromise.isFulfilled()).toBe(false) | |
| const contentPromise = waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER)) | |
| const winner = await Promise.race([ | |
| contentPromise.then(() => 'content'), | |
| renderPromise.then(() => 'exit'), | |
| ]) | |
| expect(winner).toBe('content') |
| export default function useDeferredUnmount() { | ||
| const {exit: unmountInk} = useApp() | ||
|
|
||
| return useCallback( |
There was a problem hiding this comment.
we really should not do this unless someone can explain deterministically how the memoization-to-event-loop-tick-to-unmount works.
ryancbahan
left a comment
There was a problem hiding this comment.
I think this goes too far down the path of manual event loop management and is going to bite us.

WHY are these changes introduced?
Part of https://github.com/shop/issues-develop/issues/22483.
This is another narrow slice from the React/Ink lifecycle work.
These prompt components still tear down Ink immediately on submit/cancel. That can race with React 19 state flushing and cause the final prompt frame to disappear before it is rendered.
WHAT is this pull request doing?
TextPrompt,SelectPrompt,AutocompletePrompt, andDangerousConfirmationPromptto use itHow to test your changes?
✔/✘frame is visible before the prompt exits.Measuring impact
How do we know this change was effective? Please choose one:
Checklist