Why doesn't react's test-util act() flush the job queue?

Viewed 90

React design principles documentation states that "setState() is asynchronous". Although it does not mention useEffect, this documentation states:

What does useEffect do? By using this Hook, you tell React that your component needs to do something after render.

Which to me indicates it is also asynchronous.

The act() documentation states:

When writing UI tests, tasks like rendering, user events, or data fetching can be considered as “units” of interaction with a user interface. react-dom/test-utils provides a helper called act() that makes sure all updates related to these “units” have been processed and applied to the DOM before you make any assertions

Sunil Pai clarifies this a bit in his "Secrets of the act() API" article:

It guarantees 2 things for any code run inside its scope:

  • any state updates will be executed
  • any enqueued effects will be executed

Since state updates are asynchronous and act() gaurantees all states update will be executed, how can it do this without us having to call await in our tests?

And further, why doesn't act ensure other settled promises callbacks are executed as well?

Consider this example component:

function App() {
  const [foo, setFoo] = useState("");
  const [bar, setBar] = useState("");

  useEffect(() => {
    // Callback added to job queue as a microtask
    Promise.resolve().then(() => {
      setBar("bar");
    });
    // Making an asynchronous(?) state update
    setFoo(() => {
      return "foo";
    });
  }, []);

  return <p>{foo}{bar}</p>;
}

And my test fails with Received: "foo":

test('foobar', async () => {
  act(() => {
    render(<App />, container);
  })
  expect(container.textContent).toBe("foobar");
});

From above referenced documentation I've later understood that if you want to flush queued microtasks, you have the use the async version of act. But it doesn't explain how the synchronous act can flush reacts internal asynchronous logic without flushing my apps asynchronous logic. Act just feels like magic and a source to so much confusion for me.

1 Answers

I believe the answer to this question is a rabbit hole which I'm not capable of fully understanding, let alone explaining to to any particular detail. The answer lies in Reacts scheduling implementation called React Fiber (introduced in react 16):

https://github.com/acdlite/react-fiber-architecture

Its headline feature is incremental rendering: the ability to split rendering work into chunks and spread it out over multiple frames.

[...]

Fiber is reimplementation of the stack, specialized for React components. You can think of a single fiber as a virtual stack frame.

The advantage of reimplementing the stack is that you can keep stack frames in memory and execute them however (and whenever) you want. This is crucial for accomplishing the goals we have for scheduling.

React Fiber works with the Channel Messaging API to perform small units of work asynchronously (explanation, source code, Github PR).

I assume it is because of this novel way of scheduling work to be performed that they can flush their asynchronous jobs in the scheduler (with synchronous act()) without having to await. Since we are not awaiting any async code, we are not flushing the runtime's event loop job queue. I think that is why we need to use the async version of act to both flush the job queue and the react scheduler.

I'll leave this question as unanswered in case someone can correct me or give a more in-depth answer.

Related