Skip to content

Commit

Permalink
[Fiber/Fizz] Support AsyncIterable as Children and AsyncGenerator Cli…
Browse files Browse the repository at this point in the history
…ent 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.
  • Loading branch information
sebmarkbage committed Apr 22, 2024
1 parent 3b551c8 commit 9f2eebd
Show file tree
Hide file tree
Showing 15 changed files with 447 additions and 65 deletions.
61 changes: 8 additions & 53 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2170,7 +2170,7 @@ describe('ReactFlight', () => {
);
});

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

function ListClient({children}) {
// TODO: Unwrap AsyncIterables natively in React. For now we do it in this wrapper.
const resolvedChildren = [];
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const fragment of children) {
// We should've wrapped each child in a keyed Fragment.
expect(fragment.type).toBe(React.Fragment);
const fragmentChildren = [];
const iterator = fragment.props.children[Symbol.asyncIterator]();
if (iterator === fragment.props.children) {
console.error(
'AyncIterators are not valid children of React. It must be a multi-shot AsyncIterable.',
);
}
for (let entry; !(entry = React.use(iterator.next())).done; ) {
fragmentChildren.push(entry.value);
}
resolvedChildren.push(
<React.Fragment key={fragment.key}>
{fragmentChildren}
</React.Fragment>,
);
}
return <div>{resolvedChildren}</div>;
}

const List = clientReference(ListClient);

const transport = ReactNoopFlightServer.render(
<List>
<div>
<ServerComponent key="A" initial={1} />
<ServerComponent key="B" initial={2} />
</List>,
</div>,
);

