DEV Community

Cover image for How act() works inside react?
Dmitriy Kovalenko
Dmitriy Kovalenko

Posted on

How act() works inside react?

Hola! Lazy dev here.

React testing is hard. Especially react testing outside the browser environment, like with Jest and JSdom. Let's try to reverse engineer react's act(), understand why do we need it, and think about UI testing overall.

History

Today I meet this tweet by @floydophone

And was inspired to write about how your tests work inside your terminal when you are testing in node.js. Let's start from the question – why do we need this "magic" act() function.

What is act()

Here is a quote from react.js docs:

To prepare a component for assertions, wrap the code rendering it and performing updates inside an act() call. This makes your test run closer to how React works in the browser.

So the problem that act() is solving – It delays your tests until all of your updates were applied before proceeding to the next steps. When you are doing any kind of user interaction, like this

act(() => {
  button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
Enter fullscreen mode Exit fullscreen mode

React is not updating UI immediately, thanks to the Fiber architecture. It will update it asynchronously in some time after the click, so we need to wait for UI to be updated.

And here is a problem

The main problem here – act() is actually a crutch and you will probably agree that it is not a perfect solution. Tests that you (probably) are writing are synchronous. It means that commands and assertions that tests are doing are executed one-by-one without any waiting.

UI works differently – UI is async by nature.

Reverse engineer it

Let's look more closely at the implementation of this function, right from the react sources. We only need 2 files ReactTestUtilsPublicAct and ReactFiberWorkLoop.

I will skip not interesting parts, but the code is not so big so you can read it yourself 🙃 Let's start from the main point of the act function:

  let result;
  try {
    result = batchedUpdates(callback);
  } catch (error) {
    // on sync errors, we still want to 'cleanup' and decrement actingUpdatesScopeDepth
    onDone();
    throw error;
  }
Enter fullscreen mode Exit fullscreen mode

And this magic batchedUpdates function has a pretty simple yet powerful implementation.

export function batchedUpdates<A, R>(fn: A => R, a: A): R {
  const prevExecutionContext = executionContext;
  executionContext |= BatchedContext;
  try {
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;
    if (executionContext === NoContext) {
      // Flush the immediate callbacks that were scheduled during this batch
      resetRenderTimer();
      flushSyncCallbackQueue();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This particular function is called inside the react when during the render phase react exactly knows that all updates are done and we can render the dom. And it starts the reconciliation and synchronous dom updating after.

After batchedUpdates our code has 2 branches depends on how you used it. If you passed a synchronous function inside the act, like

 act(() => {
   ReactDOM.render(<Counter />, container);
 });
Enter fullscreen mode Exit fullscreen mode

It will call the function flushWork which is nothing more than a sync while loop

const flushWork =
  Scheduler.unstable_flushAllWithoutAsserting ||
  function() {
    let didFlushWork = false;
    while (flushPassiveEffects()) {
      didFlushWork = true;
    }

    return didFlushWork;
  };
Enter fullscreen mode Exit fullscreen mode

It looks like for concurrent mode a new specific hook implemented to stop all the effects together (unstable_flushAllWithoutAsserting)

But for now, it is just a sync while loop that stops the synchronous execution until all the DOM updating work is done. Pretty clumsy solution, don't you think?

Async execution

More interesting is coming when you are passing an async function as a callback. Lets go to another code branch:

if (
  result !== null &&
  typeof result === 'object' &&
  typeof result.then === 'function'
)

// ... not interesting

result.then(
  () => {
    if (
      actingUpdatesScopeDepth > 1 ||
      (isSchedulerMocked === true &&
        previousIsSomeRendererActing === true)
    ) {
      onDone();
      resolve();
      return;
    }
    // we're about to exit the act() scope,
    // now's the time to flush tasks/effects
    flushWorkAndMicroTasks((err: ?Error) => {
      onDone();
      if (err) {
        reject(err);
      } else {
        resolve();
      }
    });
  },
  err => {
    onDone();
    reject(err);
  },
);
Enter fullscreen mode Exit fullscreen mode

Here we are waiting for our passed callback (the result is returned by batchedUpdates function) and if after we are going to more interesting function flushWorkAndMicroTasks. Probably the most interesting function here :)


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

It is doing the same as the sync version (that only calling flushWork()). But it wraps the call enqueueTask, which is a hack only to avoid setTimeout(fn, 0).

an enqueueTask function
export default function enqueueTask(task: () => void) {
  if (enqueueTaskImpl === null) {
    try {
      // read require off the module object to get around the bundlers.
      // we don't want them to detect a require and bundle a Node polyfill.
      const requireString = ('require' + Math.random()).slice(0, 7);
      const nodeRequire = module && module[requireString];
      // assuming we're in node, let's try to get node's
      // version of setImmediate, bypassing fake timers if any.
      enqueueTaskImpl = nodeRequire.call(module, 'timers').setImmediate;
    } catch (_err) {
      // we're in a browser
      // we can't use regular timers because they may still be faked
      // so we try MessageChannel+postMessage instead
      enqueueTaskImpl = function(callback: () => void) {
        if (__DEV__) {
          if (didWarnAboutMessageChannel === false) {
            didWarnAboutMessageChannel = true;
            if (typeof MessageChannel === 'undefined') {
              console.error(
                'This browser does not have a MessageChannel implementation, ' +
                  'so enqueuing tasks via await act(async () => ...) will fail. ' +
                  'Please file an issue at https://github.com/facebook/react/issues ' +
                  'if you encounter this warning.',
              );
            }
          }
        }
        const channel = new MessageChannel();
        channel.port1.onmessage = callback;
        channel.port2.postMessage(undefined);
      };
    }
  }
  return enqueueTaskImpl(task);
}
Enter fullscreen mode Exit fullscreen mode

The main goal of this function is only to execute a callback in the next tick of the event loop. That's probably why react is not the best in terms of bundle size :)

Why async?

It is a pretty new feature, probably needed more for concurrent mode, but it allows you to immediately run stuff like Promise.resolve aka microtasks for example when mocking API and changing real promise using Promise.resolve with fake data.

import * as ReactDOM from "react-dom";
import { act } from "react-dom/test-utils";

const AsyncApp = () => {
  const [data, setData] = React.useState("idle value");

  const simulatedFetch = async () => {
    const fetchedValue = await Promise.resolve("fetched value");
    setData(fetchedValue);
  };

  React.useEffect(() => {
    simulatedFetch();
  }, []);

  return <h1>{data}</h1>;
};


let el = null;
beforeEach(() => {
  // setup a DOM element as a render target
  el = document.createElement("div");
  // container *must* be attached to document so events work correctly.
  document.body.appendChild(el);
});

it("should render with the correct text with sync act", async () => {
  act(() => {
    ReactDOM.render(<AsyncApp />, el);
  });
  expect(el.innerHTML).toContain("idle value");
});

it("should render with the correct text with async act", async () => {
  await act(async () => {
    ReactDOM.render(<AsyncApp />, el);
  });

  expect(el.innerHTML).toContain("fetched value");
});
Enter fullscreen mode Exit fullscreen mode

Both tests passing 😌. Here is a live example (you can open sandbox and run tests inside using "Tests" tab):

It is fun that it works, but if you will change Promise.resolve to literally anything like this:

const fetchedValue = await new Promise((res, rej) => {
  setTimeout(() => res("fetched value"), 0);
});

// it doesn't work ¯\_(ツ)_/¯
Enter fullscreen mode Exit fullscreen mode

Replace

It is pretty easy to replace any act() call by using simple setTimeout(fn, 0):

button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
await new Promise((res, rej) => {
 setTimeout(res, 0);
});
Enter fullscreen mode Exit fullscreen mode

will work in most cases :) some sources

But why

image

The main question – why do we need it? So much ~not good~ code that confuses everybody? The answer – our tests that are running inside node.js and trying to be "sync" while the UI as async.

And that's why you will never need any kind of act() if you are rendering React components in the real browser and using async test-runner, like Cypress for component testing

Thank you

Thank you for reading, hope it is more clear why do we need act() for most plain react unit testing.

And no act() was not harmed in the making of this article :D

Top comments (2)

Collapse
 
jackyang profile image
tianjidiaobao

I think the act is weird too, your blog helped me a lot, thx~

Collapse
 
jackyang profile image
tianjidiaobao

In Replace,the source code may like this: codesandbox.io/s/determined-pine-x...

Image description