diff --git a/.eslintrc.js b/.eslintrc.js index 13746fb3c672d..aff4fa6ce48bc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -325,6 +325,7 @@ module.exports = { 'packages/react-native-renderer/**/*.js', 'packages/eslint-plugin-react-hooks/**/*.js', 'packages/jest-react/**/*.js', + 'packages/internal-test-utils/**/*.js', 'packages/**/__tests__/*.js', 'packages/**/npm/*.js', ], diff --git a/packages/internal-test-utils/ReactInternalTestUtils.js b/packages/internal-test-utils/ReactInternalTestUtils.js new file mode 100644 index 0000000000000..db47ff910ca86 --- /dev/null +++ b/packages/internal-test-utils/ReactInternalTestUtils.js @@ -0,0 +1,182 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// 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( + 'The event log is not empty. Call assertLog(...) 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; +} + +export async function waitForThrow(expectedError: mixed) { + 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. + error.message = 'Expected something to throw, but nothing did.'; + throw error; + } + try { + SchedulerMock.unstable_flushAllWithoutAsserting(); + } catch (x) { + if (equals(x, expectedError)) { + return; + } + if (typeof x === 'object' && x !== null && x.message === expectedError) { + return; + } + error.message = ` +Expected error was not thrown. + +${diff(expectedError, x)} +`; + throw error; + } + } while (true); +} + +// 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; +} + +export function assertLog(expectedLog) { + const actualLog = SchedulerMock.unstable_clearYields(); + if (equals(actualLog, expectedLog)) { + return; + } + + const error = new Error(` +Expected sequence of events did not occur. + +${diff(expectedLog, actualLog)} +`); + Error.captureStackTrace(error, assertLog); + throw error; +} diff --git a/packages/internal-test-utils/index.js b/packages/internal-test-utils/index.js new file mode 100644 index 0000000000000..7b6e30be3728f --- /dev/null +++ b/packages/internal-test-utils/index.js @@ -0,0 +1 @@ +export * from './ReactInternalTestUtils'; diff --git a/packages/internal-test-utils/package.json b/packages/internal-test-utils/package.json new file mode 100644 index 0000000000000..4748827d8003a --- /dev/null +++ b/packages/internal-test-utils/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "internal-test-utils", + "version": "0.0.0" +} 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..31326a338e840 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -3,6 +3,10 @@ let Fragment; let ReactNoop; let Scheduler; let act; +let waitFor; +let waitForAll; +let assertLog; +let waitForPaint; let Suspense; let getCacheForType; @@ -19,6 +23,11 @@ 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; + assertLog = InternalTestUtils.assertLog; getCacheForType = React.unstable_getCacheForType; @@ -208,7 +217,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { React.startTransition(() => { ReactNoop.render(); }); - expect(Scheduler).toFlushAndYieldThrough([ + await waitFor([ 'Foo', 'Bar', // A suspends @@ -226,7 +235,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 +244,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( <> @@ -544,7 +550,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { ReactNoop.flushSync(() => { ReactNoop.render(); }); - expect(Scheduler).toHaveYielded(['B', '1']); + assertLog(['B', '1']); expect(ReactNoop).toMatchRenderedOutput( <> diff --git a/scripts/jest/config.build.js b/scripts/jest/config.build.js index 5b04ab05df7cd..9b8d328a509e7 100644 --- a/scripts/jest/config.build.js +++ b/scripts/jest/config.build.js @@ -12,6 +12,12 @@ const NODE_MODULES_DIR = // Find all folders in packages/* with package.json const packagesRoot = join(__dirname, '..', '..', 'packages'); const packages = readdirSync(packagesRoot).filter(dir => { + if (dir === 'internal-test-utils') { + // This is an internal package used only for testing. It's OK to read + // from source. + // TODO: Maybe let's have some convention for this? + return false; + } if (dir.charAt(0) === '.') { return false; } 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; } }