`act`: Resolve to return value of scope function (#21759)

When migrating some internal tests I found it annoying that I couldn't
return anything from the `act` scope. You would have to declare the
variable on the outside then assign to it. But this doesn't play well
with type systems — when you use the variable, you have to check
the type.

Before:

```js
let renderer;
act(() => {
  renderer = ReactTestRenderer.create(<App />);
})

// Type system can't tell that renderer is never undefined
renderer?.root.findByType(Component);
```

After:

```js
const renderer = await act(() => {
  return ReactTestRenderer.create(<App />);
})
renderer.root.findByType(Component);
```
This commit is contained in:
Andrew Clark 2021-06-26 14:51:23 -04:00 committed by GitHub
parent e2453e2007
commit cae635054e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 56 additions and 15 deletions

View File

@ -47,4 +47,35 @@ describe('isomorphic act()', () => {
}); });
expect(root).toMatchRenderedOutput('B'); expect(root).toMatchRenderedOutput('B');
}); });
// @gate __DEV__
test('return value sync callback', async () => {
expect(await act(() => 'hi')).toEqual('hi');
});
// @gate __DEV__
test('return value sync callback, nested', async () => {
const returnValue = await act(() => {
return act(() => 'hi');
});
expect(returnValue).toEqual('hi');
});
// @gate __DEV__
test('return value async callback', async () => {
const returnValue = await act(async () => {
return await Promise.resolve('hi');
});
expect(returnValue).toEqual('hi');
});
// @gate __DEV__
test('return value async callback, nested', async () => {
const returnValue = await act(async () => {
return await act(async () => {
return await Promise.resolve('hi');
});
});
expect(returnValue).toEqual('hi');
});
}); });

View File

@ -51,7 +51,7 @@ import {ConcurrentRoot, LegacyRoot} from 'react-reconciler/src/ReactRootTags';
import {allowConcurrentByDefault} from 'shared/ReactFeatureFlags'; import {allowConcurrentByDefault} from 'shared/ReactFeatureFlags';
const act_notBatchedInLegacyMode = React.unstable_act; const act_notBatchedInLegacyMode = React.unstable_act;
function act(callback: () => Thenable<mixed>): Thenable<void> { function act<T>(callback: () => T): Thenable<T> {
return act_notBatchedInLegacyMode(() => { return act_notBatchedInLegacyMode(() => {
return batchedUpdates(callback); return batchedUpdates(callback);
}); });

View File

@ -15,7 +15,7 @@ import enqueueTask from 'shared/enqueueTask';
let actScopeDepth = 0; let actScopeDepth = 0;
let didWarnNoAwaitAct = false; let didWarnNoAwaitAct = false;
export function act(callback: () => Thenable<mixed>): Thenable<void> { export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
if (__DEV__) { if (__DEV__) {
// `act` calls can be nested, so we track the depth. This represents the // `act` calls can be nested, so we track the depth. This represents the
// number of `act` scopes on the stack. // number of `act` scopes on the stack.
@ -41,21 +41,22 @@ export function act(callback: () => Thenable<mixed>): Thenable<void> {
typeof result === 'object' && typeof result === 'object' &&
typeof result.then === 'function' typeof result.then === 'function'
) { ) {
const thenableResult: Thenable<T> = (result: any);
// The callback is an async function (i.e. returned a promise). Wait // The callback is an async function (i.e. returned a promise). Wait
// for it to resolve before exiting the current scope. // for it to resolve before exiting the current scope.
let wasAwaited = false; let wasAwaited = false;
const thenable = { const thenable: Thenable<T> = {
then(resolve, reject) { then(resolve, reject) {
wasAwaited = true; wasAwaited = true;
result.then( thenableResult.then(
() => { returnValue => {
popActScope(prevActScopeDepth); popActScope(prevActScopeDepth);
if (actScopeDepth === 0) { if (actScopeDepth === 0) {
// We've exited the outermost act scope. Recursively flush the // We've exited the outermost act scope. Recursively flush the
// queue until there's no remaining work. // queue until there's no remaining work.
recursivelyFlushAsyncActWork(resolve, reject); recursivelyFlushAsyncActWork(returnValue, resolve, reject);
} else { } else {
resolve(); resolve(returnValue);
} }
}, },
error => { error => {
@ -88,6 +89,7 @@ export function act(callback: () => Thenable<mixed>): Thenable<void> {
} }
return thenable; return thenable;
} else { } else {
const returnValue: T = (result: any);
// The callback is not an async function. Exit the current scope // The callback is not an async function. Exit the current scope
// immediately, without awaiting. // immediately, without awaiting.
popActScope(prevActScopeDepth); popActScope(prevActScopeDepth);
@ -100,7 +102,7 @@ export function act(callback: () => Thenable<mixed>): Thenable<void> {
} }
// Return a thenable. If the user awaits it, we'll flush again in // Return a thenable. If the user awaits it, we'll flush again in
// case additional work was scheduled by a microtask. // case additional work was scheduled by a microtask.
return { const thenable: Thenable<T> = {
then(resolve, reject) { then(resolve, reject) {
// Confirm we haven't re-entered another `act` scope, in case // Confirm we haven't re-entered another `act` scope, in case
// the user does something weird like await the thenable // the user does something weird like await the thenable
@ -108,18 +110,22 @@ export function act(callback: () => Thenable<mixed>): Thenable<void> {
if (ReactCurrentActQueue.current === null) { if (ReactCurrentActQueue.current === null) {
// Recursively flush the queue until there's no remaining work. // Recursively flush the queue until there's no remaining work.
ReactCurrentActQueue.current = []; ReactCurrentActQueue.current = [];
recursivelyFlushAsyncActWork(resolve, reject); recursivelyFlushAsyncActWork(returnValue, resolve, reject);
} else {
resolve(returnValue);
} }
}, },
}; };
return thenable;
} else { } else {
// Since we're inside a nested `act` scope, the returned thenable // Since we're inside a nested `act` scope, the returned thenable
// immediately resolves. The outer scope will flush the queue. // immediately resolves. The outer scope will flush the queue.
return { const thenable: Thenable<T> = {
then(resolve, reject) { then(resolve, reject) {
resolve(); resolve(returnValue);
}, },
}; };
return thenable;
} }
} }
} else { } else {
@ -142,7 +148,11 @@ function popActScope(prevActScopeDepth) {
} }
} }
function recursivelyFlushAsyncActWork(resolve, reject) { function recursivelyFlushAsyncActWork<T>(
returnValue: T,
resolve: T => mixed,
reject: mixed => mixed,
) {
if (__DEV__) { if (__DEV__) {
const queue = ReactCurrentActQueue.current; const queue = ReactCurrentActQueue.current;
if (queue !== null) { if (queue !== null) {
@ -152,17 +162,17 @@ function recursivelyFlushAsyncActWork(resolve, reject) {
if (queue.length === 0) { if (queue.length === 0) {
// No additional work was scheduled. Finish. // No additional work was scheduled. Finish.
ReactCurrentActQueue.current = null; ReactCurrentActQueue.current = null;
resolve(); resolve(returnValue);
} else { } else {
// Keep flushing work until there's none left. // Keep flushing work until there's none left.
recursivelyFlushAsyncActWork(resolve, reject); recursivelyFlushAsyncActWork(returnValue, resolve, reject);
} }
}); });
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
} else { } else {
resolve(); resolve(returnValue);
} }
} }
} }