Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli-kit/src/private/node/testing/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
}

Choose a reason for hiding this comment

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

Unbounded setImmediate re-emit on every readable listener addition (can cause hangs/flakes)

The newListener handler schedules setImmediate(() => this.emit('readable')) whenever a 'readable' listener is added and data !== null.

Evidence (current code):

this.on('newListener', (event: string) => {
  if (event === 'readable' && this.data !== null) {
    setImmediate(() => this.emit('readable'))
  }
})

If a consumer adds multiple 'readable' listeners (or re-attaches on rerenders / effect re-runs), you’ll queue a new setImmediate each time while data remains non-null. If the consumer’s readable handler doesn’t drain via .read() (or drains later), this can lead to a large backlog of immediates continually emitting 'readable', potentially starving the event loop and causing test timeouts/flakiness.

Impact:

  • User impact: Indirect but real—CI can become flaky/hang in UI tests (Ink/React) that manipulate listeners during renders/effects.
  • Infra impact: Increased CPU + longer test runtime; worst case, stuck test workers in CI.
  • Scale: Affects any test using this Stdin mock (shared helper) where listeners are added while buffered data exists.

})
}

write = (data: string) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {AutocompletePrompt, SearchResults} from './AutocompletePrompt.js'
import {
getLastFrameAfterUnmount,
sendInputAndWait,
sendInputAndWaitForChange,
sendInputAndWaitForContent,
waitForInputsToBeReady,
Expand Down Expand Up @@ -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█
Expand Down Expand Up @@ -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<SearchResults<string>>((resolve) => {
setTimeout(() => {
resolve({data: [{label: 'a', value: 'b'}]})
}, 2000)
})
return new Promise<SearchResults<string>>(() => {})
}

const renderInstance = render(
Expand All @@ -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█
Expand Down
Loading