Skip to content

Commit 453d657

Browse files
committed
Support AsyncIterables in Fizz
1 parent 22ab188 commit 453d657

File tree

4 files changed

+217
-5
lines changed

4 files changed

+217
-5
lines changed

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

Lines changed: 66 additions & 2 deletions
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-server/src/ReactFizzHooks.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ import type {TransitionStatus} from './ReactFizzConfig';
2525

2626
import {readContext as readContextImpl} from './ReactFizzNewContext';
2727
import {getTreeId} from './ReactFizzTreeContext';
28-
import {createThenableState, trackUsedThenable} from './ReactFizzThenable';
28+
import {
29+
createThenableState,
30+
trackUsedThenable,
31+
readPreviousThenable,
32+
} from './ReactFizzThenable';
2933

3034
import {makeId, NotPendingTransition} from './ReactFizzConfig';
3135
import {createFastHash} from './ReactServerStreamConfig';
@@ -229,6 +233,13 @@ export function prepareToUseHooks(
229233
thenableState = prevThenableState;
230234
}
231235

236+
export function prepareToUseThenableState(
237+
prevThenableState: ThenableState | null,
238+
): void {
239+
thenableIndexCounter = 0;
240+
thenableState = prevThenableState;
241+
}
242+
232243
export function finishHooks(
233244
Component: any,
234245
props: any,
@@ -765,6 +776,15 @@ export function unwrapThenable<T>(thenable: Thenable<T>): T {
765776
return trackUsedThenable(thenableState, thenable, index);
766777
}
767778

779+
export function readPreviousThenableFromState<T>(): T | void {
780+
const index = thenableIndexCounter;
781+
thenableIndexCounter += 1;
782+
if (thenableState === null) {
783+
return undefined;
784+
}
785+
return readPreviousThenable(thenableState, index);
786+
}
787+
768788
function unsupportedRefresh() {
769789
throw new Error('Cache cannot be refreshed during server rendering.');
770790
}

packages/react-server/src/ReactFizzServer.js

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import {
9898
} from './ReactFizzNewContext';
9999
import {
100100
prepareToUseHooks,
101+
prepareToUseThenableState,
101102
finishHooks,
102103
checkDidRenderIdHook,
103104
resetHooksState,
@@ -106,6 +107,7 @@ import {
106107
setCurrentResumableState,
107108
getThenableStateAfterSuspending,
108109
unwrapThenable,
110+
readPreviousThenableFromState,
109111
getActionStateCount,
110112
getActionStateMatchingIndex,
111113
} from './ReactFizzHooks';
@@ -115,6 +117,7 @@ import {emptyTreeContext, pushTreeContext} from './ReactFizzTreeContext';
115117

116118
import {
117119
getIteratorFn,
120+
ASYNC_ITERATOR,
118121
REACT_ELEMENT_TYPE,
119122
REACT_PORTAL_TYPE,
120123
REACT_LAZY_TYPE,
@@ -144,6 +147,7 @@ import {
144147
enableRenderableContext,
145148
enableRefAsProp,
146149
disableDefaultPropsExceptForClasses,
150+
enableAsyncIterableChildren,
147151
} from 'shared/ReactFeatureFlags';
148152

149153
import assign from 'shared/assign';
@@ -2165,6 +2169,7 @@ function validateIterable(
21652169
// as its direct child since we can recreate those by rerendering the component
21662170
// as needed.
21672171
const isGeneratorComponent =
2172+
childIndex === -1 && // Only the root child is valid
21682173
task.componentStack !== null &&
21692174
task.componentStack.tag === 1 && // FunctionComponent
21702175
// $FlowFixMe[method-unbinding]
@@ -2197,6 +2202,43 @@ function validateIterable(
21972202
}
21982203
}
21992204

2205+
function validateAsyncIterable(
2206+
task: Task,
2207+
iterable: AsyncIterable<any>,
2208+
childIndex: number,
2209+
iterator: AsyncIterator<any>,
2210+
): void {
2211+
if (__DEV__) {
2212+
if (iterator === iterable) {
2213+
// We don't support rendering Generators as props because it's a mutation.
2214+
// See https://github.com/facebook/react/issues/12995
2215+
// We do support generators if they were created by a GeneratorFunction component
2216+
// as its direct child since we can recreate those by rerendering the component
2217+
// as needed.
2218+
const isGeneratorComponent =
2219+
childIndex === -1 && // Only the root child is valid
2220+
task.componentStack !== null &&
2221+
task.componentStack.tag === 1 && // FunctionComponent
2222+
// $FlowFixMe[method-unbinding]
2223+
Object.prototype.toString.call(task.componentStack.type) ===
2224+
'[object AsyncGeneratorFunction]' &&
2225+
// $FlowFixMe[method-unbinding]
2226+
Object.prototype.toString.call(iterator) === '[object AsyncGenerator]';
2227+
if (!isGeneratorComponent) {
2228+
if (!didWarnAboutGenerators) {
2229+
console.error(
2230+
'Using AsyncIterators as children is unsupported and will likely yield ' +
2231+
'unexpected results because enumerating a generator mutates it. ' +
2232+
'You can use an AsyncIterable that can iterate multiple times over ' +
2233+
'the same items.',
2234+
);
2235+
}
2236+
didWarnAboutGenerators = true;
2237+
}
2238+
}
2239+
}
2240+
}
2241+
22002242
function warnOnFunctionType(invalidChild: Function) {
22012243
if (__DEV__) {
22022244
const name = invalidChild.displayName || invalidChild.name || 'Component';
@@ -2327,20 +2369,83 @@ function renderNodeDestructive(
23272369
// TODO: This is not great but I think it's inherent to the id
23282370
// generation algorithm.
23292371
let step = iterator.next();
2330-
// If there are not entries, we need to push an empty so we start by checking that.
23312372
if (!step.done) {
23322373
const children = [];
23332374
do {
23342375
children.push(step.value);
23352376
step = iterator.next();
23362377
} while (!step.done);
23372378
renderChildrenArray(request, task, children, childIndex);
2338-
return;
23392379
}
23402380
return;
23412381
}
23422382
}
23432383

2384+
if (
2385+
enableAsyncIterableChildren &&
2386+
typeof (node: any)[ASYNC_ITERATOR] === 'function'
2387+
) {
2388+
const iterator: AsyncIterator<ReactNodeList> = (node: any)[
2389+
ASYNC_ITERATOR
2390+
]();
2391+
if (iterator) {
2392+
if (__DEV__) {
2393+
validateAsyncIterable(task, (node: any), childIndex, iterator);
2394+
}
2395+
// TODO: Update the task.node to be the iterator to avoid asking
2396+
// for new iterators, but we currently warn for rendering these
2397+
// so needs some refactoring to deal with the warning.
2398+
2399+
// We need to push a component stack because if this suspends, we'll pop a stack.
2400+
const previousComponentStack = task.componentStack;
2401+
task.componentStack = createBuiltInComponentStack(
2402+
task,
2403+
'AsyncIterable',
2404+
);
2405+
2406+
// Restore the thenable state before resuming.
2407+
const prevThenableState = task.thenableState;
2408+
task.thenableState = null;
2409+
prepareToUseThenableState(prevThenableState);
2410+
2411+
// We need to know how many total children are in this set, so that we
2412+
// can allocate enough id slots to acommodate them. So we must exhaust
2413+
// the iterator before we start recursively rendering the children.
2414+
// TODO: This is not great but I think it's inherent to the id
2415+
// generation algorithm.
2416+
const children = [];
2417+
2418+
let done = false;
2419+
2420+
if (iterator === node) {
2421+
// If it's an iterator we need to continue reading where we left
2422+
// off. We can do that by reading the first few rows from the previous
2423+
// thenable state.
2424+
// $FlowFixMe
2425+
let step = readPreviousThenableFromState();
2426+
while (step !== undefined) {
2427+
if (step.done) {
2428+
done = true;
2429+
break;
2430+
}
2431+
children.push(step.value);
2432+
step = readPreviousThenableFromState();
2433+
}
2434+
}
2435+
2436+
if (!done) {
2437+
let step = unwrapThenable(iterator.next());
2438+
while (!step.done) {
2439+
children.push(step.value);
2440+
step = unwrapThenable(iterator.next());
2441+
}
2442+
}
2443+
task.componentStack = previousComponentStack;
2444+
renderChildrenArray(request, task, children, childIndex);
2445+
return;
2446+
}
2447+
}
2448+
23442449
// Usables are a valid React node type. When React encounters a Usable in
23452450
// a child position, it unwraps it using the same algorithm as `use`. For
23462451
// example, for promises, React will throw an exception to unwind the
@@ -3554,6 +3659,11 @@ function retryRenderTask(
35543659
const ping = task.ping;
35553660
x.then(ping, ping);
35563661
task.thenableState = getThenableStateAfterSuspending();
3662+
// We pop one task off the stack because the node that suspended will be tried again,
3663+
// which will add it back onto the stack.
3664+
if (task.componentStack !== null) {
3665+
task.componentStack = task.componentStack.parent;
3666+
}
35573667
return;
35583668
} else if (
35593669
enablePostpone &&
@@ -3639,6 +3749,11 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
36393749
const ping = task.ping;
36403750
x.then(ping, ping);
36413751
task.thenableState = getThenableStateAfterSuspending();
3752+
// We pop one task off the stack because the node that suspended will be tried again,
3753+
// which will add it back onto the stack.
3754+
if (task.componentStack !== null) {
3755+
task.componentStack = task.componentStack.parent;
3756+
}
36423757
return;
36433758
}
36443759
}

packages/react-server/src/ReactFizzThenable.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ export function trackUsedThenable<T>(
131131
}
132132
}
133133

134+
export function readPreviousThenable<T>(
135+
thenableState: ThenableState,
136+
index: number,
137+
): void | T {
138+
const previous = thenableState[index];
139+
if (previous === undefined) {
140+
return undefined;
141+
} else {
142+
// We assume this has been resolved already.
143+
return (previous: any).value;
144+
}
145+
}
146+
134147
// This is used to track the actual thenable that suspended so it can be
135148
// passed to the rest of the Suspense implementation — which, for historical
136149
// reasons, expects to receive a thenable.

0 commit comments

Comments
 (0)