mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[Fiber] Support AsyncIterable children in SuspenseList (#33299)
We support AsyncIterable (more so when it's a cached form like in coming from Flight) as children. This fixes some warnings and bugs when passed to SuspenseList. Ideally SuspenseList with `tail="hidden"` should support unblocking before the full result has resolved but that's an optimization on top. We also might want to change semantics for this for `revealOrder="backwards"` so it becomes possible to stream items in reverse order.
This commit is contained in:
+101
@@ -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 <SuspenseList />. Wrap it in ' +
|
||||
'an additional SuspenseList to configure its revealOrder: ' +
|
||||
'<SuspenseList revealOrder=...> ... ' +
|
||||
'<SuspenseList revealOrder=...>{%s}</SuspenseList> ... ' +
|
||||
'</SuspenseList>',
|
||||
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 <SuspenseList revealOrder="%s" />. ' +
|
||||
'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 <SuspenseList revealOrder="%s" />. ' +
|
||||
'This is not useful since it needs multiple rows. ' +
|
||||
'Did you mean to pass multiple children or an array?',
|
||||
revealOrder,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-77
@@ -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 <SuspenseList />. Wrap it in ' +
|
||||
'an additional SuspenseList to configure its revealOrder: ' +
|
||||
'<SuspenseList revealOrder=...> ... ' +
|
||||
'<SuspenseList revealOrder=...>{%s}</SuspenseList> ... ' +
|
||||
'</SuspenseList>',
|
||||
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 <SuspenseList revealOrder="%s" />. ' +
|
||||
'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
|
||||
|
||||
@@ -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 (
|
||||
<SuspenseList revealOrder="forwards">
|
||||
<Generator />
|
||||
</SuspenseList>
|
||||
);
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
React.startTransition(() => {
|
||||
ReactNoop.render(<Foo />);
|
||||
});
|
||||
});
|
||||
assertConsoleErrorDev([
|
||||
'A generator Component was passed to a <SuspenseList revealOrder="forwards" />. ' +
|
||||
'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 **)',
|
||||
'<Generator> 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 = (
|
||||
<Suspense key="A" fallback={<Text text="Loading A" />}>
|
||||
<A />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const BSlot = (
|
||||
<Suspense key="B" fallback={<Text text="Loading B" />}>
|
||||
<B />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const iterable = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield ASlot;
|
||||
yield BSlot;
|
||||
},
|
||||
};
|
||||
|
||||
function Foo() {
|
||||
return <SuspenseList revealOrder="forwards">{iterable}</SuspenseList>;
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
React.startTransition(() => {
|
||||
ReactNoop.render(<Foo />);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<>
|
||||
<span>Loading A</span>
|
||||
<span>Loading B</span>
|
||||
</>,
|
||||
);
|
||||
|
||||
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(
|
||||
<>
|
||||
<span>A</span>
|
||||
<span>Loading B</span>
|
||||
</>,
|
||||
);
|
||||
|
||||
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(
|
||||
<>
|
||||
<span>A</span>
|
||||
<span>B</span>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
// @gate enableSuspenseList && enableAsyncIterableChildren
|
||||
it('warns if a nested async iterable is passed to a "forwards" list', async () => {
|
||||
function Foo({items}) {
|
||||
return (
|
||||
<SuspenseList revealOrder="forwards">
|
||||
{items}
|
||||
<div>Tail</div>
|
||||
</SuspenseList>
|
||||
);
|
||||
}
|
||||
|
||||
const iterable = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield (
|
||||
<Suspense key={'A'} fallback="Loading">
|
||||
A
|
||||
</Suspense>
|
||||
);
|
||||
yield (
|
||||
<Suspense key={'B'} fallback="Loading">
|
||||
B
|
||||
</Suspense>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await act(() => {
|
||||
React.startTransition(() => {
|
||||
ReactNoop.render(<Foo items={iterable} />);
|
||||
});
|
||||
});
|
||||
assertConsoleErrorDev([
|
||||
'A nested async iterable was passed to row #0 in <SuspenseList />. ' +
|
||||
'Wrap it in an additional SuspenseList to configure its revealOrder: ' +
|
||||
'<SuspenseList revealOrder=...> ... ' +
|
||||
'<SuspenseList revealOrder=...>{async iterable}</SuspenseList> ... ' +
|
||||
'</SuspenseList>' +
|
||||
'\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 **)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user