Skip to content

Commit 9f2eebd

Browse files
authoredApr 22, 2024··
[Fiber/Fizz] Support AsyncIterable as Children and AsyncGenerator Client Components (#28868)
Stacked on #28849, #28854, #28853. Behind a flag. If you're following along from the side-lines. This is probably not what you think it is. It's NOT a way to get updates to a component over time. The AsyncIterable works like an Iterable already works in React which is how an Array works. I.e. it's a list of children - not the value of a child over time. It also doesn't actually render one component at a time. The way it works is more like awaiting the entire list to become an array and then it shows up. Before that it suspends the parent. To actually get these to display one at a time, you have to opt-in with `<SuspenseList>` to describe how they should appear. That's really the interesting part and that not implemented yet. Additionally, since these are effectively Async Functions and uncached promises, they're not actually fully "supported" on the client yet for the same reason rendering plain Promises and Async Functions aren't. They warn. It's only really useful when paired with RSC that produces instrumented versions of these. Ideally we'd published instrumented helpers to help with map/filter style operations that yield new instrumented AsyncIterables. The way the implementation works basically just relies on unwrapThenable and otherwise works like a plain Iterator. There is one quirk with these that are different than just promises. We ask for a new iterator each time we rerender. This means that upon retry we kick off another iteration which itself might kick off new requests that block iterating further. To solve this and make it actually efficient enough to use on the client we'd need to stash something like a buffer of the previous iteration and maybe iterator on the iterable so that we can continue where we left off or synchronously iterate if we've seen it before. Similar to our `.value` convention on Promises. In Fizz, I had to do a special case because when we render an iterator child we don't actually rerender the parent again like we do in Fiber. However, it's more efficient to just continue on where we left off by reusing the entries from the thenable state from before in that case.
1 parent 3b551c8 commit 9f2eebd

15 files changed

+447
-65
lines changed
 

‎packages/react-client/src/__tests__/ReactFlight-test.js

+8-53
Original file line numberDiff line numberDiff line change
@@ -2170,7 +2170,7 @@ describe('ReactFlight', () => {
21702170
);
21712171
});
21722172

