From 8a926aea53dad60cb82976cb59655a0d6e9c24c3 Mon Sep 17 00:00:00 2001 From: Peng Zhou Date: Mon, 16 Mar 2026 13:27:00 +0800 Subject: [PATCH] fix: add Vitest fake timer detection to asyncWrapper The asyncWrapper's setTimeout(0) drain mechanism requires advancing fake timers when they are active. Previously, only Jest fake timers were detected (via `jest` global), causing Vitest users with fake timers to experience hangs in `waitFor` and `user.click` (see #1197, #1187). This adds a `vitestFakeTimersAreEnabled()` function that checks for the `vi` global (Vitest's equivalent of `jest`) combined with the `setTimeout.clock` property set by @sinonjs/fake-timers. When Vitest fake timers are detected, `vi.advanceTimersByTime(0)` is called to unblock the drain promise. The detection is extracted into a unified `advanceFakeTimers()` helper that handles both Jest and Vitest paths, keeping the asyncWrapper itself clean. Closes #1197 Relates to #1187 --- src/__tests__/fake-timers.js | 115 +++++++++++++++++++++++++++++++++++ src/pure.js | 30 ++++++++- 2 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/fake-timers.js diff --git a/src/__tests__/fake-timers.js b/src/__tests__/fake-timers.js new file mode 100644 index 00000000..15935915 --- /dev/null +++ b/src/__tests__/fake-timers.js @@ -0,0 +1,115 @@ +import * as React from 'react' +import {render, waitFor, screen} from '../' + +// Regression tests: verify asyncWrapper works correctly with Jest fake timers. +// These test the advanceFakeTimers() unified path that handles both Jest and Vitest. +describe.each([ + ['fake legacy timers', () => jest.useFakeTimers('legacy')], + ['fake modern timers', () => jest.useFakeTimers('modern')], +])('asyncWrapper advances fake timers with Jest %s', (label, useTimers) => { + beforeEach(() => { + useTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const fetchData = () => + new Promise(resolve => { + setTimeout(() => resolve('Hello World'), 100) + }) + + function AsyncComponent() { + const [data, setData] = React.useState(null) + React.useEffect(() => { + let cancelled = false + fetchData().then(result => { + if (!cancelled) { + setData(result) + } + }) + return () => { + cancelled = true + } + }, []) + if (!data) return
Loading
+ return
{data}
+ } + + test('waitFor resolves when data loads asynchronously', async () => { + render() + expect(screen.getByText('Loading')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByTestId('result')).toHaveTextContent('Hello World') + }) + }) +}) + +// Verify asyncWrapper works with real timers (no fake timer detection needed) +describe('asyncWrapper with real timers', () => { + beforeEach(() => { + jest.useRealTimers() + }) + + function MicrotaskComponent() { + const [show, setShow] = React.useState(false) + React.useEffect(() => { + Promise.resolve().then(() => setShow(true)) + }, []) + if (show) { + return
Done
+ } + return
Loading
+ } + + test('waitFor resolves with microtask-based updates', async () => { + render() + await waitFor(() => { + expect(screen.getByTestId('result')).toHaveTextContent('Done') + }) + }) +}) + +// Unit tests for the fake timer detection helpers. +// These test the detection logic directly since we can't fully simulate +// a Vitest environment from within Jest (jest global is always injected +// into module scope by the test runner). +describe('fake timer detection logic', () => { + test('jestFakeTimersAreEnabled returns true with modern fake timers', () => { + jest.useFakeTimers('modern') + // setTimeout.clock is set by @sinonjs/fake-timers (used by Jest modern timers) + expect( + // eslint-disable-next-line prefer-object-has-own + Object.prototype.hasOwnProperty.call(setTimeout, 'clock'), + ).toBe(true) + jest.useRealTimers() + }) + + test('jestFakeTimersAreEnabled returns true with legacy fake timers', () => { + jest.useFakeTimers('legacy') + // Legacy timers use @sinonjs/fake-timers which attaches a clock property + expect( + // eslint-disable-next-line prefer-object-has-own + Object.prototype.hasOwnProperty.call(setTimeout, 'clock'), + ).toBe(true) + jest.useRealTimers() + }) + + test('setTimeout.clock is absent with real timers', () => { + jest.useRealTimers() + expect( + // eslint-disable-next-line prefer-object-has-own + Object.prototype.hasOwnProperty.call(setTimeout, 'clock'), + ).toBe(false) + }) + + test('vi global is not defined by default in Jest', () => { + // This verifies that in Jest, the Vitest detection path is not triggered + // because `vi` is not defined. In a real Vitest environment, `vi` would + // be available and vitestFakeTimersAreEnabled() would check for + // setTimeout.clock to determine if fake timers are active. + expect(typeof vi).toBe('undefined') + }) +}) diff --git a/src/pure.js b/src/pure.js index 0f9c487d..a88b83a5 100644 --- a/src/pure.js +++ b/src/pure.js @@ -27,6 +27,32 @@ function jestFakeTimersAreEnabled() { return false } +/* istanbul ignore next */ +function vitestFakeTimersAreEnabled() { + // Vitest uses @sinonjs/fake-timers which attaches a `clock` property to setTimeout. + // Unlike Jest, Vitest does not set `setTimeout._isMockFunction`. + return ( + // eslint-disable-next-line no-undef -- `vi` is a global in Vitest, like `jest` in Jest. + typeof vi !== 'undefined' && + // eslint-disable-next-line no-undef + vi !== null && + // eslint-disable-next-line no-undef + typeof vi.advanceTimersByTime === 'function' && + // eslint-disable-next-line prefer-object-has-own -- No Object.hasOwn in all target environments we support. + Object.prototype.hasOwnProperty.call(setTimeout, 'clock') + ) +} + +function advanceFakeTimers() { + if (jestFakeTimersAreEnabled()) { + jest.advanceTimersByTime(0) + // istanbul ignore next -- Vitest path cannot be covered in Jest tests + } else if (vitestFakeTimersAreEnabled()) { + // eslint-disable-next-line no-undef -- `vi` is a global in Vitest. + vi.advanceTimersByTime(0) + } +} + configureDTL({ unstable_advanceTimersWrapper: cb => { return act(cb) @@ -47,9 +73,7 @@ configureDTL({ resolve() }, 0) - if (jestFakeTimersAreEnabled()) { - jest.advanceTimersByTime(0) - } + advanceFakeTimers() }) return result