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;
}
}