2173-
// @gate enableFlightReadableStream
2173+
// @gate enableFlightReadableStream && enableAsyncIterableChildren
21742174
it('shares state when moving keyed Server Components that render async iterables', async () => {
21752175
function StatefulClient({name, initial}) {
21762176
const [state] = React.useState(initial);
@@ -2183,39 +2183,11 @@ describe('ReactFlight', () => {
21832183
yield <Stateful key="b" initial={'b' + initial} />;
21842184
}
21852185

2186-
function ListClient({children}) {
2187-
// TODO: Unwrap AsyncIterables natively in React. For now we do it in this wrapper.
2188-
const resolvedChildren = [];
2189-
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
2190-
for (const fragment of children) {
2191-
// We should've wrapped each child in a keyed Fragment.
2192-
expect(fragment.type).toBe(React.Fragment);
2193-
const fragmentChildren = [];
2194-
const iterator = fragment.props.children[Symbol.asyncIterator]();
2195-
if (iterator === fragment.props.children) {
2196-
console.error(
2197-
'AyncIterators are not valid children of React. It must be a multi-shot AsyncIterable.',
2198-
);
2199-
}
2200-
for (let entry; !(entry = React.use(iterator.next())).done; ) {
2201-
fragmentChildren.push(entry.value);
2202-
}
2203-
resolvedChildren.push(
2204-
<React.Fragment key={fragment.key}>
2205-
{fragmentChildren}
2206-
</React.Fragment>,
2207-
);
2208-
}
2209-
return <div>{resolvedChildren}</div>;
2210-
}
2211-
2212-
const List = clientReference(ListClient);
2213-
22142186
const transport = ReactNoopFlightServer.render(
2215-
<List>
2187+
<div>
22162188
<ServerComponent key="A" initial={1} />
22172189
<ServerComponent key="B" initial={2} />
2218-
</List>,
2190+
</div>,
22192191
);
22202192

22212193
await act(async () => {
@@ -2234,10 +2206,10 @@ describe('ReactFlight', () => {
22342206
// We swap the Server Components and the state of each child inside each fragment should move.
22352207
// Really the Fragment itself moves.
22362208
const transport2 = ReactNoopFlightServer.render(
2237-
<List>
2209+
<div>
22382210
<ServerComponent key="B" initial={4} />
22392211
<ServerComponent key="A" initial={3} />
2240-
</List>,
2212+
</div>,
22412213
);
22422214

22432215
await act(async () => {
@@ -2336,7 +2308,7 @@ describe('ReactFlight', () => {
23362308
);
23372309
});
23382310

2339-
// @gate enableFlightReadableStream
2311+
// @gate enableFlightReadableStream && enableAsyncIterableChildren
23402312
it('preserves debug info for server-to-server pass through of async iterables', async () => {
23412313
let resolve;
23422314
const iteratorPromise = new Promise(r => (resolve = r));
@@ -2347,23 +2319,6 @@ describe('ReactFlight', () => {
23472319
resolve();
23482320
}
23492321

2350-
function ListClient({children: fragment}) {
2351-
// TODO: Unwrap AsyncIterables natively in React. For now we do it in this wrapper.
2352-
const resolvedChildren = [];
2353-
const iterator = fragment.props.children[Symbol.asyncIterator]();
2354-
if (iterator === fragment.props.children) {
2355-
console.error(
2356-
'AyncIterators are not valid children of React. It must be a multi-shot AsyncIterable.',
2357-
);
2358-
}
2359-
for (let entry; !(entry = React.use(iterator.next())).done; ) {
2360-
resolvedChildren.push(entry.value);
2361-
}
2362-
return <div>{resolvedChildren}</div>;
2363-
}
2364-
2365-
const List = clientReference(ListClient);
2366-
23672322
function Keyed({children}) {
23682323
// Keying this should generate a fragment.
23692324
return children;
@@ -2375,9 +2330,9 @@ describe('ReactFlight', () => {
23752330
ReactNoopFlightClient.read(transport),
23762331
).root;
23772332
return (
2378-
<List>
2333+
<div>
23792334
<Keyed key="keyed">{children}</Keyed>
2380-
</List>
2335+
</div>
23812336
);
23822337
}
23832338

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+66-2
Original file line numberDiff line numberDiff line change
@@ -3346,7 +3346,7 @@ describe('ReactDOMFizzServer', () => {
33463346
]);
33473347
});
33483348

3349-
it('Supports iterable', async () => {
3349+
it('supports iterable', async () => {
33503350
const Immutable = require('immutable');
33513351

33523352
const mappedJSX = Immutable.fromJS([
@@ -3366,7 +3366,71 @@ describe('ReactDOMFizzServer', () => {
33663366
);
33673367
});
33683368

3369-
it('Supports bigint', async () => {
3369+
// @gate enableAsyncIterableChildren
3370+
it('supports async generator component', async () => {
3371+
async function* App() {
3372+
yield <span key="1">{await Promise.resolve('Hi')}</span>;
3373+
yield ' ';
3374+
yield <span key="2">{await Promise.resolve('World')}</span>;
3375+
}
3376+
3377+
await act(async () => {
3378+
const {pipe} = renderToPipeableStream(
3379+
<div>
3380+
<App />
3381+
</div>,
3382+
);
3383+
pipe(writable);
3384+
});
3385+
3386+
// Each act retries once which causes a new ping which schedules
3387+
// new work but only after the act has finished rendering.
3388+
await act(() => {});
3389+
await act(() => {});
3390+
await act(() => {});
3391+
await act(() => {});
3392+
3393+
expect(getVisibleChildren(container)).toEqual(
3394+
<div>
3395+
<span>Hi</span> <span>World</span>
3396+
</div>,
3397+
);
3398+
});
3399+
3400+
// @gate enableAsyncIterableChildren
3401+
it('supports async iterable children', async () => {
3402+
const iterable = {
3403+
async *[Symbol.asyncIterator]() {
3404+
yield <span key="1">{await Promise.resolve('Hi')}</span>;
3405+
yield ' ';
3406+
yield <span key="2">{await Promise.resolve('World')}</span>;
3407+
},
3408+
};
3409+
3410+
function App({children}) {
3411+
return <div>{children}</div>;
3412+
}
3413+
3414+
await act(() => {
3415+
const {pipe} = renderToPipeableStream(<App>{iterable}</App>);
3416+
pipe(writable);
3417+
});
3418+
3419+
// Each act retries once which causes a new ping which schedules
3420+
// new work but only after the act has finished rendering.
3421+
await act(() => {});
3422+
await act(() => {});
3423+
await act(() => {});
3424+
await act(() => {});
3425+
3426+
expect(getVisibleChildren(container)).toEqual(
3427+
<div>
3428+
<span>Hi</span> <span>World</span>
3429+
</div>,
3430+
);
3431+
});
3432+
3433+
it('supports bigint', async () => {
33703434
await act(async () => {
33713435
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
33723436
<div>{10n}</div>,

‎packages/react-reconciler/src/ReactChildFiber.js

+112-6
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from './ReactFiberFlags';
2828
import {
2929
getIteratorFn,
30+
ASYNC_ITERATOR,
3031
REACT_ELEMENT_TYPE,
3132
REACT_FRAGMENT_TYPE,
3233
REACT_PORTAL_TYPE,
@@ -42,7 +43,10 @@ import {
4243
FunctionComponent,
4344
} from './ReactWorkTags';
4445
import isArray from 'shared/isArray';
45-
import {enableRefAsProp} from 'shared/ReactFeatureFlags';
46+
import {
47+
enableRefAsProp,
48+
enableAsyncIterableChildren,
49+
} from 'shared/ReactFeatureFlags';
4650

4751
import {
4852
createWorkInProgress,
@@ -587,7 +591,12 @@ function createChildReconciler(
587591
}
588592
}
589593

590-
if (isArray(newChild) || getIteratorFn(newChild)) {
594+
if (
595+
isArray(newChild) ||
596+
getIteratorFn(newChild) ||
597+
(enableAsyncIterableChildren &&
598+
typeof newChild[ASYNC_ITERATOR] === 'function')
599+
) {
591600
const created = createFiberFromFragment(
592601
newChild,
593602
returnFiber.mode,
@@ -711,7 +720,12 @@ function createChildReconciler(
711720
}
712721
}
713722

714-
if (isArray(newChild) || getIteratorFn(newChild)) {
723+
if (
724+
isArray(newChild) ||
725+
getIteratorFn(newChild) ||
726+
(enableAsyncIterableChildren &&
727+
typeof newChild[ASYNC_ITERATOR] === 'function')
728+
) {
715729
if (key !== null) {
716730
return null;
717731
}
@@ -833,7 +847,12 @@ function createChildReconciler(
833847
);
834848
}
835849

836-
if (isArray(newChild) || getIteratorFn(newChild)) {
850+
if (
851+
isArray(newChild) ||
852+
getIteratorFn(newChild) ||
853+
(enableAsyncIterableChildren &&
854+
typeof newChild[ASYNC_ITERATOR] === 'function')
855+
) {
837856
const matchedFiber = existingChildren.get(newIdx) || null;
838857
return updateFragment(
839858
returnFiber,
@@ -1112,7 +1131,7 @@ function createChildReconciler(
11121131
return resultingFirstChild;
11131132
}
11141133

1115-
function reconcileChildrenIterator(
1134+
function reconcileChildrenIteratable(
11161135
returnFiber: Fiber,
11171136
currentFirstChild: Fiber | null,
11181137
newChildrenIterable: Iterable<mixed>,
@@ -1171,6 +1190,80 @@ function createChildReconciler(
11711190
}
11721191
}
11731192

1193+
return reconcileChildrenIterator(
1194+
returnFiber,
1195+
currentFirstChild,
1196+
newChildren,
1197+
lanes,
1198+
debugInfo,
1199+
);
1200+
}
1201+
1202+
function reconcileChildrenAsyncIteratable(
1203+
returnFiber: Fiber,
1204+
currentFirstChild: Fiber | null,
1205+
newChildrenIterable: AsyncIterable<mixed>,
1206+
lanes: Lanes,
1207+
debugInfo: ReactDebugInfo | null,
1208+
): Fiber | null {
1209+
const newChildren = newChildrenIterable[ASYNC_ITERATOR]();
1210+
1211+
if (__DEV__) {
1212+
if (newChildren === newChildrenIterable) {
1213+
// We don't support rendering AsyncGenerators as props because it's a mutation.
1214+
// We do support generators if they were created by a AsyncGeneratorFunction component
1215+
// as its direct child since we can recreate those by rerendering the component
1216+
// as needed.
1217+
const isGeneratorComponent =
1218+
returnFiber.tag === FunctionComponent &&
1219+
// $FlowFixMe[method-unbinding]
1220+
Object.prototype.toString.call(returnFiber.type) ===
1221+
'[object AsyncGeneratorFunction]' &&
1222+
// $FlowFixMe[method-unbinding]
1223+
Object.prototype.toString.call(newChildren) ===
1224+
'[object AsyncGenerator]';
1225+
if (!isGeneratorComponent) {
1226+
if (!didWarnAboutGenerators) {
1227+
console.error(
1228+
'Using AsyncIterators as children is unsupported and will likely yield ' +
1229+
'unexpected results because enumerating a generator mutates it. ' +
1230+
'You can use an AsyncIterable that can iterate multiple times over ' +
1231+
'the same items.',
1232+
);
1233+
}
1234+
didWarnAboutGenerators = true;
1235+
}
1236+
}
1237+
}
1238+
1239+
if (newChildren == null) {
1240+
throw new Error('An iterable object provided no iterator.');
1241+
}
1242+
1243+
// To save bytes, we reuse the logic by creating a synchronous Iterable and
1244+
// reusing that code path.
1245+
const iterator: Iterator<mixed> = ({
1246+
next(): IteratorResult<mixed, void> {
1247+
return unwrapThenable(newChildren.next());
1248+
},
1249+
}: any);
1250+
1251+
return reconcileChildrenIterator(
1252+
returnFiber,
1253+
currentFirstChild,
1254+
iterator,
1255+
lanes,
1256+
debugInfo,
1257+
);
1258+
}
1259+
1260+
function reconcileChildrenIterator(
1261+
returnFiber: Fiber,
1262+
currentFirstChild: Fiber | null,
1263+
newChildren: ?Iterator<mixed>,
1264+
lanes: Lanes,
1265+
debugInfo: ReactDebugInfo | null,
1266+
): Fiber | null {
11741267
if (newChildren == null) {
11751268
throw new Error('An iterable object provided no iterator.');
11761269
}
@@ -1563,7 +1656,20 @@ function createChildReconciler(
15631656
}
15641657

15651658
if (getIteratorFn(newChild)) {
1566-
return reconcileChildrenIterator(
1659+
return reconcileChildrenIteratable(
1660+
returnFiber,
1661+
currentFirstChild,
1662+
newChild,
1663+
lanes,
1664+
mergeDebugInfo(debugInfo, newChild._debugInfo),
1665+
);
1666+
}
1667+
1668+
if (
1669+
enableAsyncIterableChildren &&
1670+
typeof newChild[ASYNC_ITERATOR] === 'function'
1671+
) {
1672+
return reconcileChildrenAsyncIteratable(
15671673
returnFiber,
15681674
currentFirstChild,
15691675
newChild,

‎packages/react-reconciler/src/ReactFiberHooks.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,10 @@ function warnIfAsyncClientComponent(Component: Function) {
414414
// bulletproof but together they cover the most common cases.
415415
const isAsyncFunction =
416416
// $FlowIgnore[method-unbinding]
417-
Object.prototype.toString.call(Component) === '[object AsyncFunction]';
417+
Object.prototype.toString.call(Component) === '[object AsyncFunction]' ||
418+
// $FlowIgnore[method-unbinding]
419+
Object.prototype.toString.call(Component) ===
420+
'[object AsyncGeneratorFunction]';
418421
if (isAsyncFunction) {
419422
// Encountered an async Client Component. This is not yet supported.
420423
const componentName = getComponentNameFromFiber(currentlyRenderingFiber);

0 commit comments

Comments
 (0)
Please sign in to comment.