From 55dbfc8259518c64a0e6ac7c1bc1e9edfe44ee19 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/3] 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 758be5136479f22008c8d1d2413ddb03b3f11cc8 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/3] 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 3f3d1c4ebe7df9434f8ea3ed34eb86b0aadc6e10 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/3] 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)