diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js
index 435ee06aac44b..b30f9d34d8189 100644
--- a/packages/react-client/src/ReactFlightClient.js
+++ b/packages/react-client/src/ReactFlightClient.js
@@ -584,6 +584,13 @@ function createModelResolver(
}
return value => {
parentObject[key] = value;
+
+ // If this is the root object for a model reference, where `blocked.value`
+ // is a stale `null`, the resolved value can be used directly.
+ if (key === '' && blocked.value === null) {
+ blocked.value = value;
+ }
+
blocked.deps--;
if (blocked.deps === 0) {
if (chunk.status !== BLOCKED) {
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 9ff119ac1419d..b792844a0b49f 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 d253db44c4a15..ad0a28f37175c 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -243,11 +243,16 @@ export type ReactClientValue =
type ReactClientObject = {+[key: string]: ReactClientValue};
+// task status
const PENDING = 0;
const COMPLETED = 1;
const ABORTED = 3;
const ERRORED = 4;
+// object reference status
+const SEEN_BUT_NOT_YET_OUTLINED = -1;
+const NEVER_OUTLINED = -2;
+
type Task = {
id: number,
status: 0 | 1 | 3 | 4,
@@ -280,7 +285,7 @@ export type Request = {
writtenSymbols: Map,
writtenClientReferences: Map,
writtenServerReferences: Map, number>,
- writtenObjects: WeakMap, // -1 means "seen" but not outlined.
+ writtenObjects: WeakMap,
identifierPrefix: string,
identifierCount: number,
taintCleanupQueue: Array,
@@ -1125,8 +1130,7 @@ function serializeMap(
const writtenObjects = request.writtenObjects;
const existingId = writtenObjects.get(key);
if (existingId === undefined) {
- // Mark all object keys as seen so that they're always outlined.
- writtenObjects.set(key, -1);
+ writtenObjects.set(key, SEEN_BUT_NOT_YET_OUTLINED);
}
}
}
@@ -1142,8 +1146,7 @@ function serializeSet(request: Request, set: Set): string {
const writtenObjects = request.writtenObjects;
const existingId = writtenObjects.get(key);
if (existingId === undefined) {
- // Mark all object keys as seen so that they're always outlined.
- writtenObjects.set(key, -1);
+ writtenObjects.set(key, SEEN_BUT_NOT_YET_OUTLINED);
}
}
}
@@ -1328,8 +1331,7 @@ function renderModelDestructive(
// This is the ID we're currently emitting so we need to write it
// once but if we discover it again, we refer to it by id.
modelRoot = null;
- } else if (existingId === -1) {
- // Seen but not yet outlined.
+ } else if (existingId === SEEN_BUT_NOT_YET_OUTLINED) {
// TODO: If we throw here we can treat this as suspending which causes an outline
// 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.
@@ -1348,7 +1350,10 @@ function renderModelDestructive(
} else {
// This is the first time we've seen this object. We may never see it again
// so we'll inline it. Mark it as seen. If we see it again, we'll outline.
- writtenObjects.set(value, -1);
+ writtenObjects.set(value, SEEN_BUT_NOT_YET_OUTLINED);
+ // The element's props are marked as "never outlined" so that they are inlined into
+ // the same row as the element itself.
+ writtenObjects.set((value: any).props, NEVER_OUTLINED);
}
const element: React$Element = (value: any);
@@ -1477,11 +1482,10 @@ function renderModelDestructive(
// This is the ID we're currently emitting so we need to write it
// once but if we discover it again, we refer to it by id.
modelRoot = null;
- } else if (existingId === -1) {
- // Seen but not yet outlined.
+ } else if (existingId === SEEN_BUT_NOT_YET_OUTLINED) {
const newId = outlineModel(request, (value: any));
return serializeByValueID(newId);
- } else {
+ } else if (existingId !== NEVER_OUTLINED) {
// We've already emitted this as an outlined object, so we can
// just refer to that by its existing ID.
return serializeByValueID(existingId);
@@ -1489,7 +1493,7 @@ function renderModelDestructive(
} else {
// This is the first time we've seen this object. We may never see it again
// so we'll inline it. Mark it as seen. If we see it again, we'll outline.
- writtenObjects.set(value, -1);
+ writtenObjects.set(value, SEEN_BUT_NOT_YET_OUTLINED);
}
if (isArray(value)) {
@@ -2007,7 +2011,7 @@ function renderConsoleValue(
return serializeInfinitePromise();
}
- if (existingId !== undefined && existingId !== -1) {
+ if (existingId !== undefined && existingId >= 0) {
// We've already emitted this as a real object, so we can
// just refer to that by its existing ID.
return serializeByValueID(existingId);