diff --git a/packages/react-reconciler/src/ReactFiberThenable.js b/packages/react-reconciler/src/ReactFiberThenable.js index 956f81e89270a..e34d9ad5e2275 100644 --- a/packages/react-reconciler/src/ReactFiberThenable.js +++ b/packages/react-reconciler/src/ReactFiberThenable.js @@ -212,19 +212,19 @@ export function trackUsedThenable( } }, ); + } - // Check one more time in case the thenable resolved synchronously. - switch (thenable.status) { - case 'fulfilled': { - const fulfilledThenable: FulfilledThenable = (thenable: any); - return fulfilledThenable.value; - } - case 'rejected': { - const rejectedThenable: RejectedThenable = (thenable: any); - const rejectedError = rejectedThenable.reason; - checkIfUseWrappedInAsyncCatch(rejectedError); - throw rejectedError; - } + // Check one more time in case the thenable resolved synchronously. + switch (thenable.status) { + case 'fulfilled': { + const fulfilledThenable: FulfilledThenable = (thenable: any); + return fulfilledThenable.value; + } + case 'rejected': { + const rejectedThenable: RejectedThenable = (thenable: any); + const rejectedError = rejectedThenable.reason; + checkIfUseWrappedInAsyncCatch(rejectedError); + throw rejectedError; } } diff --git a/packages/react/src/ReactChildren.js b/packages/react/src/ReactChildren.js index 93e5d5225ff13..adddda2ff3210 100644 --- a/packages/react/src/ReactChildren.js +++ b/packages/react/src/ReactChildren.js @@ -7,7 +7,13 @@ * @flow */ -import type {ReactNodeList} from 'shared/ReactTypes'; +import type { + ReactNodeList, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; import isArray from 'shared/isArray'; import { @@ -75,6 +81,68 @@ function getElementKey(element: any, index: number): string { return index.toString(36); } +function noop() {} + +function resolveThenable(thenable: Thenable): T { + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + if (typeof thenable.status === 'string') { + // Only instrument the thenable if the status if not defined. If + // it's defined, but an unknown value, assume it's been instrumented by + // some custom userspace implementation. We treat it as "pending". + // Attach a dummy listener, to ensure that any lazy initialization can + // happen. Flight lazily parses JSON when the value is actually awaited. + thenable.then(noop, noop); + } else { + // This is an uncached thenable that we haven't seen before. + + // TODO: Detect infinite ping loops caused by uncached promises. + + const pendingThenable: PendingThenable = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + } + + // Check one more time in case the thenable resolved synchronously. + switch (thenable.status) { + case 'fulfilled': { + const fulfilledThenable: FulfilledThenable = (thenable: any); + return fulfilledThenable.value; + } + case 'rejected': { + const rejectedThenable: RejectedThenable = (thenable: any); + const rejectedError = rejectedThenable.reason; + throw rejectedError; + } + } + } + } + throw thenable; +} + function mapIntoArray( children: ?ReactNodeList, array: Array, @@ -106,9 +174,14 @@ function mapIntoArray( invokeCallback = true; break; case REACT_LAZY_TYPE: - throw new Error( - 'Cannot render an Async Component, Promise or React.Lazy inside React.Children. ' + - 'We recommend not iterating over children and just rendering them plain.', + const payload = (children: any)._payload; + const init = (children: any)._init; + return mapIntoArray( + init(payload), + array, + escapedPrefix, + nameSoFar, + callback, ); } } @@ -211,16 +284,19 @@ function mapIntoArray( ); } } else if (type === 'object') { - // eslint-disable-next-line react-internal/safe-string-coercion - const childrenString = String((children: any)); - if (typeof (children: any).then === 'function') { - throw new Error( - 'Cannot render an Async Component, Promise or React.Lazy inside React.Children. ' + - 'We recommend not iterating over children and just rendering them plain.', + return mapIntoArray( + resolveThenable((children: any)), + array, + escapedPrefix, + nameSoFar, + callback, ); } + // eslint-disable-next-line react-internal/safe-string-coercion + const childrenString = String((children: any)); + throw new Error( `Objects are not valid as a React child (found: ${ childrenString === '[object Object]' diff --git a/packages/react/src/__tests__/ReactChildren-test.js b/packages/react/src/__tests__/ReactChildren-test.js index 4c64117e215e4..4d6a89be8bcce 100644 --- a/packages/react/src/__tests__/ReactChildren-test.js +++ b/packages/react/src/__tests__/ReactChildren-test.js @@ -948,26 +948,36 @@ describe('ReactChildren', () => { ); }); - it('should throw on React.lazy', async () => { + it('should render React.lazy after suspending', async () => { const lazyElement = React.lazy(async () => ({default:
})); - await expect(() => { - React.Children.forEach([lazyElement], () => {}, null); - }).toThrowError( - 'Cannot render an Async Component, Promise or React.Lazy inside React.Children. ' + - 'We recommend not iterating over children and just rendering them plain.', - {withoutStack: true}, // There's nothing on the stack - ); + function Component() { + return React.Children.map([lazyElement], c => + React.cloneElement(c, {children: 'hi'}), + ); + } + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.innerHTML).toBe('
hi
'); }); - it('should throw on Promises', async () => { + it('should render cached Promises after suspending', async () => { const promise = Promise.resolve(
); - await expect(() => { - React.Children.forEach([promise], () => {}, null); - }).toThrowError( - 'Cannot render an Async Component, Promise or React.Lazy inside React.Children. ' + - 'We recommend not iterating over children and just rendering them plain.', - {withoutStack: true}, // There's nothing on the stack - ); + function Component() { + return React.Children.map([promise], c => + React.cloneElement(c, {children: 'hi'}), + ); + } + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.innerHTML).toBe('
hi
'); }); it('should throw on regex', () => {