await act(async () => {
Expand All @@ -2234,10 +2206,10 @@ describe('ReactFlight', () => {
// We swap the Server Components and the state of each child inside each fragment should move.
// Really the Fragment itself moves.
const transport2 = ReactNoopFlightServer.render(
<List>
<div>
<ServerComponent key="B" initial={4} />
<ServerComponent key="A" initial={3} />
</List>,
</div>,
);

await act(async () => {
Expand Down Expand Up @@ -2336,7 +2308,7 @@ describe('ReactFlight', () => {
);
});

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

function ListClient({children: fragment}) {
// TODO: Unwrap AsyncIterables natively in React. For now we do it in this wrapper.
const resolvedChildren = [];
const iterator = fragment.props.children[Symbol.asyncIterator]();
if (iterator === fragment.props.children) {
console.error(
'AyncIterators are not valid children of React. It must be a multi-shot AsyncIterable.',
);
}
for (let entry; !(entry = React.use(iterator.next())).done; ) {
resolvedChildren.push(entry.value);
}
return <div>{resolvedChildren}</div>;
}

const List = clientReference(ListClient);

function Keyed({children}) {
// Keying this should generate a fragment.
return children;
Expand All @@ -2375,9 +2330,9 @@ describe('ReactFlight', () => {
ReactNoopFlightClient.read(transport),
).root;
return (
<List>
<div>
<Keyed key="keyed">{children}</Keyed>
</List>
</div>
);
}

Expand Down
68 changes: 66 additions & 2 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3346,7 +3346,7 @@ describe('ReactDOMFizzServer', () => {
]);
});

it('Supports iterable', async () => {
it('supports iterable', async () => {
const Immutable = require('immutable');

const mappedJSX = Immutable.fromJS([
Expand All @@ -3366,7 +3366,71 @@ describe('ReactDOMFizzServer', () => {
);
});

it('Supports bigint', async () => {
// @gate enableAsyncIterableChildren
it('supports async generator component', async () => {
async function* App() {
yield <span key="1">{await Promise.resolve('Hi')}</span>;
yield ' ';
yield <span key="2">{await Promise.resolve('World')}</span>;
}

await act(async () => {
const {pipe} = renderToPipeableStream(
<div>
<App />
</div>,
);
pipe(writable);
});

// Each act retries once which causes a new ping which schedules
// new work but only after the act has finished rendering.
await act(() => {});
await act(() => {});
await act(() => {});
await act(() => {});

expect(getVisibleChildren(container)).toEqual(
<div>
<span>Hi</span> <span>World</span>
</div>,
);
});

// @gate enableAsyncIterableChildren
it('supports async iterable children', async () => {
const iterable = {
async *[Symbol.asyncIterator]() {
yield <span key="1">{await Promise.resolve('Hi')}</span>;
yield ' ';
yield <span key="2">{await Promise.resolve('World')}</span>;
},
};

function App({children}) {
return <div>{children}</div>;
}

await act(() => {
const {pipe} = renderToPipeableStream(<App>{iterable}</App>);
pipe(writable);
});

// Each act retries once which causes a new ping which schedules
// new work but only after the act has finished rendering.
await act(() => {});
await act(() => {});
await act(() => {});
await act(() => {});

expect(getVisibleChildren(container)).toEqual(
<div>
<span>Hi</span> <span>World</span>
</div>,
);
});

it('supports bigint', async () => {
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>{10n}</div>,
Expand Down
118 changes: 112 additions & 6 deletions packages/react-reconciler/src/ReactChildFiber.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from './ReactFiberFlags';
import {
getIteratorFn,
ASYNC_ITERATOR,
REACT_ELEMENT_TYPE,
REACT_FRAGMENT_TYPE,
REACT_PORTAL_TYPE,
Expand All @@ -42,7 +43,10 @@ import {
FunctionComponent,
} from './ReactWorkTags';
import isArray from 'shared/isArray';
import {enableRefAsProp} from 'shared/ReactFeatureFlags';
import {
enableRefAsProp,
enableAsyncIterableChildren,
} from 'shared/ReactFeatureFlags';

import {
createWorkInProgress,
Expand Down Expand Up @@ -587,7 +591,12 @@ function createChildReconciler(
}
}

if (isArray(newChild) || getIteratorFn(newChild)) {
if (
isArray(newChild) ||
getIteratorFn(newChild) ||
(enableAsyncIterableChildren &&
typeof newChild[ASYNC_ITERATOR] === 'function')
) {
const created = createFiberFromFragment(
newChild,
returnFiber.mode,
Expand Down Expand Up @@ -711,7 +720,12 @@ function createChildReconciler(
}
}

if (isArray(newChild) || getIteratorFn(newChild)) {
if (
isArray(newChild) ||
getIteratorFn(newChild) ||
(enableAsyncIterableChildren &&
typeof newChild[ASYNC_ITERATOR] === 'function')
) {
if (key !== null) {
return null;
}
Expand Down Expand Up @@ -833,7 +847,12 @@ function createChildReconciler(
);
}

if (isArray(newChild) || getIteratorFn(newChild)) {
if (
isArray(newChild) ||
getIteratorFn(newChild) ||
(enableAsyncIterableChildren &&
typeof newChild[ASYNC_ITERATOR] === 'function')
) {
const matchedFiber = existingChildren.get(newIdx) || null;
return updateFragment(
returnFiber,
Expand Down Expand Up @@ -1112,7 +1131,7 @@ function createChildReconciler(
return resultingFirstChild;
}

function reconcileChildrenIterator(
function reconcileChildrenIteratable(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildrenIterable: Iterable<mixed>,
Expand Down Expand Up @@ -1171,6 +1190,80 @@ function createChildReconciler(
}
}

return reconcileChildrenIterator(
returnFiber,
currentFirstChild,
newChildren,
lanes,
debugInfo,
);
}

function reconcileChildrenAsyncIteratable(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildrenIterable: AsyncIterable<mixed>,
lanes: Lanes,
debugInfo: ReactDebugInfo | null,
): Fiber | null {
const newChildren = newChildrenIterable[ASYNC_ITERATOR]();

if (__DEV__) {
if (newChildren === newChildrenIterable) {
// We don't support rendering AsyncGenerators as props because it's a mutation.
// We do support generators if they were created by a AsyncGeneratorFunction component
// as its direct child since we can recreate those by rerendering the component
// as needed.
const isGeneratorComponent =
returnFiber.tag === FunctionComponent &&
// $FlowFixMe[method-unbinding]
Object.prototype.toString.call(returnFiber.type) ===
'[object AsyncGeneratorFunction]' &&
// $FlowFixMe[method-unbinding]
Object.prototype.toString.call(newChildren) ===
'[object AsyncGenerator]';
if (!isGeneratorComponent) {
if (!didWarnAboutGenerators) {
console.error(
'Using AsyncIterators as children is unsupported and will likely yield ' +
'unexpected results because enumerating a generator mutates it. ' +
'You can use an AsyncIterable that can iterate multiple times over ' +
'the same items.',
);
}
didWarnAboutGenerators = true;
}
}
}

if (newChildren == null) {
throw new Error('An iterable object provided no iterator.');
}

// To save bytes, we reuse the logic by creating a synchronous Iterable and
// reusing that code path.
const iterator: Iterator<mixed> = ({
next(): IteratorResult<mixed, void> {
return unwrapThenable(newChildren.next());
},
}: any);

return reconcileChildrenIterator(
returnFiber,
currentFirstChild,
iterator,
lanes,
debugInfo,
);
}

function reconcileChildrenIterator(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: ?Iterator<mixed>,
lanes: Lanes,
debugInfo: ReactDebugInfo | null,
): Fiber | null {
if (newChildren == null) {
throw new Error('An iterable object provided no iterator.');
}
Expand Down Expand Up @@ -1563,7 +1656,20 @@ function createChildReconciler(
}

if (getIteratorFn(newChild)) {
return reconcileChildrenIterator(
return reconcileChildrenIteratable(
returnFiber,
currentFirstChild,
newChild,
lanes,
mergeDebugInfo(debugInfo, newChild._debugInfo),
);
}

if (
enableAsyncIterableChildren &&
typeof newChild[ASYNC_ITERATOR] === 'function'
) {
return reconcileChildrenAsyncIteratable(
returnFiber,
currentFirstChild,
newChild,
Expand Down
5 changes: 4 additions & 1 deletion packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,10 @@ function warnIfAsyncClientComponent(Component: Function) {
// bulletproof but together they cover the most common cases.
const isAsyncFunction =
// $FlowIgnore[method-unbinding]
Object.prototype.toString.call(Component) === '[object AsyncFunction]';
Object.prototype.toString.call(Component) === '[object AsyncFunction]' ||
// $FlowIgnore[method-unbinding]
Object.prototype.toString.call(Component) ===
'[object AsyncGeneratorFunction]';
if (isAsyncFunction) {
// Encountered an async Client Component. This is not yet supported.
const componentName = getComponentNameFromFiber(currentlyRenderingFiber);
Expand Down
Loading

0 comments on commit 9f2eebd

Please sign in to comment.