From 451820658c27f4dae504ef2f55cd43cc7b93f2e7 Mon Sep 17 00:00:00 2001 From: Tewson Seeoun Date: Mon, 2 Mar 2026 12:54:29 +0000 Subject: [PATCH 1/2] Fix flaky TextInput and AutocompletePrompt tests under React 19 Replace async useEffect cursor clamping in TextInput with synchronous clamping during render to eliminate stale cursorOffset race condition exposed by React 19 scheduler changes. Also make AutocompletePrompt DELETE tests wait for deterministic content instead of any frame change. Co-Authored-By: Claude Opus 4.6 --- .../ui/components/AutocompletePrompt.test.tsx | 4 +- .../private/node/ui/components/TextInput.tsx | 39 +++++++++---------- 2 files changed, 20 insertions(+), 23 deletions(-) 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 6f1f390a539..8e04bb76d7c 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 @@ -448,7 +448,7 @@ describe('AutocompletePrompt', async () => { " `) - await sendInputAndWaitForChange(renderInstance, DELETE) + await sendInputAndWaitForContent(renderInstance, 'ype to search', DELETE) expect(renderInstance.lastFrame()).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? Type to search... @@ -697,7 +697,7 @@ describe('AutocompletePrompt', async () => { " `) - await sendInputAndWaitForChange(renderInstance, DELETE) + await sendInputAndWaitForContent(renderInstance, 'ype to search', DELETE) expect(renderInstance.lastFrame()).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? Type to search... diff --git a/packages/cli-kit/src/private/node/ui/components/TextInput.tsx b/packages/cli-kit/src/private/node/ui/components/TextInput.tsx index 95cee6c85d9..bd27bd32f02 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextInput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextInput.tsx @@ -1,6 +1,6 @@ /* eslint-disable no-nested-ternary */ import {shouldDisplayColors} from '../../../../public/node/output.js' -import React, {useEffect, useState} from 'react' +import React, {useState} from 'react' import {Text, useInput} from 'ink' import chalk from 'chalk' import figures from 'figures' @@ -29,18 +29,11 @@ const TextInput: FunctionComponent = ({ }: TextInputProps) => { const [cursorOffset, setCursorOffset] = useState((originalValue || '').length) - // if the updated value is shorter than the last one we need to reset the cursor - useEffect(() => { - setCursorOffset((previousOffset) => { - const newValue = originalValue || '' - - if (previousOffset > newValue.length - 1) { - return newValue.length - } - - return previousOffset - }) - }, [originalValue]) + // Clamp cursor synchronously so useInput never sees a stale offset + const clampedCursorOffset = Math.min(cursorOffset, (originalValue || '').length) + if (clampedCursorOffset !== cursorOffset) { + setCursorOffset(clampedCursorOffset) + } const value = password ? '*'.repeat(originalValue.length) : originalValue let renderedValue @@ -60,7 +53,7 @@ const TextInput: FunctionComponent = ({ renderedValue = value .split('') .map((char, index) => { - if (index === cursorOffset) { + if (index === clampedCursorOffset) { return noColor ? cursorChar : chalk.inverse(char) } else { return char @@ -68,7 +61,7 @@ const TextInput: FunctionComponent = ({ }) .join('') - if (cursorOffset === value.length) { + if (clampedCursorOffset === value.length) { renderedValue = ( {renderedValue} @@ -89,25 +82,29 @@ const TextInput: FunctionComponent = ({ } } - let nextCursorOffset = cursorOffset + let nextCursorOffset = clampedCursorOffset let nextValue = originalValue if (key.leftArrow) { - if (cursorOffset > 0) { + if (clampedCursorOffset > 0) { nextCursorOffset-- } } else if (key.rightArrow) { - if (cursorOffset < originalValue.length) { + if (clampedCursorOffset < originalValue.length) { nextCursorOffset++ } } else if (key.backspace || key.delete) { - if (cursorOffset > 0) { - nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length) + if (clampedCursorOffset > 0) { + nextValue = + originalValue.slice(0, clampedCursorOffset - 1) + + originalValue.slice(clampedCursorOffset, originalValue.length) nextCursorOffset-- } } else { nextValue = - originalValue.slice(0, cursorOffset) + input + originalValue.slice(cursorOffset, originalValue.length) + originalValue.slice(0, clampedCursorOffset) + + input + + originalValue.slice(clampedCursorOffset, originalValue.length) nextCursorOffset += input.length } From c855e1734b7c8bfeb54eee04635905997bcb792e Mon Sep 17 00:00:00 2001 From: Tewson Seeoun Date: Tue, 3 Mar 2026 11:26:42 +0000 Subject: [PATCH 2/2] Move TextInput cursor offset correction from render phase to useLayoutEffect --- .../src/private/node/ui/components/TextInput.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli-kit/src/private/node/ui/components/TextInput.tsx b/packages/cli-kit/src/private/node/ui/components/TextInput.tsx index bd27bd32f02..fa74d41c75b 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextInput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextInput.tsx @@ -1,6 +1,6 @@ /* eslint-disable no-nested-ternary */ import {shouldDisplayColors} from '../../../../public/node/output.js' -import React, {useState} from 'react' +import React, {useLayoutEffect, useState} from 'react' import {Text, useInput} from 'ink' import chalk from 'chalk' import figures from 'figures' @@ -29,11 +29,13 @@ const TextInput: FunctionComponent = ({ }: TextInputProps) => { const [cursorOffset, setCursorOffset] = useState((originalValue || '').length) - // Clamp cursor synchronously so useInput never sees a stale offset const clampedCursorOffset = Math.min(cursorOffset, (originalValue || '').length) - if (clampedCursorOffset !== cursorOffset) { - setCursorOffset(clampedCursorOffset) - } + + useLayoutEffect(() => { + if (clampedCursorOffset !== cursorOffset) { + setCursorOffset(clampedCursorOffset) + } + }, [clampedCursorOffset, cursorOffset]) const value = password ? '*'.repeat(originalValue.length) : originalValue let renderedValue