Skip to content

Bug: with Jest and Testing Library, React schedules work with wrong Jest timers functions #25889

@jsnajdr

Description

@jsnajdr

React version: 18.2.0

Steps To Reproduce

Have a Jest setup that uses fake timers by default (the fakeTimers: { enableGlobally: true } config option), and then enable real timers for a specific test suite, like this:

import { screen, render } from "@testing-library/react";
import { useEffect, useState } from "react";

jest.useRealTimers();

it("loads", async () => {
  function App() {
    const [data, setData] = useState(null);
    useEffect(() => {
      const t = setTimeout(() => {
        setData("data");
      }, 100);
      return () => clearTimeout(t);
    }, []);
    return <div>{ data || "loading" }</div>;
  }

  render(<App />);

  await screen.findByText("data");
});

This test renders an initial UI in "loading" state, then schedules a state update to "load data" in 100ms, and waits for the updated UI to appear, by calling await findByText(). This test is supposed to succeed.

But it fails. The state update scheduled in setData is never executed. That's because React schedules it using the fake timers version of setImmediate, but we want the real timers. Nobody is advancing the fake timers in our test, so they never run.

This timing bug is a combination of two factors: first, React statically initializes references to the timers functions, when loading the react-dom module:

const localSetImmediate = typeof setImmediate === 'function' ? setImmediate : /* ... fallbacks */;

At this time, fake timers are still active, the jest.useRealTimers() switch happens only later. The fake version is captured forever.

Second, the Testing Library's waitFor function (used by findByText internally) is implemented in such a way that it disables the React Act environment while it's running (in the getConfig().asyncWrapper function). Then all the updates are not controlled by act(), but happen "naturally", using the native timers. Typically, in tests, updates would be wrapped in act() and timers are never called, but not here -- "waitFor + real timers" is different.

The same issue is in the scheduleMicrotask function that uses the fake version of queueMicrotask. This is used to schedule updates in useSyncExternalStore. This is how I originally discovered this bug: by debugging a custom useSyncExternalStore-using hook.

One solution would be to always look at the current value of globalThis.setImmediate or globalThis.queueMicrotask, like:

const localSetImmediate = typeof setImmediate === 'function' ? (cb) => setImmediate(cb) : /* ... fallbacks */;

I'm linking to a minimalistic GitHub repo that demonstrates the bug.

Link to code example: https://github.com/jsnajdr/react-timers-bug

Metadata

Metadata

Assignees

No one assigned

    Labels

    Resolution: StaleAutomatically closed due to inactivityStatus: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions