Skip to content

Commit f49bf4f

Browse files
authored
Merge pull request #7157 from Shopify/revert-7153-rcb/rendering-changes-react-fix
Revert "use central unmount for ink render tree; stop messing with node's event loop"
2 parents c6af5ec + f3a5b94 commit f49bf4f

File tree

15 files changed

+70
-201
lines changed

15 files changed

+70
-201
lines changed

packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,9 @@ vi.mock('@shopify/cli-kit/node/themes/api')
2222
vi.mock('@shopify/cli-kit/node/context/fqdn')
2323
vi.mock('@shopify/cli-kit/node/ui', async (realImport) => {
2424
const realModule = await realImport<typeof import('@shopify/cli-kit/node/ui')>()
25+
const mockModule = {renderInfo: vi.fn()}
2526

26-
return {
27-
...realModule,
28-
renderInfo: vi.fn(),
29-
renderTasks: vi.fn(async (tasks: any[]) => {
30-
for (const task of tasks) {
31-
// eslint-disable-next-line no-await-in-loop
32-
await task.task({}, task)
33-
}
34-
return {}
35-
}),
36-
}
27+
return {...realModule, ...mockModule}
3728
})
3829

3930
describe('setupPreviewThemeAppExtensionsProcess', () => {

packages/cli-kit/src/private/node/testing/ui.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {Stdout, InkLifecycleRoot} from '../ui.js'
2-
import React, {ReactElement} from 'react'
1+
import {Stdout} from '../ui.js'
2+
import {ReactElement} from 'react'
33
import {render as inkRender} from 'ink'
44

55
import {EventEmitter} from 'events'
@@ -66,7 +66,7 @@ export const render = (tree: ReactElement, options: RenderOptions = {}): Instanc
6666
const stderr = new Stderr()
6767
const stdin = new Stdin()
6868

69-
const instance = inkRender(React.createElement(InkLifecycleRoot, null, tree), {
69+
const instance = inkRender(tree, {
7070
stdout: options.stdout ?? (stdout as any),
7171

7272
stderr: options.stderr ?? (stderr as any),
@@ -78,7 +78,7 @@ export const render = (tree: ReactElement, options: RenderOptions = {}): Instanc
7878
})
7979

8080
return {
81-
rerender: (tree: ReactElement) => instance.rerender(React.createElement(InkLifecycleRoot, null, tree)),
81+
rerender: instance.rerender,
8282
unmount: instance.unmount,
8383
cleanup: instance.cleanup,
8484
waitUntilExit: () => trackPromise(instance.waitUntilExit().then(() => {})),

packages/cli-kit/src/private/node/ui.tsx

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,49 +3,11 @@ import {Logger, LogLevel} from '../../public/node/output.js'
33
import {isUnitTest} from '../../public/node/context/local.js'
44
import {treeKill} from '../../public/node/tree-kill.js'
55

6-
import React, {ReactElement, createContext, useCallback, useContext, useEffect, useState} from 'react'
7-
import {Key, render as inkRender, RenderOptions, useApp} from 'ink'
6+
import {ReactElement} from 'react'
7+
import {Key, render as inkRender, RenderOptions} from 'ink'
88

99
import {EventEmitter} from 'events'
1010

11-
const CompletionContext = createContext<((error?: Error) => void) | null>(null)
12-
13-
/**
14-
* Signal that the current Ink tree is done. Must be called within an
15-
* InkLifecycleRoot — throws if the provider is missing so lifecycle
16-
* bugs surface immediately instead of silently hanging.
17-
*/
18-
export function useComplete(): (error?: Error) => void {
19-
const complete = useContext(CompletionContext)
20-
if (!complete) {
21-
throw new Error('useComplete() called outside InkLifecycleRoot')
22-
}
23-
return complete
24-
}
25-
26-
/**
27-
* Root wrapper for Ink trees. Owns the single `exit()` call site — children
28-
* signal completion via `useComplete()`, which sets state here. The `useEffect`
29-
* fires post-render, guaranteeing all batched state updates have been flushed
30-
* before the tree is torn down.
31-
*/
32-
export function InkLifecycleRoot({children}: {children: React.ReactNode}) {
33-
const {exit} = useApp()
34-
const [exitResult, setExitResult] = useState<{error?: Error} | null>(null)
35-
36-
const complete = useCallback((error?: Error) => {
37-
setExitResult({error})
38-
}, [])
39-
40-
useEffect(() => {
41-
if (exitResult !== null) {
42-
exit(exitResult.error)
43-
}
44-
}, [exitResult, exit])
45-
46-
return <CompletionContext.Provider value={complete}>{children}</CompletionContext.Provider>
47-
}
48-
4911
interface RenderOnceOptions {
5012
logLevel?: LogLevel
5113
logger?: Logger
@@ -65,8 +27,10 @@ export function renderOnce(element: JSX.Element, {logLevel = 'info', renderOptio
6527
}
6628

6729
export async function render(element: JSX.Element, options?: RenderOptions) {
68-
const {waitUntilExit} = inkRender(<InkLifecycleRoot>{element}</InkLifecycleRoot>, options)
30+
const {waitUntilExit} = inkRender(element, options)
6931
await waitUntilExit()
32+
// We need to wait for other pending tasks -- unmounting of the ink component -- to complete
33+
return new Promise((resolve) => setImmediate(resolve))
7034
}
7135

7236
interface Instance {

packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ import {InfoMessageProps} from './Prompts/InfoMessage.js'
55
import {Message, PromptLayout} from './Prompts/PromptLayout.js'
66
import {throttle} from '../../../../public/common/function.js'
77
import {AbortSignal} from '../../../../public/node/abort.js'
8-
import {useComplete} from '../../ui.js'
98
import usePrompt, {PromptState} from '../hooks/use-prompt.js'
109

1110
import React, {ReactElement, useCallback, useEffect, useRef, useState} from 'react'
12-
import {Box} from 'ink'
11+
import {Box, useApp} from 'ink'
1312

1413
export interface SearchResults<T> {
1514
data: SelectItem<T>[]
@@ -43,7 +42,7 @@ function AutocompletePrompt<T>({
4342
infoMessage,
4443
groupOrder,
4544
}: React.PropsWithChildren<AutocompletePromptProps<T>>): ReactElement | null {
46-
const complete = useComplete()
45+
const {exit: unmountInk} = useApp()
4746
const [searchTerm, setSearchTerm] = useState('')
4847
const [searchResults, setSearchResults] = useState<SelectItem<T>[]>(choices)
4948
const canSearch = choices.length > MIN_NUMBER_OF_ITEMS_FOR_SEARCH
@@ -73,10 +72,10 @@ function AutocompletePrompt<T>({
7372
useEffect(() => {
7473
if (promptState === PromptState.Submitted && answer) {
7574
setSearchTerm('')
75+
unmountInk()
7676
onSubmit(answer.value)
77-
complete()
7877
}
79-
}, [answer, onSubmit, promptState, complete])
78+
}, [answer, onSubmit, promptState, unmountInk])
8079

8180
const setLoadingWhenSlow = useRef<NodeJS.Timeout>()
8281

packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ describe('ConcurrentOutput', () => {
2828
// Given
2929
const backendSync = new Synchronizer()
3030
const frontendSync = new Synchronizer()
31-
const gate = new Synchronizer()
3231

3332
const backendProcess = {
3433
prefix: 'backend',
@@ -38,7 +37,6 @@ describe('ConcurrentOutput', () => {
3837
stdout.write('third backend message')
3938

4039
backendSync.resolve()
41-
await gate.promise
4240
},
4341
}
4442

@@ -52,7 +50,6 @@ describe('ConcurrentOutput', () => {
5250
stdout.write('third frontend message')
5351

5452
frontendSync.resolve()
55-
await gate.promise
5653
},
5754
}
5855
// When
@@ -75,23 +72,19 @@ describe('ConcurrentOutput', () => {
7572
00:00:00 │ frontend │ third frontend message
7673
"
7774
`)
78-
79-
gate.resolve()
8075
})
8176

8277
test('strips ansi codes from the output by default', async () => {
8378
const output = 'foo'
8479

8580
// Given
8681
const processSync = new Synchronizer()
87-
const gate = new Synchronizer()
8882
const processes = [
8983
{
9084
prefix: '1',
9185
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
9286
stdout.write(`\u001b[32m${output}\u001b[39m`)
9387
processSync.resolve()
94-
await gate.promise
9588
},
9689
},
9790
]
@@ -105,15 +98,13 @@ describe('ConcurrentOutput', () => {
10598
const logColumns = renderInstance.lastFrame()!.split('│')
10699
expect(logColumns.length).toBe(3)
107100
expect(logColumns[2]?.trim()).toEqual(output)
108-
gate.resolve()
109101
})
110102

111103
test('does not strip ansi codes from the output when stripAnsi is false', async () => {
112104
const output = '\u001b[32mfoo\u001b[39m'
113105

114106
// Given
115107
const processSync = new Synchronizer()
116-
const gate = new Synchronizer()
117108
const processes = [
118109
{
119110
prefix: '1',
@@ -122,7 +113,6 @@ describe('ConcurrentOutput', () => {
122113
stdout.write(output)
123114
})
124115
processSync.resolve()
125-
await gate.promise
126116
},
127117
},
128118
]
@@ -136,13 +126,11 @@ describe('ConcurrentOutput', () => {
136126
const logColumns = renderInstance.lastFrame()!.split('│')
137127
expect(logColumns.length).toBe(3)
138128
expect(logColumns[2]?.trim()).toEqual(output)
139-
gate.resolve()
140129
})
141130

142131
test('renders custom prefixes on log lines', async () => {
143132
// Given
144133
const processSync = new Synchronizer()
145-
const gate = new Synchronizer()
146134
const extensionName = 'my-extension'
147135
const processes = [
148136
{
@@ -152,7 +140,6 @@ describe('ConcurrentOutput', () => {
152140
stdout.write('foo bar')
153141
})
154142
processSync.resolve()
155-
await gate.promise
156143
},
157144
},
158145
]
@@ -174,14 +161,12 @@ describe('ConcurrentOutput', () => {
174161
const logColumns = unstyled(renderInstance.lastFrame()!).split('│')
175162
expect(logColumns.length).toBe(3)
176163
expect(logColumns[1]?.trim()).toEqual(extensionName)
177-
gate.resolve()
178164
})
179165

180166
test('renders prefix column width based on prefixColumnSize', async () => {
181167
// Given
182168
const processSync1 = new Synchronizer()
183169
const processSync2 = new Synchronizer()
184-
const gate = new Synchronizer()
185170

186171
const columnSize = 5
187172
const processes = [
@@ -190,15 +175,13 @@ describe('ConcurrentOutput', () => {
190175
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
191176
stdout.write('foo')
192177
processSync1.resolve()
193-
await gate.promise
194178
},
195179
},
196180
{
197181
prefix: '1',
198182
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
199183
stdout.write('bar')
200184
processSync2.resolve()
201-
await gate.promise
202185
},
203186
},
204187
]
@@ -223,25 +206,22 @@ describe('ConcurrentOutput', () => {
223206
// Including spacing
224207
expect(logColumns[1]?.length).toBe(columnSize + 2)
225208
})
226-
gate.resolve()
227209
})
228210

229211
test('renders prefix column width based on processes by default', async () => {
230212
// Given
231213
const processSync = new Synchronizer()
232-
const gate = new Synchronizer()
233214
const processes = [
234215
{
235216
prefix: '1',
236217
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
237218
stdout.write('foo')
238219
processSync.resolve()
239-
await gate.promise
240220
},
241221
},
242-
{prefix: '12', action: async () => gate.promise},
243-
{prefix: '123', action: async () => gate.promise},
244-
{prefix: '1234', action: async () => gate.promise},
222+
{prefix: '12', action: async () => {}},
223+
{prefix: '123', action: async () => {}},
224+
{prefix: '1234', action: async () => {}},
245225
]
246226

247227
// When
@@ -254,23 +234,20 @@ describe('ConcurrentOutput', () => {
254234
expect(logColumns.length).toBe(3)
255235
// 4 is largest prefix, plus spacing
256236
expect(logColumns[1]?.length).toBe(4 + 2)
257-
gate.resolve()
258237
})
259238

260239
test('does not render prefix column larger than max', async () => {
261240
// Given
262241
const processSync = new Synchronizer()
263-
const gate = new Synchronizer()
264242
const processes = [
265243
{
266244
prefix: '1',
267245
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
268246
stdout.write('foo')
269247
processSync.resolve()
270-
await gate.promise
271248
},
272249
},
273-
{prefix: new Array(26).join('0'), action: async () => gate.promise},
250+
{prefix: new Array(26).join('0'), action: async () => {}},
274251
]
275252

276253
// When
@@ -283,7 +260,6 @@ describe('ConcurrentOutput', () => {
283260
expect(logColumns.length).toBe(3)
284261
// 25 is largest column allowed, plus spacing
285262
expect(logColumns[1]?.length).toBe(25 + 2)
286-
gate.resolve()
287263
})
288264

289265
test('rejects with the error thrown inside one of the processes', async () => {

packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import {OutputProcess} from '../../../../public/node/output.js'
22
import {AbortSignal} from '../../../../public/node/abort.js'
3-
import {useComplete} from '../../ui.js'
43
import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react'
5-
import {Box, Static, Text, TextProps} from 'ink'
4+
import {Box, Static, Text, TextProps, useApp} from 'ink'
65
import figures from 'figures'
76
import stripAnsi from 'strip-ansi'
87

@@ -93,8 +92,7 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
9392
useAlternativeColorPalette = false,
9493
}) => {
9594
const [processOutput, setProcessOutput] = useState<Chunk[]>([])
96-
const [completionResult, setCompletionResult] = useState<{error?: Error} | null>(null)
97-
const complete = useComplete()
95+
const {exit: unmountInk} = useApp()
9896
const concurrentColors: TextProps['color'][] = useMemo(
9997
() =>
10098
useAlternativeColorPalette
@@ -181,25 +179,24 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
181179
}),
182180
)
183181
if (!keepRunningAfterProcessesResolve) {
184-
setCompletionResult({})
182+
// Defer unmount so React 19 can flush batched setProcessOutput
183+
// state updates before the component tree is torn down.
184+
// Use setImmediate → setTimeout(0) to span two event-loop phases,
185+
// giving React's scheduler (which uses setImmediate in Node.js)
186+
// a full cycle to flush before we tear down the component tree.
187+
setImmediate(() => setTimeout(() => unmountInk(), 0))
185188
}
186189
// eslint-disable-next-line no-catch-all/no-catch-all
187190
} catch (error: unknown) {
188191
if (!keepRunningAfterProcessesResolve) {
189-
setCompletionResult({error: error as Error})
192+
setImmediate(() => setTimeout(() => unmountInk(error as Error | undefined), 0))
190193
}
191194
}
192195
}
193196

194197
// eslint-disable-next-line @typescript-eslint/no-floating-promises
195198
runProcesses()
196-
}, [abortSignal, processes, writableStream, keepRunningAfterProcessesResolve])
197-
198-
useEffect(() => {
199-
if (completionResult !== null) {
200-
complete(completionResult.error)
201-
}
202-
}, [completionResult, complete])
199+
}, [abortSignal, processes, writableStream, unmountInk, keepRunningAfterProcessesResolve])
203200

204201
const {lineVertical} = figures
205202

0 commit comments

Comments
 (0)