diff --git a/packages/app/package.json b/packages/app/package.json index 71c2da6af82..79542777683 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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" }, @@ -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" diff --git a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx b/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx index 1ae482ccde2..d4420c27939 100644 --- a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx +++ b/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx @@ -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) @@ -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) @@ -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) }) diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index 5be41b16166..f41ff142733 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -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() }) diff --git a/packages/app/src/cli/services/dev/ui/components/Dev.test.tsx b/packages/app/src/cli/services/dev/ui/components/Dev.test.tsx index b4435030ee0..c2eca30bb75 100644 --- a/packages/app/src/cli/services/dev/ui/components/Dev.test.tsx +++ b/packages/app/src/cli/services/dev/ui/components/Dev.test.tsx @@ -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(` @@ -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(` @@ -319,23 +323,12 @@ 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 @@ -343,7 +336,7 @@ describe('Dev', () => { "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() @@ -384,23 +377,12 @@ 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 @@ -408,7 +390,7 @@ describe('Dev', () => { "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() @@ -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 diff --git a/packages/app/src/cli/services/dev/ui/components/Dev.tsx b/packages/app/src/cli/services/dev/ui/components/Dev.tsx index eb93e7f529a..0e4fd7bf7b1 100644 --- a/packages/app/src/cli/services/dev/ui/components/Dev.tsx +++ b/packages/app/src/cli/services/dev/ui/components/Dev.tsx @@ -274,6 +274,11 @@ const Dev: FunctionComponent = ({ {error ? {error} : null} ) : null} + {isAborted && isShuttingDownMessage ? ( + + {isShuttingDownMessage} + + ) : null} ) } diff --git a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx index 6a89dc34e93..5831bb5a850 100644 --- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx +++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx @@ -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()!) @@ -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 ...') @@ -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()!) @@ -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') diff --git a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx index 35f3cf44187..a3946c7a59e 100644 --- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx +++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx @@ -278,6 +278,11 @@ const DevSessionUI: FunctionComponent = ({ )} ) : null} + {isAborted && isShuttingDownMessage ? ( + + {isShuttingDownMessage} + + ) : null} {error ? ( {error} diff --git a/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx b/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx index bf14e988045..8e0038e61a2 100644 --- a/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx +++ b/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx @@ -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 diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 9f89b666d79..7cadf05ac57 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -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", @@ -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", @@ -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", diff --git a/packages/cli-kit/src/private/node/testing/ui.ts b/packages/cli-kit/src/private/node/testing/ui.ts index 70c7fa9ffc9..d88c07bc2da 100644 --- a/packages/cli-kit/src/private/node/testing/ui.ts +++ b/packages/cli-kit/src/private/node/testing/ui.ts @@ -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' @@ -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((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((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))) + } } /** @@ -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) { - return isTruthy(process.env.CI) ? renderInstance.frames[renderInstance.frames.length - 2] : renderInstance.lastFrame() + return renderInstance.lastFrame() } type TrackedPromise = Promise & { diff --git a/packages/cli-kit/src/private/node/ui.tsx b/packages/cli-kit/src/private/node/ui.tsx index 7df2c5e09ee..d5b0fead132 100644 --- a/packages/cli-kit/src/private/node/ui.tsx +++ b/packages/cli-kit/src/private/node/ui.tsx @@ -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 = () => { diff --git a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx index 44386351e15..02e8fb02ad1 100644 --- a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx @@ -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) }) }) diff --git a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx index e44394f8aa0..6fa4a076b91 100644 --- a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.tsx @@ -140,6 +140,12 @@ function AutocompletePrompt({ 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) diff --git a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx index 719c2820dc4..df65eaa389b 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx @@ -1,5 +1,5 @@ import {ConcurrentOutput, useConcurrentOutputContext} from './ConcurrentOutput.js' -import {render} from '../../testing/ui.js' +import {render, waitForContent} from '../../testing/ui.js' import {AbortController, AbortSignal} from '../../../../public/node/abort.js' import {unstyled} from '../../../../public/node/output.js' import React from 'react' @@ -57,6 +57,8 @@ describe('ConcurrentOutput', () => { ) await frontendSync.promise + // 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(` @@ -88,6 +90,7 @@ describe('ConcurrentOutput', () => { // When const renderInstance = render() await processSync.promise + await waitForContent(renderInstance, output) // Then const logColumns = renderInstance.lastFrame()!.split('│') @@ -116,6 +119,7 @@ describe('ConcurrentOutput', () => { // When const renderInstance = render() await processSync.promise + await waitForContent(renderInstance, 'foo') // Then const logColumns = renderInstance.lastFrame()!.split('│') @@ -151,6 +155,7 @@ describe('ConcurrentOutput', () => { ) await processSync.promise + await waitForContent(renderInstance, 'foo bar') // Then const logColumns = unstyled(renderInstance.lastFrame()!).split('│') @@ -190,6 +195,7 @@ describe('ConcurrentOutput', () => { />, ) await Promise.all([processSync1.promise, processSync2.promise]) + await waitForContent(renderInstance, 'bar') // Then const logLines = unstyled(renderInstance.lastFrame()!).split('\n').filter(Boolean) @@ -221,6 +227,7 @@ describe('ConcurrentOutput', () => { // When const renderInstance = render() await processSync.promise + await waitForContent(renderInstance, 'foo') // Then const logColumns = unstyled(renderInstance.lastFrame()!).split('│') @@ -246,6 +253,7 @@ describe('ConcurrentOutput', () => { // When const renderInstance = render() await processSync.promise + await waitForContent(renderInstance, 'foo') // Then const logColumns = unstyled(renderInstance.lastFrame()!).split('│') diff --git a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx index da83738b860..78b0b2ccddf 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx @@ -178,12 +178,14 @@ const ConcurrentOutput: FunctionComponent = ({ }), ) if (!keepRunningAfterProcessesResolve) { - unmountInk() + // Defer unmount so React 19 can flush batched setProcessOutput + // state updates before the component tree is torn down. + setImmediate(() => unmountInk()) } // eslint-disable-next-line no-catch-all/no-catch-all } catch (error: unknown) { if (!keepRunningAfterProcessesResolve) { - unmountInk(error as Error | undefined) + setImmediate(() => unmountInk(error as Error | undefined)) } } } diff --git a/packages/cli-kit/src/private/node/ui/components/SelectInput.tsx b/packages/cli-kit/src/private/node/ui/components/SelectInput.tsx index 6155d66ab43..705fa2195f2 100644 --- a/packages/cli-kit/src/private/node/ui/components/SelectInput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SelectInput.tsx @@ -2,17 +2,12 @@ import {Scrollbar} from './Scrollbar.js' import {useSelectState} from '../hooks/use-select-state.js' import useLayout from '../hooks/use-layout.js' import {handleCtrlC} from '../../ui.js' -import React, {useCallback, forwardRef, useEffect} from 'react' +import React, {useCallback, useEffect} from 'react' import {Box, Key, useInput, Text, DOMElement} from 'ink' import chalk from 'chalk' import figures from 'figures' import sortBy from 'lodash/sortBy.js' -declare module 'react' { - function forwardRef( - render: (props: P, ref: React.Ref) => React.ReactElement | null, - ): (props: P & React.RefAttributes) => React.ReactElement | null -} export interface SelectInputProps { items: Item[] initialItems?: Item[] @@ -28,7 +23,8 @@ export interface SelectInputProps { morePagesMessage?: string availableLines?: number onSubmit?: (item: Item) => void - inputFixedAreaRef?: React.RefObject + inputFixedAreaRef?: React.Ref + ref?: React.Ref groupOrder?: string[] } @@ -133,27 +129,25 @@ function Item({ const MAX_AVAILABLE_LINES = 25 // eslint-disable-next-line react/function-component-definition -function SelectInputInner( - { - items: rawItems, - initialItems = rawItems, - onChange, - enableShortcuts = true, - focus = true, - emptyMessage = 'No items to select.', - defaultValue, - highlightedTerm, - loading = false, - errorMessage, - hasMorePages = false, - morePagesMessage, - availableLines = MAX_AVAILABLE_LINES, - onSubmit, - inputFixedAreaRef, - groupOrder, - }: SelectInputProps, - ref: React.ForwardedRef, -): React.ReactElement | null { +function SelectInput({ + items: rawItems, + initialItems = rawItems, + onChange, + enableShortcuts = true, + focus = true, + emptyMessage = 'No items to select.', + defaultValue, + highlightedTerm, + loading = false, + errorMessage, + hasMorePages = false, + morePagesMessage, + availableLines = MAX_AVAILABLE_LINES, + onSubmit, + inputFixedAreaRef, + ref, + groupOrder, +}: SelectInputProps): React.ReactElement | null { let noItems = false if (rawItems.length === 0) { @@ -325,4 +319,4 @@ function SelectInputInner( } } -export const SelectInput = forwardRef(SelectInputInner) +export {SelectInput} diff --git a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx index d553ee40e7b..ad4b04e1a9e 100644 --- a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx @@ -468,7 +468,6 @@ describe('SelectPrompt', 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) }) }) diff --git a/packages/cli-kit/src/private/node/ui/components/Tasks.test.tsx b/packages/cli-kit/src/private/node/ui/components/Tasks.test.tsx index ab192ca1a45..a2b5159ead5 100644 --- a/packages/cli-kit/src/private/node/ui/components/Tasks.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Tasks.test.tsx @@ -1,6 +1,6 @@ import {Task, Tasks} from './Tasks.js' -import {getLastFrameAfterUnmount, render} from '../../testing/ui.js' -import {unstyled, TokenizedString} from '../../../../public/node/output.js' +import {render} from '../../testing/ui.js' +import {TokenizedString} from '../../../../public/node/output.js' import {AbortController} from '../../../../public/node/abort.js' import {Stdout} from '../../ui.js' import React from 'react' @@ -44,9 +44,6 @@ describe('Tasks', () => { const renderInstance = render() await renderInstance.waitUntilExit() - - // Then - expect(getLastFrameAfterUnmount(renderInstance)).toMatchInlineSnapshot('""') }) test('stops at the task that throws error', async () => { @@ -389,7 +386,6 @@ describe('Tasks', () => { await new Promise((resolve) => setTimeout(resolve, 0)) // Then - expect(unstyled(getLastFrameAfterUnmount(renderInstance)!)).toEqual('') await expect(promise).resolves.toEqual(undefined) }) @@ -413,7 +409,6 @@ describe('Tasks', () => { await renderInstance.waitUntilExit() // Then - expect(getLastFrameAfterUnmount(renderInstance)).toMatchInlineSnapshot('""') expect(firstTaskFunction).toHaveBeenCalled() expect(secondTaskFunction).toHaveBeenCalled() }) diff --git a/packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx index da4804114e3..e52d2c2e202 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextPrompt.test.tsx @@ -204,7 +204,6 @@ describe('TextPrompt', () => { // wait for the onAbort promise to resolve await new Promise((resolve) => setTimeout(resolve, 0)) - expect(getLastFrameAfterUnmount(renderInstance)).toEqual('') await expect(promise).resolves.toEqual(undefined) }) diff --git a/packages/cli-kit/src/private/node/ui/hooks/use-abort-signal.ts b/packages/cli-kit/src/private/node/ui/hooks/use-abort-signal.ts index 64e908ef34a..3cd216ad98f 100644 --- a/packages/cli-kit/src/private/node/ui/hooks/use-abort-signal.ts +++ b/packages/cli-kit/src/private/node/ui/hooks/use-abort-signal.ts @@ -14,7 +14,15 @@ export default function useAbortSignal(abortSignal?: AbortSignal, onAbort: (erro onAbort(abortWithError) .then(() => { setIsAborted(true) - unmountInk(abortWithError) + // Defer unmounting to the next setImmediate so React 19 can flush + // batched state updates before the tree is torn down. React 19's + // scheduler also uses setImmediate in Node.js (check phase), and + // since it was queued first (by setIsAborted above), it renders + // before this callback fires (FIFO within the check phase). + // NOTE: setTimeout(fn, 0) is NOT safe here because its timers-phase + // fires BEFORE the check phase on slow CI machines where >1 ms has + // elapsed, causing unmount to race ahead of the render. + setImmediate(() => unmountInk(abortWithError)) }) .catch(() => {}) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 628b9da743f..65c4fa88c57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,10 +170,10 @@ importers: version: link:../plugin-cloudflare '@shopify/polaris': specifier: 12.27.0 - version: 12.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 12.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@shopify/polaris-icons': specifier: 8.11.1 - version: 8.11.1(react@18.3.1) + version: 8.11.1(react@19.2.4) '@shopify/theme': specifier: 3.90.0 version: link:../theme @@ -223,11 +223,11 @@ importers: specifier: 4.1.2 version: 4.1.2 react: - specifier: ^18.2.0 - version: 18.3.1 + specifier: 19.2.4 + version: 19.2.4 react-dom: - specifier: 18.3.1 - version: 18.3.1(react@18.3.1) + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) which: specifier: 4.0.0 version: 4.0.0 @@ -254,8 +254,8 @@ importers: specifier: 18.3.12 version: 18.3.12 '@types/react-dom': - specifier: ^18.2.0 - version: 18.2.0 + specifier: ^19.0.0 + version: 19.2.3(@types/react@18.3.12) '@types/which': specifier: 3.0.4 version: 3.0.4 @@ -292,7 +292,7 @@ importers: version: link:../app '@shopify/cli-hydrogen': specifier: 11.1.5 - version: 11.1.5(@graphql-codegen/cli@5.0.4(@parcel/watcher@2.5.1)(@types/node@24.7.0)(crossws@0.3.5)(enquirer@2.4.1)(graphql@16.10.0)(typescript@5.8.3))(graphql-config@5.1.5(@types/node@24.7.0)(crossws@0.3.5)(graphql@16.10.0)(typescript@5.8.3))(graphql@16.10.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.2)) + version: 11.1.5(@graphql-codegen/cli@5.0.4(@parcel/watcher@2.5.1)(@types/node@24.7.0)(crossws@0.3.5)(enquirer@2.4.1)(graphql@16.10.0)(typescript@5.8.3))(graphql-config@5.1.5(@types/node@24.7.0)(crossws@0.3.5)(graphql@16.10.0)(typescript@5.8.3))(graphql@16.10.0)(react-dom@19.2.4(react@18.3.1))(react@18.3.1)(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.2)) '@shopify/cli-kit': specifier: 3.90.0 version: link:../cli-kit @@ -435,8 +435,8 @@ importers: specifier: 6.0.2 version: 6.0.2 ink: - specifier: 5.2.1 - version: 5.2.1(@types/react@18.3.12)(react@18.3.1) + specifier: 6.2.0 + version: 6.2.0(@types/react@18.3.12)(react@19.2.4) is-executable: specifier: 2.0.1 version: 2.0.1 @@ -483,8 +483,8 @@ importers: specifier: 1.1.2 version: 1.1.2 react: - specifier: ^18.2.0 - version: 18.3.1 + specifier: 19.2.4 + version: 19.2.4 semver: specifier: 7.6.3 version: 7.6.3 @@ -791,7 +791,7 @@ importers: devDependencies: '@shopify/react-testing': specifier: ^3.0.0 - version: 3.3.10(react-dom@18.3.1(react@17.0.2))(react@17.0.2) + version: 3.3.10(react-dom@19.2.4(react@17.0.2))(react@17.0.2) '@shopify/ui-extensions-test-utils': specifier: 3.26.0 version: link:../ui-extensions-test-utils @@ -4484,8 +4484,10 @@ packages: peerDependencies: '@types/react': 18.3.12 - '@types/react-dom@18.2.0': - resolution: {integrity: sha512-8yQrvS6sMpSwIovhPOwfyNf2Wz6v/B62LFSVYQ85+Rq3tLsBIG7rP5geMxaijTUxSkrO6RzN/IRuIAADYQsleA==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': 18.3.12 '@types/react-transition-group@4.4.12': resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} @@ -6947,12 +6949,12 @@ packages: react-devtools-core: optional: true - ink@5.2.1: - resolution: {integrity: sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==} - engines: {node: '>=18'} + ink@6.2.0: + resolution: {integrity: sha512-NQbNokT11cuxlIcCDfBMk1vEwaqc/cjTSqc4R4JugBO4BpWVe2B2A6ElC2koZQ9Vj91z0C40zid/jxOF2hJL9A==} + engines: {node: '>=20'} peerDependencies: '@types/react': 18.3.12 - react: '>=18.0.0' + react: '>=19.0.0' react-devtools-core: ^4.19.1 peerDependenciesMeta: '@types/react': @@ -8504,10 +8506,10 @@ packages: peerDependencies: react: 17.0.2 - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^18.3.1 + react: ^19.2.4 react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -8533,6 +8535,12 @@ packages: peerDependencies: react: ^18.3.1 + react-reconciler@0.32.0: + resolution: {integrity: sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.1.0 + react-refresh@0.10.0: resolution: {integrity: sha512-PgidR3wST3dDYKr6b4pJoqQFpPGNKDSCDx4cZoshjXipw3LzO7mG1My2pwEzz2JVkF+inx3xRpDeQLFQGH/hsQ==} engines: {node: '>=0.10.0'} @@ -8570,6 +8578,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + read-package-up@11.0.0: resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} engines: {node: '>=18'} @@ -8823,6 +8835,12 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scuid@1.1.0: resolution: {integrity: sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==} @@ -14287,7 +14305,7 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@shopify/cli-hydrogen@11.1.5(@graphql-codegen/cli@5.0.4(@parcel/watcher@2.5.1)(@types/node@24.7.0)(crossws@0.3.5)(enquirer@2.4.1)(graphql@16.10.0)(typescript@5.8.3))(graphql-config@5.1.5(@types/node@24.7.0)(crossws@0.3.5)(graphql@16.10.0)(typescript@5.8.3))(graphql@16.10.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.2))': + '@shopify/cli-hydrogen@11.1.5(@graphql-codegen/cli@5.0.4(@parcel/watcher@2.5.1)(@types/node@24.7.0)(crossws@0.3.5)(enquirer@2.4.1)(graphql@16.10.0)(typescript@5.8.3))(graphql-config@5.1.5(@types/node@24.7.0)(crossws@0.3.5)(graphql@16.10.0)(typescript@5.8.3))(graphql@16.10.0)(react-dom@19.2.4(react@18.3.1))(react@18.3.1)(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.2))': dependencies: '@ast-grep/napi': 0.34.1 '@oclif/core': 3.26.5 @@ -14308,7 +14326,7 @@ snapshots: tar-fs: 2.1.4 tempy: 3.0.0 ts-morph: 20.0.0 - use-resize-observer: 9.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + use-resize-observer: 9.1.0(react-dom@19.2.4(react@18.3.1))(react@18.3.1) optionalDependencies: '@graphql-codegen/cli': 5.0.4(@parcel/watcher@2.5.1)(@types/node@24.7.0)(crossws@0.3.5)(enquirer@2.4.1)(graphql@16.10.0)(typescript@5.8.3) graphql-config: 5.1.5(@types/node@24.7.0)(crossws@0.3.5)(graphql@16.10.0)(typescript@5.8.3) @@ -14464,25 +14482,25 @@ snapshots: optionalDependencies: react: 17.0.2 - '@shopify/polaris-icons@8.11.1(react@18.3.1)': + '@shopify/polaris-icons@8.11.1(react@19.2.4)': optionalDependencies: - react: 18.3.1 + react: 19.2.4 '@shopify/polaris-tokens@8.10.0': dependencies: deepmerge: 4.3.1 - '@shopify/polaris@12.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@shopify/polaris@12.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@shopify/polaris-icons': 8.11.1(react@18.3.1) + '@shopify/polaris-icons': 8.11.1(react@19.2.4) '@shopify/polaris-tokens': 8.10.0 '@types/react': 18.3.12 - '@types/react-dom': 18.2.0 + '@types/react-dom': 19.2.3(@types/react@18.3.12) '@types/react-transition-group': 4.4.12(@types/react@18.3.12) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) react-fast-compare: 3.2.2 - react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@shopify/react-effect@4.1.12(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': dependencies: @@ -14527,12 +14545,12 @@ snapshots: react-dom: 17.0.2(react@17.0.2) react-reconciler: 0.26.2(react@17.0.2) - '@shopify/react-testing@3.3.10(react-dom@18.3.1(react@17.0.2))(react@17.0.2)': + '@shopify/react-testing@3.3.10(react-dom@19.2.4(react@17.0.2))(react@17.0.2)': dependencies: '@shopify/useful-types': 4.0.3 jest-matcher-utils: 26.6.2 react: 17.0.2 - react-dom: 18.3.1(react@17.0.2) + react-dom: 19.2.4(react@17.0.2) react-reconciler: 0.26.2(react@17.0.2) '@shopify/theme-check-common@3.23.0': @@ -15145,7 +15163,7 @@ snapshots: dependencies: '@types/react': 18.3.12 - '@types/react-dom@18.2.0': + '@types/react-dom@19.2.3(@types/react@18.3.12)': dependencies: '@types/react': 18.3.12 @@ -18199,7 +18217,7 @@ snapshots: - bufferutil - utf-8-validate - ink@5.2.1(@types/react@18.3.12)(react@18.3.1): + ink@6.2.0(@types/react@18.3.12)(react@19.2.4): dependencies: '@alcalzone/ansi-tokenize': 0.1.3 ansi-escapes: 7.2.0 @@ -18214,8 +18232,8 @@ snapshots: indent-string: 5.0.0 is-in-ci: 1.0.0 patch-console: 2.0.0 - react: 18.3.1 - react-reconciler: 0.29.2(react@18.3.1) + react: 19.2.4 + react-reconciler: 0.32.0(react@19.2.4) scheduler: 0.23.2 signal-exit: 3.0.7 slice-ansi: 7.1.2 @@ -19854,17 +19872,20 @@ snapshots: react: 17.0.2 scheduler: 0.20.2 - react-dom@18.3.1(react@17.0.2): + react-dom@19.2.4(react@17.0.2): dependencies: - loose-envify: 1.4.0 react: 17.0.2 - scheduler: 0.23.2 + scheduler: 0.27.0 - react-dom@18.3.1(react@18.3.1): + react-dom@19.2.4(react@18.3.1): dependencies: - loose-envify: 1.4.0 react: 18.3.1 - scheduler: 0.23.2 + scheduler: 0.27.0 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 react-fast-compare@3.2.2: {} @@ -19887,6 +19908,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-reconciler@0.32.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.26.0 + react-refresh@0.10.0: {} react-router-dom@6.30.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2): @@ -19916,14 +19942,14 @@ snapshots: react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@babel/runtime': 7.28.4 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) react@17.0.2: dependencies: @@ -19934,6 +19960,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + react@19.2.4: {} + read-package-up@11.0.0: dependencies: find-up-simple: 1.0.1 @@ -20244,6 +20272,10 @@ snapshots: dependencies: loose-envify: 1.4.0 + scheduler@0.26.0: {} + + scheduler@0.27.0: {} + scuid@1.1.0: optional: true @@ -21080,11 +21112,11 @@ snapshots: urlpattern-polyfill@10.1.0: {} - use-resize-observer@9.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + use-resize-observer@9.1.0(react-dom@19.2.4(react@18.3.1))(react@18.3.1): dependencies: '@juggle/resize-observer': 3.4.0 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.4(react@18.3.1) util-arity@1.1.0: {}