@samitouri / QOS-React-2 / commits / e6dc25daea

[Flight] Always defer Promise values if they're not already resolved (#33742)

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.

Sebastian Markbåge committed Jul 9, 2025 at 09:08 UTC e6dc25daea3d949fa5eae3bdffb8b6b79e30fc82
2 files changed +83 -2
packages/react-client/src/ReactFlightClient.js
+12
@@ -2048,6 +2048,18 @@ function parseModelString(
2048 if (value.length > 2) {
2049 const debugChannel = response._debugChannel;
2050 if (debugChannel) {
2051 + if (value[2] === '@') {
2052 + // This is a deferred Promise.
2053 + const ref = value.slice(3); // We assume this doesn't have a path just id.
2054 + const id = parseInt(ref, 16);
2055 + if (!response._chunks.has(id)) {
2056 + // We haven't seen this id before. Query the server to start sending it.
2057 + debugChannel('P:' + ref);
2058 + }
2059 + // Start waiting. This now creates a pending chunk if it doesn't already exist.
2060 + // This is the actual Promise we're waiting for.
2061 + return getChunk(response, id);
2062 + }
2063 const ref = value.slice(2); // We assume this doesn't have a path just id.
2064 const id = parseInt(ref, 16);
2065 if (!response._chunks.has(id)) {
packages/react-server/src/ReactFlightServer.js
+71 -2
@@ -801,6 +801,19 @@ function serializeDebugThenable(
801 return ref;
802 }
803
804 + const deferredDebugObjects = request.deferredDebugObjects;
805 + if (deferredDebugObjects !== null) {
806 + // For Promises that are not yet resolved, we always defer them. They are async anyway so it's
807 + // safe to defer them. This also ensures that we don't eagerly call .then() on a Promise that
808 + // otherwise wouldn't have initialized. It also ensures that we don't "handle" a rejection
809 + // that otherwise would have triggered unhandled rejection.
810 + deferredDebugObjects.retained.set(id, (thenable: any));
811 + const deferredRef = '$Y@' + id.toString(16);
812 + // We can now refer to the deferred object in the future.
813 + request.writtenDebugObjects.set(thenable, deferredRef);
814 + return deferredRef;
815 + }
816 +
817 let cancelled = false;
818
819 thenable.then(
@@ -853,6 +866,36 @@ function serializeDebugThenable(
866 return ref;
867 }
868
869 +function emitRequestedDebugThenable(
870 + request: Request,
871 + id: number,
872 + counter: {objectLimit: number},
873 + thenable: Thenable<any>,
874 +): void {
875 + thenable.then(
876 + value => {
877 + if (request.status === ABORTING) {
878 + emitDebugHaltChunk(request, id);
879 + enqueueFlush(request);
880 + return;
881 + }
882 + emitOutlinedDebugModelChunk(request, id, counter, value);
883 + enqueueFlush(request);
884 + },
885 + reason => {
886 + if (request.status === ABORTING) {
887 + emitDebugHaltChunk(request, id);
888 + enqueueFlush(request);
889 + return;
890 + }
891 + // We don't log these errors since they didn't actually throw into Flight.
892 + const digest = '';
893 + emitErrorChunk(request, id, digest, reason, true);
894 + enqueueFlush(request);
895 + },
896 + );
897 +}
898 +
899 function serializeThenable(
900 request: Request,
901 task: Task,
@@ -4384,8 +4427,15 @@ function renderDebugModel(
4427 } else if (debugNoOutline !== value) {
4428 // If this isn't the root object (like meta data) and we don't have an id for it, outline
4429 // it so that we can dedupe it by reference later.
4387 - const outlinedId = outlineDebugModel(request, counter, value);
4388 - return serializeByValueID(outlinedId);
4430 + // $FlowFixMe[method-unbinding]
4431 + if (typeof value.then === 'function') {
4432 + // If this is a Promise we're going to assign it an external ID anyway which can be deduped.
4433 + const thenable: Thenable<any> = (value: any);
4434 + return serializeDebugThenable(request, counter, thenable);
4435 + } else {
4436 + const outlinedId = outlineDebugModel(request, counter, value);
4437 + return serializeByValueID(outlinedId);
4438 + }
4439 }
4440 }
4441
@@ -5787,6 +5837,25 @@ export function resolveDebugMessage(request: Request, message: string): void {
5837 }
5838 }
5839 break;
5840 + case 80 /* "P" */:
5841 + // Query Promise IDs
5842 + for (let i = 0; i < ids.length; i++) {
5843 + const id = ids[i];
5844 + const retainedValue = deferredDebugObjects.retained.get(id);
5845 + if (retainedValue !== undefined) {
5846 + // If we still have this Promise, and haven't emitted it before, wait for it
5847 + // and then emit it on the stream.
5848 + const counter = {objectLimit: 10};
5849 + deferredDebugObjects.retained.delete(id);
5850 + emitRequestedDebugThenable(
5851 + request,
5852 + id,
5853 + counter,
5854 + (retainedValue: any),
5855 + );
5856 + }
5857 + }
5858 + break;
5859 default:
5860 throw new Error(
5861 'Unknown command. The debugChannel was not wired up properly.',