diff --git a/.changeset/cool-papers-behave.md b/.changeset/cool-papers-behave.md new file mode 100644 index 00000000000..59be7244eba --- /dev/null +++ b/.changeset/cool-papers-behave.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Task progress bars once again clear when complete diff --git a/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx b/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx index a58113df20f..e0a931721c9 100644 --- a/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx @@ -9,11 +9,12 @@ interface SingleTaskProps { title: TokenizedString task: (updateStatus: (status: TokenizedString) => void) => Promise onComplete?: (result: T) => void + onError?: (error: Error) => void onAbort?: () => void noColor?: boolean } -const SingleTask = ({task, title, onComplete, onAbort, noColor}: SingleTaskProps) => { +const SingleTask = ({task, title, onComplete, onError, onAbort, noColor}: SingleTaskProps) => { const [status, setStatus] = useState(title) const [isDone, setIsDone] = useState(false) const {exit: unmountInk} = useApp() @@ -35,13 +36,16 @@ const SingleTask = ({task, title, onComplete, onAbort, noColor}: SingleTaskP .then((result) => { setIsDone(true) onComplete?.(result) - unmountInk() + // Defer unmount so React 19 can flush batched state updates + // before the component tree is torn down. + setImmediate(() => unmountInk()) }) .catch((error) => { setIsDone(true) - unmountInk(error) + onError?.(error) + setImmediate(() => unmountInk(error)) }) - }, [task, unmountInk, onComplete]) + }, [task, unmountInk, onComplete, onError]) if (isDone) { // clear things once done diff --git a/packages/cli-kit/src/private/node/ui/hooks/use-async-and-unmount.ts b/packages/cli-kit/src/private/node/ui/hooks/use-async-and-unmount.ts index d2ed970f157..e9a38890478 100644 --- a/packages/cli-kit/src/private/node/ui/hooks/use-async-and-unmount.ts +++ b/packages/cli-kit/src/private/node/ui/hooks/use-async-and-unmount.ts @@ -16,11 +16,13 @@ export default function useAsyncAndUnmount( asyncFunction() .then(() => { onFulfilled() - unmountInk() + // Defer unmount so React 19 can flush batched state updates + // before the component tree is torn down. + setImmediate(() => unmountInk()) }) .catch((error) => { onRejected(error) - unmountInk(error) + setImmediate(() => unmountInk(error)) }) }, []) } diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index c9ac3e6b3c7..15546381ed3 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -486,15 +486,26 @@ interface RenderTasksOptions { export async function renderTasks( tasks: Task[], {renderOptions, noProgressBar}: RenderTasksOptions = {}, -) { - return new Promise((resolve, reject) => { - render(, { +): Promise { + let result: TContext | undefined + let taskError: Error | undefined + await render( + { + result = ctx + }} + noProgressBar={noProgressBar} + />, + { ...renderOptions, exitOnCtrlC: false, - }) - .then(() => {}) - .catch(reject) + }, + ).catch((error) => { + taskError = error }) + if (taskError) throw taskError + return result as TContext } export interface RenderSingleTaskOptions { @@ -521,12 +532,18 @@ export async function renderSingleTask({ onAbort, renderOptions, }: RenderSingleTaskOptions): Promise { - return new Promise((resolve, reject) => { - render(, { + // Result/error come from callbacks because render() may resolve prematurely + // when multiple concurrent ink instances interfere with each other's waitUntilExit(). + // We race the callback promise against render() to handle both cases: + // - Normal: callback fires, then render() completes + // - Concurrent: render() resolves early due to cross-instance interference + const callbackPromise = new Promise((resolve, reject) => { + render(, { ...renderOptions, exitOnCtrlC: false, }).catch(reject) }) + return callbackPromise } export interface RenderTextPromptOptions extends Omit {