diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
index 9ff119ac141..b792844a0b4 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
@@ -812,6 +812,110 @@ describe('ReactFlightDOM', () => {
expect(reportedErrors).toEqual([]);
});
+ it('should handle streaming async server components', async () => {
+ const reportedErrors = [];
+
+ const Row = async ({current, next}) => {
+ const chunk = await next;
+
+ if (chunk.done) {
+ return chunk.value;
+ }
+
+ return (
+
+
+
+ );
+ };
+
+ function createResolvablePromise() {
+ let _resolve, _reject;
+
+ const promise = new Promise((resolve, reject) => {
+ _resolve = resolve;
+ _reject = reject;
+ });
+
+ return {promise, resolve: _resolve, reject: _reject};
+ }
+
+ function createSuspendedChunk(initialValue) {
+ const {promise, resolve, reject} = createResolvablePromise();
+
+ return {
+ row: (
+
+
+
+ ),
+ resolve,
+ reject,
+ };
+ }
+
+ function makeDelayedText() {
+ const {promise, resolve, reject} = createResolvablePromise();
+ async function DelayedText() {
+ const data = await promise;
+ return
{data}
;
+ }
+ return [DelayedText, resolve, reject];
+ }
+
+ const [Posts, resolvePostsData] = makeDelayedText();
+ const [Photos, resolvePhotosData] = makeDelayedText();
+ const suspendedChunk = createSuspendedChunk(loading
);
+ const {writable, readable} = getTestStream();
+ const {pipe} = ReactServerDOMServer.renderToPipeableStream(
+ suspendedChunk.row,
+ webpackMap,
+ {
+ onError(error) {
+ reportedErrors.push(error);
+ },
+ },
+ );
+ pipe(writable);
+ const response = ReactServerDOMClient.createFromReadableStream(readable);
+ const container = document.createElement('div');
+ const root = ReactDOMClient.createRoot(container);
+
+ function ClientRoot() {
+ return use(response);
+ }
+
+ await act(() => {
+ root.render();
+ });
+
+ expect(container.innerHTML).toBe('loading
');
+
+ const donePromise = createResolvablePromise();
+
+ const value = (
+ loading posts and photos}>
+
+
+
+ );
+
+ await act(async () => {
+ suspendedChunk.resolve({value, done: false, next: donePromise.promise});
+ donePromise.resolve({value, done: true});
+ });
+
+ expect(container.innerHTML).toBe('loading posts and photos
');
+
+ await act(async () => {
+ await resolvePostsData('posts');
+ await resolvePhotosData('photos');
+ });
+
+ expect(container.innerHTML).toBe('posts
photos
');
+ expect(reportedErrors).toEqual([]);
+ });
+
it('should preserve state of client components on refetch', async () => {
// Client
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index d253db44c4a..c1eab782a4a 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -281,6 +281,7 @@ export type Request = {
writtenClientReferences: Map,
writtenServerReferences: Map, number>,
writtenObjects: WeakMap, // -1 means "seen" but not outlined.
+ emittedModelChunkIds: Set,
identifierPrefix: string,
identifierCount: number,
taintCleanupQueue: Array,
@@ -379,6 +380,7 @@ export function createRequest(
writtenClientReferences: new Map(),
writtenServerReferences: new Map(),
writtenObjects: new WeakMap(),
+ emittedModelChunkIds: new Set(),
identifierPrefix: identifierPrefix || '',
identifierCount: 1,
taintCleanupQueue: cleanupQueue,
@@ -1334,16 +1336,14 @@ function renderModelDestructive(
// but that is able to reuse the same task if we're already in one but then that
// will be a lazy future value rather than guaranteed to exist but maybe that's good.
const newId = outlineModel(request, (value: any));
- return serializeByValueID(newId);
+ return request.emittedModelChunkIds.has(newId)
+ ? serializeByValueID(newId)
+ : serializeLazyID(newId);
} else {
- // We've already emitted this as an outlined object, so we can refer to that by its
- // existing ID. TODO: We should use a lazy reference since, unlike plain objects,
- // elements might suspend so it might not have emitted yet even if we have the ID for
- // it. However, this creates an extra wrapper when it's not needed. We should really
- // detect whether this already was emitted and synchronously available. In that
- // case we can refer to it synchronously and only make it lazy otherwise.
- // We currently don't have a data structure that lets us see that though.
- return serializeByValueID(existingId);
+ // We've already outlined this model, so we can refer to that by its existing ID.
+ return request.emittedModelChunkIds.has(existingId)
+ ? serializeByValueID(existingId)
+ : serializeLazyID(existingId);
}
} else {
// This is the first time we've seen this object. We may never see it again
@@ -1884,6 +1884,7 @@ function emitSymbolChunk(request: Request, id: number, name: string): void {
function emitModelChunk(request: Request, id: number, json: string): void {
const row = id.toString(16) + ':' + json + '\n';
const processedChunk = stringToChunk(row);
+ request.emittedModelChunkIds.add(id);
request.completedRegularChunks.push(processedChunk);
}