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