diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts index bf9e0d15b01..fad6a5dc1c2 100644 --- a/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts @@ -22,18 +22,9 @@ vi.mock('@shopify/cli-kit/node/themes/api') vi.mock('@shopify/cli-kit/node/context/fqdn') vi.mock('@shopify/cli-kit/node/ui', async (realImport) => { const realModule = await realImport() + const mockModule = {renderInfo: vi.fn()} - return { - ...realModule, - renderInfo: vi.fn(), - renderTasks: vi.fn(async (tasks: any[]) => { - for (const task of tasks) { - // eslint-disable-next-line no-await-in-loop - await task.task({}, task) - } - return {} - }), - } + return {...realModule, ...mockModule} }) describe('setupPreviewThemeAppExtensionsProcess', () => { diff --git a/packages/cli-kit/src/private/node/testing/ui.ts b/packages/cli-kit/src/private/node/testing/ui.ts index 76f31f36b69..58b8a7df29c 100644 --- a/packages/cli-kit/src/private/node/testing/ui.ts +++ b/packages/cli-kit/src/private/node/testing/ui.ts @@ -1,5 +1,5 @@ -import {Stdout, InkLifecycleRoot} from '../ui.js' -import React, {ReactElement} from 'react' +import {Stdout} from '../ui.js' +import {ReactElement} from 'react' import {render as inkRender} from 'ink' import {EventEmitter} from 'events' @@ -66,7 +66,7 @@ export const render = (tree: ReactElement, options: RenderOptions = {}): Instanc const stderr = new Stderr() const stdin = new Stdin() - const instance = inkRender(React.createElement(InkLifecycleRoot, null, tree), { + const instance = inkRender(tree, { stdout: options.stdout ?? (stdout as any), stderr: options.stderr ?? (stderr as any), @@ -78,7 +78,7 @@ export const render = (tree: ReactElement, options: RenderOptions = {}): Instanc }) return { - rerender: (tree: ReactElement) => instance.rerender(React.createElement(InkLifecycleRoot, null, tree)), + rerender: instance.rerender, unmount: instance.unmount, cleanup: instance.cleanup, waitUntilExit: () => trackPromise(instance.waitUntilExit().then(() => {})), diff --git a/packages/cli-kit/src/private/node/ui.tsx b/packages/cli-kit/src/private/node/ui.tsx index d7cc567d0aa..06a91c7ba45 100644 --- a/packages/cli-kit/src/private/node/ui.tsx +++ b/packages/cli-kit/src/private/node/ui.tsx @@ -3,49 +3,11 @@ import {Logger, LogLevel} from '../../public/node/output.js' import {isUnitTest} from '../../public/node/context/local.js' import {treeKill} from '../../public/node/tree-kill.js' -import React, {ReactElement, createContext, useCallback, useContext, useEffect, useState} from 'react' -import {Key, render as inkRender, RenderOptions, useApp} from 'ink' +import {ReactElement} from 'react' +import {Key, render as inkRender, RenderOptions} from 'ink' import {EventEmitter} from 'events' -const CompletionContext = createContext<((error?: Error) => void) | null>(null) - -/** - * Signal that the current Ink tree is done. Must be called within an - * InkLifecycleRoot — throws if the provider is missing so lifecycle - * bugs surface immediately instead of silently hanging. - */ -export function useComplete(): (error?: Error) => void { - const complete = useContext(CompletionContext) - if (!complete) { - throw new Error('useComplete() called outside InkLifecycleRoot') - } - return complete -} - -/** - * Root wrapper for Ink trees. Owns the single `exit()` call site — children - * signal completion via `useComplete()`, which sets state here. The `useEffect` - * fires post-render, guaranteeing all batched state updates have been flushed - * before the tree is torn down. - */ -export function InkLifecycleRoot({children}: {children: React.ReactNode}) { - const {exit} = useApp() - const [exitResult, setExitResult] = useState<{error?: Error} | null>(null) - - const complete = useCallback((error?: Error) => { - setExitResult({error}) - }, []) - - useEffect(() => { - if (exitResult !== null) { - exit(exitResult.error) - } - }, [exitResult, exit]) - - return {children} -} - interface RenderOnceOptions { logLevel?: LogLevel logger?: Logger @@ -65,8 +27,10 @@ export function renderOnce(element: JSX.Element, {logLevel = 'info', renderOptio } export async function render(element: JSX.Element, options?: RenderOptions) { - const {waitUntilExit} = inkRender({element}, options) + const {waitUntilExit} = inkRender(element, options) await waitUntilExit() + // We need to wait for other pending tasks -- unmounting of the ink component -- to complete + return new Promise((resolve) => setImmediate(resolve)) } interface Instance { diff --git a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx index 01f277dfe66..80bf98d3ed2 100644 --- a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx @@ -5,11 +5,10 @@ import {InfoMessageProps} from './Prompts/InfoMessage.js' import {Message, PromptLayout} from './Prompts/PromptLayout.js' import {throttle} from '../../../../public/common/function.js' import {AbortSignal} from '../../../../public/node/abort.js' -import {useComplete} from '../../ui.js' import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {ReactElement, useCallback, useEffect, useRef, useState} from 'react' -import {Box} from 'ink' +import {Box, useApp} from 'ink' export interface SearchResults { data: SelectItem[] @@ -43,7 +42,7 @@ function AutocompletePrompt({ infoMessage, groupOrder, }: React.PropsWithChildren>): ReactElement | null { - const complete = useComplete() + const {exit: unmountInk} = useApp() const [searchTerm, setSearchTerm] = useState('') const [searchResults, setSearchResults] = useState[]>(choices) const canSearch = choices.length > MIN_NUMBER_OF_ITEMS_FOR_SEARCH @@ -73,10 +72,10 @@ function AutocompletePrompt({ useEffect(() => { if (promptState === PromptState.Submitted && answer) { setSearchTerm('') + unmountInk() onSubmit(answer.value) - complete() } - }, [answer, onSubmit, promptState, complete]) + }, [answer, onSubmit, promptState, unmountInk]) const setLoadingWhenSlow = useRef() diff --git a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx index d0978a81a0b..f9d61c55556 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx @@ -28,7 +28,6 @@ describe('ConcurrentOutput', () => { // Given const backendSync = new Synchronizer() const frontendSync = new Synchronizer() - const gate = new Synchronizer() const backendProcess = { prefix: 'backend', @@ -38,7 +37,6 @@ describe('ConcurrentOutput', () => { stdout.write('third backend message') backendSync.resolve() - await gate.promise }, } @@ -52,7 +50,6 @@ describe('ConcurrentOutput', () => { stdout.write('third frontend message') frontendSync.resolve() - await gate.promise }, } // When @@ -75,8 +72,6 @@ describe('ConcurrentOutput', () => { 00:00:00 │ frontend │ third frontend message " `) - - gate.resolve() }) test('strips ansi codes from the output by default', async () => { @@ -84,14 +79,12 @@ describe('ConcurrentOutput', () => { // Given const processSync = new Synchronizer() - const gate = new Synchronizer() const processes = [ { prefix: '1', action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => { stdout.write(`\u001b[32m${output}\u001b[39m`) processSync.resolve() - await gate.promise }, }, ] @@ -105,7 +98,6 @@ describe('ConcurrentOutput', () => { const logColumns = renderInstance.lastFrame()!.split('│') expect(logColumns.length).toBe(3) expect(logColumns[2]?.trim()).toEqual(output) - gate.resolve() }) test('does not strip ansi codes from the output when stripAnsi is false', async () => { @@ -113,7 +105,6 @@ describe('ConcurrentOutput', () => { // Given const processSync = new Synchronizer() - const gate = new Synchronizer() const processes = [ { prefix: '1', @@ -122,7 +113,6 @@ describe('ConcurrentOutput', () => { stdout.write(output) }) processSync.resolve() - await gate.promise }, }, ] @@ -136,13 +126,11 @@ describe('ConcurrentOutput', () => { const logColumns = renderInstance.lastFrame()!.split('│') expect(logColumns.length).toBe(3) expect(logColumns[2]?.trim()).toEqual(output) - gate.resolve() }) test('renders custom prefixes on log lines', async () => { // Given const processSync = new Synchronizer() - const gate = new Synchronizer() const extensionName = 'my-extension' const processes = [ { @@ -152,7 +140,6 @@ describe('ConcurrentOutput', () => { stdout.write('foo bar') }) processSync.resolve() - await gate.promise }, }, ] @@ -174,14 +161,12 @@ describe('ConcurrentOutput', () => { const logColumns = unstyled(renderInstance.lastFrame()!).split('│') expect(logColumns.length).toBe(3) expect(logColumns[1]?.trim()).toEqual(extensionName) - gate.resolve() }) test('renders prefix column width based on prefixColumnSize', async () => { // Given const processSync1 = new Synchronizer() const processSync2 = new Synchronizer() - const gate = new Synchronizer() const columnSize = 5 const processes = [ @@ -190,7 +175,6 @@ describe('ConcurrentOutput', () => { action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => { stdout.write('foo') processSync1.resolve() - await gate.promise }, }, { @@ -198,7 +182,6 @@ describe('ConcurrentOutput', () => { action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => { stdout.write('bar') processSync2.resolve() - await gate.promise }, }, ] @@ -223,25 +206,22 @@ describe('ConcurrentOutput', () => { // Including spacing expect(logColumns[1]?.length).toBe(columnSize + 2) }) - gate.resolve() }) test('renders prefix column width based on processes by default', async () => { // Given const processSync = new Synchronizer() - const gate = new Synchronizer() const processes = [ { prefix: '1', action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => { stdout.write('foo') processSync.resolve() - await gate.promise }, }, - {prefix: '12', action: async () => gate.promise}, - {prefix: '123', action: async () => gate.promise}, - {prefix: '1234', action: async () => gate.promise}, + {prefix: '12', action: async () => {}}, + {prefix: '123', action: async () => {}}, + {prefix: '1234', action: async () => {}}, ] // When @@ -254,23 +234,20 @@ describe('ConcurrentOutput', () => { expect(logColumns.length).toBe(3) // 4 is largest prefix, plus spacing expect(logColumns[1]?.length).toBe(4 + 2) - gate.resolve() }) test('does not render prefix column larger than max', async () => { // Given const processSync = new Synchronizer() - const gate = new Synchronizer() const processes = [ { prefix: '1', action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => { stdout.write('foo') processSync.resolve() - await gate.promise }, }, - {prefix: new Array(26).join('0'), action: async () => gate.promise}, + {prefix: new Array(26).join('0'), action: async () => {}}, ] // When @@ -283,7 +260,6 @@ describe('ConcurrentOutput', () => { expect(logColumns.length).toBe(3) // 25 is largest column allowed, plus spacing expect(logColumns[1]?.length).toBe(25 + 2) - gate.resolve() }) test('rejects with the error thrown inside one of the processes', async () => { diff --git a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx index b866189e5ae..e447b8814a6 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx @@ -1,8 +1,7 @@ import {OutputProcess} from '../../../../public/node/output.js' import {AbortSignal} from '../../../../public/node/abort.js' -import {useComplete} from '../../ui.js' import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react' -import {Box, Static, Text, TextProps} from 'ink' +import {Box, Static, Text, TextProps, useApp} from 'ink' import figures from 'figures' import stripAnsi from 'strip-ansi' @@ -93,8 +92,7 @@ const ConcurrentOutput: FunctionComponent = ({ useAlternativeColorPalette = false, }) => { const [processOutput, setProcessOutput] = useState([]) - const [completionResult, setCompletionResult] = useState<{error?: Error} | null>(null) - const complete = useComplete() + const {exit: unmountInk} = useApp() const concurrentColors: TextProps['color'][] = useMemo( () => useAlternativeColorPalette @@ -181,25 +179,24 @@ const ConcurrentOutput: FunctionComponent = ({ }), ) if (!keepRunningAfterProcessesResolve) { - setCompletionResult({}) + // Defer unmount so React 19 can flush batched setProcessOutput + // state updates before the component tree is torn down. + // Use setImmediate → setTimeout(0) to span two event-loop phases, + // giving React's scheduler (which uses setImmediate in Node.js) + // a full cycle to flush before we tear down the component tree. + setImmediate(() => setTimeout(() => unmountInk(), 0)) } // eslint-disable-next-line no-catch-all/no-catch-all } catch (error: unknown) { if (!keepRunningAfterProcessesResolve) { - setCompletionResult({error: error as Error}) + setImmediate(() => setTimeout(() => unmountInk(error as Error | undefined), 0)) } } } // eslint-disable-next-line @typescript-eslint/no-floating-promises runProcesses() - }, [abortSignal, processes, writableStream, keepRunningAfterProcessesResolve]) - - useEffect(() => { - if (completionResult !== null) { - complete(completionResult.error) - } - }, [completionResult, complete]) + }, [abortSignal, processes, writableStream, unmountInk, keepRunningAfterProcessesResolve]) const {lineVertical} = figures diff --git a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx index 533b20ac8c3..d877dd98440 100644 --- a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx @@ -1,7 +1,7 @@ import {TextInput} from './TextInput.js' import {InlineToken, TokenItem, TokenizedText} from './TokenizedText.js' import {InfoTable, InfoTableProps} from './Prompts/InfoTable.js' -import {handleCtrlC, useComplete} from '../../ui.js' +import {handleCtrlC} from '../../ui.js' import useLayout from '../hooks/use-layout.js' import {messageWithPunctuation} from '../utilities.js' import {AbortSignal} from '../../../../public/node/abort.js' @@ -9,7 +9,7 @@ import useAbortSignal from '../hooks/use-abort-signal.js' import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {FunctionComponent, useCallback, useEffect, useState} from 'react' -import {Box, useInput, Text} from 'ink' +import {Box, useApp, useInput, Text} from 'ink' import figures from 'figures' export interface DangerousConfirmationPromptProps { @@ -38,7 +38,7 @@ const DangerousConfirmationPrompt: FunctionComponent({ initialAnswer: '', }) - const complete = useComplete() + const {exit: unmountInk} = useApp() const [error, setError] = useState | undefined>(undefined) const color = promptState === PromptState.Error ? 'red' : 'cyan' const underline = new Array(oneThird - 3).fill('▔') @@ -67,12 +67,12 @@ const DangerousConfirmationPrompt: FunctionComponent { if (promptState === PromptState.Submitted) { onSubmit(true) - complete() + unmountInk() } else if (promptState === PromptState.Cancelled) { onSubmit(false) - complete() + unmountInk() } - }, [onSubmit, promptState, complete]) + }, [onSubmit, promptState, unmountInk]) const completed = promptState === PromptState.Submitted || promptState === PromptState.Cancelled diff --git a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx index 0794bf447c5..8e1606efb37 100644 --- a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.tsx @@ -3,10 +3,10 @@ import {InfoTableProps} from './Prompts/InfoTable.js' import {InfoMessageProps} from './Prompts/InfoMessage.js' import {Message, PromptLayout} from './Prompts/PromptLayout.js' import {AbortSignal} from '../../../../public/node/abort.js' -import {useComplete} from '../../ui.js' import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {ReactElement, useCallback, useEffect} from 'react' +import {useApp} from 'ink' export interface SelectPromptProps { message: Message @@ -32,7 +32,7 @@ function SelectPrompt({ if (choices.length === 0) { throw new Error('SelectPrompt requires at least one choice') } - const complete = useComplete() + const {exit: unmountInk} = useApp() const {promptState, setPromptState, answer, setAnswer} = usePrompt | undefined>({ initialAnswer: undefined, }) @@ -47,10 +47,10 @@ function SelectPrompt({ useEffect(() => { if (promptState === PromptState.Submitted && answer) { + unmountInk() onSubmit(answer.value) - complete() } - }, [answer, onSubmit, promptState, complete]) + }, [answer, onSubmit, promptState, unmountInk]) return ( { title: TokenizedString @@ -16,8 +16,7 @@ interface SingleTaskProps { const SingleTask = ({task, title, onComplete, onAbort, noColor}: SingleTaskProps) => { const [status, setStatus] = useState(title) const [isDone, setIsDone] = useState(false) - const [taskResult, setTaskResult] = useState<{error?: Error} | null>(null) - const complete = useComplete() + const {exit: unmountInk} = useApp() const {isRawModeSupported} = useStdin() useInput( @@ -36,19 +35,13 @@ const SingleTask = ({task, title, onComplete, onAbort, noColor}: SingleTaskP .then((result) => { setIsDone(true) onComplete?.(result) - setTaskResult({}) + unmountInk() }) .catch((error) => { setIsDone(true) - setTaskResult({error}) + unmountInk(error) }) - }, [task, onComplete]) - - useEffect(() => { - if (taskResult !== null) { - complete(taskResult.error) - } - }, [taskResult, complete]) + }, [task, unmountInk, onComplete]) if (isDone) { // clear things once done diff --git a/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx index 6e398a324a9..d9d94e586da 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx @@ -1,6 +1,6 @@ import {InlineToken, TokenItem, TokenizedText} from './TokenizedText.js' import {TextInput} from './TextInput.js' -import {handleCtrlC, useComplete} from '../../ui.js' +import {handleCtrlC} from '../../ui.js' import useLayout from '../hooks/use-layout.js' import {messageWithPunctuation} from '../utilities.js' import {AbortSignal} from '../../../../public/node/abort.js' @@ -8,7 +8,7 @@ import useAbortSignal from '../hooks/use-abort-signal.js' import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {FunctionComponent, useCallback, useEffect, useState} from 'react' -import {Box, useInput, Text} from 'ink' +import {Box, useApp, useInput, Text} from 'ink' import figures from 'figures' export interface TextPromptProps { @@ -60,7 +60,7 @@ const TextPrompt: FunctionComponent = ({ const answerOrDefault = answer.length > 0 ? answer : defaultValue const displayEmptyValue = answerOrDefault === '' const displayedAnswer = displayEmptyValue ? emptyDisplayedValue : answerOrDefault - const complete = useComplete() + const {exit: unmountInk} = useApp() const [error, setError] = useState(undefined) const color = promptState === PromptState.Error ? 'red' : 'cyan' const underline = new Array(oneThird - 3).fill('▔') @@ -84,9 +84,9 @@ const TextPrompt: FunctionComponent = ({ useEffect(() => { if (promptState === PromptState.Submitted) { onSubmit(answerOrDefault) - complete() + unmountInk() } - }, [answerOrDefault, onSubmit, promptState, complete]) + }, [answerOrDefault, onSubmit, promptState, unmountInk]) return isAborted ? null : ( diff --git a/packages/cli-kit/src/private/node/ui/hooks/use-abort-signal.ts b/packages/cli-kit/src/private/node/ui/hooks/use-abort-signal.ts index a2b782b21ed..3cd216ad98f 100644 --- a/packages/cli-kit/src/private/node/ui/hooks/use-abort-signal.ts +++ b/packages/cli-kit/src/private/node/ui/hooks/use-abort-signal.ts @@ -1,28 +1,32 @@ import {AbortSignal} from '../../../../public/node/abort.js' -import {useComplete} from '../../ui.js' -import {useEffect, useLayoutEffect, useState} from 'react' +import {useApp} from 'ink' +import {useLayoutEffect, useState} from 'react' const noop = () => Promise.resolve() export default function useAbortSignal(abortSignal?: AbortSignal, onAbort: (error?: unknown) => Promise = noop) { - const complete = useComplete() + const {exit: unmountInk} = useApp() const [isAborted, setIsAborted] = useState(false) useLayoutEffect(() => { abortSignal?.addEventListener('abort', () => { const abortWithError = abortSignal.reason.message === 'AbortError' ? undefined : abortSignal.reason onAbort(abortWithError) - .then(() => setIsAborted(true)) + .then(() => { + setIsAborted(true) + // Defer unmounting to the next setImmediate so React 19 can flush + // batched state updates before the tree is torn down. React 19's + // scheduler also uses setImmediate in Node.js (check phase), and + // since it was queued first (by setIsAborted above), it renders + // before this callback fires (FIFO within the check phase). + // NOTE: setTimeout(fn, 0) is NOT safe here because its timers-phase + // fires BEFORE the check phase on slow CI machines where >1 ms has + // elapsed, causing unmount to race ahead of the render. + setImmediate(() => unmountInk(abortWithError)) + }) .catch(() => {}) }) }, []) - useEffect(() => { - if (isAborted) { - const abortWithError = abortSignal?.reason?.message === 'AbortError' ? undefined : abortSignal?.reason - complete(abortWithError) - } - }, [isAborted]) - return {isAborted} } 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 9ba9e5ef26e..d2ed970f157 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 @@ -1,5 +1,5 @@ -import {useComplete} from '../../ui.js' -import {useEffect, useState} from 'react' +import {useApp} from 'ink' +import {useEffect} from 'react' interface Options { onFulfilled?: () => unknown @@ -10,24 +10,17 @@ export default function useAsyncAndUnmount( asyncFunction: () => Promise, {onFulfilled = () => {}, onRejected = () => {}}: Options = {}, ) { - const complete = useComplete() - const [result, setResult] = useState<{error?: Error} | null>(null) + const {exit: unmountInk} = useApp() useEffect(() => { asyncFunction() .then(() => { onFulfilled() - setResult({}) + unmountInk() }) .catch((error) => { onRejected(error) - setResult({error}) + unmountInk(error) }) }, []) - - useEffect(() => { - if (result !== null) { - complete(result.error) - } - }, [result]) } diff --git a/packages/cli-kit/src/public/node/ui.test.ts b/packages/cli-kit/src/public/node/ui.test.ts index a4dc1797043..f119b5de08c 100644 --- a/packages/cli-kit/src/public/node/ui.test.ts +++ b/packages/cli-kit/src/public/node/ui.test.ts @@ -504,31 +504,3 @@ describe('renderSingleTask', async () => { expect(result3).toBe('result3') }) }) - -describe('sequential renders', () => { - test('consecutive renders do not interleave or leak teardown output', async () => { - const output = mockAndCaptureOutput() - - await renderTasks([ - { - title: 'First batch', - task: async () => { - await new Promise((resolve) => setTimeout(resolve, 50)) - }, - }, - ]) - - await renderSingleTask({ - title: new TokenizedString('Second batch'), - task: async () => { - await new Promise((resolve) => setTimeout(resolve, 50)) - return 'done' - }, - }) - - // The key assertion: no interleaving. The second render's output should - // not contain fragments from the first render's teardown. - const frames = output.output() - expect(frames).not.toContain('First batch') - }) -}) diff --git a/packages/theme/src/cli/services/init.test.ts b/packages/theme/src/cli/services/init.test.ts index 1f8466ebc44..eb79893e22f 100644 --- a/packages/theme/src/cli/services/init.test.ts +++ b/packages/theme/src/cli/services/init.test.ts @@ -27,13 +27,6 @@ vi.mock('@shopify/cli-kit/node/ui', async () => { return { ...actual, renderSelectPrompt: vi.fn(), - renderTasks: vi.fn(async (tasks: any[]) => { - for (const task of tasks) { - // eslint-disable-next-line no-await-in-loop - await task.task({}, task) - } - return {} - }), } }) diff --git a/packages/theme/src/cli/utilities/theme-downloader.test.ts b/packages/theme/src/cli/utilities/theme-downloader.test.ts index 6bb6fe27657..530b7434f88 100644 --- a/packages/theme/src/cli/utilities/theme-downloader.test.ts +++ b/packages/theme/src/cli/utilities/theme-downloader.test.ts @@ -6,19 +6,6 @@ import {test, describe, expect, vi} from 'vitest' vi.mock('./theme-fs.js') vi.mock('@shopify/cli-kit/node/themes/api') -vi.mock('@shopify/cli-kit/node/ui', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/ui') - return { - ...actual, - renderTasks: vi.fn(async (tasks: any[]) => { - for (const task of tasks) { - // eslint-disable-next-line no-await-in-loop - await task.task({}, task) - } - return {} - }), - } -}) describe('theme-downloader', () => { describe('downloadTheme', () => {