[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.
Sebastian Markbåge committed
May 20, 2025 at 09:39 UTC
4c6967be290fc31182c61cfdac19915fdb16aa60
3 files changed
+306
-77
packages/react-reconciler/src/ReactChildFiber.js
+101
@@ -13,6 +13,7 @@ import type {
13
Thenable,
14
ReactContext,
15
ReactDebugInfo,
16
+ SuspenseListRevealOrder,
17
} from 'shared/ReactTypes';
18
import type {Fiber} from './ReactInternalTypes';
19
import type {Lanes} from './ReactFiberLane';
@@ -2057,3 +2058,103 @@ export function resetChildFibers(workInProgress: Fiber, lanes: Lanes): void {
2058
child = child.sibling;
2059
}
2060
}
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 if (
2133
+ enableAsyncIterableChildren &&
2134
+ children.$$typeof === REACT_ELEMENT_TYPE &&
2135
+ typeof children.type === 'function' &&
2136
+ // $FlowFixMe
2137
+ (Object.prototype.toString.call(children.type) ===
2138
+ '[object GeneratorFunction]' ||
2139
+ // $FlowFixMe
2140
+ Object.prototype.toString.call(children.type) ===
2141
+ '[object AsyncGeneratorFunction]')
2142
+ ) {
2143
+ console.error(
2144
+ 'A generator Component was passed to a <SuspenseList revealOrder="%s" />. ' +
2145
+ 'This is not supported as a way to generate lists. Instead, pass an ' +
2146
+ 'iterable as the children.',
2147
+ revealOrder,
2148
+ );
2149
+ } else {
2150
+ console.error(
2151
+ 'A single row was passed to a <SuspenseList revealOrder="%s" />. ' +
2152
+ 'This is not useful since it needs multiple rows. ' +
2153
+ 'Did you mean to pass multiple children or an array?',
2154
+ revealOrder,
2155
+ );
2156
+ }
2157
+ }
2158
+ }
2159
+ }
2160
+}
packages/react-reconciler/src/ReactFiberBeginWork.js
+12
-77
@@ -123,7 +123,6 @@ import {
123
enableViewTransition,
124
enableFragmentRefs,
125
} from 'shared/ReactFeatureFlags';
126
-import isArray from 'shared/isArray';
126
import shallowEqual from 'shared/shallowEqual';
127
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
128
import getComponentNameFromType from 'shared/getComponentNameFromType';
@@ -132,7 +131,6 @@ import {
131
REACT_LAZY_TYPE,
132
REACT_FORWARD_REF_TYPE,
133
REACT_MEMO_TYPE,
135
- getIteratorFn,
134
} from 'shared/ReactSymbols';
135
import {setCurrentFiber} from './ReactCurrentFiber';
136
import {
@@ -145,6 +143,7 @@ import {
143
mountChildFibers,
144
reconcileChildFibers,
145
cloneChildFibers,
146
+ validateSuspenseListChildren,
147
} from './ReactChildFiber';
148
import {
149
processUpdateQueue,
@@ -3302,73 +3301,6 @@ function validateTailOptions(
3301
}
3302
}
3303
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
-
3304
function initSuspenseListRenderState(
3305
workInProgress: Fiber,
3306
isBackwards: boolean,
@@ -3415,12 +3347,6 @@ function updateSuspenseListComponent(
3347
const tailMode: SuspenseListTailMode = nextProps.tail;
3348
const newChildren = nextProps.children;
3349
3418
- validateRevealOrder(revealOrder);
3419
- validateTailOptions(tailMode, revealOrder);
3420
- validateSuspenseListChildren(newChildren, revealOrder);
3421
-
3422
- reconcileChildren(current, workInProgress, newChildren, renderLanes);
3423
-
3350
let suspenseContext: SuspenseContext = suspenseStackCursor.current;
3351
3352
const shouldForceFallback = hasSuspenseListContext(
@@ -3434,6 +3360,17 @@ function updateSuspenseListComponent(
3360
);
3361
workInProgress.flags |= DidCapture;
3362
} 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) {
3374
const didSuspendBefore =
3375
current !== null && (current.flags & DidCapture) !== NoFlags;
3376
if (didSuspendBefore) {
@@ -3446,9 +3383,7 @@ function updateSuspenseListComponent(
3383
renderLanes,
3384
);
3385
}
3449
- suspenseContext = setDefaultShallowSuspenseListContext(suspenseContext);
3386
}
3451
- pushSuspenseListContext(workInProgress, suspenseContext);
3387
3388
if (!disableLegacyMode && (workInProgress.mode & ConcurrentMode) === NoMode) {
3389
// In legacy mode, SuspenseList doesn't work so we just
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
+193
@@ -3119,4 +3119,197 @@ describe('ReactSuspenseList', () => {
3119
);
3120
},
3121
);
3122
+
3123
+ // @gate enableSuspenseList && enableAsyncIterableChildren
3124
+ it('warns for async generator components in "forwards" order', async () => {
3125
+ async function* Generator() {
3126
+ yield 'A';
3127
+ yield 'B';
3128
+ }
3129
+ function Foo() {
3130
+ return (
3131
+ <SuspenseList revealOrder="forwards">
3132
+ <Generator />
3133
+ </SuspenseList>
3134
+ );
3135
+ }
3136
+
3137
+ await act(() => {
3138
+ React.startTransition(() => {
3139
+ ReactNoop.render(<Foo />);
3140
+ });
3141
+ });
3142
+ assertConsoleErrorDev([
3143
+ 'A generator Component was passed to a <SuspenseList revealOrder="forwards" />. ' +
3144
+ 'This is not supported as a way to generate lists. Instead, pass an ' +
3145
+ 'iterable as the children.' +
3146
+ '\n in SuspenseList (at **)' +
3147
+ '\n in Foo (at **)',
3148
+ '<Generator> is an async Client Component. ' +
3149
+ 'Only Server Components can be async at the moment. ' +
3150
+ "This error is often caused by accidentally adding `'use client'` " +
3151
+ 'to a module that was originally written for the server.\n' +
3152
+ ' in Foo (at **)',
3153
+ // We get this warning because the generator's promise themselves are not cached.
3154
+ 'A component was suspended by an uncached promise. ' +
3155
+ 'Creating promises inside a Client Component or hook is not yet supported, ' +
3156
+ 'except via a Suspense-compatible library or framework.\n' +
3157
+ ' in Foo (at **)',
3158
+ ]);
3159
+ });
3160
+
3161
+ // @gate enableSuspenseList && enableAsyncIterableChildren
3162
+ it('can display async iterable in "forwards" order', async () => {
3163
+ const A = createAsyncText('A');
3164
+ const B = createAsyncText('B');
3165
+
3166
+ // We use Cached elements to avoid rerender.
3167
+ const ASlot = (
3168
+ <Suspense key="A" fallback={<Text text="Loading A" />}>
3169
+ <A />
3170
+ </Suspense>
3171
+ );
3172
+
3173
+ const BSlot = (
3174
+ <Suspense key="B" fallback={<Text text="Loading B" />}>
3175
+ <B />
3176
+ </Suspense>
3177
+ );
3178
+
3179
+ const iterable = {
3180
+ async *[Symbol.asyncIterator]() {
3181
+ yield ASlot;
3182
+ yield BSlot;
3183
+ },
3184
+ };
3185
+
3186
+ function Foo() {
3187
+ return <SuspenseList revealOrder="forwards">{iterable}</SuspenseList>;
3188
+ }
3189
+
3190
+ await act(() => {
3191
+ React.startTransition(() => {
3192
+ ReactNoop.render(<Foo />);
3193
+ });
3194
+ });
3195
+
3196
+ assertLog([
3197
+ 'Suspend! [A]',
3198
+ 'Loading A',
3199
+ 'Loading B',
3200
+ // pre-warming
3201
+ 'Suspend! [A]',
3202
+ ]);
3203
+
3204
+ assertConsoleErrorDev([
3205
+ // We get this warning because the generator's promise themselves are not cached.
3206
+ 'A component was suspended by an uncached promise. ' +
3207
+ 'Creating promises inside a Client Component or hook is not yet supported, ' +
3208
+ 'except via a Suspense-compatible library or framework.\n' +
3209
+ ' in SuspenseList (at **)\n' +
3210
+ ' in Foo (at **)',
3211
+ 'A component was suspended by an uncached promise. ' +
3212
+ 'Creating promises inside a Client Component or hook is not yet supported, ' +
3213
+ 'except via a Suspense-compatible library or framework.\n' +
3214
+ ' in SuspenseList (at **)\n' +
3215
+ ' in Foo (at **)',
3216
+ ]);
3217
+
3218
+ expect(ReactNoop).toMatchRenderedOutput(
3219
+ <>
3220
+ <span>Loading A</span>
3221
+ <span>Loading B</span>
3222
+ </>,
3223
+ );
3224
+
3225
+ await act(() => A.resolve());
3226
+ assertLog(['A', 'Suspend! [B]', 'Suspend! [B]']);
3227
+
3228
+ assertConsoleErrorDev([
3229
+ // We get this warning because the generator's promise themselves are not cached.
3230
+ 'A component was suspended by an uncached promise. ' +
3231
+ 'Creating promises inside a Client Component or hook is not yet supported, ' +
3232
+ 'except via a Suspense-compatible library or framework.\n' +
3233
+ ' in SuspenseList (at **)\n' +
3234
+ ' in Foo (at **)',
3235
+ 'A component was suspended by an uncached promise. ' +
3236
+ 'Creating promises inside a Client Component or hook is not yet supported, ' +
3237
+ 'except via a Suspense-compatible library or framework.\n' +
3238
+ ' in SuspenseList (at **)\n' +
3239
+ ' in Foo (at **)',
3240
+ ]);
3241
+
3242
+ expect(ReactNoop).toMatchRenderedOutput(
3243
+ <>
3244
+ <span>A</span>
3245
+ <span>Loading B</span>
3246
+ </>,
3247
+ );
3248
+
3249
+ await act(() => B.resolve());
3250
+ assertLog(['B']);
3251
+
3252
+ assertConsoleErrorDev([
3253
+ // We get this warning because the generator's promise themselves are not cached.
3254
+ 'A component was suspended by an uncached promise. ' +
3255
+ 'Creating promises inside a Client Component or hook is not yet supported, ' +
3256
+ 'except via a Suspense-compatible library or framework.\n' +
3257
+ ' in SuspenseList (at **)\n' +
3258
+ ' in Foo (at **)',
3259
+ ]);
3260
+
3261
+ expect(ReactNoop).toMatchRenderedOutput(
3262
+ <>
3263
+ <span>A</span>
3264
+ <span>B</span>
3265
+ </>,
3266
+ );
3267
+ });
3268
+
3269
+ // @gate enableSuspenseList && enableAsyncIterableChildren
3270
+ it('warns if a nested async iterable is passed to a "forwards" list', async () => {
3271
+ function Foo({items}) {
3272
+ return (
3273
+ <SuspenseList revealOrder="forwards">
3274
+ {items}
3275
+ <div>Tail</div>
3276
+ </SuspenseList>
3277
+ );
3278
+ }
3279
+
3280
+ const iterable = {
3281
+ async *[Symbol.asyncIterator]() {
3282
+ yield (
3283
+ <Suspense key={'A'} fallback="Loading">
3284
+ A
3285
+ </Suspense>
3286
+ );
3287
+ yield (
3288
+ <Suspense key={'B'} fallback="Loading">
3289
+ B
3290
+ </Suspense>
3291
+ );
3292
+ },
3293
+ };
3294
+
3295
+ await act(() => {
3296
+ React.startTransition(() => {
3297
+ ReactNoop.render(<Foo items={iterable} />);
3298
+ });
3299
+ });
3300
+ assertConsoleErrorDev([
3301
+ 'A nested async iterable was passed to row #0 in <SuspenseList />. ' +
3302
+ 'Wrap it in an additional SuspenseList to configure its revealOrder: ' +
3303
+ '<SuspenseList revealOrder=...> ... ' +
3304
+ '<SuspenseList revealOrder=...>{async iterable}</SuspenseList> ... ' +
3305
+ '</SuspenseList>' +
3306
+ '\n in SuspenseList (at **)' +
3307
+ '\n in Foo (at **)',
3308
+ // We get this warning because the generator's promise themselves are not cached.
3309
+ 'A component was suspended by an uncached promise. ' +
3310
+ 'Creating promises inside a Client Component or hook is not yet supported, ' +
3311
+ 'except via a Suspense-compatible library or framework.\n' +
3312
+ ' in Foo (at **)',
3313
+ ]);
3314
+ });
3315
});