diff --git a/packages/internal-test-utils/ReactInternalTestUtils.js b/packages/internal-test-utils/ReactInternalTestUtils.js index 63b4ebe702092..ff068dbc6429c 100644 --- a/packages/internal-test-utils/ReactInternalTestUtils.js +++ b/packages/internal-test-utils/ReactInternalTestUtils.js @@ -5,4 +5,129 @@ * LICENSE file in the root directory of this source tree. */ -// TODO: Move `internalAct` and other test helpers to this package +// TODO: Move `internalAct` and other test helpers to this package, too + +import * as SchedulerMock from 'scheduler/unstable_mock'; +import {diff} from 'jest-diff'; +import {equals} from '@jest/expect-utils'; + +function assertYieldsWereCleared(Scheduler) { + const actualYields = Scheduler.unstable_clearYields(); + if (actualYields.length !== 0) { + const error = Error( + 'Log of yielded values is not empty. ' + + 'Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first.', + ); + Error.captureStackTrace(error, assertYieldsWereCleared); + throw error; + } +} + +export async function waitFor(expectedLog) { + assertYieldsWereCleared(SchedulerMock); + + // Create the error object before doing any async work, to get a better + // stack trace. + const error = new Error(); + Error.captureStackTrace(error, waitFor); + + const actualLog = []; + do { + // Wait until end of current task/microtask. + await null; + if (SchedulerMock.unstable_hasPendingWork()) { + SchedulerMock.unstable_flushNumberOfYields( + expectedLog.length - actualLog.length, + ); + actualLog.push(...SchedulerMock.unstable_clearYields()); + if (expectedLog.length > actualLog.length) { + // Continue flushing until we've logged the expected number of items. + } else { + // Once we've reached the expected sequence, wait one more microtask to + // flush any remaining synchronous work. + await null; + actualLog.push(...SchedulerMock.unstable_clearYields()); + break; + } + } else { + // There's no pending work, even after a microtask. + break; + } + } while (true); + + if (equals(actualLog, expectedLog)) { + return; + } + + error.message = ` +Expected sequence of events did not occur. + +${diff(expectedLog, actualLog)} +`; + throw error; +} + +export async function waitForAll(expectedLog) { + assertYieldsWereCleared(SchedulerMock); + + // Create the error object before doing any async work, to get a better + // stack trace. + const error = new Error(); + Error.captureStackTrace(error, waitFor); + + do { + // Wait until end of current task/microtask. + await null; + if (!SchedulerMock.unstable_hasPendingWork()) { + // There's no pending work, even after a microtask. Stop flushing. + break; + } + SchedulerMock.unstable_flushAllWithoutAsserting(); + } while (true); + + const actualLog = SchedulerMock.unstable_clearYields(); + if (equals(actualLog, expectedLog)) { + return; + } + + error.message = ` +Expected sequence of events did not occur. + +${diff(expectedLog, actualLog)} +`; + throw error; +} + +// TODO: This name is a bit misleading currently because it will stop as soon as +// React yields for any reason, not just for a paint. I've left it this way for +// now because that's how untable_flushUntilNextPaint already worked, but maybe +// we should split these use cases into separate APIs. +export async function waitForPaint(expectedLog) { + assertYieldsWereCleared(SchedulerMock); + + // Create the error object before doing any async work, to get a better + // stack trace. + const error = new Error(); + Error.captureStackTrace(error, waitFor); + + // Wait until end of current task/microtask. + await null; + if (SchedulerMock.unstable_hasPendingWork()) { + // Flush until React yields. + SchedulerMock.unstable_flushUntilNextPaint(); + // Wait one more microtask to flush any remaining synchronous work. + await null; + } + + const actualLog = SchedulerMock.unstable_clearYields(); + if (equals(actualLog, expectedLog)) { + return; + } + + error.message = ` +Expected sequence of events did not occur. + +${diff(expectedLog, actualLog)} +`; + throw error; +} diff --git a/packages/jest-react/src/JestReact.js b/packages/jest-react/src/JestReact.js index f67ddd8636f1a..a46dc8d2ac1bc 100644 --- a/packages/jest-react/src/JestReact.js +++ b/packages/jest-react/src/JestReact.js @@ -31,10 +31,12 @@ function assertYieldsWereCleared(root) { const Scheduler = root._Scheduler; const actualYields = Scheduler.unstable_clearYields(); if (actualYields.length !== 0) { - throw new Error( + const error = Error( 'Log of yielded values is not empty. ' + 'Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first.', ); + Error.captureStackTrace(error, assertYieldsWereCleared); + throw error; } } diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index c73cd296b8695..0be6c6b2dc594 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -3,6 +3,9 @@ let Fragment; let ReactNoop; let Scheduler; let act; +let waitFor; +let waitForAll; +let waitForPaint; let Suspense; let getCacheForType; @@ -19,6 +22,10 @@ describe('ReactSuspenseWithNoopRenderer', () => { Scheduler = require('scheduler'); act = require('jest-react').act; Suspense = React.Suspense; + const InternalTestUtils = require('internal-test-utils'); + waitFor = InternalTestUtils.waitFor; + waitForAll = InternalTestUtils.waitForAll; + waitForPaint = InternalTestUtils.waitForPaint; getCacheForType = React.unstable_getCacheForType; @@ -208,7 +215,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { React.startTransition(() => { ReactNoop.render(); }); - expect(Scheduler).toFlushAndYieldThrough([ + await waitFor([ 'Foo', 'Bar', // A suspends @@ -226,7 +233,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { // Even though the promise has resolved, we should now flush // and commit the in progress render instead of restarting. - expect(Scheduler).toFlushAndYield(['D']); + await waitForPaint(['D']); expect(ReactNoop).toMatchRenderedOutput( <> @@ -235,11 +242,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { , ); - // Await one micro task to attach the retry listeners. - await null; - // Next, we'll flush the complete content. - expect(Scheduler).toFlushAndYield(['Bar', 'A', 'B']); + await waitForAll(['Bar', 'A', 'B']); expect(ReactNoop).toMatchRenderedOutput( <> diff --git a/scripts/jest/matchers/schedulerTestMatchers.js b/scripts/jest/matchers/schedulerTestMatchers.js index f18ccfc548093..645d8a58cc59f 100644 --- a/scripts/jest/matchers/schedulerTestMatchers.js +++ b/scripts/jest/matchers/schedulerTestMatchers.js @@ -18,11 +18,14 @@ function captureAssertion(fn) { function assertYieldsWereCleared(Scheduler) { const actualYields = Scheduler.unstable_clearYields(); + if (actualYields.length !== 0) { - throw new Error( + const error = Error( 'Log of yielded values is not empty. ' + 'Call expect(Scheduler).toHaveYielded(...) first.' ); + Error.captureStackTrace(error, assertYieldsWereCleared); + throw error; } }