-
Notifications
You must be signed in to change notification settings - Fork 49k
Description
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