From b24e7d98982ca159a4cb46415a5815e6161f52df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 20 Jul 2026 11:44:58 +0200 Subject: [PATCH] fix: setImmediate fake timer issue with jsdom --- src/helpers/__tests__/timers.test.ts | 35 ++++++++++++++++++++++++++++ src/helpers/timers.ts | 18 +++++++++----- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/helpers/__tests__/timers.test.ts b/src/helpers/__tests__/timers.test.ts index 4967c2e37..4a81eb9b3 100644 --- a/src/helpers/__tests__/timers.test.ts +++ b/src/helpers/__tests__/timers.test.ts @@ -5,4 +5,39 @@ describe('timers', () => { jest.useFakeTimers(); expect(jestFakeTimersAreEnabled()).toEqual(false); }); + + it('setImmediate polyfill keeps using real timers when fake timers are enabled', async () => { + // Regression test for https://github.com/callstack/react-native-testing-library/issues/1767. + // The module captures its timer functions once at import time. When the environment + // has no native `setImmediate` (e.g. jsdom/browser) it falls back to a polyfill built + // on `setTimeout`; that `setTimeout` must be the real one, so the polyfill keeps + // working after fake timers are re-applied (as happens during auto-cleanup). + delete process.env.RNTL_SKIP_AUTO_DETECT_FAKE_TIMERS; + jest.useRealTimers(); + + // Simulate a missing native `setImmediate` before the module captures its functions. + const originalSetImmediate = globalThis.setImmediate; + // @ts-expect-error force the polyfill fallback + delete globalThis.setImmediate; + + let setImmediate!: (fn: () => void) => unknown; + try { + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + setImmediate = require('../timers').setImmediate; + }); + } finally { + globalThis.setImmediate = originalSetImmediate; + } + + jest.useFakeTimers({ legacyFakeTimers: false }); + try { + // Before the fix this never resolved (the polyfill read the fake `setTimeout`), + // so the test would hang until the Jest timeout. + const resolved = await new Promise((resolve) => setImmediate(() => resolve(true))); + expect(resolved).toBe(true); + } finally { + jest.useRealTimers(); + } + }, 10_000); }); diff --git a/src/helpers/timers.ts b/src/helpers/timers.ts index 1284f9305..691a9920c 100644 --- a/src/helpers/timers.ts +++ b/src/helpers/timers.ts @@ -63,11 +63,6 @@ function getFakeTimersConfigFromType(type: FakeTimersTypes) { const jestFakeTimersAreEnabled = (): boolean => Boolean(getJestFakeTimersType()); -// we only run our tests in node, and setImmediate is supported in node. -function setImmediatePolyfill(fn: () => void) { - return globalObj.setTimeout(fn, 0); -} - type BindTimeFunctions = { clearTimeoutFn: typeof clearTimeout; setImmediateFn: typeof setImmediate; @@ -75,10 +70,21 @@ type BindTimeFunctions = { }; function bindTimeFunctions(): BindTimeFunctions { + // Capture the current (real) `setTimeout` so the `setImmediate` polyfill keeps + // using real timers even after fake timers are re-applied. Reading + // `globalObj.setTimeout` lazily inside the polyfill would pick up fake timers. + const realSetTimeout = globalObj.setTimeout; + + // `setImmediate` exists in Node, but not in environments such as jsdom, so we + // fall back to a `setTimeout`-based polyfill there (see #1767). + function setImmediatePolyfill(fn: () => void) { + return realSetTimeout(fn, 0); + } + return { clearTimeoutFn: globalObj.clearTimeout, setImmediateFn: globalObj.setImmediate || setImmediatePolyfill, - setTimeoutFn: globalObj.setTimeout, + setTimeoutFn: realSetTimeout, }; }