Repository: facebook/react
Pull request: Allow async blocks in `to(Error|Warn)Dev`

## Summary

Allows async blocks in `toWarnDev` and `toErrorDev` blocks e.g. 

```js
await expect(async () => {
   await act(async () => {})
}).toWarnDev('Some warning')
```

Note that this means that `expect(() => act()).toWarnDev` is now async and should be either voided 

```diff
-expect(() => act()).toWarnDev()
+expect(() => { act() }).toWarnDev()
```

or awaited.
```diff
-expect(() => act()).toWarnDev()
+await expect(() => act()).toWarnDev()
```

This will enable using `toErrorDev` in ReactDOMServerIntegration tests and allows explicitly asserting a warning we previously ignored in tests for `useSyncExternalStore` that should probably not exist in the first place?

## How did you test this change?

- [x] CI

Changed files:
filename: packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
patch: @@ -1777,7 +1777,7 @@ describe('ReactHooksWithNoopRenderer', () => {
         }, [props.count]);
         return <Text text={'Count: ' + count} />;
       }
-      expect(() =>
+      expect(() => {
         act(() => {
           ReactNoop.render(<Counter count={0} />, () =>
             Scheduler.unstable_yieldValue('Sync effect'),
@@ -1787,8 +1787,8 @@ describe('ReactHooksWithNoopRenderer', () => {
             'Sync effect',
           ]);
           expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
-        }),
-      ).toErrorDev('flushSync was called from inside a lifecycle method');
+        });
+      }).toErrorDev('flushSync was called from inside a lifecycle method');
       expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
     });
 
