Skip to content

Commit d1a090e

Browse files
committed
Support AsyncIterable children in SuspenseList
1 parent 462d08f commit d1a090e

File tree

3 files changed

+251
-77
lines changed

3 files changed

+251
-77
lines changed

packages/react-reconciler/src/ReactChildFiber.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
Thenable,
1414
ReactContext,
1515
ReactDebugInfo,
16+
SuspenseListRevealOrder,
1617
} from 'shared/ReactTypes';
1718
import type {Fiber} from './ReactInternalTypes';
1819
import type {Lanes} from './ReactFiberLane';
@@ -2057,3 +2058,86 @@ export function resetChildFibers(workInProgress: Fiber, lanes: Lanes): void {
20572058
child = child.sibling;
20582059
}
20592060
}
2061+
2062+
function validateSuspenseListNestedChild(childSlot: mixed, index: number) {
2063+
if (__DEV__) {
2064+
const isAnArray = isArray(childSlot);
2065+
const isIterable =
2066+
!isAnArray && typeof getIteratorFn(childSlot) === 'function';
2067+
const isAsyncIterable =
2068+
enableAsyncIterableChildren &&
2069+
typeof childSlot === 'object' &&
2070+
childSlot !== null &&
2071+
typeof (childSlot: any)[ASYNC_ITERATOR] === 'function';
2072+
if (isAnArray || isIterable || isAsyncIterable) {
2073+
const type = isAnArray
2074+
? 'array'
2075+
: isAsyncIterable
2076+
? 'async iterable'
2077+
: 'iterable';
2078+
console.error(
2079+
'A nested %s was passed to row #%s in <SuspenseList />. Wrap it in ' +
2080+
'an additional SuspenseList to configure its revealOrder: ' +
2081+
'<SuspenseList revealOrder=...> ... ' +
2082+
'<SuspenseList revealOrder=...>{%s}</SuspenseList> ... ' +
2083+
'</SuspenseList>',
2084+
type,
2085+
index,
2086+
type,
2087+
);
2088+
return false;
2089+
}
2090+
}
2091+
return true;
2092+
}
2093+
2094+
export function validateSuspenseListChildren(
2095+
children: mixed,
2096+
revealOrder: SuspenseListRevealOrder,
2097+
) {
2098+
if (__DEV__) {
2099+
if (
2100+
(revealOrder === 'forwards' || revealOrder === 'backwards') &&
2101+
children !== undefined &&
2102+
children !== null &&
2103+
children !== false
2104+
) {
2105+
if (isArray(children)) {
2106+
for (let i = 0; i < children.length; i++) {
2107+
if (!validateSuspenseListNestedChild(children[i], i)) {
2108+
return;
2109+
}
2110+
}
2111+
} else {
2112+
const iteratorFn = getIteratorFn(children);
2113+
if (typeof iteratorFn === 'function') {
2114+
const childrenIterator = iteratorFn.call(children);
2115+
if (childrenIterator) {
2116+
let step = childrenIterator.next();
2117+
let i = 0;
2118+
for (; !step.done; step = childrenIterator.next()) {
2119+
if (!validateSuspenseListNestedChild(step.value, i)) {
2120+
return;
2121+
}
2122+
i++;
2123+
}
2124+
}
2125+
} else if (
2126+
enableAsyncIterableChildren &&
2127+
typeof (children: any)[ASYNC_ITERATOR] === 'function'
2128+
) {
2129+
// TODO: Technically we should warn for nested arrays inside the
2130+
// async iterable but it would require unwrapping the array.
2131+
// However, this mistake is not as easy to make so it's ok not to warn.
2132+
} else {
2133+
console.error(
2134+
'A single row was passed to a <SuspenseList revealOrder="%s" />. ' +
2135+
'This is not useful since it needs multiple rows. ' +
2136+
'Did you mean to pass multiple children or an array?',
2137+
revealOrder,
2138+
);
2139+
}
2140+
}
2141+
}
2142+
}
2143+
}

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 12 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,6 @@ import {
123123
enableViewTransition,
124124
enableFragmentRefs,
125125
} from 'shared/ReactFeatureFlags';
126-
import isArray from 'shared/isArray';
127126
import shallowEqual from 'shared/shallowEqual';
128127
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
129128
import getComponentNameFromType from 'shared/getComponentNameFromType';
@@ -132,7 +131,6 @@ import {
132131
REACT_LAZY_TYPE,
133132
REACT_FORWARD_REF_TYPE,
134133
REACT_MEMO_TYPE,
135-
getIteratorFn,
136134
} from 'shared/ReactSymbols';
137135
import {setCurrentFiber} from './ReactCurrentFiber';
138136
import {
@@ -145,6 +143,7 @@ import {
145143
mountChildFibers,
146144
reconcileChildFibers,
147145
cloneChildFibers,
146+
validateSuspenseListChildren,
148147
} from './ReactChildFiber';
149148
import {
150149
processUpdateQueue,
@@ -3302,73 +3301,6 @@ function validateTailOptions(
33023301
}
33033302
}
33043303

3305-
function validateSuspenseListNestedChild(childSlot: mixed, index: number) {
3306-
if (__DEV__) {
3307-
const isAnArray = isArray(childSlot);
3308-
const isIterable =
3309-
!isAnArray && typeof getIteratorFn(childSlot) === 'function';
3310-
if (isAnArray || isIterable) {
3311-
const type = isAnArray ? 'array' : 'iterable';
3312-
console.error(
3313-
'A nested %s was passed to row #%s in <SuspenseList />. Wrap it in ' +
3314-
'an additional SuspenseList to configure its revealOrder: ' +
3315-
'<SuspenseList revealOrder=...> ... ' +
3316-
'<SuspenseList revealOrder=...>{%s}</SuspenseList> ... ' +
3317-
'</SuspenseList>',
3318-
type,
3319-
index,
3320-
type,
3321-
);
3322-
return false;
3323-
}
3324-
}
3325-
return true;
3326-
}
3327-
3328-
function validateSuspenseListChildren(
3329-
children: mixed,
3330-
revealOrder: SuspenseListRevealOrder,
3331-
) {
3332-
if (__DEV__) {
3333-
if (
3334-
(revealOrder === 'forwards' || revealOrder === 'backwards') &&
3335-
children !== undefined &&
3336-
children !== null &&
3337-
children !== false
3338-
) {
3339-
if (isArray(children)) {
3340-
for (let i = 0; i < children.length; i++) {
3341-
if (!validateSuspenseListNestedChild(children[i], i)) {
3342-
return;
3343-
}
3344-
}
3345-
} else {
3346-
const iteratorFn = getIteratorFn(children);
3347-
if (typeof iteratorFn === 'function') {
3348-
const childrenIterator = iteratorFn.call(children);
3349-
if (childrenIterator) {
3350-
let step = childrenIterator.next();
3351-
let i = 0;
3352-
for (; !step.done; step = childrenIterator.next()) {
3353-
if (!validateSuspenseListNestedChild(step.value, i)) {
3354-
return;
3355-
}
3356-
i++;
3357-
}
3358-
}
3359-
} else {
3360-
console.error(
3361-
'A single row was passed to a <SuspenseList revealOrder="%s" />. ' +
3362-
'This is not useful since it needs multiple rows. ' +
3363-
'Did you mean to pass multiple children or an array?',
3364-
revealOrder,
3365-
);
3366-
}
3367-
}
3368-
}
3369-
}
3370-
}
3371-
33723304
function initSuspenseListRenderState(
33733305
workInProgress: Fiber,
33743306
isBackwards: boolean,
@@ -3415,12 +3347,6 @@ function updateSuspenseListComponent(
34153347
const tailMode: SuspenseListTailMode = nextProps.tail;
34163348
const newChildren = nextProps.children;
34173349

3418-
validateRevealOrder(revealOrder);
3419-
validateTailOptions(tailMode, revealOrder);
3420-
validateSuspenseListChildren(newChildren, revealOrder);
3421-
3422-
reconcileChildren(current, workInProgress, newChildren, renderLanes);
3423-
34243350
let suspenseContext: SuspenseContext = suspenseStackCursor.current;
34253351

34263352
const shouldForceFallback = hasSuspenseListContext(
@@ -3434,6 +3360,17 @@ function updateSuspenseListComponent(
34343360
);
34353361
workInProgress.flags |= DidCapture;
34363362
} else {
3363+
suspenseContext = setDefaultShallowSuspenseListContext(suspenseContext);
3364+
}
3365+
pushSuspenseListContext(workInProgress, suspenseContext);
3366+
3367+
validateRevealOrder(revealOrder);
3368+
validateTailOptions(tailMode, revealOrder);
3369+
validateSuspenseListChildren(newChildren, revealOrder);
3370+
3371+
reconcileChildren(current, workInProgress, newChildren, renderLanes);
3372+
3373+
if (!shouldForceFallback) {
34373374
const didSuspendBefore =
34383375
current !== null && (current.flags & DidCapture) !== NoFlags;
34393376
if (didSuspendBefore) {
@@ -3446,9 +3383,7 @@ function updateSuspenseListComponent(
34463383
renderLanes,
34473384
);
34483385
}
3449-
suspenseContext = setDefaultShallowSuspenseListContext(suspenseContext);
34503386
}
3451-
pushSuspenseListContext(workInProgress, suspenseContext);
34523387

34533388
if (!disableLegacyMode && (workInProgress.mode & ConcurrentMode) === NoMode) {
34543389
// In legacy mode, SuspenseList doesn't work so we just

packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3119,4 +3119,159 @@ describe('ReactSuspenseList', () => {
31193119
);
31203120
},
31213121
);
3122+
3123+
// @gate enableSuspenseList && enableAsyncIterableChildren
3124+
it('can display async iterable in "forwards" order', async () => {
3125+
const A = createAsyncText('A');
3126+
const B = createAsyncText('B');
3127+
3128+
// We use Cached elements to avoid rerender.
3129+
const ASlot = (
3130+
<Suspense key="A" fallback={<Text text="Loading A" />}>
3131+
<A />
3132+
</Suspense>
3133+
);
3134+
3135+
const BSlot = (
3136+
<Suspense key="B" fallback={<Text text="Loading B" />}>
3137+
<B />
3138+
</Suspense>
3139+
);
3140+
3141+
const iterable = {
3142+
async *[Symbol.asyncIterator]() {
3143+
yield ASlot;
3144+
yield BSlot;
3145+
},
3146+
};
3147+
3148+
function Foo() {
3149+
return <SuspenseList revealOrder="forwards">{iterable}</SuspenseList>;
3150+
}
3151+
3152+
await act(() => {
3153+
React.startTransition(() => {
3154+
ReactNoop.render(<Foo />);
3155+
});
3156+
});
3157+
3158+
assertLog([
3159+
'Suspend! [A]',
3160+
'Loading A',
3161+
'Loading B',
3162+
// pre-warming
3163+
'Suspend! [A]',
3164+
]);
3165+
3166+
assertConsoleErrorDev([
3167+
// We get this warning because the generator's promise themselves are not cached.
3168+
'A component was suspended by an uncached promise. ' +
3169+
'Creating promises inside a Client Component or hook is not yet supported, ' +
3170+
'except via a Suspense-compatible library or framework.\n' +
3171+
' in SuspenseList (at **)\n' +
3172+
' in Foo (at **)',
3173+
'A component was suspended by an uncached promise. ' +
3174+
'Creating promises inside a Client Component or hook is not yet supported, ' +
3175+
'except via a Suspense-compatible library or framework.\n' +
3176+
' in SuspenseList (at **)\n' +
3177+
' in Foo (at **)',
3178+
]);
3179+
3180+
expect(ReactNoop).toMatchRenderedOutput(
3181+
<>
3182+
<span>Loading A</span>
3183+
<span>Loading B</span>
3184+
</>,
3185+
);
3186+
3187+
await act(() => A.resolve());
3188+
assertLog(['A', 'Suspend! [B]', 'Suspend! [B]']);
3189+
3190+
assertConsoleErrorDev([
3191+
// We get this warning because the generator's promise themselves are not cached.
3192+
'A component was suspended by an uncached promise. ' +
3193+
'Creating promises inside a Client Component or hook is not yet supported, ' +
3194+
'except via a Suspense-compatible library or framework.\n' +
3195+
' in SuspenseList (at **)\n' +
3196+
' in Foo (at **)',
3197+
'A component was suspended by an uncached promise. ' +
3198+
'Creating promises inside a Client Component or hook is not yet supported, ' +
3199+
'except via a Suspense-compatible library or framework.\n' +
3200+
' in SuspenseList (at **)\n' +
3201+
' in Foo (at **)',
3202+
]);
3203+
3204+
expect(ReactNoop).toMatchRenderedOutput(
3205+
<>
3206+
<span>A</span>
3207+
<span>Loading B</span>
3208+
</>,
3209+
);
3210+
3211+
await act(() => B.resolve());
3212+
assertLog(['B']);
3213+
3214+
assertConsoleErrorDev([
3215+
// We get this warning because the generator's promise themselves are not cached.
3216+
'A component was suspended by an uncached promise. ' +
3217+
'Creating promises inside a Client Component or hook is not yet supported, ' +
3218+
'except via a Suspense-compatible library or framework.\n' +
3219+
' in SuspenseList (at **)\n' +
3220+
' in Foo (at **)',
3221+
]);
3222+
3223+
expect(ReactNoop).toMatchRenderedOutput(
3224+
<>
3225+
<span>A</span>
3226+
<span>B</span>
3227+
</>,
3228+
);
3229+
});
3230+
3231+
// @gate enableSuspenseList && enableAsyncIterableChildren
3232+
it('warns if a nested async iterable is passed to a "forwards" list', async () => {
3233+
function Foo({items}) {
3234+
return (
3235+
<SuspenseList revealOrder="forwards">
3236+
{items}
3237+
<div>Tail</div>
3238+
</SuspenseList>
3239+
);
3240+
}
3241+
3242+
const iterable = {
3243+
async *[Symbol.asyncIterator]() {
3244+
yield (
3245+
<Suspense key={'A'} fallback="Loading">
3246+
A
3247+
</Suspense>
3248+
);
3249+
yield (
3250+
<Suspense key={'B'} fallback="Loading">
3251+
B
3252+
</Suspense>
3253+
);
3254+
},
3255+
};
3256+
3257+
await act(() => {
3258+
React.startTransition(() => {
3259+
ReactNoop.render(<Foo items={iterable} />);
3260+
});
3261+
});
3262+
assertConsoleErrorDev([
3263+
'A nested async iterable was passed to row #0 in <SuspenseList />. ' +
3264+
'Wrap it in an additional SuspenseList to configure its revealOrder: ' +
3265+
'<SuspenseList revealOrder=...> ... ' +
3266+
'<SuspenseList revealOrder=...>{async iterable}</SuspenseList> ... ' +
3267+
'</SuspenseList>' +
3268+
'\n in SuspenseList (at **)' +
3269+
'\n in Foo (at **)',
3270+
// We get this warning because the generator's promise themselves are not cached.
3271+
'A component was suspended by an uncached promise. ' +
3272+
'Creating promises inside a Client Component or hook is not yet supported, ' +
3273+
'except via a Suspense-compatible library or framework.\n' +
3274+
' in Foo (at **)',
3275+
]);
3276+
});
31223277
});

0 commit comments

Comments
 (0)