avatar

Coderek's blog

Loneliness is the gift of life

React Internals - act

(The discussions in the React Internals Series will be based on React v16.9.0-rc.0 and for web only)

When testing React component, we usually use jest to render the component into the JSDOM. JSDOM is a simple implementation of DOM. It does provide almost all the DOM APIs, but the internal implementations can be quite different than a real browser.

Here is a simple jest Test.

it('should render', () => {
  function App(props) {
    return props.name;
  }
  const div = document.createElement('div');
  document.body.appendChild(div);
  ReactDOM.render(<App name="derek" />, div);
  expect(document.body.innerHTML).toBe('<div>derek</div>');
});

It is assumed that document is given from the jest runtime environment which is from JSDOM. In the test environment, because everything is declaratively written, there is no need to account for any user interruption. The scheduler used in test is mocked and simplified.

export function requestHostCallback(callback: boolean => void) {
  scheduledCallback = callback;
}

So the rendering tasks are run synchronously. The above example works fast and without any issue.

But when we involve useEffect hooks, the story is different. React treat useEffect as passiveEffects, it's only flushed after the main update is committed and browser is painted. Thus by default it's scheduled asynchronously.

Hence we can't assert those unrun passiveEffects in the same test function.

One way to do it is to flush the tasks in the scheduler like this

it('should render', async () => {
  const {unstable_flushAll} = require('scheduler/unstable_mock');

  function App(props) {
    const [greeting, setGreeting] = React.useState('hello');
    React.useEffect(() => {
      setGreeting('yoyo');
    }, []);
    return greeting + ' ' + props.name;
  }
  const div = document.createElement('div');
  document.body.appendChild(div);
  ReactDOM.render(<App name="derek" />, div);

  unstable_flushAll();

  expect(document.body.innerHTML).toBe('<div>yoyo derek</div>');
});

So here comes the subject of this post: act which stand for arrange-act-assert. It's just a user-friendly API exposed by React to do just just the above.

So now you could do

it('should render', async () => {
  const {act} = require('react-dom/test-utils');

  function App(props) {
    const [greeting, setGreeting] = React.useState('hello');
    React.useEffect(() => {
      setGreeting('yoyo');
    }, []);
    return greeting + ' ' + props.name;
  }
  act(() => {
    const div = document.createElement('div');
    document.body.appendChild(div);
    ReactDOM.render(<App name="derek" />, div);
  });

  expect(document.body.innerHTML).toBe('<div>yoyo derek</div>');
});

A little simpler.

The act implementation is here.

So basically it replaces flushWork with flushAllWorkWithoutAsserting

const flushWork = Scheduler.unstable_flushAllWithoutAsserting

act does batch update on the callback passed int and then in the end it calls flushWork to flush all the tasks.

However, it does not account for the case where new tasks are spawned from passiveEffects.

Later in the version 16.9 it added an asynchronous version of act to allow flushing those new tasks.

You can see from this function

function flushWorkAndMicroTasks(onDone: (err: ?Error) => void) {
  try {
    flushWork();
    enqueueTask(() => {
      if (flushWork()) {
        flushWorkAndMicroTasks(onDone);
      } else {
        onDone();
      }
    });
  } catch (err) {
    onDone(err);
  }
}

It recursively schedules another callback at the end of the frame to check whether all tasks are flushed. Thus it can make sure the scheduler queue are indeed empty.

Since it has to use a callback for the scheduled check, it has to provide an async API for that.

it('should render', async () => {
  const {act} = require('react-dom/test-utils');

  function App(props) {
    const [greeting, setGreeting] = React.useState('hello');
    React.useEffect(() => {
      Promise.resolve().then(() => setGreeting('yoyo'));
    }, []);
    return greeting + ' ' + props.name;
  }
  await act(async () => {
    const div = document.createElement('div');
    document.body.appendChild(div);
    ReactDOM.render(<App name="derek" />, div);
  });

  expect(document.body.innerHTML).toBe('<div>yoyo derek</div>');
});

act must be passed in a function argument that returns a promise in order for this to work.

Another thing is, if you use setTimeout to schedule a macrotask in useEffect, you have to use jest.useFakeTimers then use jest.advanceTimersByTime before assertions for the setTimeout.

act doesn't account for that.

(End of article)