Skip to content

Defer Ink teardown in prompt components#7121

Open
dmerand wants to merge 1 commit intomainfrom
dlm-ink-prompt-unmount
Open

Defer Ink teardown in prompt components#7121
dmerand wants to merge 1 commit intomainfrom
dlm-ink-prompt-unmount

Conversation

@dmerand
Copy link
Copy Markdown
Contributor

@dmerand dmerand commented Mar 26, 2026

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?

  • adds a shared deferred-unmount hook for prompt teardown
  • updates TextPrompt, SelectPrompt, AutocompletePrompt, and DangerousConfirmationPrompt to use it
  • strengthens prompt tests so submit/cancel paths verify the completion frame is rendered before exit resolves

How to test your changes?

  • Exercise prompt submission and cancellation paths and verify the final / frame is visible before the prompt exits.

Measuring impact

How do we know this change was effective? Please choose one:

  • n/a - this doesn't need measurement, e.g. a linting rule or a bug-fix
  • Existing analytics will cater for this addition
  • PR includes analytics changes to measure impact

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've considered possible documentation changes

Copy link
Copy Markdown
Contributor Author

dmerand commented Mar 26, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions
Copy link
Copy Markdown
Contributor

Differences in type declarations

We 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:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

packages/cli-kit/dist/private/node/ui/hooks/use-deferred-unmount.d.ts
export default function useDeferredUnmount(): (error?: Parameters<(errorOrResult?: Error | unknown) => void>[0]) => void;

Existing type declarations

We found no diffs with existing type declarations

@github-actions
Copy link
Copy Markdown
Contributor

Coverage report

St.
Category Percentage Covered / Total
🟢 Statements 82.28% 15113/18367
🟡 Branches 74.84% 7453/9959
🟢 Functions 81.33% 3798/4670
🟢 Lines 82.67% 14289/17284

Test suite run success

3994 tests passing in 1529 suites.

Report generated by 🧪jest coverage report action from 14c22e0

@dmerand dmerand marked this pull request as ready for review March 27, 2026 00:52
@dmerand dmerand requested a review from a team as a code owner March 27, 2026 00:52
Copilot AI review requested due to automatic review settings March 27, 2026 00:52
@github-actions
Copy link
Copy Markdown
Contributor

We detected some changes at packages/*/src and there are no updates in the .changeset.
If the changes are user-facing, run pnpm changeset add to track your changes and include them in the next release CHANGELOG.

Caution

DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useDeferredUnmount hook that defers Ink exit() via setImmediate.
  • Updates TextPrompt, SelectPrompt, AutocompletePrompt, and DangerousConfirmationPrompt to 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.

Comment on lines +82 to +87
const renderPromise = renderInstance.waitUntilExit()
await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER))

expect(renderPromise.isFulfilled()).toBe(false)

await renderPromise
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +67
expect(renderPromise.isFulfilled()).toBe(false)

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
expect(renderPromise.isFulfilled()).toBe(false)

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +74
const renderPromise = renderInstance.waitUntilExit()
await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER))

expect(renderPromise.isFulfilled()).toBe(false)
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
})

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +126
await waitForContent(renderInstance, '✔', () => renderInstance.stdin.write(ENTER))

expect(renderPromise.isFulfilled()).toBe(false)

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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')

Copilot uses AI. Check for mistakes.
export default function useDeferredUnmount() {
const {exit: unmountInk} = useApp()

return useCallback(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we really should not do this unless someone can explain deterministically how the memoization-to-event-loop-tick-to-unmount works.

Copy link
Copy Markdown
Contributor

@ryancbahan ryancbahan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this goes too far down the path of manual event loop management and is going to bite us.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants