diff --git a/packages/cli-kit/src/private/node/testing/ui.ts b/packages/cli-kit/src/private/node/testing/ui.ts index 13c5019050..66d47e1144 100644 --- a/packages/cli-kit/src/private/node/testing/ui.ts +++ b/packages/cli-kit/src/private/node/testing/ui.ts @@ -23,6 +23,17 @@ export class Stdin extends EventEmitter { constructor(options: {isTTY?: boolean} = {}) { super() this.isTTY = options.isTTY ?? true + + // When a 'readable' listener is added and there's pending data, + // re-emit 'readable' so the new listener can read it. This mirrors + // real Node streams where buffered data is available to new readers, + // and prevents dropped input when data is written before Ink's + // useInput effect registers its listener. + this.on('newListener', (event: string) => { + if (event === 'readable' && this.data !== null) { + setImmediate(() => this.emit('readable')) + } + }) } write = (data: string) => { 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 8e04bb76d7..b097057eca 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 @@ -1,7 +1,6 @@ import {AutocompletePrompt, SearchResults} from './AutocompletePrompt.js' import { getLastFrameAfterUnmount, - sendInputAndWait, sendInputAndWaitForChange, sendInputAndWaitForContent, waitForInputsToBeReady, @@ -286,8 +285,9 @@ describe('AutocompletePrompt', async () => { await waitForInputsToBeReady() await sendInputAndWaitForContent(renderInstance, 'No results found', 'a') - // prompt doesn't change when enter is pressed - await sendInputAndWait(renderInstance, 100, ENTER) + // prompt doesn't change when enter is pressed — yield to let React + // 19's scheduler process any batched updates from the keypress + await sendInputAndWaitForContent(renderInstance, 'No results found', ENTER) expect(renderInstance.lastFrame()).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? a█ @@ -328,12 +328,11 @@ describe('AutocompletePrompt', async () => { test('has a loading state', async () => { const onEnter = vi.fn() + // Use a promise that never resolves so the loading state persists + // for the entire test, eliminating timing races with React 19's + // batched rendering on slow CI runners. const search = () => { - return new Promise>((resolve) => { - setTimeout(() => { - resolve({data: [{label: 'a', value: 'b'}]}) - }, 2000) - }) + return new Promise>(() => {}) } const renderInstance = render( @@ -347,9 +346,9 @@ describe('AutocompletePrompt', async () => { await waitForInputsToBeReady() await sendInputAndWaitForContent(renderInstance, 'Loading...', 'a') - // prompt doesn't change when enter is pressed - await new Promise((resolve) => setTimeout(resolve, 100)) - await sendInputAndWait(renderInstance, 100, ENTER) + // prompt doesn't change when enter is pressed — yield to let React + // 19's scheduler process any batched updates from the keypress + await sendInputAndWaitForContent(renderInstance, 'Loading...', ENTER) expect(renderInstance.lastFrame()).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? a█