[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.
This commit is contained in:
Sebastian Markbåge
2025-07-09 09:08:27 -04:00
committed by GitHub
parent 150f022444
commit e6dc25daea
2 changed files with 83 additions and 2 deletions

View File

@@ -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)) {

View File

@@ -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.',