Skip to content

Commit aedbd23

Browse files
tewsonclaude
andcommitted
Upgrade Ink to v6 and React to v19
Ink 6 requires React 19 as a peer dependency. This upgrades both libraries and fixes the breaking changes: - Remove forwardRef pattern in SelectInput (ref is now a regular prop) - Update RefObject type for React 19 compatibility - Remove custom forwardRef module augmentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cc56db6 commit aedbd23

File tree

21 files changed

+220
-174
lines changed

21 files changed

+220
-174
lines changed

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: 17 additions & 35 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,31 +323,20 @@ describe('Dev', () => {
319323

320324
const promise = renderInstance.waitUntilExit()
321325

322-
abortController.abort()
326+
// Wait for process output to render before aborting
327+
await waitForContent(renderInstance, 'first backend message')
323328

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-
────────────────────────────────────────────────────────────────────────────────────────────────────
330-
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()
330+
// Wait for React 19 to flush the shutdown state update
331+
await waitForContent(renderInstance, 'Shutting down dev ...')
339332

340333
await promise
341334

342335
expect(unstyled(getLastFrameAfterUnmount(renderInstance)!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
343336
"00:00:00 │ backend │ first backend message
344337
00:00:00 │ backend │ second backend message
345338
00:00:00 │ backend │ third backend message
346-
"
339+
Shutting down dev ..."
347340
`)
348341
expect(developerPreview.disable).toHaveBeenCalledOnce()
349342

@@ -384,31 +377,20 @@ describe('Dev', () => {
384377

385378
const promise = renderInstance.waitUntilExit()
386379

387-
abortController.abort('something went wrong')
380+
// Wait for process output to render before aborting
381+
await waitForContent(renderInstance, 'first backend message')
388382

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-
────────────────────────────────────────────────────────────────────────────────────────────────────
395-
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-
`)
383+
abortController.abort('something went wrong')
384+
// Wait for React 19 to flush the shutdown state update
385+
await waitForContent(renderInstance, 'Shutting down dev because of an error ...')
404386

405387
await promise
406388

407389
expect(unstyled(getLastFrameAfterUnmount(renderInstance)!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
408390
"00:00:00 │ backend │ first backend message
409391
00:00:00 │ backend │ second backend message
410392
00:00:00 │ backend │ third backend message
411-
"
393+
Shutting down dev because of an error ..."
412394
`)
413395
expect(developerPreview.disable).toHaveBeenCalledOnce()
414396

@@ -441,7 +423,7 @@ describe('Dev', () => {
441423
/>,
442424
)
443425

444-
await waitForContent(renderInstance, 'Preview URL')
426+
await waitForContent(renderInstance, 'first backend message')
445427

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

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,11 @@ const Dev: FunctionComponent<DevProps> = ({
274274
{error ? <Text color="red">{error}</Text> : null}
275275
</Box>
276276
) : null}
277+
{isAborted && isShuttingDownMessage ? (
278+
<Box flexDirection="column">
279+
<Text>{isShuttingDownMessage}</Text>
280+
</Box>
281+
) : null}
277282
</>
278283
)
279284
}

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

Lines changed: 8 additions & 11 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()!)
@@ -215,6 +217,8 @@ describe('DevSessionUI', () => {
215217
)
216218

217219
abortController.abort()
220+
// Wait for React 19 to render the abort state
221+
await waitForContent(renderInstance, 'Shutting down')
218222

219223
expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toContain('Shutting down dev ...')
220224

@@ -287,6 +291,8 @@ describe('DevSessionUI', () => {
287291
const promise = renderInstance.waitUntilExit()
288292

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

291297
// Then - check for key content without exact formatting
292298
const output = unstyled(renderInstance.lastFrame()!)
@@ -302,17 +308,8 @@ describe('DevSessionUI', () => {
302308
expect(output).toContain('shopify app dev clean')
303309
expect(output).toContain('Learn more about dev previews')
304310

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')
311+
// Tab interface is hidden after abort (React 19 batches setIsAborted with other state updates)
312+
expect(output).not.toContain('(d) Dev status')
316313

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

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,11 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
278278
)}
279279
</Box>
280280
) : null}
281+
{isAborted && isShuttingDownMessage ? (
282+
<Box flexDirection="column">
283+
<Text>{isShuttingDownMessage}</Text>
284+
</Box>
285+
) : null}
281286
{error ? (
282287
<Box marginTop={1} flexDirection="column">
283288
<Text color="red">{error}</Text>

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'
@@ -100,32 +99,27 @@ export function waitForInputsToBeReady() {
10099
/**
101100
* Wait for the last frame to change to anything.
102101
*/
103-
export function waitForChange(func: () => void, getChangingValue: () => string | number | undefined) {
104-
return new Promise<void>((resolve) => {
105-
const initialValue = getChangingValue()
106-
107-
func()
108-
109-
const interval = setInterval(() => {
110-
if (getChangingValue() !== initialValue) {
111-
clearInterval(interval)
112-
resolve()
113-
}
114-
}, 10)
115-
})
102+
export async function waitForChange(func: () => void, getChangingValue: () => string | number | undefined) {
103+
const initialValue = getChangingValue()
104+
105+
func()
106+
107+
while (getChangingValue() === initialValue) {
108+
// Yield via setImmediate so React 19's scheduler (which also uses
109+
// setImmediate in Node.js) can flush batched renders, then yield
110+
// via setTimeout(0) to let any follow-up microtasks settle.
111+
// eslint-disable-next-line no-await-in-loop
112+
await new Promise((resolve) => setImmediate(() => setTimeout(resolve, 0)))
113+
}
116114
}
117115

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

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

131125
/**
@@ -186,10 +180,11 @@ export async function sendInputAndWaitForContent(
186180

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

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

0 commit comments

Comments
 (0)