How the React team tested concurrency features

React18 has been a while since I entered your field of vision. Have you tried the concurrency feature ?

When concurrency is enabled, React will change from synchronous updates to asynchronous, prioritized, interruptible updates .

This also brings some difficulty to writing unit tests.

This article will talk about React how the team tests concurrency features.

Welcome to join the human high-quality front-end framework group , with flying

Difficulties encountered

There are two main problems to face.

1. How to express the rendering result?

React Renderers that can be connected to different host environments. The most familiar renderer must be ReactDOM , which is used to connect the browser and Node environment (SSR).

For some scenarios, you can test with the output of ReactDOM .

For example, the following is to use the output of ReactDOM to test whether the rendering result of the stateless component is as expected (the test framework is jest ):

 it('should render stateless component', () => {
        const el = document.createElement('div');
        ReactDOM.render(<FunctionComponent name="A" />, el);
        expect(el.textContent).toBe('A');
    });

There is an inconvenience here – this use case depends on 浏览器环境 and DOM API (for example, using document.createElement ).

For scenarios such as testing the internal operating mechanism of React , incorporating information about the host environment will obviously make the test case more cumbersome to write.

2. How to test a concurrent environment?

If the above use case is changed from ReactDOM.render to ReactDOM.createRoot , then the use case will fail:

 // 之前
ReactDOM.render(<FunctionComponent name="A" />, el);
expect(el.textContent).toBe('A');

// 之后
ReactDOM.createRoot(el).render(<FunctionComponent name="A" />);
expect(el.textContent).toBe('A');

This is because under the new architecture, many synchronous updates have become concurrent updates . When render executed, the page has not been rendered.

The simplest modification to make the above use case successful is:

 ReactDOM.createRoot(el).render(<FunctionComponent name="A" />);

setTimeout(() => {
  // 异步获取结果
  expect(el.textContent).toBe('A');
})

How to gracefully cope with this change?

React’s coping strategy

Next, let’s look React the team responded.

Let’s first look at the first question – how to express the rendering result?

Since ReactDOM renderer corresponds to the browser, Node environment, ReactNative renderer corresponds to the Native environment.

Can a renderer be developed specifically for the internal running process of the test?

The answer is yes.

This renderer is called React-Noop-Renderer .

Simply put, this renderer will render pure JS objects.

implement a renderer

React There is a package called Reconciler , and he will refer to some operating host environment API .

For example, the following method is used to insert a node into the container :

 function appendChildToContainer(child, container) {
    // 具体实现
}

For the browser environment ( ReactDOM ), use the appendChild method to implement:

 function appendChildToContainer(child, container) {
    // 使用appendChild方法
  container.appendChild(child);
}

The packaging tool ( rollup ) will package the Reconciler package with the above API for the browser environment , which is the ReactDOM package.

In React-Noop-Renderer ReactDOM the DOM node in —6c9d7ec6eac4e764d68aea41b6b2ad0d— is aligned with the following data structure:

 const instance = {
  id: instanceCounter++,
  type: type,
  children: [],
  parent: -1,
  props
};

Note the children field, which is used to save child nodes.

So the appendChildToContainer method can be implemented very simply in React-Noop-Renderer :

 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 will package the Reconciler package with the above API for React-Noop , which is the React-Noop-Renderer package.

Based on React-Noop-Renderer , it can be completely separated from the normal host environment and test the internal logic of Reconciler .

Next, let’s look at the second question.

How to test concurrent environments?

No matter how complicated the concurrency feature is, it is only a variety of asynchronous code execution strategies . The final execution strategy API is nothing more than setTimeout , setInterval , Promise etc.

In jest , these asynchronous API can be simulated to control their execution timing.

For example, the above asynchronous code, the test case in React will be written like this:

 // 测试用例修改后:
await act(() => {
  ReactDOM.createRoot(el).render(<FunctionComponent name="A" />);
})
expect(el.textContent).toBe('A');

act method comes from the jest-react package, which will execute the jest.runOnlyPendingTimers method internally to make all waiting timers trigger callbacks.

For example the following code:

 setTimeout(() => {
  console.log('执行')
}, 9999999)

After executing jest.runOnlyPendingTimers , it will print and execute immediately.

In this way, human control React the speed of concurrent updates, and at the same time intrusion into the framework code 0.

In addition, the Scheduler (scheduler) module for driving concurrent updates has a version for testing itself.

In this version, developers can manually control the input and output of Scheduler .

For example, I want to test the order in which the callbacks are executed when the component is uninstalled useEffect .

As shown in the following code, where Parent is the mounted component under test :

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

According to whether the insertion order of yieldValue is as expected, it can be determined whether the logic of useEffect is as expected:

 expect(Scheduler).toHaveYielded(['Unmount parent', 'Unmount child']);

Summarize

The strategy for writing test cases in React is:

  • You can use the test case of ReactDOM a2adb68405c9f2a638a24201b84e9358—, which is generally combined with ReactDOM and ReactTestUtils (a helper method in the browser environment) to complete
  • For use cases that need to control the intermediate process, use the test package of —7c5b90198e0460164c010306ce24eaa4 Scheduler , and use Scheduler.unstable_yieldValue to record the process information
  • Separate from the host environment and test separately React for the internal running process, use React-Noop-Renderer
  • To test scenarios under concurrency, you need to combine the above tools with jest-react

If you want to study the test-related skills in React , you can read the work of Mr. Situ Zhengmei anu .

This is a class React framework, but it can run through the 800+ React use case. It implements a simplified version of ReactTestUtils and React-Noop-Renderer .