diff --git a/jest-setup.ts b/jest-setup.ts index eaf3123e36..b965fecfe0 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -55,8 +55,8 @@ beforeAll(() => { console.error = (...args: unknown[]) => { const message = args.map(String).join(); - // FIXME: Remove these ignored errors once we have enabled react 18 features - if (/Formik|createRoot|React.act|findDOMNode/.test(message)) { + // FIXME: Remove after we resolve these third-party warnings + if (/Formik|React.act|findDOMNode/.test(message)) { return; } diff --git a/packages/bluesnap-direct-integration/src/BlueSnapV2PaymentMethod.test.tsx b/packages/bluesnap-direct-integration/src/BlueSnapV2PaymentMethod.test.tsx index a3c3a8cb69..9e99eaa5a8 100644 --- a/packages/bluesnap-direct-integration/src/BlueSnapV2PaymentMethod.test.tsx +++ b/packages/bluesnap-direct-integration/src/BlueSnapV2PaymentMethod.test.tsx @@ -5,11 +5,10 @@ import { createLanguageService, type PaymentInitializeOptions, } from '@bigcommerce/checkout-sdk'; -import { act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Formik } from 'formik'; import { noop } from 'lodash'; -import React, { type FunctionComponent } from 'react'; +import React, { act, type FunctionComponent } from 'react'; import { CheckoutProvider, @@ -108,9 +107,11 @@ describe('when using BlueSnapV2 payment', () => { iframe.setAttribute('data-test', 'my-iframe'); - act(() => initializeOptions.bluesnapv2?.onLoad(iframe, jest.fn())); + act(() => { + initializeOptions.bluesnapv2?.onLoad(iframe, jest.fn()); + }); - await new Promise((resolve) => process.nextTick(resolve)); + await screen.findByTestId('my-iframe'); const user = userEvent.setup(); @@ -120,7 +121,7 @@ describe('when using BlueSnapV2 payment', () => { expect(screen.getByTestId('my-iframe')).toBeInTheDocument(); }); - it('renders modal but does not append bluesnap payment page because is empty', async () => { + it('renders modal but does not append bluesnap payment page because is empty', () => { render(); const initializeOptions = initializePayment.mock.calls[0][0]; @@ -129,8 +130,6 @@ describe('when using BlueSnapV2 payment', () => { initializeOptions.bluesnapv2?.onLoad(undefined, jest.fn()); }); - await new Promise((resolve) => process.nextTick(resolve)); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByTestId('modal-body')).not.toBeInTheDocument(); expect(screen.queryByTestId('my-iframe')).not.toBeInTheDocument(); @@ -152,7 +151,6 @@ describe('when using BlueSnapV2 payment', () => { const user = userEvent.setup(); - await new Promise((resolve) => process.nextTick(resolve)); await user.click(screen.getByText('Close')); expect(cancelBlueSnapV2Payment).toHaveBeenCalledTimes(1); diff --git a/packages/contexts/src/checkout/CheckoutProvider.tsx b/packages/contexts/src/checkout/CheckoutProvider.tsx index eda2e4a641..8bc1a45111 100644 --- a/packages/contexts/src/checkout/CheckoutProvider.tsx +++ b/packages/contexts/src/checkout/CheckoutProvider.tsx @@ -7,6 +7,7 @@ import React, { useRef, useState, } from 'react'; +import { flushSync } from 'react-dom'; import { CapabilitiesProvider } from '../capabilities'; import CheckoutContext from './CheckoutContext'; @@ -31,8 +32,14 @@ const CheckoutProviderV2: React.FC = ({ const unsubscribeRef = useRef<(() => void) | undefined>(); useEffect(() => { + // flushSync ensures checkout service state updates are applied synchronously, + // so stepsRef.current in CheckoutPage is up-to-date before any async + // continuation (e.g. onContinueAsGuest) reads from it. Without this, React 18 + // Concurrent Mode batches the setState and defers the re-render, causing + // navigateToNextIncompleteStep to read stale step statuses and navigate to the + // wrong step (Customer instead of Shipping) after guest checkout. unsubscribeRef.current = checkoutService.subscribe((newCheckoutState) => - setCheckoutState(newCheckoutState), + flushSync(() => setCheckoutState(newCheckoutState)), ); return () => { @@ -80,8 +87,9 @@ const CheckoutProviderV1: React.FC = ({ ); useEffect(() => { + // See comment in CheckoutProviderV2 for why flushSync is needed here. unsubscribeRef.current = checkoutService.subscribe((newCheckoutState) => - setCheckoutState(newCheckoutState), + flushSync(() => setCheckoutState(newCheckoutState)), ); return () => { diff --git a/packages/core/src/app/checkout/renderCheckout.test.tsx b/packages/core/src/app/checkout/renderCheckout.test.tsx index 9cb6caf65d..92793c826c 100644 --- a/packages/core/src/app/checkout/renderCheckout.test.tsx +++ b/packages/core/src/app/checkout/renderCheckout.test.tsx @@ -1,4 +1,4 @@ -import React, { type FunctionComponent } from 'react'; +import React, { act, type FunctionComponent } from 'react'; import { configurePublicPath } from '../common/bundler'; @@ -53,8 +53,16 @@ describe('renderCheckout()', () => { publicPath = ''; }); + it('throws when container element does not exist', () => { + expect(() => { + renderCheckout({ ...options, containerId: 'non-existent-id' }); + }).toThrow('Unable to find checkout container: #non-existent-id'); + }); + it('configures public path before mounting app component', () => { - renderCheckout(options); + act(() => { + renderCheckout(options); + }); expect(configurePublicPath).toHaveBeenCalledWith(options.publicPath); @@ -62,13 +70,17 @@ describe('renderCheckout()', () => { }); it('passes props to app component', () => { - renderCheckout(options); + act(() => { + renderCheckout(options); + }); expect(CheckoutApp).toHaveBeenCalledWith(options, {}); }); it('does not configure `whyDidYouRender` if not in development mode', () => { - renderCheckout(options); + act(() => { + renderCheckout(options); + }); expect(require('@welldone-software/why-did-you-render')).not.toHaveBeenCalled(); @@ -80,7 +92,9 @@ describe('renderCheckout()', () => { process.env.NODE_ENV = 'development'; - renderCheckout(options); + act(() => { + renderCheckout(options); + }); expect(require('@welldone-software/why-did-you-render')).toHaveBeenCalledWith(React, { trackAllPureComponents: false, diff --git a/packages/core/src/app/checkout/renderCheckout.tsx b/packages/core/src/app/checkout/renderCheckout.tsx index f3392abbf8..94b9ba9563 100644 --- a/packages/core/src/app/checkout/renderCheckout.tsx +++ b/packages/core/src/app/checkout/renderCheckout.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import { configurePublicPath } from '../common/bundler'; @@ -22,8 +22,15 @@ export default function renderCheckout({ // first before importing the app component and its dependencies. const { default: CheckoutApp } = require('./CheckoutApp'); - ReactDOM.render( + const container = document.getElementById(containerId); + + if (!container) { + throw new Error(`Unable to find checkout container: #${containerId}`); + } + + const root = createRoot(container); + + root.render( , - document.getElementById(containerId), ); } diff --git a/packages/core/src/app/order/renderOrderConfirmation.test.tsx b/packages/core/src/app/order/renderOrderConfirmation.test.tsx index 9c270c750f..9d031694bf 100644 --- a/packages/core/src/app/order/renderOrderConfirmation.test.tsx +++ b/packages/core/src/app/order/renderOrderConfirmation.test.tsx @@ -1,4 +1,4 @@ -import React, { type FunctionComponent } from 'react'; +import React, { act, type FunctionComponent } from 'react'; import { configurePublicPath } from '../common/bundler'; @@ -53,8 +53,16 @@ describe('renderOrderConfirmation()', () => { publicPath = ''; }); + it('throws when container element does not exist', () => { + expect(() => { + renderOrderConfirmation({ ...options, containerId: 'non-existent-id' }); + }).toThrow('Unable to find order confirmation container: #non-existent-id'); + }); + it('configures public path before mounting app component', () => { - renderOrderConfirmation(options); + act(() => { + renderOrderConfirmation(options); + }); expect(configurePublicPath).toHaveBeenCalledWith(options.publicPath); @@ -62,13 +70,17 @@ describe('renderOrderConfirmation()', () => { }); it('passes props to app component', () => { - renderOrderConfirmation(options); + act(() => { + renderOrderConfirmation(options); + }); expect(OrderConfirmationApp).toHaveBeenCalledWith(options, {}); }); it('does not configure `whyDidYouRender` if not in development mode', () => { - renderOrderConfirmation(options); + act(() => { + renderOrderConfirmation(options); + }); expect(require('@welldone-software/why-did-you-render')).not.toHaveBeenCalled(); @@ -80,7 +92,9 @@ describe('renderOrderConfirmation()', () => { process.env.NODE_ENV = 'development'; - renderOrderConfirmation(options); + act(() => { + renderOrderConfirmation(options); + }); expect(require('@welldone-software/why-did-you-render')).toHaveBeenCalledWith(React, { collapseGroups: true, diff --git a/packages/core/src/app/order/renderOrderConfirmation.tsx b/packages/core/src/app/order/renderOrderConfirmation.tsx index 6aa101b918..49ab0e491c 100644 --- a/packages/core/src/app/order/renderOrderConfirmation.tsx +++ b/packages/core/src/app/order/renderOrderConfirmation.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import { configurePublicPath } from '../common/bundler'; @@ -29,12 +29,19 @@ export default function renderOrderConfirmation({ }); } - ReactDOM.render( + const container = document.getElementById(containerId); + + if (!container) { + throw new Error(`Unable to find order confirmation container: #${containerId}`); + } + + const root = createRoot(container); + + root.render( , - document.getElementById(containerId), ); } diff --git a/packages/core/src/app/payment/creditCard/getCreditCardInputStyles.tsx b/packages/core/src/app/payment/creditCard/getCreditCardInputStyles.tsx index 09816685c4..b4003df0f0 100644 --- a/packages/core/src/app/payment/creditCard/getCreditCardInputStyles.tsx +++ b/packages/core/src/app/payment/creditCard/getCreditCardInputStyles.tsx @@ -1,6 +1,6 @@ import { noop } from 'lodash'; import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import { getAppliedStyles } from '@bigcommerce/checkout/dom-utils'; import { FormContext } from '@bigcommerce/checkout/ui'; @@ -30,6 +30,8 @@ export default function getCreditCardInputStyles( parentContainer.appendChild(container); return new Promise((resolve) => { + const root = createRoot(container); + const callbackRef = (element: HTMLInputElement | null) => { if (!element) { return; @@ -37,14 +39,18 @@ export default function getCreditCardInputStyles( resolve(getAppliedStyles(element, properties)); - ReactDOM.unmountComponentAtNode(container); + // Defer cleanup so we don't unmount during React's commit phase, + // which would happen if this function is called from a lifecycle method. + queueMicrotask(() => { + root.unmount(); - if (container.parentElement) { - container.parentElement.removeChild(container); - } + if (container.parentElement) { + container.parentElement.removeChild(container); + } + }); }; - ReactDOM.render( + root.render( , - container, ); }); } diff --git a/packages/instrument-utils/src/creditCard/getCreditCardInputStyles/getCreditCardInputStyles.tsx b/packages/instrument-utils/src/creditCard/getCreditCardInputStyles/getCreditCardInputStyles.tsx index b33a70efbd..4f471836d9 100644 --- a/packages/instrument-utils/src/creditCard/getCreditCardInputStyles/getCreditCardInputStyles.tsx +++ b/packages/instrument-utils/src/creditCard/getCreditCardInputStyles/getCreditCardInputStyles.tsx @@ -1,6 +1,6 @@ import { noop } from 'lodash'; import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import { getAppliedStyles } from '@bigcommerce/checkout/dom-utils'; import { FormContext, FormFieldContainer, TextInput } from '@bigcommerce/checkout/ui'; @@ -28,6 +28,8 @@ export default function getCreditCardInputStyles( parentContainer.appendChild(container); return new Promise((resolve) => { + const root = createRoot(container); + const callbackRef = (element: HTMLInputElement | null) => { if (!element) { return; @@ -35,14 +37,18 @@ export default function getCreditCardInputStyles( resolve(getAppliedStyles(element, properties)); - ReactDOM.unmountComponentAtNode(container); + // Defer cleanup so we don't unmount during React's commit phase, + // which would happen if this function is called from a lifecycle method. + queueMicrotask(() => { + root.unmount(); - if (container.parentElement) { - container.parentElement.removeChild(container); - } + if (container.parentElement) { + container.parentElement.removeChild(container); + } + }); }; - ReactDOM.render( + root.render( , - container, ); }); } diff --git a/packages/worldpay-access-integration/src/WorldPayCreditCardPaymentMethod.test.tsx b/packages/worldpay-access-integration/src/WorldPayCreditCardPaymentMethod.test.tsx index 69a578bce7..c26b3f5cc5 100644 --- a/packages/worldpay-access-integration/src/WorldPayCreditCardPaymentMethod.test.tsx +++ b/packages/worldpay-access-integration/src/WorldPayCreditCardPaymentMethod.test.tsx @@ -9,8 +9,7 @@ import { import { createWorldpayAccessPaymentStrategy } from '@bigcommerce/checkout-sdk/integrations/worldpayaccess'; import { Formik } from 'formik'; import { noop } from 'lodash'; -import React, { type FunctionComponent } from 'react'; -import { act } from 'react-dom/test-utils'; +import React, { act, type FunctionComponent } from 'react'; import { CheckoutProvider, @@ -124,17 +123,17 @@ describe('WorldpayCreditCardPaymentMethod', () => { render(); - await new Promise((resolve) => process.nextTick(resolve)); - - expect(checkoutService.initializePayment).toHaveBeenCalledWith( - expect.objectContaining({ - gatewayId: undefined, - methodId: 'worldpayaccess', - integrations: [createWorldpayAccessPaymentStrategy], - worldpay: { - onLoad: expect.any(Function), - }, - }), + await waitFor(() => + expect(checkoutService.initializePayment).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayId: undefined, + methodId: 'worldpayaccess', + integrations: [createWorldpayAccessPaymentStrategy], + worldpay: { + onLoad: expect.any(Function), + }, + }), + ), ); }); @@ -166,7 +165,7 @@ describe('WorldpayCreditCardPaymentMethod', () => { it('renders modal that hosts worldpay payment page', async () => { render(); - await new Promise((resolve) => process.nextTick(resolve)); + await waitFor(() => expect(checkoutService.initializePayment).toHaveBeenCalled()); const initializeOptions = (checkoutService.initializePayment as jest.Mock).mock.calls[0][0]; @@ -174,16 +173,14 @@ describe('WorldpayCreditCardPaymentMethod', () => { initializeOptions.worldpay.onLoad(document.createElement('iframe'), jest.fn()); }); - await new Promise((resolve) => process.nextTick(resolve)); - - expect(screen.getByTestId('modal-body')).toBeInTheDocument(); + await screen.findByTestId('modal-body'); expect(screen.getByText('Name on Card')).toBeInTheDocument(); }); it('renders modal', async () => { render(); - await new Promise((resolve) => process.nextTick(resolve)); + await waitFor(() => expect(checkoutService.initializePayment).toHaveBeenCalled()); const initializeOptions = (checkoutService.initializePayment as jest.Mock).mock.calls[0][0]; @@ -193,8 +190,7 @@ describe('WorldpayCreditCardPaymentMethod', () => { initializeOptions.worldpay.onLoad(iframe, jest.fn()); }); - await new Promise((resolve) => process.nextTick(resolve)); - expect(screen.getByTestId('modal-body')).toBeInTheDocument(); + await screen.findByTestId('modal-body'); }); it('renders field container with focus styles', () => { @@ -210,7 +206,7 @@ describe('WorldpayCreditCardPaymentMethod', () => { render(); - await new Promise((resolve) => process.nextTick(resolve)); + await waitFor(() => expect(checkoutService.initializePayment).toHaveBeenCalled()); const initializeOptions = (checkoutService.initializePayment as jest.Mock).mock.calls[0][0]; @@ -221,7 +217,7 @@ describe('WorldpayCreditCardPaymentMethod', () => { ); }); - await new Promise((resolve) => process.nextTick(resolve)); + await screen.findByText('Close'); fireEvent.click(screen.getByText('Close')); expect(cancelWorldpayPayment).toHaveBeenCalledTimes(1);