Repository: facebook/react
Pull request: [Flight] Serialize already resolved Promises as debug models

We already support serializing the values of instrumented Promises as debug values such as in console logs. However, we don't support plain native promises.

This waits a microtask to see if we can read the value within a microtask and if so emit it. This is so that we can still close the connection.

Otherwise, we emit a "halted" row into its row id which replaces the old "Infinite Promise" reference.

We could potentially wait until the end of the render before cancelling so that if it resolves before we exit we can still include its value but that would require a bit more work. Ideally we'd have a way to get these lazily later anyway.

Changed files:
filename: packages/react-client/src/ReactFlightClient.js
patch: @@ -155,6 +155,7 @@ const RESOLVED_MODEL = 'resolved_model';
 const RESOLVED_MODULE = 'resolved_module';
 const INITIALIZED = 'fulfilled';
 const ERRORED = 'rejected';
+const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes.
 
 type PendingChunk<T> = {
   status: 'pending',
@@ -221,13 +222,23 @@ type ErroredChunk<T> = {
   _debugInfo?: null | ReactDebugInfo, // DEV-only
   then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
 };
+type HaltedChunk<T> = {
+  status: 'halted',
+  value: null,
+  reason: null,
+  _response: Response,
+  _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
+  _debugInfo?: null | ReactDebugInfo, // DEV-only
+  then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
+};
 type SomeChunk<T> =
   | PendingChunk<T>
   | BlockedChunk<T>
   | ResolvedModelChunk<T>
   | ResolvedModuleChunk<T>
   | InitializedChunk<T>
-  | ErroredChunk<T>;
+  | ErroredChunk<T>
+  | HaltedChunk<T>;
 
 // $FlowFixMe[missing-this-annot]
 function ReactPromise(
@@ -311,6 +322,9 @@ ReactPromise.prototype.then = function <T>(
         chunk.reason.push(reject);
       }
       break;
+    case HALTED: {
+      break;
+    }
     default:
       if (reject) {
         reject(chunk.reason);
@@ -368,6 +382,7 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
       return chunk.value;
     case PENDING:
     case BLOCKED:
+    case HALTED:
       // eslint-disable-next-line no-throw-literal
       throw ((chunk: any): Thenable<T>);
     default:
@@ -1367,6 +1382,7 @@ function getOutlinedModel<T>(
       return chunkValue;
     case PENDING:
     case BLOCKED:
+    case HALTED:
       return waitForReference(chunk, parentObject, key, response, map, path);
     default:
       // This is an error. Instead of erroring directly, we're going to encode this on
@@ -1470,10 +1486,6 @@ function parseModelString(
       }
       case '@': {
         // Promise
-        if (value.length === 2) {
-          // Infinite promise that never resolves.
-          return new Promise(() => {});
-        }
         const id = parseInt(value.slice(2), 16);
         const chunk = getChunk(response, id);
         if (enableProfilerTimer && enableComponentPerformanceTrack) {
@@ -1769,6 +1781,22 @@ export function createResponse(
   );
 }
 
+function resolveDebugHalt(response: Response, id: number): void {
+  const chunks = response._chunks;
+  let chunk = chunks.get(id);
+  if (!chunk) {
+    chunks.set(id, (chunk = createPendingChunk(response)));
+  } else {
+  }
+  if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
+    return;
+  }
+  const haltedChunk: HaltedChunk<any> = (chunk: any);
+  haltedChunk.status = HALTED;
+  haltedChunk.value = null;
+  haltedChunk.reason = null;
+}
+
 function resolveModel(
   response: Response,
   id: number,
@@ -3339,6 +3367,10 @@ function processFullStringRow(
     }
     // Fallthrough
     default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
+      if (__DEV__ && row === '') {
+        resolveDebugHalt(response, id);
+        return;
+      }
       // We assume anything else is JSON.
       resolveModel(response, id, row);
       return;

filename: packages/react-client/src/__tests__/ReactFlight-test.js
patch: @@ -3213,7 +3213,8 @@ describe('ReactFlight', () => {
         prop: 123,
         fn: foo,
         map: new Map([['foo', foo]]),
-        promise: new Promise(() => {}),
+        promise: Promise.resolve('yo'),
+        infinitePromise: new Promise(() => {}),
       });
       throw new Error('err');
     }
@@ -3258,9 +3259,14 @@ describe('ReactFlight', () => {
     });
     ownerStacks = [];
 
+    // Let the Promises resolve.
+    await 0;
+    await 0;
+    await 0;
+
     // The error should not actually get logged because we're not awaiting the root
     // so it's not thrown but the server log also shouldn't be replayed.
-    await ReactNoopFlightClient.read(transport);
+    await ReactNoopFlightClient.read(transport, {close: true});
 
     expect(mockConsoleLog).toHaveBeenCalledTimes(1);
     expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
@@ -3280,6 +3286,23 @@ describe('ReactFlight', () => {
 
     const promise = mockConsoleLog.mock.calls[0][1].promise;
     expect(promise).toBeInstanceOf(Promise);
+    expect(await promise).toBe('yo');
+
+    const infinitePromise = mockConsoleLog.mock.calls[0][1].infinitePromise;
+    expect(infinitePromise).toBeInstanceOf(Promise);
+    let resolved = false;
+    infinitePromise.then(
+      () => (resolved = true),
+      x => {
+        console.error(x);
+        resolved = true;
+      },
+    );
+    await 0;
+    await 0;
+    await 0;
+    // This should not reject upon aborting the stream.
+    expect(resolved).toBe(false);
 
     expect(ownerStacks).toEqual(['\n    in App (at **)']);
   });

filename: packages/react-noop-renderer/src/ReactNoopFlightClient.js
patch: @@ -24,7 +24,7 @@ type Source = Array<Uint8Array>;
 
 const decoderOptions = {stream: true};
 
-const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({
+const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({
   createStringDecoder() {
     return new TextDecoder();
   },
@@ -56,6 +56,7 @@ const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({
 
 type ReadOptions = {|
   findSourceMapURL?: FindSourceMapURLCallback,
+  close?: boolean,
 |};
 
 function read<T>(source: Source, options: ReadOptions): Thenable<T> {
@@ -74,6 +75,9 @@ function read<T>(source: Source, options: ReadOptions): Thenable<T> {
   for (let i = 0; i < source.length; i++) {
     processBinaryChunk(response, source[i], 0);
   }
+  if (options !== undefined && options.close) {
+    close(response);
+  }
   return getRoot(response);
 }
 

filename: packages/react-server/src/ReactFlightServer.js
patch: @@ -677,6 +677,105 @@ export function resolveRequest(): null | Request {
   return null;
 }
 
+function serializeDebugThenable(
+  request: Request,
+  counter: {objectLimit: number},
+  thenable: Thenable<any>,
+): string {
+  // Like serializeThenable but for renderDebugModel
+  request.pendingChunks++;
+  const id = request.nextChunkId++;
+  const ref = serializePromiseID(id);
+  request.writtenDebugObjects.set(thenable, ref);
+
+  switch (thenable.status) {
+    case 'fulfilled': {
+      emitOutlinedDebugModelChunk(request, id, counter, thenable.value);
+      return ref;
+    }
+    case 'rejected': {
+      const x = thenable.reason;
+      if (
+        enablePostpone &&
+        typeof x === 'object' &&
+        x !== null &&
+        (x: any).$$typeof === REACT_POSTPONE_TYPE
+      ) {
+        const postponeInstance: Postpone = (x: any);
+        // We don't log this postpone.
+        emitPostponeChunk(request, id, postponeInstance);
+      } else {
+        // We don't log these errors since they didn't actually throw into Flight.
+        const digest = '';
+        emitErrorChunk(request, id, digest, x);
+      }
+      return ref;
+    }
+  }
+
+  let cancelled = false;
+
+  thenable.then(
+    value => {
+      if (cancelled) {
+        return;
+      }
+      cancelled = true;
+      if (request.status === ABORTING) {
+        emitDebugHaltChunk(request, id);
+        enqueueFlush(request);
+        return;
+      }
+      emitOutlinedDebugModelChunk(request, id, counter, value);
+      enqueueFlush(request);
+    },
+    reason => {
+      if (cancelled) {
+        return;
+      }
+      cancelled = true;
+      if (request.status === ABORTING) {
+        emitDebugHaltChunk(request, id);
+        enqueueFlush(request);
+        return;
+      }
+      if (
+        enablePostpone &&
+        typeof reason === 'object' &&
+        reason !== null &&
+        (reason: any).$$typeof === REACT_POSTPONE_TYPE
+      ) {
+        const postponeInstance: Postpone = (reason: any);
+        // We don't log this postpone.
+        emitPostponeChunk(request, id, postponeInstance);
+      } else {
+        // We don't log these errors since they didn't actually throw into Flight.
+        const digest = '';
+        emitErrorChunk(request, id, digest, reason);
+      }
+      enqueueFlush(request);
+    },
+  );
+
+  // We don't use scheduleMicrotask here because it doesn't actually schedule a microtask
+  // in all our configs which is annoying.
+  Promise.resolve().then(() => {
+    // If we don't resolve the Promise within a microtask. Leave it as hanging since we
+    // don't want to block the render forever on a Promise that might never resolve.
+    if (cancelled) {
+      return;
+    }
+    cancelled = true;
+    emitDebugHaltChunk(request, id);
+    enqueueFlush(request);
+    // Clean up the request so we don't leak this forever.
+    request = (null: any);
+    counter = (null: any);
+  });
+
+  return ref;
+}
+
 function serializeThenable(
   request: Request,
   task: Task,
@@ -2194,10 +2293,6 @@ function serializeLazyID(id: number): string {
   return '$L' + id.toString(16);
 }
 
-function serializeInfinitePromise(): string {
-  return '$@';
-}
-
 function serializePromiseID(id: number): string {
   return '$@' + id.toString(16);
 }
@@ -3514,6 +3609,21 @@ function emitModelChunk(request: Request, id: number, json: string): void {
   request.completedRegularChunks.push(processedChunk);
 }
 
+function emitDebugHaltChunk(request: Request, id: number): void {
+  if (!__DEV__) {
+    // These errors should never make it into a build so we don't need to encode them in codes.json
+    // eslint-disable-next-line react-internal/prod-error-codes
+    throw new Error(
+      'emitDebugHaltChunk should never be called in production mode. This is a bug in React.',
+    );
+  }
+  // This emits a marker that this row will never complete and should intentionally never resolve
+  // even when the client stream is closed. We use just the lack of data to indicate this.
+  const row = id.toString(16) + ':\n';
+  const processedChunk = stringToChunk(row);
+  request.completedRegularChunks.push(processedChunk);
+}
+
 function emitDebugChunk(
   request: Request,
   id: number,
@@ -3950,36 +4060,7 @@ function renderDebugModel(
     // $FlowFixMe[method-unbinding]
     if (typeof value.then === 'function') {
       const thenable: Thenable<any> = (value: any);
-      switch (thenable.status) {
-        case 'fulfilled': {
-          return serializePromiseID(
-            outlineDebugModel(request, counter, thenable.value),
-          );
-        }
-        case 'rejected': {
-          const x = thenable.reason;
-          request.pendingChunks++;
-          const errorId = request.nextChunkId++;
-          if (
-            enablePostpone &&
-            typeof x === 'object' &&
-            x !== null &&
-            (x: any).$$typeof === REACT_POSTPONE_TYPE
-          ) {
-            const postponeInstance: Postpone = (x: any);
-            // We don't log this postpone.
-            emitPostponeChunk(request, errorId, postponeInstance);
-          } else {
-            // We don't log these errors since they didn't actually throw into Flight.
-            const digest = '';
-            emitErrorChunk(request, errorId, digest, x);
-          }
-          return serializePromiseID(errorId);
-        }
-      }
-      // If it hasn't already resolved (and been instrumented) we just encode an infinite
-      // promise that will never resolve.
-      return serializeInfinitePromise();
+      return serializeDebugThenable(request, counter, thenable);
     }
 
     if (isArray(value)) {
@@ -4206,16 +4287,17 @@ function serializeDebugModel(
   }
 }
 
-function outlineDebugModel(
+function emitOutlinedDebugModelChunk(
   request: Request,
+  id: number,
   counter: {objectLimit: number},
   model: ReactClientValue,
-): number {
+): void {
   if (!__DEV__) {
     // These errors should never make it into a build so we don't need to encode them in codes.json
     // eslint-disable-next-line react-internal/prod-error-codes
     throw new Error(
-      'outlineDebugModel should never be called in production mode. This is a bug in React.',
+      'emitOutlinedDebugModel should never be called in production mode. This is a bug in React.',
     );
   }
 
@@ -4246,7 +4328,6 @@ function outlineDebugModel(
     }
   }
 
-  const id = request.nextChunkId++;
   const prevModelRoot = debugModelRoot;
   debugModelRoot = model;
   if (typeof model === 'object' && model !== null) {
@@ -4266,10 +4347,27 @@ function outlineDebugModel(
     debugModelRoot = prevModelRoot;
   }
 
-  request.pendingChunks++;
   const row = id.toString(16) + ':' + json + '\n';
   const processedChunk = stringToChunk(row);
   request.completedRegularChunks.push(processedChunk);
+}
+
+function outlineDebugModel(
+  request: Request,
+  counter: {objectLimit: number},
+  model: ReactClientValue,
+): number {
+  if (!__DEV__) {
+    // These errors should never make it into a build so we don't need to encode them in codes.json
+    // eslint-disable-next-line react-internal/prod-error-codes
+    throw new Error(
+      'outlineDebugModel should never be called in production mode. This is a bug in React.',
+    );
+  }
+
+  const id = request.nextChunkId++;
+  request.pendingChunks++;
+  emitOutlinedDebugModelChunk(request, id, counter, model);
   return id;
 }