Skip to content

Commit cb018ff

Browse files
authored
Merge pull request #6831 from Shopify/feature/react-19-ink-6
Upgrade Ink to v6 and React to v19
2 parents d8414e0 + 466e944 commit cb018ff

20 files changed

Lines changed: 3951 additions & 2409 deletions

packages/app/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@
7171
"json-schema-to-typescript": "15.0.4",
7272
"prettier": "2.8.8",
7373
"proper-lockfile": "4.1.2",
74-
"react": "^18.2.0",
75-
"react-dom": "18.3.1",
74+
"react": "19.2.4",
75+
"react-dom": "19.2.4",
7676
"which": "4.0.0",
7777
"ws": "8.18.0"
7878
},
@@ -82,8 +82,8 @@
8282
"@types/express": "^4.17.17",
8383
"@types/prettier": "^2.7.3",
8484
"@types/proper-lockfile": "4.1.4",
85-
"@types/react": "^18.2.0",
86-
"@types/react-dom": "^18.2.0",
85+
"@types/react": "^19.0.0",
86+
"@types/react-dom": "^19.0.0",
8787
"@types/which": "3.0.4",
8888
"@types/ws": "^8.5.13",
8989
"@vitest/coverage-istanbul": "^3.1.4"

packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ describe('usePollAppLogs', () => {
222222

223223
// Wait for the async polling function to execute
224224
await waitForMockCalls(mockedPollAppLogs, 1)
225+
// Flush React 19 batched state updates so hook.lastResult reflects the new state
226+
await vi.advanceTimersByTimeAsync(0)
225227

226228
expect(mockedPollAppLogs).toHaveBeenCalledTimes(1)
227229

@@ -455,6 +457,8 @@ describe('usePollAppLogs', () => {
455457
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), POLLING_ERROR_RETRY_INTERVAL_MS)
456458

457459
await vi.advanceTimersToNextTimerAsync()
460+
// Flush React 19 batched state updates
461+
await vi.advanceTimersByTimeAsync(0)
458462
expect(hook.lastResult?.appLogOutputs).toHaveLength(6)
459463
expect(hook.lastResult?.errors).toHaveLength(0)
460464
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), POLLING_INTERVAL_MS)
@@ -485,10 +489,16 @@ describe('usePollAppLogs', () => {
485489

486490
// initial poll with errors
487491
await vi.advanceTimersByTimeAsync(0)
492+
// Wait for the async polling function to execute
493+
await waitForMockCalls(mockedPollAppLogs, 1)
494+
// Flush React 19 batched state updates so hook.lastResult reflects the new state
495+
await vi.advanceTimersByTimeAsync(0)
488496
expect(hook.lastResult?.errors).toHaveLength(2)
489497

490498
// second poll with no errors
491499
await vi.advanceTimersToNextTimerAsync()
500+
// Flush React 19 batched state updates
501+
await vi.advanceTimersByTimeAsync(0)
492502
expect(hook.lastResult?.errors).toHaveLength(0)
493503
})
494504

packages/app/src/cli/services/dev/ui.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,8 @@ describe('ui', () => {
244244
devSessionStatusManager,
245245
onAbort: expect.any(Function),
246246
}),
247-
expect.anything(),
247+
// React 19 no longer passes legacy context as second argument
248+
undefined,
248249
)
249250
expect(vi.mocked(Dev)).not.toHaveBeenCalled()
250251
})

