Article summary
My team recently solved an error, “Cannot read properties of null (reading ‘createEvent’)” by upgrading to React v18.0. We were seeing this error occur almost daily in our CI pipeline as part of our React component tests:
/my-project/node_modules/react-dom/cjs/react-dom.development.js:3905
var evt = document.createEvent('Event');
^
TypeError: Cannot read properties of null (reading 'createEvent')
at Object.invokeGuardedCallbackDev (/my-project/node_modules/react-dom/cjs/react-dom.development.js:3905:26)
at invokeGuardedCallback (/my-project/node_modules/react-dom/cjs/react-dom.development.js:4056:31)
at flushPassiveEffectsImpl (/my-project/node_modules/react-dom/cjs/react-dom.development.js:23543:11)
at unstable_runWithPriority (/my-project/node_modules/scheduler/cjs/scheduler.development.js:468:12)
at runWithPriority$1 (/my-project/node_modules/react-dom/cjs/react-dom.development.js:11276:10)
at flushPassiveEffects (/my-project/node_modules/react-dom/cjs/react-dom.development.js:23447:14)
at Object..flushWork (/my-project/node_modules/react-dom/cjs/react-dom-test-utils.development.js:992:10)
at Immediate. (/my-project/node_modules/react-dom/cjs/react-dom-test-utils.development.js:1003:11)
at processImmediate (node:internal/timers:466:21)
We could never reproduce the error locally or pin it on one specific test case. Rerunning the CI job usually resolved the issue, but eventually, it got too time-consuming, so I decided to track down the root cause.
Potential causes
Unhelpfully, it turns out that this is an error that can be caused by a number of different issues in your tests:
- Forgetting to mock API calls
- Timing out while waiting for a component to render
- Using findBy without awaiting the result
The first step to getting rid of this error is to make sure all your API calls are mocked and that you are correctly using async
and await
where needed. You might get lucky and find that it solves random failures.
Upgrade to React 18
I made sure the issues above were fixed, but the tests were still randomly failing with this error. It seemed like there must be some deeper issue.
After a lot of Googling, I finally came across this comment on a Jest pull request that seemed to describe the real issue we were facing:
I think this is not an issue with Jest, but a bug in React 17’s act implementation, when the act callback returns a promise. Then, after the promise resolves, React will synchronously flush all updates scheduled before resolving. But that code is buggy: it leaves the act environment before doing that flush, so these updates no longer work with the act queue, but are using the regular scheduler. setImmediate etc. And then they run long after the JSDOM env has been teared down.
React 18 has that fixed: it will flush the updates while still inside the act environment.
In your repro example, upgrading to React 18 in package.json immediately fixes the test for me.
We were in fact using React 17, and I decided to try upgrading to React 18. That did the trick, and we no longer had failing tests.
However, the upgrade did introduce some new errors, even though the tests were technically passing:
Warning: An update to [something] inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
It seems like React 18 tightened up the rules around using act()
, so we also had to make changes in many of our component tests.
Fix bad component testing patterns
Our component tests had several different bad patterns that we had to hunt down and fix:
- Making
userEvent
calls outside ofact
. The main culprits were calls touserEvent.clear
oruserEvent.type
. It’s just as important for those calls to be done inside ofact
asuserEvent.click
since they also usually change state. - Not using
await
foruserEvent
calls. All of theuserEvent
functions are asynchronous so they all need to be awaited. - Clicking components through
screen
. All interactions need to be done through auserEvent
, not by calling things likescreen.getByTestId('some id').click()
It took some experimentation, but we finally settled on following this pattern for all component unit tests that require user interactions:
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ApiClient } from 'src/client/api-client';
// mock if needed
jest.mock('src/client/api-client');
it('my test case', async () => {
// mock any API client behaviors if needed
jest.mocked(ApiClient).doSomething.mockResolvedValue({
success: true,
data: {
key: 'value',
},
});
// render the component
render(<MyComponent />);
// interact with the component - always do this within an act() call
await act(async () => {
// always use userEvent, not screen.getWhatever().click. and make sure to await the call
await userEvent.click(screen.getByTestId('my-button'));
});
// do any assertions on expected API calls or UI appearance outside of the act() call
await waitFor(() => expect(jest.mocked(ApiClient).doSomething).toHaveBeenCalled());
await waitFor(() => expect(screen.getByTestId('something').toBeEnabled()));
expect(screen.getByTestId('another-thing')).toHaveTextContent('some text');
});
The most important thing here is to perform any user interactions inside of act
, and to always do it via a userEvent
.
Once you fix any incorrect user interactions in your component tests, you shouldn’t see any errors in the test output or intermittent pipeline failures.