Testing Concurrency Features in React 18: A Comprehensive Guide
Written on
Chapter 1: Introduction to React 18
React 18 has generated significant interest, especially with its new concurrency features. Have you had the chance to explore this capability yet? When concurrency is activated, React transitions from "synchronous updates" to "asynchronous, prioritized, interruptible updates." This shift complicates the writing of unit tests. In this article, we will delve into how the React team has approached testing these concurrency features.
The Dilemma
There are two main challenges to address:
Expressing the Rendering Result
React interacts with renderers across different host environments. The most commonly used renderer is ReactDOM, which serves the "browser" and "Node environment" (SSR). In certain instances, the output from ReactDOM can be utilized for testing. For instance, the following code tests if a stateless component renders as expected using ReactDOM's output (with Jest as the testing framework):
it('should render stateless component', () => {
const el = document.createElement('div');
ReactDOM.render(<MyComponent />, el);
expect(el.textContent).toBe('A');
});
However, this method has a drawback; it relies on the DOM API and the browser environment (e.g., document.createElement), making it challenging to create test cases for scenarios like "React's internal runtime mechanism."
Testing Concurrent Environments
Changing ReactDOM.render to ReactDOM.createRoot in the previous example will cause the test to fail:
// before
ReactDOM.render(<MyComponent />, el);
expect(el.textContent).toBe('A');
// after
ReactDOM.createRoot(el).render(<MyComponent />);
expect(el.textContent).toBe('A');
This failure occurs because many previously "synchronous updates" have now turned into "concurrent updates," meaning the page is still rendering when the render method is called. To make the above test pass, you can modify it as follows:
ReactDOM.createRoot(el).render(<MyComponent />);
setTimeout(() => {
// synchronous
expect(el.textContent).toBe('A');
});
How Does React Respond to This Change?
Next, we will explore how the React team has tackled these challenges, starting with the first question: How should the rendering result be expressed? Unlike React Native, which operates in a Native environment, ReactDOM targets the browser and Node environments.
Can we create a renderer specifically designed to examine the "internal runtime flow"? The answer is yes. This renderer is known as React-Noop-Renderer. Essentially, this renderer will only deal with JavaScript objects.
Implementing a Renderer
Internally, React has a package called Reconciler that references several APIs meant to "manipulate the host environment." For example, this method is utilized to "insert nodes into the container":
function appendChildToContainer(child, container) {
// Implement}
In the browser context (ReactDOM), this is done using the appendChild method:
function appendChildToContainer(child, container) {
container.appendChild(child);}
The Reconciler package is bundled with the aforementioned "API for browser environment" via the rollup tool, which constitutes the ReactDOM package. In React-Noop-Renderer, the DOM nodes in ReactDOM align with the following data structures:
const instance = {
id: instanceCounter++,
type: type,
children: [],
parent: -1,
props
};
Note the children field, which stores the child nodes. Thus, the appendChildToContainer method can be simplified in React-Noop-Renderer as follows:
function appendChildToContainer(child, container) {
const index = container.children.indexOf(child);
if (index !== -1) {
container.children.splice(index, 1);}
container.children.push(child);
};
The packaging tool incorporates the Reconciler package along with the "API for React-Noop," creating the React-Noop-Renderer package. This allows for the testing of Reconciler's internal logic completely independent of the normal host environment.
How to Test Concurrent Environments
Regardless of the complexity of the "concurrency features," they ultimately boil down to "various strategies for executing code asynchronously." The APIs that carry out these operations include setTimeout, setInterval, Promise, etc. In Jest, you can simulate these asynchronous APIs and manage their execution timing. For instance, the asynchronous code above would look like this in a React test:
await act(() => {
ReactDOM.createRoot(el).render(<MyComponent />);});
expect(el.textContent).toBe('A');
The act method from the jest-react package will execute jest.runOnlyPendingTimers, triggering any waiting timers. For example:
setTimeout(() => {
console.log('go');}, 9999999);
jest.runOnlyPendingTimers(); // Outputs "go" immediately
This method allows for the control of React’s concurrent update pace without interfering with the framework’s code. Additionally, the Scheduler module, responsible for driving concurrent updates, has its own testing version. In this version, developers can manually manage the Scheduler’s input and output. For instance, if you want to test the execution order of the useEffect callback during component unmounting, you might write:
function Parent() {
useEffect(() => {
return () => Scheduler.unstable_yieldValue('Unmount parent');});
return <Child />;
}
function Child() {
useEffect(() => {
return () => Scheduler.unstable_yieldValue('Unmount child');});
return 'Child';
}
await act(async () => {
root.render(<Parent />);});
expect(Scheduler).toHaveYielded(['Unmount parent', 'Unmount child']);
Summary
The strategy for creating test cases in React involves:
- Use cases measurable with ReactDOM, typically combined with ReactDOM and ReactTestUtils (a browser environment helper).
- For use cases requiring control of the intermediate process, utilize the Scheduler's test package and Scheduler.unstable_yieldValue to track process information.
- To test React's internal processes separately from the host environment, employ React-Noop-Renderer.
- For concurrent scenario testing, use the aforementioned tools in conjunction with jest-react.
For those interested in further exploring testing techniques in React, I recommend checking out RubyLouvre's work, which operates through over 800 React use cases and includes a simplified version of ReactTestUtils and React-Noop-Renderer.
Stay connected with me on Twitter, LinkedIn, and GitHub! Writing is my passion, and I enjoy helping and inspiring others. If you have any questions, don’t hesitate to reach out!
More content at PlainEnglish.io. Sign up for our free weekly newsletter. Follow us on Twitter, LinkedIn, YouTube, and Discord!
Chapter 2: Further Learning Resources
Discover the key changes in React 18, including concurrency, transitions, and suspense.
Learn about concurrent rendering in React 18 in this insightful video by Ifeoma Imoh.