From 9575086f0f2c87bf8c0c7ca254d5ac6c44d3e2da Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:18:11 -0400 Subject: [PATCH 1/4] Fix progress bars not clearing on completion after React 19 upgrade The Ink 6 / React 19 upgrade (269f3aa6) deferred unmountInk() in ConcurrentOutput to let React 19 flush batched state updates, but missed the same pattern in useAsyncAndUnmount (used by Tasks) and SingleTask. Without the deferral, unmountInk() fires before the setState that triggers `return null` is flushed, so the final render still contains the LoadingBar and it is never erased. Wrap unmountInk() in setImmediate() in both places, matching the existing fix in ConcurrentOutput. Co-Authored-By: Claude Opus 4.6 --- .../cli-kit/src/private/node/ui/components/SingleTask.tsx | 6 ++++-- .../src/private/node/ui/hooks/use-async-and-unmount.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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..eba6e997122 100644 --- a/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SingleTask.tsx @@ -35,11 +35,13 @@ 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) + setImmediate(() => unmountInk(error)) }) }, [task, unmountInk, onComplete]) 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)) }) }, []) } From 0b5a7d9db56764bc2bf58577fcb8f5f206583051 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:26:19 -0400 Subject: [PATCH 2/4] Added changeset --- .changeset/cool-papers-behave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cool-papers-behave.md diff --git a/.changeset/cool-papers-behave.md b/.changeset/cool-papers-behave.md new file mode 100644 index 00000000000..5cd3c20c869 --- /dev/null +++ b/.changeset/cool-papers-behave.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +[fix] Task progress bars once again clear when complete From c3e9c1a38d1a2cf83f7fd13278fd22386b6ed2f8 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:58:13 -0400 Subject: [PATCH 3/4] Fix renderSingleTask error swallowing on sequential calls The setImmediate deferral of unmountInk() introduced a race condition when renderSingleTask is called sequentially: the first instance's deferred unmountInk() can fire after the second instance starts, causing the second waitUntilExit() to resolve prematurely. If the second task then throws, the error is silently swallowed because render().catch(reject) never fires. Fix: add onError callback to SingleTask (mirroring onComplete) so errors are propagated directly via the callback rather than relying on waitUntilExit() rejection, which is unreliable across sequential ink renders. Co-Authored-By: Claude Opus 4.6 --- .../cli-kit/src/private/node/ui/components/SingleTask.tsx | 6 ++++-- packages/cli-kit/src/public/node/ui.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) 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 eba6e997122..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() @@ -41,9 +42,10 @@ const SingleTask = ({task, title, onComplete, onAbort, noColor}: SingleTaskP }) .catch((error) => { setIsDone(true) + 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/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index c9ac3e6b3c7..c9d4da69dd4 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -522,7 +522,7 @@ export async function renderSingleTask({ renderOptions, }: RenderSingleTaskOptions): Promise { return new Promise((resolve, reject) => { - render(, { + render(, { ...renderOptions, exitOnCtrlC: false, }).catch(reject) From d421cf78aa66ed7a4eea5311dcc8f8c00972c685 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:44:21 -0400 Subject: [PATCH 4/4] Await render() in renderTasks to prevent dangling promises renderTasks: await render() directly so the returned promise doesn't settle until the full ink unmount cycle completes. This prevents dangling promises that caused test timeouts (e.g. theme-downloader tests). renderSingleTask: keep the callback-based pattern because render() may resolve prematurely when multiple concurrent ink instances interfere with each other's waitUntilExit(). Co-Authored-By: Claude Opus 4.6 --- .changeset/cool-papers-behave.md | 2 +- packages/cli-kit/src/public/node/ui.tsx | 31 +++++++++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.changeset/cool-papers-behave.md b/.changeset/cool-papers-behave.md index 5cd3c20c869..59be7244eba 100644 --- a/.changeset/cool-papers-behave.md +++ b/.changeset/cool-papers-behave.md @@ -2,4 +2,4 @@ '@shopify/app': patch --- -[fix] Task progress bars once again clear when complete +Task progress bars once again clear when complete diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index c9d4da69dd4..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) => { + // 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 {