Skip to content

Commit 3705486

Browse files
authored
[Flight] Forward debugInfo from awaited instrumented Promises (#33415)
Stacked on #33403. When a Promise is coming from React such as when it's passed from another environment, we should forward the debug information from that environment. We already do that when rendered as a child. This makes it possible to also `await promise` and have the information from that instrumented promise carry through to the next render. This is a bit tricky because the current protocol is that we have to read it from the Promise after it resolves so it has time to be assigned to the promise. `async_hooks` doesn't pass us the instance (even though it has it) when it gets resolved so we need to keep it around. However, we have to be very careful because if we get this wrong it'll cause a memory leak since we retain things by `asyncId` and then manually listen for `destroy()` which can only be called once a Promise is GC:ed, which it can't be if we retain it. We have to therefore use a `WeakRef` in case it never resolves, and then read the `_debugInfo` when it resolves. We could maybe install a setter or something instead but that's also heavy. The other issues is that we don't use native Promises in ReactFlightClient so our instrumented promises aren't picked up by the `async_hooks` implementation and so we never get a handle to our thenable instance. To solve this we can create a native wrapper only in DEV.
1 parent d742611 commit 3705486

12 files changed

+733
-92
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,7 @@ module.exports = {
561561
ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be.
562562
ReturnType: 'readonly',
563563
AnimationFrameID: 'readonly',
564+
WeakRef: 'readonly',
564565
// For Flow type annotation. Only `BigInt` is valid at runtime.
565566
bigint: 'readonly',
566567
BigInt: 'readonly',

packages/react-client/src/ReactFlightClient.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,27 @@ ReactPromise.prototype.then = function <T>(
266266
initializeModuleChunk(chunk);
267267
break;
268268
}
269+
if (__DEV__ && enableAsyncDebugInfo) {
270+
// Because only native Promises get picked up when we're awaiting we need to wrap
271+
// this in a native Promise in DEV. This means that these callbacks are no longer sync
272+
// but the lazy initialization is still sync and the .value can be inspected after,
273+
// allowing it to be read synchronously anyway.
274+
const resolveCallback = resolve;
275+
const rejectCallback = reject;
276+
const wrapperPromise: Promise<T> = new Promise((res, rej) => {
277+
resolve = value => {
278+
// $FlowFixMe
279+
wrapperPromise._debugInfo = this._debugInfo;
280+
res(value);
281+
};
282+
reject = reason => {
283+
// $FlowFixMe
284+
wrapperPromise._debugInfo = this._debugInfo;
285+
rej(reason);
286+
};
287+
});
288+
wrapperPromise.then(resolveCallback, rejectCallback);
289+
}
269290
// The status might have changed after initialization.
270291
switch (chunk.status) {
271292
case INITIALIZED:

packages/react-server/src/ReactFlightAsyncSequence.js

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,33 @@
77
* @flow
88
*/
99

10-
import type {ReactComponentInfo} from 'shared/ReactTypes';
10+
import type {ReactDebugInfo, ReactComponentInfo} from 'shared/ReactTypes';
1111

1212
export const IO_NODE = 0;
1313
export const PROMISE_NODE = 1;
1414
export const AWAIT_NODE = 2;
15+
export const UNRESOLVED_PROMISE_NODE = 3;
16+
export const UNRESOLVED_AWAIT_NODE = 4;
17+
18+
type PromiseWithDebugInfo = interface extends Promise<any> {
19+
_debugInfo?: ReactDebugInfo,
20+
};
1521

1622
export type IONode = {
1723
tag: 0,
1824
owner: null | ReactComponentInfo,
1925
stack: Error, // callsite that spawned the I/O
26+
debugInfo: null, // not used on I/O
2027
start: number, // start time when the first part of the I/O sequence started
2128
end: number, // we typically don't use this. only when there's no promise intermediate.
2229
awaited: null, // I/O is only blocked on external.
23-
previous: null | AwaitNode, // the preceeding await that spawned this new work
30+
previous: null | AwaitNode | UnresolvedAwaitNode, // the preceeding await that spawned this new work
2431
};
2532

2633
export type PromiseNode = {
2734
tag: 1,
2835
owner: null | ReactComponentInfo,
36+
debugInfo: null | ReactDebugInfo, // forwarded debugInfo from the Promise
2937
stack: Error, // callsite that created the Promise
3038
start: number, // start time when the Promise was created
3139
end: number, // end time when the Promise was resolved.
@@ -36,11 +44,39 @@ export type PromiseNode = {
3644
export type AwaitNode = {
3745
tag: 2,
3846
owner: null | ReactComponentInfo,
47+
debugInfo: null | ReactDebugInfo, // forwarded debugInfo from the Promise
3948
stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...)
4049
start: number, // when we started blocking. This might be later than the I/O started.
4150
end: number, // when we unblocked. This might be later than the I/O resolved if there's CPU time.
4251
awaited: null | AsyncSequence, // the promise we were waiting on
4352
previous: null | AsyncSequence, // the sequence that was blocking us from awaiting in the first place
4453
};
4554

46-
export type AsyncSequence = IONode | PromiseNode | AwaitNode;
55+
export type UnresolvedPromiseNode = {
56+
tag: 3,
57+
owner: null | ReactComponentInfo,
58+
debugInfo: WeakRef<PromiseWithDebugInfo>, // holds onto the Promise until we can extract debugInfo when it resolves
59+
stack: Error, // callsite that created the Promise
60+
start: number, // start time when the Promise was created
61+
end: -1.1, // set when we resolve.
62+
awaited: null | AsyncSequence, // the thing that ended up resolving this promise
63+
previous: null, // where we created the promise is not interesting since creating it doesn't mean waiting.
64+
};
65+
66+
export type UnresolvedAwaitNode = {
67+
tag: 4,
68+
owner: null | ReactComponentInfo,
69+
debugInfo: WeakRef<PromiseWithDebugInfo>, // holds onto the Promise until we can extract debugInfo when it resolves
70+
stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...)
71+
start: number, // when we started blocking. This might be later than the I/O started.
72+
end: -1.1, // set when we resolve.
73+
awaited: null | AsyncSequence, // the promise we were waiting on
74+
previous: null | AsyncSequence, // the sequence that was blocking us from awaiting in the first place
75+
};
76+
77+
export type AsyncSequence =
78+
| IONode
79+
| PromiseNode
80+
| AwaitNode
81+
| UnresolvedPromiseNode
82+
| UnresolvedAwaitNode;

packages/react-server/src/ReactFlightServer.js

Lines changed: 88 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
requestStorage,
9090
createHints,
9191
initAsyncDebugInfo,
92+
markAsyncSequenceRootTask,
9293
getCurrentAsyncSequence,
9394
parseStackTrace,
9495
supportsComponentStorage,
@@ -149,7 +150,13 @@ import binaryToComparableString from 'shared/binaryToComparableString';
149150

150151
import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
151152

152-
import {IO_NODE, PROMISE_NODE, AWAIT_NODE} from './ReactFlightAsyncSequence';
153+
import {
154+
IO_NODE,
155+
PROMISE_NODE,
156+
AWAIT_NODE,
157+
UNRESOLVED_AWAIT_NODE,
158+
UNRESOLVED_PROMISE_NODE,
159+
} from './ReactFlightAsyncSequence';
153160

154161
// DEV-only set containing internal objects that should not be limited and turned into getters.
155162
const doNotLimit: WeakSet<Reference> = __DEV__ ? new WeakSet() : (null: any);
@@ -1879,6 +1886,9 @@ function visitAsyncNode(
18791886
case IO_NODE: {
18801887
return node;
18811888
}
1889+
case UNRESOLVED_PROMISE_NODE: {
1890+
return null;
1891+
}
18821892
case PROMISE_NODE: {
18831893
if (node.end < cutOff) {
18841894
// This was already resolved when we started this sequence. It must have been
@@ -1888,6 +1898,7 @@ function visitAsyncNode(
18881898
return null;
18891899
}
18901900
const awaited = node.awaited;
1901+
let match = null;
18911902
if (awaited !== null) {
18921903
const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited);
18931904
if (ioNode !== null) {
@@ -1907,72 +1918,104 @@ function visitAsyncNode(
19071918
// If we haven't defined an end time, use the resolve of the outer Promise.
19081919
ioNode.end = node.end;
19091920
}
1910-
return ioNode;
1921+
match = ioNode;
1922+
} else {
1923+
match = node;
19111924
}
1912-
return node;
19131925
}
19141926
}
1915-
return null;
1927+
// We need to forward after we visit awaited nodes because what ever I/O we requested that's
1928+
// the thing that generated this node and its virtual children.
1929+
const debugInfo = node.debugInfo;
1930+
if (debugInfo !== null) {
1931+
forwardDebugInfo(request, task.id, debugInfo);
1932+
}
1933+
return match;
19161934
}
1935+
case UNRESOLVED_AWAIT_NODE:
1936+
// We could be inside the .then() which is about to resolve this node.
1937+
// TODO: We could call emitAsyncSequence in a microtask to avoid this issue.
1938+
// Fallthrough to the resolved path.
19171939
case AWAIT_NODE: {
19181940
const awaited = node.awaited;
1941+
let match = null;
19191942
if (awaited !== null) {
19201943
const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited);
19211944
if (ioNode !== null) {
1922-
if (node.end < 0) {
1945+
let endTime: number;
1946+
if (node.tag === UNRESOLVED_AWAIT_NODE) {
19231947
// If we haven't defined an end time, use the resolve of the inner Promise.
19241948
// This can happen because the ping gets invoked before the await gets resolved.
19251949
if (ioNode.end < node.start) {
19261950
// If we're awaiting a resolved Promise it could have finished before we started.
1927-
node.end = node.start;
1951+
endTime = node.start;
19281952
} else {
1929-
node.end = ioNode.end;
1953+
endTime = ioNode.end;
19301954
}
1955+
} else {
1956+
endTime = node.end;
19311957
}
1932-
if (node.end < cutOff) {
1958+
if (endTime < cutOff) {
19331959
// This was already resolved when we started this sequence. It must have been
19341960
// part of a different component.
19351961
// TODO: Think of some other way to exclude irrelevant data since if we awaited
19361962
// a cached promise, we should still log this component as being dependent on that data.
1937-
return null;
1938-
}
1939-
1940-
const stack = filterStackTrace(
1941-
request,
1942-
parseStackTrace(node.stack, 1),
1943-
);
1944-
if (stack.length === 0) {
1945-
// If this await was fully filtered out, then it was inside third party code
1946-
// such as in an external library. We return the I/O node and try another await.
1947-
return ioNode;
1948-
}
1949-
// Outline the IO node.
1950-
serializeIONode(request, ioNode);
1951-
// We log the environment at the time when the last promise pigned ping which may
1952-
// be later than what the environment was when we actually started awaiting.
1953-
const env = (0, request.environmentName)();
1954-
if (node.start <= cutOff) {
1955-
// If this was an await that started before this sequence but finished after,
1956-
// then we clamp it to the start of this sequence. We don't need to emit a time
1957-
// TODO: Typically we'll already have a previous time stamp with the cutOff time
1958-
// so we shouldn't need to emit another one. But not always.
1959-
emitTimingChunk(request, task.id, cutOff);
19601963
} else {
1961-
emitTimingChunk(request, task.id, node.start);
1964+
const stack = filterStackTrace(
1965+
request,
1966+
parseStackTrace(node.stack, 1),
1967+
);
1968+
if (stack.length === 0) {
1969+
// If this await was fully filtered out, then it was inside third party code
1970+
// such as in an external library. We return the I/O node and try another await.
1971+
match = ioNode;
1972+
} else {
1973+
// Outline the IO node.
1974+
if (ioNode.end < 0) {
1975+
ioNode.end = endTime;
1976+
}
1977+
serializeIONode(request, ioNode);
1978+
// We log the environment at the time when the last promise pigned ping which may
1979+
// be later than what the environment was when we actually started awaiting.
1980+
const env = (0, request.environmentName)();
1981+
if (node.start <= cutOff) {
1982+
// If this was an await that started before this sequence but finished after,
1983+
// then we clamp it to the start of this sequence. We don't need to emit a time
1984+
// TODO: Typically we'll already have a previous time stamp with the cutOff time
1985+
// so we shouldn't need to emit another one. But not always.
1986+
emitTimingChunk(request, task.id, cutOff);
1987+
} else {
1988+
emitTimingChunk(request, task.id, node.start);
1989+
}
1990+
// Then emit a reference to us awaiting it in the current task.
1991+
request.pendingChunks++;
1992+
emitDebugChunk(request, task.id, {
1993+
awaited: ((ioNode: any): ReactIOInfo), // This is deduped by this reference.
1994+
env: env,
1995+
owner: node.owner,
1996+
stack: stack,
1997+
});
1998+
emitTimingChunk(request, task.id, node.end);
1999+
}
19622000
}
1963-
// Then emit a reference to us awaiting it in the current task.
1964-
request.pendingChunks++;
1965-
emitDebugChunk(request, task.id, {
1966-
awaited: ((ioNode: any): ReactIOInfo), // This is deduped by this reference.
1967-
env: env,
1968-
owner: node.owner,
1969-
stack: stack,
1970-
});
1971-
emitTimingChunk(request, task.id, node.end);
19722001
}
19732002
}
1974-
// If we had awaited anything we would have written it now.
1975-
return null;
2003+
// We need to forward after we visit awaited nodes because what ever I/O we requested that's
2004+
// the thing that generated this node and its virtual children.
2005+
let debugInfo: null | ReactDebugInfo;
2006+
if (node.tag === UNRESOLVED_AWAIT_NODE) {
2007+
const promise = node.debugInfo.deref();
2008+
debugInfo =
2009+
promise === undefined || promise._debugInfo === undefined
2010+
? null
2011+
: promise._debugInfo;
2012+
} else {
2013+
debugInfo = node.debugInfo;
2014+
}
2015+
if (debugInfo !== null) {
2016+
forwardDebugInfo(request, task.id, debugInfo);
2017+
}
2018+
return match;
19762019
}
19772020
default: {
19782021
// eslint-disable-next-line react-internal/prod-error-codes
@@ -4513,6 +4556,8 @@ function tryStreamTask(request: Request, task: Task): void {
45134556
}
45144557

45154558
function performWork(request: Request): void {
4559+
markAsyncSequenceRootTask();
4560+
45164561
const prevDispatcher = ReactSharedInternals.H;
45174562
ReactSharedInternals.H = HooksDispatcher;
45184563
const prevRequest = currentRequest;

0 commit comments

Comments
 (0)