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);