Skip to content

Commit fdfccd5

Browse files
authored
Merge pull request #7217 from Shopify/rcb/rendering-changes-react-fix-v2
Reapply: central unmount for ink render tree + fix render lifecycle race
2 parents 7a735be + 9219ad1 commit fdfccd5

16 files changed

Lines changed: 235 additions & 88 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,18 @@ 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()}
2625

27-
return {...realModule, ...mockModule}
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+
}
2837
})
2938

3039
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} from '../ui.js'
2-
import {ReactElement} from 'react'
1+
import {Stdout, InkLifecycleRoot} from '../ui.js'
2+
import React, {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(tree, {
69+
const instance = inkRender(React.createElement(InkLifecycleRoot, null, 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: instance.rerender,
81+
rerender: (tree: ReactElement) => instance.rerender(React.createElement(InkLifecycleRoot, null, tree)),
8282
unmount: instance.unmount,
8383
cleanup: instance.cleanup,
8484
waitUntilExit: () => trackPromise(instance.waitUntilExit().then(() => {})),

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

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,49 @@ 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 {ReactElement} from 'react'
7-
import {Key, render as inkRender, RenderOptions} from 'ink'
6+
import React, {ReactElement, createContext, useCallback, useContext, useEffect, useState} from 'react'
7+
import {Key, render as inkRender, RenderOptions, useApp} 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+
1149
interface RenderOnceOptions {
1250
logLevel?: LogLevel
1351
logger?: Logger
@@ -27,10 +65,8 @@ export function renderOnce(element: JSX.Element, {logLevel = 'info', renderOptio
2765
}
2866

2967
export async function render(element: JSX.Element, options?: RenderOptions) {
30-
const {waitUntilExit} = inkRender(element, options)
68+
const {waitUntilExit} = inkRender(<InkLifecycleRoot>{element}</InkLifecycleRoot>, options)
3169
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))
3470
}
3571

3672
interface Instance {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ 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'
89
import usePrompt, {PromptState} from '../hooks/use-prompt.js'
910

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

1314
export interface SearchResults<T> {
1415
data: SelectItem<T>[]
@@ -42,7 +43,7 @@ function AutocompletePrompt<T>({
4243
infoMessage,
4344
groupOrder,
4445
}: React.PropsWithChildren<AutocompletePromptProps<T>>): ReactElement | null {
45-
const {exit: unmountInk} = useApp()
46+
const complete = useComplete()
4647
const [searchTerm, setSearchTerm] = useState('')
4748
const [searchResults, setSearchResults] = useState<SelectItem<T>[]>(choices)
4849
const canSearch = choices.length > MIN_NUMBER_OF_ITEMS_FOR_SEARCH
@@ -72,10 +73,10 @@ function AutocompletePrompt<T>({
7273
useEffect(() => {
7374
if (promptState === PromptState.Submitted && answer) {
7475
setSearchTerm('')
75-
unmountInk()
7676
onSubmit(answer.value)
77+
complete()
7778
}
78-
}, [answer, onSubmit, promptState, unmountInk])
79+
}, [answer, onSubmit, promptState, complete])
7980

8081
const setLoadingWhenSlow = useRef<NodeJS.Timeout>()
8182

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

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

3233
const backendProcess = {
3334
prefix: 'backend',
@@ -37,6 +38,7 @@ describe('ConcurrentOutput', () => {
3738
stdout.write('third backend message')
3839

3940
backendSync.resolve()
41+
await gate.promise
4042
},
4143
}
4244

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

5254
frontendSync.resolve()
55+
await gate.promise
5356
},
5457
}
5558
// When
@@ -72,19 +75,23 @@ describe('ConcurrentOutput', () => {
7275
00:00:00 │ frontend │ third frontend message
7376
"
7477
`)
78+
79+
gate.resolve()
7580
})
7681

7782
test('strips ansi codes from the output by default', async () => {
7883
const output = 'foo'
7984

8085
// Given
8186
const processSync = new Synchronizer()
87+
const gate = new Synchronizer()
8288
const processes = [
8389
{
8490
prefix: '1',
8591
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
8692
stdout.write(`\u001b[32m${output}\u001b[39m`)
8793
processSync.resolve()
94+
await gate.promise
8895
},
8996
},
9097
]
@@ -98,13 +105,15 @@ describe('ConcurrentOutput', () => {
98105
const logColumns = renderInstance.lastFrame()!.split('│')
99106
expect(logColumns.length).toBe(3)
100107
expect(logColumns[2]?.trim()).toEqual(output)
108+
gate.resolve()
101109
})
102110

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

106114
// Given
107115
const processSync = new Synchronizer()
116+
const gate = new Synchronizer()
108117
const processes = [
109118
{
110119
prefix: '1',
@@ -113,6 +122,7 @@ describe('ConcurrentOutput', () => {
113122
stdout.write(output)
114123
})
115124
processSync.resolve()
125+
await gate.promise
116126
},
117127
},
118128
]
@@ -126,11 +136,13 @@ describe('ConcurrentOutput', () => {
126136
const logColumns = renderInstance.lastFrame()!.split('│')
127137
expect(logColumns.length).toBe(3)
128138
expect(logColumns[2]?.trim()).toEqual(output)
139+
gate.resolve()
129140
})
130141

131142
test('renders custom prefixes on log lines', async () => {
132143
// Given
133144
const processSync = new Synchronizer()
145+
const gate = new Synchronizer()
134146
const extensionName = 'my-extension'
135147
const processes = [
136148
{
@@ -140,6 +152,7 @@ describe('ConcurrentOutput', () => {
140152
stdout.write('foo bar')
141153
})
142154
processSync.resolve()
155+
await gate.promise
143156
},
144157
},
145158
]
@@ -161,12 +174,14 @@ describe('ConcurrentOutput', () => {
161174
const logColumns = unstyled(renderInstance.lastFrame()!).split('│')
162175
expect(logColumns.length).toBe(3)
163176
expect(logColumns[1]?.trim()).toEqual(extensionName)
177+
gate.resolve()
164178
})
165179

166180
test('renders prefix column width based on prefixColumnSize', async () => {
167181
// Given
168182
const processSync1 = new Synchronizer()
169183
const processSync2 = new Synchronizer()
184+
const gate = new Synchronizer()
170185

171186
const columnSize = 5
172187
const processes = [
@@ -175,13 +190,15 @@ describe('ConcurrentOutput', () => {
175190
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
176191
stdout.write('foo')
177192
processSync1.resolve()
193+
await gate.promise
178194
},
179195
},
180196
{
181197
prefix: '1',
182198
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
183199
stdout.write('bar')
184200
processSync2.resolve()
201+
await gate.promise
185202
},
186203
},
187204
]
@@ -206,22 +223,25 @@ describe('ConcurrentOutput', () => {
206223
// Including spacing
207224
expect(logColumns[1]?.length).toBe(columnSize + 2)
208225
})
226+
gate.resolve()
209227
})
210228

211229
test('renders prefix column width based on processes by default', async () => {
212230
// Given
213231
const processSync = new Synchronizer()
232+
const gate = new Synchronizer()
214233
const processes = [
215234
{
216235
prefix: '1',
217236
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
218237
stdout.write('foo')
219238
processSync.resolve()
239+
await gate.promise
220240
},
221241
},
222-
{prefix: '12', action: async () => {}},
223-
{prefix: '123', action: async () => {}},
224-
{prefix: '1234', action: async () => {}},
242+
{prefix: '12', action: async () => gate.promise},
243+
{prefix: '123', action: async () => gate.promise},
244+
{prefix: '1234', action: async () => gate.promise},
225245
]
226246

227247
// When
@@ -234,20 +254,23 @@ describe('ConcurrentOutput', () => {
234254
expect(logColumns.length).toBe(3)
235255
// 4 is largest prefix, plus spacing
236256
expect(logColumns[1]?.length).toBe(4 + 2)
257+
gate.resolve()
237258
})
238259

239260
test('does not render prefix column larger than max', async () => {
240261
// Given
241262
const processSync = new Synchronizer()
263+
const gate = new Synchronizer()
242264
const processes = [
243265
{
244266
prefix: '1',
245267
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
246268
stdout.write('foo')
247269
processSync.resolve()
270+
await gate.promise
248271
},
249272
},
250-
{prefix: new Array(26).join('0'), action: async () => {}},
273+
{prefix: new Array(26).join('0'), action: async () => gate.promise},
251274
]
252275

253276
// When
@@ -260,6 +283,7 @@ describe('ConcurrentOutput', () => {
260283
expect(logColumns.length).toBe(3)
261284
// 25 is largest column allowed, plus spacing
262285
expect(logColumns[1]?.length).toBe(25 + 2)
286+
gate.resolve()
263287
})
264288

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

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

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

@@ -92,7 +93,8 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
9293
useAlternativeColorPalette = false,
9394
}) => {
9495
const [processOutput, setProcessOutput] = useState<Chunk[]>([])
95-
const {exit: unmountInk} = useApp()
96+
const [completionResult, setCompletionResult] = useState<{error?: Error} | null>(null)
97+
const complete = useComplete()
9698
const concurrentColors: TextProps['color'][] = useMemo(
9799
() =>
98100
useAlternativeColorPalette
@@ -179,24 +181,25 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
179181
}),
180182
)
181183
if (!keepRunningAfterProcessesResolve) {
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))
184+
setCompletionResult({})
188185
}
189186
// eslint-disable-next-line no-catch-all/no-catch-all
190187
} catch (error: unknown) {
191188
if (!keepRunningAfterProcessesResolve) {
192-
setImmediate(() => setTimeout(() => unmountInk(error as Error | undefined), 0))
189+
setCompletionResult({error: error as Error})
193190
}
194191
}
195192
}
196193

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

201204
const {lineVertical} = figures
202205

0 commit comments

Comments
 (0)