Skip to content
Open
4 changes: 2 additions & 2 deletions jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand All @@ -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(<BlueSnapV2PaymentMethodTest />);

const initializeOptions = initializePayment.mock.calls[0][0];
Expand All @@ -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();
Expand All @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions packages/contexts/src/checkout/CheckoutProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, {
useRef,
useState,
} from 'react';
import { flushSync } from 'react-dom';

import { CapabilitiesProvider } from '../capabilities';
import CheckoutContext from './CheckoutContext';
Expand All @@ -31,8 +32,14 @@ const CheckoutProviderV2: React.FC<CheckoutProviderProps> = ({
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 () => {
Expand Down Expand Up @@ -80,8 +87,9 @@ const CheckoutProviderV1: React.FC<CheckoutProviderProps> = ({
);

useEffect(() => {
// See comment in CheckoutProviderV2 for why flushSync is needed here.
unsubscribeRef.current = checkoutService.subscribe((newCheckoutState) =>
setCheckoutState(newCheckoutState),
flushSync(() => setCheckoutState(newCheckoutState)),
);

return () => {
Expand Down
24 changes: 19 additions & 5 deletions packages/core/src/app/checkout/renderCheckout.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { type FunctionComponent } from 'react';
import React, { act, type FunctionComponent } from 'react';

import { configurePublicPath } from '../common/bundler';

Expand Down Expand Up @@ -53,22 +53,34 @@ 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);

expect(container.innerHTML).toEqual(options.publicPath);
});

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

Expand All @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/app/checkout/renderCheckout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

import { configurePublicPath } from '../common/bundler';

Expand All @@ -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(
<CheckoutApp containerId={containerId} publicPath={configuredPublicPath} {...props} />,
document.getElementById(containerId),
);
}
24 changes: 19 additions & 5 deletions packages/core/src/app/order/renderOrderConfirmation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { type FunctionComponent } from 'react';
import React, { act, type FunctionComponent } from 'react';

import { configurePublicPath } from '../common/bundler';

Expand Down Expand Up @@ -53,22 +53,34 @@ 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);

expect(container.innerHTML).toEqual(options.publicPath);
});

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

Expand All @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/app/order/renderOrderConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

import { configurePublicPath } from '../common/bundler';

Expand Down Expand Up @@ -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(
<OrderConfirmationApp
containerId={containerId}
publicPath={configuredPublicPath}
{...props}
/>,
document.getElementById(containerId),
);
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,21 +30,27 @@ export default function getCreditCardInputStyles(
parentContainer.appendChild(container);

return new Promise((resolve) => {
const root = createRoot(container);

const callbackRef = (element: HTMLInputElement | null) => {
if (!element) {
return;
}

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(
<FormContext.Provider value={{ isSubmitted: true, setSubmitted: noop }}>
<FormFieldContainer hasError={type === CreditCardInputStylesType.Error}>
<TextInput
Expand All @@ -53,7 +59,6 @@ export default function getCreditCardInputStyles(
/>
</FormFieldContainer>
</FormContext.Provider>,
container,
);
});
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -28,21 +28,27 @@ export default function getCreditCardInputStyles(
parentContainer.appendChild(container);

return new Promise((resolve) => {
const root = createRoot(container);

const callbackRef = (element: HTMLInputElement | null) => {
if (!element) {
return;
}

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(
<FormContext.Provider value={{ isSubmitted: true, setSubmitted: noop }}>
<FormFieldContainer hasError={type === CreditCardInputStylesType.Error}>
<TextInput
Expand All @@ -51,7 +57,6 @@ export default function getCreditCardInputStyles(
/>
</FormFieldContainer>
</FormContext.Provider>,
container,
);
});
}
Loading