@samitouri / QOS-React / commits / 4c6967be29

[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 });