Repository: facebook/react
Pull request: [Flight] Always defer Promise values if they're not already resolved

If we have the ability to lazy load Promise values, i.e. if we have a debug channel, then we should always use it for Promises that aren't already resolved and instrumented.

There's little downside to this since they're async anyway.

This also lets us avoid adding `.then()` listeners too early. E.g. if adding the listener would have side-effect. This avoids covering up "unhandled rejection" errors. Since if we listen to a promise eagerly, including reject listeners, we'd have marked that Promise's rejection as handled where as maybe it wouldn't have been otherwise.

In this mode we can also indefinitely wait for the Promise to resolve instead of just waiting a microtask for it to resolve.


Changed files:
filename: packages/react-client/src/ReactFlightClient.js
patch: @@ -2048,6 +2048,18 @@ function parseModelString(
           if (value.length > 2) {
             const debugChannel = response._debugChannel;
             if (debugChannel) {
+              if (value[2] === '@') {
+                // This is a deferred Promise.
+                const ref = value.slice(3); // We assume this doesn't have a path just id.
+                const id = parseInt(ref, 16);
+                if (!response._chunks.has(id)) {
+                  // We haven't seen this id before. Query the server to start sending it.
+                  debugChannel('P:' + ref);
+                }
+                // Start waiting. This now creates a pending chunk if it doesn't already exist.
+                // This is the actual Promise we're waiting for.
+                return getChunk(response, id);
+              }
               const ref = value.slice(2); // We assume this doesn't have a path just id.
               const id = parseInt(ref, 16);
               if (!response._chunks.has(id)) {

filename: packages/react-server/src/ReactFlightServer.js
patch: @@ -801,6 +801,19 @@ function serializeDebugThenable(
     return ref;
   }
 
+  const deferredDebugObjects = request.deferredDebugObjects;
+  if (deferredDebugObjects !== null) {
+    // For Promises that are not yet resolved, we always defer them. They are async anyway so it's
+    // safe to defer them. This also ensures that we don't eagerly call .then() on a Promise that
+    // otherwise wouldn't have initialized. It also ensures that we don't "handle" a rejection
+    // that otherwise would have triggered unhandled rejection.
+    deferredDebugObjects.retained.set(id, (thenable: any));
+    const deferredRef = '$Y@' + id.toString(16);
+    // We can now refer to the deferred object in the future.
+    request.writtenDebugObjects.set(thenable, deferredRef);
+    return deferredRef;
+  }
+
   let cancelled = false;
 
   thenable.then(
@@ -853,6 +866,36 @@ function serializeDebugThenable(
   return ref;
 }
 
+function emitRequestedDebugThenable(
+  request: Request,
+  id: number,
+  counter: {objectLimit: number},
+  thenable: Thenable<any>,
+): void {
+  thenable.then(
+    value => {
+      if (request.status === ABORTING) {
+        emitDebugHaltChunk(request, id);
+        enqueueFlush(request);
+        return;
+      }
+      emitOutlinedDebugModelChunk(request, id, counter, value);
+      enqueueFlush(request);
+    },
+    reason => {
+      if (request.status === ABORTING) {
+        emitDebugHaltChunk(request, id);
+        enqueueFlush(request);
+        return;
+      }
+      // We don't log these errors since they didn't actually throw into Flight.
+      const digest = '';
+      emitErrorChunk(request, id, digest, reason, true);
+      enqueueFlush(request);
+    },
+  );
+}
+
 function serializeThenable(
   request: Request,
   task: Task,
@@ -4384,8 +4427,15 @@ function renderDebugModel(
       } else if (debugNoOutline !== value) {
         // If this isn't the root object (like meta data) and we don't have an id for it, outline
         // it so that we can dedupe it by reference later.
-        const outlinedId = outlineDebugModel(request, counter, value);
-        return serializeByValueID(outlinedId);
+        // $FlowFixMe[method-unbinding]
+        if (typeof value.then === 'function') {
+          // If this is a Promise we're going to assign it an external ID anyway which can be deduped.
+          const thenable: Thenable<any> = (value: any);
+          return serializeDebugThenable(request, counter, thenable);
+        } else {
+          const outlinedId = outlineDebugModel(request, counter, value);
+          return serializeByValueID(outlinedId);
+        }
       }
     }
 
@@ -5787,6 +5837,25 @@ export function resolveDebugMessage(request: Request, message: string): void {
         }
       }
       break;
+    case 80 /* "P" */:
+      // Query Promise IDs
+      for (let i = 0; i < ids.length; i++) {
+        const id = ids[i];
+        const retainedValue = deferredDebugObjects.retained.get(id);
+        if (retainedValue !== undefined) {
+          // If we still have this Promise, and haven't emitted it before, wait for it
+          // and then emit it on the stream.
+          const counter = {objectLimit: 10};
+          deferredDebugObjects.retained.delete(id);
+          emitRequestedDebugThenable(
+            request,
+            id,
+            counter,
+            (retainedValue: any),
+          );
+        }
+      }
+      break;
     default:
       throw new Error(
         'Unknown command. The debugChannel was not wired up properly.',