diff --git a/src/__tests__/wait-for.js b/src/__tests__/wait-for.js index c0e9b5ec..de6ee5be 100644 --- a/src/__tests__/wait-for.js +++ b/src/__tests__/wait-for.js @@ -255,3 +255,46 @@ test('the real timers => fake timers error shows the original stack trace when c expect((await waitForError).stack).not.toMatch(__dirname) }) + +test('allow further async tasks to complete after the MutationObserver callback fired', async () => { + jest.useRealTimers() + renderIntoDocument(`
a
`) + let waitForCount = 0 + const el = document.getElementById('async') + const update = () => { + setTimeout(() => { + el.textContent += 'a' + }, 1) + } + + update() + update() + update() + + await waitFor(() => { + waitForCount++ + expect(el).toHaveTextContent('aaaa') + }) + + // initial sync check + mutation check + expect(waitForCount).toBe(2) +}) + +test('avoid running the next check when the current check is still pending', async () => { + jest.useRealTimers() + let currentlyRunningCount = 0 + + setTimeout(() => { + renderIntoDocument(`
`) + }, 1) + + await waitFor(async () => { + currentlyRunningCount++ + await new Promise(resolve => { + setTimeout(resolve, 55) + }) + currentlyRunningCount-- + }) + + expect(currentlyRunningCount).toBe(0) +}) diff --git a/src/wait-for.js b/src/wait-for.js index 774d8a85..002e8da3 100644 --- a/src/wait-for.js +++ b/src/wait-for.js @@ -15,6 +15,15 @@ function copyStackTrace(target, source) { target.stack = source.stack.replace(source.message, target.message) } +// `requestIdleCallback` is not available in Node/JSDOM environments, +// so we fallback to setTimeout instead with a little bit of timeout +// to allow non-test async tasks to run. +const requestIdleCallback = + // eslint-disable-next-line no-undef + globalThis.requestIdleCallback ?? (fn => setTimeout(fn, 5)) +// eslint-disable-next-line no-undef +const cancelIdleCallback = globalThis.cancelIdleCallback ?? clearTimeout + function waitFor( callback, { @@ -43,7 +52,7 @@ function waitFor( } return new Promise(async (resolve, reject) => { - let lastError, intervalId, observer + let lastError, timerId, observer, idleCallbackId let finished = false let promiseStatus = 'idle' @@ -101,9 +110,15 @@ function waitFor( reject(e) return } - intervalId = setInterval(checkRealTimersCallback, interval) const {MutationObserver} = getWindowFromNode(container) - observer = new MutationObserver(checkRealTimersCallback) + observer = new MutationObserver(() => { + // It's not unlikely for multiple async tasks to generate multiple DOM mutations in quick successions, + // so we run the check when idle to give async tasks enough room to run to completion. + // If each async tasks has to wait for an expensive `waitFor` check to complete, it can lead to timeouts. + if (idleCallbackId === undefined) { + idleCallbackId = requestIdleCallback(checkRealTimersCallback) + } + }) observer.observe(container, mutationObserverOptions) checkCallback() } @@ -113,7 +128,8 @@ function waitFor( clearTimeout(overallTimeoutTimer) if (!usingJestFakeTimers) { - clearInterval(intervalId) + clearTimeout(timerId) + cancelIdleCallback(idleCallbackId) observer.disconnect() } @@ -125,6 +141,10 @@ function waitFor( } function checkRealTimersCallback() { + clearTimeout(timerId) + cancelIdleCallback(idleCallbackId) + idleCallbackId = undefined + if (jestFakeTimersAreEnabled()) { const error = new Error( `Changed from using real timers to fake timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to fake timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`, @@ -149,7 +169,7 @@ function waitFor( }, rejectedValue => { promiseStatus = 'rejected' - lastError = rejectedValue + handleError(rejectedValue) }, ) } else { @@ -158,7 +178,14 @@ function waitFor( // If `callback` throws, wait for the next mutation, interval, or timeout. } catch (error) { // Save the most recent callback error to reject the promise with it in the event of a timeout - lastError = error + handleError(error) + } + } + + function handleError(error) { + lastError = error + if (!usingJestFakeTimers) { + timerId = setTimeout(checkRealTimersCallback, interval) } }