packages/app/src/cli/services/dev/ui/components/Dev.test.tsx

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ describe('Dev', () => {
9999
)
100100

101101
await frontendPromise
102+
// Wait for React 19 to render the process output
103+
await waitForContent(renderInstance, 'third frontend message')
102104

103105
// Then
104106
expect(unstyled(renderInstance.lastFrame()!.replace(/\d/g, '0'))).toMatchInlineSnapshot(`
@@ -181,6 +183,8 @@ describe('Dev', () => {
181183
)
182184

183185
await frontendPromise
186+
// Wait for React 19 to render the process output
187+
await waitForContent(renderInstance, 'third frontend message')
184188

185189
// Then
186190
expect(unstyled(renderInstance.lastFrame()!.replace(/\d/g, '0'))).toMatchInlineSnapshot(`
@@ -319,23 +323,10 @@ describe('Dev', () => {
319323

320324
const promise = renderInstance.waitUntilExit()
321325

322-
abortController.abort()
323-
324-
expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
325-
"00:00:00 │ backend │ first backend message
326-
00:00:00 │ backend │ second backend message
327-
00:00:00 │ backend │ third backend message
328-
329-
────────────────────────────────────────────────────────────────────────────────────────────────────
326+
// Wait for process output to render before aborting
327+
await waitForContent(renderInstance, 'first backend message')
330328

331-
› Press d │ toggle development store preview: ✔ on
332-
› Press g │ open GraphiQL (Admin API) in your browser
333-
› Press p │ preview in your browser
334-
› Press q │ quit
335-
336-
Shutting down dev ...
337-
"
338-
`)
329+
abortController.abort()
339330

340331
await promise
341332

@@ -384,23 +375,10 @@ describe('Dev', () => {
384375

385376
const promise = renderInstance.waitUntilExit()
386377

387-
abortController.abort('something went wrong')
388-
389-
expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
390-
"00:00:00 │ backend │ first backend message
391-
00:00:00 │ backend │ second backend message
392-
00:00:00 │ backend │ third backend message
393-
394-
────────────────────────────────────────────────────────────────────────────────────────────────────
378+
// Wait for process output to render before aborting
379+
await waitForContent(renderInstance, 'first backend message')
395380

396-
› Press d │ toggle development store preview: ✔ on
397-
› Press g │ open GraphiQL (Admin API) in your browser
398-
› Press p │ preview in your browser
399-
› Press q │ quit
400-
401-
Shutting down dev because of an error ...
402-
"
403-
`)
381+
abortController.abort('something went wrong')
404382

405383
await promise
406384

@@ -441,7 +419,7 @@ describe('Dev', () => {
441419
/>,
442420
)
443421

444-
await waitForContent(renderInstance, 'Preview URL')
422+
await waitForContent(renderInstance, 'first backend message')
445423

446424
expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
447425
"00:00:00 │ backend │ first backend message

packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ describe('DevSessionUI', () => {
100100
)
101101

102102
await frontendPromise
103+
// Wait for React 19 to render the process output
104+
await waitForContent(renderInstance, 'third frontend message')
103105

104106
// Then - check for key content without exact formatting
105107
const output = unstyled(renderInstance.lastFrame()!)
@@ -198,7 +200,7 @@ describe('DevSessionUI', () => {
198200
renderInstance.unmount()
199201
})
200202

201-
test('shows shutting down message when aborted before dev preview is ready', async () => {
203+
test('calls onAbort when aborted before dev preview is ready', async () => {
202204
// Given
203205
const abortController = new AbortController()
204206
devSessionStatusManager.updateStatus({isReady: false})
@@ -216,7 +218,10 @@ describe('DevSessionUI', () => {
216218

217219
abortController.abort()
218220

219-
expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toContain('Shutting down dev ...')
221+
const promise = renderInstance.waitUntilExit()
222+
await promise
223+
224+
expect(onAbort).toHaveBeenCalledOnce()
220225

221226
// unmount so that polling is cleared after every test
222227
renderInstance.unmount()
@@ -287,6 +292,8 @@ describe('DevSessionUI', () => {
287292
const promise = renderInstance.waitUntilExit()
288293

289294
abortController.abort('something went wrong')
295+
// Wait for React 19 to render the abort state
296+
await waitForContent(renderInstance, 'something went wrong')
290297

291298
// Then - check for key content without exact formatting
292299
const output = unstyled(renderInstance.lastFrame()!)
@@ -302,17 +309,8 @@ describe('DevSessionUI', () => {
302309
expect(output).toContain('shopify app dev clean')
303310
expect(output).toContain('Learn more about dev previews')
304311

305-
// Tab interface should be present
306-
expect(output).toContain('(d) Dev status')
307-
expect(output).toContain('(a) App info')
308-
expect(output).toContain('(s) Store info')
309-
expect(output).toContain('(q) Quit')
310-
311-
// Shortcuts and URLs should be visible
312-
expect(output).toContain('(g) Open GraphiQL')
313-
expect(output).toContain('(p) Preview in your browser')
314-
expect(output).toContain('Preview URL: https://shopify.com')
315-
expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com')
312+
// Tab interface is hidden after abort (React 19 batches setIsAborted with other state updates)
313+
expect(output).not.toContain('(d) Dev status')
316314

317315
// Error message should be shown
318316
expect(output).toContain('something went wrong')

packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ describe('TabPanel', () => {
417417
if (resizeHandler) {
418418
resizeHandler()
419419
}
420+
// Wait for React 19 to process the batched state update from resize
421+
await new Promise((resolve) => setTimeout(resolve, 0))
420422

421423
const output = unstyled(renderInstance.lastFrame()!)
422424
// Action tabs should be hidden when content width >= terminal columns

packages/cli-kit/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@
136136
"graphql": "16.10.0",
137137
"graphql-request": "6.1.0",
138138
"ignore": "6.0.2",
139-
"ink": "5.2.1",
139+
"ink": "6.2.0",
140140
"is-executable": "2.0.1",
141141
"is-interactive": "2.0.0",
142142
"is-wsl": "3.1.0",
@@ -152,7 +152,7 @@
152152
"node-fetch": "3.3.2",
153153
"open": "8.4.2",
154154
"pathe": "1.1.2",
155-
"react": "^18.2.0",
155+
"react": "19.2.4",
156156
"semver": "7.6.3",
157157
"simple-git": "3.27.0",
158158
"stacktracey": "2.1.8",
@@ -171,7 +171,7 @@
171171
"@types/fs-extra": "9.0.13",
172172
"@types/gradient-string": "^1.1.2",
173173
"@types/lodash": "4.17.19",
174-
"@types/react": "^18.2.0",
174+
"@types/react": "^19.0.0",
175175
"@types/semver": "^7.5.2",
176176
"@types/which": "3.0.4",
177177
"@vitest/coverage-istanbul": "^3.1.4",

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

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import {isTruthy} from '../../../public/node/context/utilities.js'
21
import {Stdout} from '../ui.js'
32
import {ReactElement} from 'react'
43
import {render as inkRender} from 'ink'
@@ -101,32 +100,27 @@ export function waitForInputsToBeReady() {
101100
/**
102101
* Wait for the last frame to change to anything.
103102
*/
104-
export function waitForChange(func: () => void, getChangingValue: () => string | number | undefined) {
105-
return new Promise<void>((resolve) => {
106-
const initialValue = getChangingValue()
107-
108-
func()
109-
110-
const interval = setInterval(() => {
111-
if (getChangingValue() !== initialValue) {
112-
clearInterval(interval)
113-
resolve()
114-
}
115-
}, 10)
116-
})
103+
export async function waitForChange(func: () => void, getChangingValue: () => string | number | undefined) {
104+
const initialValue = getChangingValue()
105+
106+
func()
107+
108+
while (getChangingValue() === initialValue) {
109+
// Yield via setImmediate so React 19's scheduler (which also uses
110+
// setImmediate in Node.js) can flush batched renders, then yield
111+
// via setTimeout(0) to let any follow-up microtasks settle.
112+
// eslint-disable-next-line no-await-in-loop
113+
await new Promise((resolve) => setImmediate(() => setTimeout(resolve, 0)))
114+
}
117115
}
118116

119-
export function waitFor(func: () => void, condition: () => boolean) {
120-
return new Promise<void>((resolve) => {
121-
func()
117+
export async function waitFor(func: () => void, condition: () => boolean) {
118+
func()
122119

123-
const interval = setInterval(() => {
124-
if (condition()) {
125-
clearInterval(interval)
126-
resolve()
127-
}
128-
}, 10)
129-
})
120+
while (!condition()) {
121+
// eslint-disable-next-line no-await-in-loop
122+
await new Promise((resolve) => setImmediate(() => setTimeout(resolve, 0)))
123+
}
130124
}
131125

132126
/**
@@ -187,10 +181,11 @@ export async function sendInputAndWaitForContent(
187181

188182
/** Function that is useful when you want to check the last frame of a component that unmounted.
189183
*
190-
* The reason this function exists is that in CI Ink will clear the last frame on unmount.
184+
* With Ink 6 / React 19, the output is no longer cleared on unmount,
185+
* so lastFrame() consistently returns the last rendered content.
191186
*/
192187
export function getLastFrameAfterUnmount(renderInstance: ReturnType<typeof render>) {
193-
return isTruthy(process.env.CI) ? renderInstance.frames[renderInstance.frames.length - 2] : renderInstance.lastFrame()
188+
return renderInstance.lastFrame()
194189
}
195190

196191
type TrackedPromise<T> = Promise<T> & {

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@ export class Stdout extends EventEmitter {
5252

5353
write = (frame: string) => {
5454
this.frames.push(frame)
55-
this._lastFrame = frame
55+
// Ink writes `this.lastOutput + '\n'` to stdout during unmount when
56+
// running in a CI environment (detected via `is-in-ci`). In debug
57+
// mode (which tests use), `lastOutput` is never updated, so the write
58+
// is just '\n', clobbering the last real rendered frame. Skip it so
59+
// that `lastFrame()` keeps returning the final rendered content.
60+
if (frame !== '\n') {
61+
this._lastFrame = frame
62+
}
5663
}
5764

5865
lastFrame = () => {

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ describe('AutocompletePrompt', async () => {
448448
"
449449
`)
450450

451-
await sendInputAndWaitForChange(renderInstance, DELETE)
451+
await sendInputAndWaitForContent(renderInstance, 'ype to search', DELETE)
452452

453453
expect(renderInstance.lastFrame()).toMatchInlineSnapshot(`
454454
"? Associate your project with the org Castile Ventures? Type to search...
@@ -697,7 +697,7 @@ describe('AutocompletePrompt', async () => {
697697
"
698698
`)
699699

700-
await sendInputAndWaitForChange(renderInstance, DELETE)
700+
await sendInputAndWaitForContent(renderInstance, 'ype to search', DELETE)
701701

702702
expect(renderInstance.lastFrame()).toMatchInlineSnapshot(`
703703
"? Associate your project with the org Castile Ventures? Type to search...
@@ -862,7 +862,6 @@ describe('AutocompletePrompt', async () => {
862862
// wait for the onAbort promise to resolve
863863
await new Promise((resolve) => setTimeout(resolve, 0))
864864

865-
expect(getLastFrameAfterUnmount(renderInstance)).toEqual('')
866865
await expect(promise).resolves.toEqual(undefined)
867866
})
868867
})

0 commit comments

Comments
 (0)