diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index 6af8c1356f9ca..fcb2406552899 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -13,6 +13,7 @@ import type { Thenable, ReactContext, ReactDebugInfo, + SuspenseListRevealOrder, } from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane'; @@ -2057,3 +2058,103 @@ export function resetChildFibers(workInProgress: Fiber, lanes: Lanes): void { child = child.sibling; } } + +function validateSuspenseListNestedChild(childSlot: mixed, index: number) { + if (__DEV__) { + const isAnArray = isArray(childSlot); + const isIterable = + !isAnArray && typeof getIteratorFn(childSlot) === 'function'; + const isAsyncIterable = + enableAsyncIterableChildren && + typeof childSlot === 'object' && + childSlot !== null && + typeof (childSlot: any)[ASYNC_ITERATOR] === 'function'; + if (isAnArray || isIterable || isAsyncIterable) { + const type = isAnArray + ? 'array' + : isAsyncIterable + ? 'async iterable' + : 'iterable'; + console.error( + 'A nested %s was passed to row #%s in . Wrap it in ' + + 'an additional SuspenseList to configure its revealOrder: ' + + ' ... ' + + '{%s} ... ' + + '', + type, + index, + type, + ); + return false; + } + } + return true; +} + +export function validateSuspenseListChildren( + children: mixed, + revealOrder: SuspenseListRevealOrder, +) { + if (__DEV__) { + if ( + (revealOrder === 'forwards' || revealOrder === 'backwards') && + children !== undefined && + children !== null && + children !== false + ) { + if (isArray(children)) { + for (let i = 0; i < children.length; i++) { + if (!validateSuspenseListNestedChild(children[i], i)) { + return; + } + } + } else { + const iteratorFn = getIteratorFn(children); + if (typeof iteratorFn === 'function') { + const childrenIterator = iteratorFn.call(children); + if (childrenIterator) { + let step = childrenIterator.next(); + let i = 0; + for (; !step.done; step = childrenIterator.next()) { + if (!validateSuspenseListNestedChild(step.value, i)) { + return; + } + i++; + } + } + } else if ( + enableAsyncIterableChildren && + typeof (children: any)[ASYNC_ITERATOR] === 'function' + ) { + // TODO: Technically we should warn for nested arrays inside the + // async iterable but it would require unwrapping the array. + // However, this mistake is not as easy to make so it's ok not to warn. + } else if ( + enableAsyncIterableChildren && + children.$$typeof === REACT_ELEMENT_TYPE && + typeof children.type === 'function' && + // $FlowFixMe + (Object.prototype.toString.call(children.type) === + '[object GeneratorFunction]' || + // $FlowFixMe + Object.prototype.toString.call(children.type) === + '[object AsyncGeneratorFunction]') + ) { + console.error( + 'A generator Component was passed to a . ' + + 'This is not supported as a way to generate lists. Instead, pass an ' + + 'iterable as the children.', + revealOrder, + ); + } else { + console.error( + 'A single row was passed to a . ' + + 'This is not useful since it needs multiple rows. ' + + 'Did you mean to pass multiple children or an array?', + revealOrder, + ); + } + } + } + } +} diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 69bc84038dac9..7b86962f778fe 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -123,7 +123,6 @@ import { enableViewTransition, enableFragmentRefs, } from 'shared/ReactFeatureFlags'; -import isArray from 'shared/isArray'; import shallowEqual from 'shared/shallowEqual'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import getComponentNameFromType from 'shared/getComponentNameFromType'; @@ -132,7 +131,6 @@ import { REACT_LAZY_TYPE, REACT_FORWARD_REF_TYPE, REACT_MEMO_TYPE, - getIteratorFn, } from 'shared/ReactSymbols'; import {setCurrentFiber} from './ReactCurrentFiber'; import { @@ -145,6 +143,7 @@ import { mountChildFibers, reconcileChildFibers, cloneChildFibers, + validateSuspenseListChildren, } from './ReactChildFiber'; import { processUpdateQueue, @@ -3302,73 +3301,6 @@ function validateTailOptions( } } -function validateSuspenseListNestedChild(childSlot: mixed, index: number) { - if (__DEV__) { - const isAnArray = isArray(childSlot); - const isIterable = - !isAnArray && typeof getIteratorFn(childSlot) === 'function'; - if (isAnArray || isIterable) { - const type = isAnArray ? 'array' : 'iterable'; - console.error( - 'A nested %s was passed to row #%s in . Wrap it in ' + - 'an additional SuspenseList to configure its revealOrder: ' + - ' ... ' + - '{%s} ... ' + - '', - type, - index, - type, - ); - return false; - } - } - return true; -} - -function validateSuspenseListChildren( - children: mixed, - revealOrder: SuspenseListRevealOrder, -) { - if (__DEV__) { - if ( - (revealOrder === 'forwards' || revealOrder === 'backwards') && - children !== undefined && - children !== null && - children !== false - ) { - if (isArray(children)) { - for (let i = 0; i < children.length; i++) { - if (!validateSuspenseListNestedChild(children[i], i)) { - return; - } - } - } else { - const iteratorFn = getIteratorFn(children); - if (typeof iteratorFn === 'function') { - const childrenIterator = iteratorFn.call(children); - if (childrenIterator) { - let step = childrenIterator.next(); - let i = 0; - for (; !step.done; step = childrenIterator.next()) { - if (!validateSuspenseListNestedChild(step.value, i)) { - return; - } - i++; - } - } - } else { - console.error( - 'A single row was passed to a . ' + - 'This is not useful since it needs multiple rows. ' + - 'Did you mean to pass multiple children or an array?', - revealOrder, - ); - } - } - } - } -} - function initSuspenseListRenderState( workInProgress: Fiber, isBackwards: boolean, @@ -3415,12 +3347,6 @@ function updateSuspenseListComponent( const tailMode: SuspenseListTailMode = nextProps.tail; const newChildren = nextProps.children; - validateRevealOrder(revealOrder); - validateTailOptions(tailMode, revealOrder); - validateSuspenseListChildren(newChildren, revealOrder); - - reconcileChildren(current, workInProgress, newChildren, renderLanes); - let suspenseContext: SuspenseContext = suspenseStackCursor.current; const shouldForceFallback = hasSuspenseListContext( @@ -3434,6 +3360,17 @@ function updateSuspenseListComponent( ); workInProgress.flags |= DidCapture; } else { + suspenseContext = setDefaultShallowSuspenseListContext(suspenseContext); + } + pushSuspenseListContext(workInProgress, suspenseContext); + + validateRevealOrder(revealOrder); + validateTailOptions(tailMode, revealOrder); + validateSuspenseListChildren(newChildren, revealOrder); + + reconcileChildren(current, workInProgress, newChildren, renderLanes); + + if (!shouldForceFallback) { const didSuspendBefore = current !== null && (current.flags & DidCapture) !== NoFlags; if (didSuspendBefore) { @@ -3446,9 +3383,7 @@ function updateSuspenseListComponent( renderLanes, ); } - suspenseContext = setDefaultShallowSuspenseListContext(suspenseContext); } - pushSuspenseListContext(workInProgress, suspenseContext); if (!disableLegacyMode && (workInProgress.mode & ConcurrentMode) === NoMode) { // In legacy mode, SuspenseList doesn't work so we just diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index 6faeae3acba0d..f9efb330cf891 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -3119,4 +3119,197 @@ describe('ReactSuspenseList', () => { ); }, ); + + // @gate enableSuspenseList && enableAsyncIterableChildren + it('warns for async generator components in "forwards" order', async () => { + async function* Generator() { + yield 'A'; + yield 'B'; + } + function Foo() { + return ( + + + + ); + } + + await act(() => { + React.startTransition(() => { + ReactNoop.render(); + }); + }); + assertConsoleErrorDev([ + 'A generator Component was passed to a . ' + + 'This is not supported as a way to generate lists. Instead, pass an ' + + 'iterable as the children.' + + '\n in SuspenseList (at **)' + + '\n in Foo (at **)', + ' is an async Client Component. ' + + 'Only Server Components can be async at the moment. ' + + "This error is often caused by accidentally adding `'use client'` " + + 'to a module that was originally written for the server.\n' + + ' in Foo (at **)', + // We get this warning because the generator's promise themselves are not cached. + 'A component was suspended by an uncached promise. ' + + 'Creating promises inside a Client Component or hook is not yet supported, ' + + 'except via a Suspense-compatible library or framework.\n' + + ' in Foo (at **)', + ]); + }); + + // @gate enableSuspenseList && enableAsyncIterableChildren + it('can display async iterable in "forwards" order', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + + // We use Cached elements to avoid rerender. + const ASlot = ( + }> + + + ); + + const BSlot = ( + }> + + + ); + + const iterable = { + async *[Symbol.asyncIterator]() { + yield ASlot; + yield BSlot; + }, + }; + + function Foo() { + return {iterable}; + } + + await act(() => { + React.startTransition(() => { + ReactNoop.render(); + }); + }); + + assertLog([ + 'Suspend! [A]', + 'Loading A', + 'Loading B', + // pre-warming + 'Suspend! [A]', + ]); + + assertConsoleErrorDev([ + // We get this warning because the generator's promise themselves are not cached. + 'A component was suspended by an uncached promise. ' + + 'Creating promises inside a Client Component or hook is not yet supported, ' + + 'except via a Suspense-compatible library or framework.\n' + + ' in SuspenseList (at **)\n' + + ' in Foo (at **)', + 'A component was suspended by an uncached promise. ' + + 'Creating promises inside a Client Component or hook is not yet supported, ' + + 'except via a Suspense-compatible library or framework.\n' + + ' in SuspenseList (at **)\n' + + ' in Foo (at **)', + ]); + + expect(ReactNoop).toMatchRenderedOutput( + <> + Loading A + Loading B + , + ); + + await act(() => A.resolve()); + assertLog(['A', 'Suspend! [B]', 'Suspend! [B]']); + + assertConsoleErrorDev([ + // We get this warning because the generator's promise themselves are not cached. + 'A component was suspended by an uncached promise. ' + + 'Creating promises inside a Client Component or hook is not yet supported, ' + + 'except via a Suspense-compatible library or framework.\n' + + ' in SuspenseList (at **)\n' + + ' in Foo (at **)', + 'A component was suspended by an uncached promise. ' + + 'Creating promises inside a Client Component or hook is not yet supported, ' + + 'except via a Suspense-compatible library or framework.\n' + + ' in SuspenseList (at **)\n' + + ' in Foo (at **)', + ]); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + Loading B + , + ); + + await act(() => B.resolve()); + assertLog(['B']); + + assertConsoleErrorDev([ + // We get this warning because the generator's promise themselves are not cached. + 'A component was suspended by an uncached promise. ' + + 'Creating promises inside a Client Component or hook is not yet supported, ' + + 'except via a Suspense-compatible library or framework.\n' + + ' in SuspenseList (at **)\n' + + ' in Foo (at **)', + ]); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + B + , + ); + }); + + // @gate enableSuspenseList && enableAsyncIterableChildren + it('warns if a nested async iterable is passed to a "forwards" list', async () => { + function Foo({items}) { + return ( + + {items} +
Tail
+
+ ); + } + + const iterable = { + async *[Symbol.asyncIterator]() { + yield ( + + A + + ); + yield ( + + B + + ); + }, + }; + + await act(() => { + React.startTransition(() => { + ReactNoop.render(); + }); + }); + assertConsoleErrorDev([ + 'A nested async iterable was passed to row #0 in . ' + + 'Wrap it in an additional SuspenseList to configure its revealOrder: ' + + ' ... ' + + '{async iterable} ... ' + + '' + + '\n in SuspenseList (at **)' + + '\n in Foo (at **)', + // We get this warning because the generator's promise themselves are not cached. + 'A component was suspended by an uncached promise. ' + + 'Creating promises inside a Client Component or hook is not yet supported, ' + + 'except via a Suspense-compatible library or framework.\n' + + ' in Foo (at **)', + ]); + }); });