Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@
"json-schema-to-typescript": "15.0.4",
"prettier": "2.8.8",
"proper-lockfile": "4.1.2",
"react": "^18.2.0",
"react-dom": "18.3.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"which": "4.0.0",
"ws": "8.18.0"
},
Expand All @@ -82,8 +82,8 @@
"@types/express": "^4.17.17",
"@types/prettier": "^2.7.3",
"@types/proper-lockfile": "4.1.4",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/which": "3.0.4",
"@types/ws": "^8.5.13",
"@vitest/coverage-istanbul": "^3.1.4"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ describe('usePollAppLogs', () => {

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

expect(mockedPollAppLogs).toHaveBeenCalledTimes(1)

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

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

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

// second poll with no errors
await vi.advanceTimersToNextTimerAsync()
// Flush React 19 batched state updates
await vi.advanceTimersByTimeAsync(0)
expect(hook.lastResult?.errors).toHaveLength(0)
})

Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/cli/services/dev/ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ describe('ui', () => {
devSessionStatusManager,
onAbort: expect.any(Function),
}),
expect.anything(),
// React 19 no longer passes legacy context as second argument
undefined,
)
expect(vi.mocked(Dev)).not.toHaveBeenCalled()
})
Expand Down
52 changes: 17 additions & 35 deletions packages/app/src/cli/services/dev/ui/components/Dev.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ describe('Dev', () => {
)

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

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

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

// Then
expect(unstyled(renderInstance.lastFrame()!.replace(/\d/g, '0'))).toMatchInlineSnapshot(`
Expand Down Expand Up @@ -319,31 +323,20 @@ describe('Dev', () => {

const promise = renderInstance.waitUntilExit()

abortController.abort()
// Wait for process output to render before aborting
await waitForContent(renderInstance, 'first backend message')

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

────────────────────────────────────────────────────────────────────────────────────────────────────

› Press d │ toggle development store preview: ✔ on
› Press g │ open GraphiQL (Admin API) in your browser
› Press p │ preview in your browser
› Press q │ quit

Shutting down dev ...
"
`)
abortController.abort()
// Wait for React 19 to flush the shutdown state update
await waitForContent(renderInstance, 'Shutting down dev ...')

await promise

expect(unstyled(getLastFrameAfterUnmount(renderInstance)!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
"00:00:00 │ backend │ first backend message
00:00:00 │ backend │ second backend message
00:00:00 │ backend │ third backend message
"
Shutting down dev ..."
`)
expect(developerPreview.disable).toHaveBeenCalledOnce()

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

const promise = renderInstance.waitUntilExit()

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

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

────────────────────────────────────────────────────────────────────────────────────────────────────

› Press d │ toggle development store preview: ✔ on
› Press g │ open GraphiQL (Admin API) in your browser
› Press p │ preview in your browser
› Press q │ quit

Shutting down dev because of an error ...
"
`)
abortController.abort('something went wrong')
// Wait for React 19 to flush the shutdown state update
await waitForContent(renderInstance, 'Shutting down dev because of an error ...')

await promise

expect(unstyled(getLastFrameAfterUnmount(renderInstance)!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
"00:00:00 │ backend │ first backend message
00:00:00 │ backend │ second backend message
00:00:00 │ backend │ third backend message
"
Shutting down dev because of an error ..."
`)
expect(developerPreview.disable).toHaveBeenCalledOnce()

Expand Down Expand Up @@ -441,7 +423,7 @@ describe('Dev', () => {
/>,
)

await waitForContent(renderInstance, 'Preview URL')
await waitForContent(renderInstance, 'first backend message')

expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
"00:00:00 │ backend │ first backend message
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/cli/services/dev/ui/components/Dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ const Dev: FunctionComponent<DevProps> = ({
{error ? <Text color="red">{error}</Text> : null}
</Box>
) : null}
{isAborted && isShuttingDownMessage ? (
<Box flexDirection="column">
<Text>{isShuttingDownMessage}</Text>
</Box>
) : null}
</>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ describe('DevSessionUI', () => {
)

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

// Then - check for key content without exact formatting
const output = unstyled(renderInstance.lastFrame()!)
Expand Down Expand Up @@ -215,6 +217,8 @@ describe('DevSessionUI', () => {
)

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

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

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

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

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

// Tab interface should be present
expect(output).toContain('(d) Dev status')
expect(output).toContain('(a) App info')
expect(output).toContain('(s) Store info')
expect(output).toContain('(q) Quit')

// Shortcuts and URLs should be visible
expect(output).toContain('(g) Open GraphiQL')
expect(output).toContain('(p) Preview in your browser')
expect(output).toContain('Preview URL: https://shopify.com')
expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com')
// Tab interface is hidden after abort (React 19 batches setIsAborted with other state updates)
expect(output).not.toContain('(d) Dev status')

// Error message should be shown
expect(output).toContain('something went wrong')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
)}
</Box>
) : null}
{isAborted && isShuttingDownMessage ? (
<Box flexDirection="column">
<Text>{isShuttingDownMessage}</Text>
</Box>
) : null}
{error ? (
<Box marginTop={1} flexDirection="column">
<Text color="red">{error}</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ describe('TabPanel', () => {
if (resizeHandler) {
resizeHandler()
}
// Wait for React 19 to process the batched state update from resize
await new Promise((resolve) => setTimeout(resolve, 0))

const output = unstyled(renderInstance.lastFrame()!)
// Action tabs should be hidden when content width >= terminal columns
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@
"graphql": "16.10.0",
"graphql-request": "6.1.0",
"ignore": "6.0.2",
"ink": "5.2.1",
"ink": "6.2.0",
"is-executable": "2.0.1",
"is-interactive": "2.0.0",
"is-wsl": "3.1.0",
Expand All @@ -152,7 +152,7 @@
"node-fetch": "3.3.2",
"open": "8.4.2",
"pathe": "1.1.2",
"react": "^18.2.0",
"react": "19.2.4",
"semver": "7.6.3",
"simple-git": "3.27.0",
"stacktracey": "2.1.8",
Expand All @@ -171,7 +171,7 @@
"@types/fs-extra": "9.0.13",
"@types/gradient-string": "^1.1.2",
"@types/lodash": "4.17.19",
"@types/react": "^18.2.0",
"@types/react": "^19.0.0",
"@types/semver": "^7.5.2",
"@types/which": "3.0.4",
"@vitest/coverage-istanbul": "^3.1.4",
Expand Down
47 changes: 21 additions & 26 deletions packages/cli-kit/src/private/node/testing/ui.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {isTruthy} from '../../../public/node/context/utilities.js'
import {Stdout} from '../ui.js'
import {ReactElement} from 'react'
import {render as inkRender} from 'ink'
Expand Down Expand Up @@ -100,32 +99,27 @@ export function waitForInputsToBeReady() {
/**
* Wait for the last frame to change to anything.
*/
export function waitForChange(func: () => void, getChangingValue: () => string | number | undefined) {
return new Promise<void>((resolve) => {
const initialValue = getChangingValue()

func()

const interval = setInterval(() => {
if (getChangingValue() !== initialValue) {
clearInterval(interval)
resolve()
}
}, 10)
})
export async function waitForChange(func: () => void, getChangingValue: () => string | number | undefined) {
const initialValue = getChangingValue()

func()

while (getChangingValue() === initialValue) {
// Yield via setImmediate so React 19's scheduler (which also uses
// setImmediate in Node.js) can flush batched renders, then yield
// via setTimeout(0) to let any follow-up microtasks settle.
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setImmediate(() => setTimeout(resolve, 0)))
}
}

export function waitFor(func: () => void, condition: () => boolean) {
return new Promise<void>((resolve) => {
func()
export async function waitFor(func: () => void, condition: () => boolean) {
func()

const interval = setInterval(() => {
if (condition()) {
clearInterval(interval)
resolve()
}
}, 10)
})
while (!condition()) {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setImmediate(() => setTimeout(resolve, 0)))
}
}

/**
Expand Down Expand Up @@ -186,10 +180,11 @@ export async function sendInputAndWaitForContent(

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

type TrackedPromise<T> = Promise<T> & {
Expand Down
9 changes: 8 additions & 1 deletion packages/cli-kit/src/private/node/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,14 @@ export class Stdout extends EventEmitter {

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

lastFrame = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,6 @@ describe('AutocompletePrompt', async () => {
// wait for the onAbort promise to resolve
await new Promise((resolve) => setTimeout(resolve, 0))

expect(getLastFrameAfterUnmount(renderInstance)).toEqual('')
await expect(promise).resolves.toEqual(undefined)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ function AutocompletePrompt<T>({
value={searchTerm}
onChange={(term) => {
setSearchTerm(term)
// Update ref immediately so that the debounceSearch's .then()
// callback sees the current term. With React 19's automatic
// batching, the render (which normally updates the ref) is
// deferred, so without this the ref would be stale when the
// search Promise resolves.
searchTermRef.current = term

if (term.length > 0) {
debounceSearch(term)
Expand Down
Loading
Loading