@@ -2648,32 +2648,32 @@ describe('ReactHooksWithNoopRenderer', () => {
       }
 
       const root1 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root1.render(<App return={17} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useEffect must not return anything besides a ' +
           'function, which is used for clean-up. You returned: 17',
       ]);
 
       const root2 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root2.render(<App return={null} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useEffect must not return anything besides a ' +
           'function, which is used for clean-up. You returned null. If your ' +
           'effect does not require clean up, return undefined (or nothing).',
       ]);
 
       const root3 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root3.render(<App return={Promise.resolve()} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useEffect must not return anything besides a ' +
           'function, which is used for clean-up.\n\n' +
           'It looks like you wrote useEffect(async () => ...) or returned a Promise.',
@@ -3052,32 +3052,32 @@ describe('ReactHooksWithNoopRenderer', () => {
       }
 
       const root1 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root1.render(<App return={17} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useInsertionEffect must not return anything besides a ' +
           'function, which is used for clean-up. You returned: 17',
       ]);
 
       const root2 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root2.render(<App return={null} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useInsertionEffect must not return anything besides a ' +
           'function, which is used for clean-up. You returned null. If your ' +
           'effect does not require clean up, return undefined (or nothing).',
       ]);
 
       const root3 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root3.render(<App return={Promise.resolve()} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useInsertionEffect must not return anything besides a ' +
           'function, which is used for clean-up.\n\n' +
           'It looks like you wrote useInsertionEffect(async () => ...) or returned a Promise.',
@@ -3104,11 +3104,11 @@ describe('ReactHooksWithNoopRenderer', () => {
       }
 
       const root = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root.render(<App />);
-        }),
-      ).toErrorDev(['Warning: useInsertionEffect must not schedule updates.']);
+        });
+      }).toErrorDev(['Warning: useInsertionEffect must not schedule updates.']);
 
       expect(() => {
         act(() => {
@@ -3359,32 +3359,32 @@ describe('ReactHooksWithNoopRenderer', () => {
       }
 
       const root1 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root1.render(<App return={17} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useLayoutEffect must not return anything besides a ' +
           'function, which is used for clean-up. You returned: 17',
       ]);
 
       const root2 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root2.render(<App return={null} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useLayoutEffect must not return anything besides a ' +
           'function, which is used for clean-up. You returned null. If your ' +
           'effect does not require clean up, return undefined (or nothing).',
       ]);
 
       const root3 = ReactNoop.createRoot();
-      expect(() =>
+      expect(() => {
         act(() => {
           root3.render(<App return={Promise.resolve()} />);
-        }),
-      ).toErrorDev([
+        });
+      }).toErrorDev([
         'Warning: useLayoutEffect must not return anything besides a ' +
           'function, which is used for clean-up.\n\n' +
           'It looks like you wrote useLayoutEffect(async () => ...) or returned a Promise.',

filename: packages/react/src/__tests__/ReactCoffeeScriptClass-test.coffee
patch: @@ -133,6 +133,7 @@ describe 'ReactCoffeeScriptClass', ->
     expect(->
       act ->
         root.render React.createElement(Foo, foo: 'foo')
+      return
     ).toErrorDev 'Foo: getDerivedStateFromProps() is defined as an instance method and will be ignored. Instead, declare it as a static method.'
 
   it 'warns if getDerivedStateFromError is not static', ->
@@ -144,6 +145,7 @@ describe 'ReactCoffeeScriptClass', ->
     expect(->
       act ->
         root.render React.createElement(Foo, foo: 'foo')
+      return
     ).toErrorDev 'Foo: getDerivedStateFromError() is defined as an instance method and will be ignored. Instead, declare it as a static method.'
 
   it 'warns if getSnapshotBeforeUpdate is static', ->
@@ -155,6 +157,7 @@ describe 'ReactCoffeeScriptClass', ->
     expect(->
       act ->
         root.render React.createElement(Foo, foo: 'foo')
+      return
     ).toErrorDev 'Foo: getSnapshotBeforeUpdate() is defined as a static method and will be ignored. Instead, declare it as an instance method.'
 
   it 'warns if state not initialized before static getDerivedStateFromProps', ->
@@ -171,6 +174,7 @@ describe 'ReactCoffeeScriptClass', ->
     expect(->
       act ->
         root.render React.createElement(Foo, foo: 'foo')
+      return
     ).toErrorDev (
       '`Foo` uses `getDerivedStateFromProps` but its initial state is ' +
       'undefined. This is not recommended. Instead, define the initial state by ' +

filename: packages/react/src/__tests__/ReactES6Class-test.js
patch: @@ -149,7 +149,9 @@ describe('ReactES6Class', () => {
         return <div />;
       }
     }
-    expect(() => act(() => root.render(<Foo foo="foo" />))).toErrorDev(
+    expect(() => {
+      act(() => root.render(<Foo foo="foo" />));
+    }).toErrorDev(
       'Foo: getDerivedStateFromProps() is defined as an instance method ' +
         'and will be ignored. Instead, declare it as a static method.',
     );
@@ -164,7 +166,9 @@ describe('ReactES6Class', () => {
         return <div />;
       }
     }
-    expect(() => act(() => root.render(<Foo foo="foo" />))).toErrorDev(
+    expect(() => {
+      act(() => root.render(<Foo foo="foo" />));
+    }).toErrorDev(
       'Foo: getDerivedStateFromError() is defined as an instance method ' +
         'and will be ignored. Instead, declare it as a static method.',
     );
@@ -177,7 +181,9 @@ describe('ReactES6Class', () => {
         return <div />;
       }
     }
-    expect(() => act(() => root.render(<Foo foo="foo" />))).toErrorDev(
+    expect(() => {
+      act(() => root.render(<Foo foo="foo" />));
+    }).toErrorDev(
       'Foo: getSnapshotBeforeUpdate() is defined as a static method ' +
         'and will be ignored. Instead, declare it as an instance method.',
     );
@@ -195,7 +201,9 @@ describe('ReactES6Class', () => {
         return <div className={`${this.state.foo} ${this.state.bar}`} />;
       }
     }
-    expect(() => act(() => root.render(<Foo foo="foo" />))).toErrorDev(
+    expect(() => {
+      act(() => root.render(<Foo foo="foo" />));
+    }).toErrorDev(
       '`Foo` uses `getDerivedStateFromProps` but its initial state is ' +
         'undefined. This is not recommended. Instead, define the initial state by ' +
         'assigning an object to `this.state` in the constructor of `Foo`. ' +

filename: packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
patch: @@ -14,6 +14,7 @@ let useSyncExternalStoreWithSelector;
 let React;
 let ReactDOM;
 let ReactDOMClient;
+let ReactFeatureFlags;
 let Scheduler;
 let act;
 let useState;
@@ -48,6 +49,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
     React = require('react');
     ReactDOM = require('react-dom');
     ReactDOMClient = require('react-dom/client');
+    ReactFeatureFlags = require('shared/ReactFeatureFlags');
     Scheduler = require('scheduler');
     useState = React.useState;
     useEffect = React.useEffect;
@@ -882,8 +884,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
 
   describe('selector and isEqual error handling in extra', () => {
     let ErrorBoundary;
-    beforeAll(() => {
-      spyOnDev(console, 'warn');
+    beforeEach(() => {
       ErrorBoundary = class extends React.Component {
         state = {error: null};
         static getDerivedStateFromError(error) {
@@ -929,9 +930,15 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
 
       expect(container.textContent).toEqual('A');
 
-      await act(() => {
-        store.set({});
-      });
+      await expect(async () => {
+        await act(async () => {
+          store.set({});
+        });
+      }).toWarnDev(
+        ReactFeatureFlags.enableUseRefAccessWarning
+          ? ['Warning: App: Unsafe read of a mutable value during render.']
+          : [],
+      );
       expect(container.textContent).toEqual('Malformed state');
     });
 
@@ -968,9 +975,15 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
 
       expect(container.textContent).toEqual('A');
 
-      await act(() => {
-        store.set({});
-      });
+      await expect(async () => {
+        await act(() => {
+          store.set({});
+        });
+      }).toWarnDev(
+        ReactFeatureFlags.enableUseRefAccessWarning
+          ? ['Warning: App: Unsafe read of a mutable value during render.']
+          : [],
+      );
       expect(container.textContent).toEqual('Malformed state');
     });
   });

filename: scripts/jest/matchers/toWarnDev.js
patch: @@ -152,11 +152,7 @@ const createMatcherFor = (consoleMethod, matcherName) =>
       // Avoid using Jest's built-in spy since it can't be removed.
       console[consoleMethod] = consoleSpy;
 
-      try {
-        callback();
-      } catch (error) {
-        caughtError = error;
-      } finally {
+      const onFinally = () => {
         // Restore the unspied method so that unexpected errors fail tests.
         console[consoleMethod] = originalMethod;
 
@@ -259,12 +255,52 @@ const createMatcherFor = (consoleMethod, matcherName) =>
         }
 
         return {pass: true};
+      };
+
+      let returnPromise = null;
+      try {
+        const result = callback();
+
+        if (
+          typeof result === 'object' &&
+          result !== null &&
+          typeof result.then === 'function'
+        ) {
+          // `act` returns a thenable that can't be chained.
+          // Once `act(async () => {}).then(() => {}).then(() => {})` works
+          // we can just return `result.then(onFinally, error => ...)`
+          returnPromise = new Promise((resolve, reject) => {
+            result.then(
+              () => {
+                resolve(onFinally());
+              },
+              error => {
+                caughtError = error;
+                return resolve(onFinally());
+              }
+            );
+          });
+        }
+      } catch (error) {
+        caughtError = error;
+      } finally {
+        return returnPromise === null ? onFinally() : returnPromise;
       }
     } else {
       // Any uncaught errors or warnings should fail tests in production mode.
-      callback();
+      const result = callback();
 
-      return {pass: true};
+      if (
+        typeof result === 'object' &&
+        result !== null &&
+        typeof result.then === 'function'
+      ) {
+        return result.then(() => {
+          return {pass: true};
+        });
+      } else {
+        return {pass: true};
+      }
     }
   };