From 37054867c15a7381abe0f73d98f3fecd06da52da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 4 Jun 2025 00:49:03 -0400 Subject: [PATCH] [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. --- .eslintrc.js | 1 + .../react-client/src/ReactFlightClient.js | 21 + .../src/ReactFlightAsyncSequence.js | 42 +- .../react-server/src/ReactFlightServer.js | 131 +++-- .../src/ReactFlightServerConfigDebugNode.js | 82 ++- .../src/ReactFlightServerConfigDebugNoop.js | 1 + .../ReactFlightAsyncDebugInfo-test.js | 542 ++++++++++++++++-- scripts/rollup/validate/eslintrc.cjs.js | 1 + scripts/rollup/validate/eslintrc.cjs2015.js | 1 + scripts/rollup/validate/eslintrc.esm.js | 1 + scripts/rollup/validate/eslintrc.fb.js | 1 + scripts/rollup/validate/eslintrc.rn.js | 1 + 12 files changed, 733 insertions(+), 92 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 8ffc5a732c877..2736a5d6d3e57 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -561,6 +561,7 @@ module.exports = { ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be. ReturnType: 'readonly', AnimationFrameID: 'readonly', + WeakRef: 'readonly', // For Flow type annotation. Only `BigInt` is valid at runtime. bigint: 'readonly', BigInt: 'readonly', diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 41421edb2a32a..78a2d85eea287 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -266,6 +266,27 @@ ReactPromise.prototype.then = function ( initializeModuleChunk(chunk); break; } + if (__DEV__ && enableAsyncDebugInfo) { + // Because only native Promises get picked up when we're awaiting we need to wrap + // this in a native Promise in DEV. This means that these callbacks are no longer sync + // but the lazy initialization is still sync and the .value can be inspected after, + // allowing it to be read synchronously anyway. + const resolveCallback = resolve; + const rejectCallback = reject; + const wrapperPromise: Promise = new Promise((res, rej) => { + resolve = value => { + // $FlowFixMe + wrapperPromise._debugInfo = this._debugInfo; + res(value); + }; + reject = reason => { + // $FlowFixMe + wrapperPromise._debugInfo = this._debugInfo; + rej(reason); + }; + }); + wrapperPromise.then(resolveCallback, rejectCallback); + } // The status might have changed after initialization. switch (chunk.status) { case INITIALIZED: diff --git a/packages/react-server/src/ReactFlightAsyncSequence.js b/packages/react-server/src/ReactFlightAsyncSequence.js index b36fb551091ee..215af7f7953b3 100644 --- a/packages/react-server/src/ReactFlightAsyncSequence.js +++ b/packages/react-server/src/ReactFlightAsyncSequence.js @@ -7,25 +7,33 @@ * @flow */ -import type {ReactComponentInfo} from 'shared/ReactTypes'; +import type {ReactDebugInfo, ReactComponentInfo} from 'shared/ReactTypes'; export const IO_NODE = 0; export const PROMISE_NODE = 1; export const AWAIT_NODE = 2; +export const UNRESOLVED_PROMISE_NODE = 3; +export const UNRESOLVED_AWAIT_NODE = 4; + +type PromiseWithDebugInfo = interface extends Promise { + _debugInfo?: ReactDebugInfo, +}; export type IONode = { tag: 0, owner: null | ReactComponentInfo, stack: Error, // callsite that spawned the I/O + debugInfo: null, // not used on I/O start: number, // start time when the first part of the I/O sequence started end: number, // we typically don't use this. only when there's no promise intermediate. awaited: null, // I/O is only blocked on external. - previous: null | AwaitNode, // the preceeding await that spawned this new work + previous: null | AwaitNode | UnresolvedAwaitNode, // the preceeding await that spawned this new work }; export type PromiseNode = { tag: 1, owner: null | ReactComponentInfo, + debugInfo: null | ReactDebugInfo, // forwarded debugInfo from the Promise stack: Error, // callsite that created the Promise start: number, // start time when the Promise was created end: number, // end time when the Promise was resolved. @@ -36,6 +44,7 @@ export type PromiseNode = { export type AwaitNode = { tag: 2, owner: null | ReactComponentInfo, + debugInfo: null | ReactDebugInfo, // forwarded debugInfo from the Promise stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...) start: number, // when we started blocking. This might be later than the I/O started. end: number, // when we unblocked. This might be later than the I/O resolved if there's CPU time. @@ -43,4 +52,31 @@ export type AwaitNode = { previous: null | AsyncSequence, // the sequence that was blocking us from awaiting in the first place }; -export type AsyncSequence = IONode | PromiseNode | AwaitNode; +export type UnresolvedPromiseNode = { + tag: 3, + owner: null | ReactComponentInfo, + debugInfo: WeakRef, // holds onto the Promise until we can extract debugInfo when it resolves + stack: Error, // callsite that created the Promise + start: number, // start time when the Promise was created + end: -1.1, // set when we resolve. + awaited: null | AsyncSequence, // the thing that ended up resolving this promise + previous: null, // where we created the promise is not interesting since creating it doesn't mean waiting. +}; + +export type UnresolvedAwaitNode = { + tag: 4, + owner: null | ReactComponentInfo, + debugInfo: WeakRef, // holds onto the Promise until we can extract debugInfo when it resolves + stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...) + start: number, // when we started blocking. This might be later than the I/O started. + end: -1.1, // set when we resolve. + awaited: null | AsyncSequence, // the promise we were waiting on + previous: null | AsyncSequence, // the sequence that was blocking us from awaiting in the first place +}; + +export type AsyncSequence = + | IONode + | PromiseNode + | AwaitNode + | UnresolvedPromiseNode + | UnresolvedAwaitNode; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index e5c6043652c85..b1d3a91dc0b7e 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -89,6 +89,7 @@ import { requestStorage, createHints, initAsyncDebugInfo, + markAsyncSequenceRootTask, getCurrentAsyncSequence, parseStackTrace, supportsComponentStorage, @@ -149,7 +150,13 @@ import binaryToComparableString from 'shared/binaryToComparableString'; import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable'; -import {IO_NODE, PROMISE_NODE, AWAIT_NODE} from './ReactFlightAsyncSequence'; +import { + IO_NODE, + PROMISE_NODE, + AWAIT_NODE, + UNRESOLVED_AWAIT_NODE, + UNRESOLVED_PROMISE_NODE, +} from './ReactFlightAsyncSequence'; // DEV-only set containing internal objects that should not be limited and turned into getters. const doNotLimit: WeakSet = __DEV__ ? new WeakSet() : (null: any); @@ -1879,6 +1886,9 @@ function visitAsyncNode( case IO_NODE: { return node; } + case UNRESOLVED_PROMISE_NODE: { + return null; + } case PROMISE_NODE: { if (node.end < cutOff) { // This was already resolved when we started this sequence. It must have been @@ -1888,6 +1898,7 @@ function visitAsyncNode( return null; } const awaited = node.awaited; + let match = null; if (awaited !== null) { const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited); if (ioNode !== null) { @@ -1907,72 +1918,104 @@ function visitAsyncNode( // If we haven't defined an end time, use the resolve of the outer Promise. ioNode.end = node.end; } - return ioNode; + match = ioNode; + } else { + match = node; } - return node; } } - return null; + // We need to forward after we visit awaited nodes because what ever I/O we requested that's + // the thing that generated this node and its virtual children. + const debugInfo = node.debugInfo; + if (debugInfo !== null) { + forwardDebugInfo(request, task.id, debugInfo); + } + return match; } + case UNRESOLVED_AWAIT_NODE: + // We could be inside the .then() which is about to resolve this node. + // TODO: We could call emitAsyncSequence in a microtask to avoid this issue. + // Fallthrough to the resolved path. case AWAIT_NODE: { const awaited = node.awaited; + let match = null; if (awaited !== null) { const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited); if (ioNode !== null) { - if (node.end < 0) { + let endTime: number; + if (node.tag === UNRESOLVED_AWAIT_NODE) { // If we haven't defined an end time, use the resolve of the inner Promise. // This can happen because the ping gets invoked before the await gets resolved. if (ioNode.end < node.start) { // If we're awaiting a resolved Promise it could have finished before we started. - node.end = node.start; + endTime = node.start; } else { - node.end = ioNode.end; + endTime = ioNode.end; } + } else { + endTime = node.end; } - if (node.end < cutOff) { + if (endTime < cutOff) { // This was already resolved when we started this sequence. It must have been // part of a different component. // TODO: Think of some other way to exclude irrelevant data since if we awaited // a cached promise, we should still log this component as being dependent on that data. - return null; - } - - const stack = filterStackTrace( - request, - parseStackTrace(node.stack, 1), - ); - if (stack.length === 0) { - // If this await was fully filtered out, then it was inside third party code - // such as in an external library. We return the I/O node and try another await. - return ioNode; - } - // Outline the IO node. - serializeIONode(request, ioNode); - // We log the environment at the time when the last promise pigned ping which may - // be later than what the environment was when we actually started awaiting. - const env = (0, request.environmentName)(); - if (node.start <= cutOff) { - // If this was an await that started before this sequence but finished after, - // then we clamp it to the start of this sequence. We don't need to emit a time - // TODO: Typically we'll already have a previous time stamp with the cutOff time - // so we shouldn't need to emit another one. But not always. - emitTimingChunk(request, task.id, cutOff); } else { - emitTimingChunk(request, task.id, node.start); + const stack = filterStackTrace( + request, + parseStackTrace(node.stack, 1), + ); + if (stack.length === 0) { + // If this await was fully filtered out, then it was inside third party code + // such as in an external library. We return the I/O node and try another await. + match = ioNode; + } else { + // Outline the IO node. + if (ioNode.end < 0) { + ioNode.end = endTime; + } + serializeIONode(request, ioNode); + // We log the environment at the time when the last promise pigned ping which may + // be later than what the environment was when we actually started awaiting. + const env = (0, request.environmentName)(); + if (node.start <= cutOff) { + // If this was an await that started before this sequence but finished after, + // then we clamp it to the start of this sequence. We don't need to emit a time + // TODO: Typically we'll already have a previous time stamp with the cutOff time + // so we shouldn't need to emit another one. But not always. + emitTimingChunk(request, task.id, cutOff); + } else { + emitTimingChunk(request, task.id, node.start); + } + // Then emit a reference to us awaiting it in the current task. + request.pendingChunks++; + emitDebugChunk(request, task.id, { + awaited: ((ioNode: any): ReactIOInfo), // This is deduped by this reference. + env: env, + owner: node.owner, + stack: stack, + }); + emitTimingChunk(request, task.id, node.end); + } } - // Then emit a reference to us awaiting it in the current task. - request.pendingChunks++; - emitDebugChunk(request, task.id, { - awaited: ((ioNode: any): ReactIOInfo), // This is deduped by this reference. - env: env, - owner: node.owner, - stack: stack, - }); - emitTimingChunk(request, task.id, node.end); } } - // If we had awaited anything we would have written it now. - return null; + // We need to forward after we visit awaited nodes because what ever I/O we requested that's + // the thing that generated this node and its virtual children. + let debugInfo: null | ReactDebugInfo; + if (node.tag === UNRESOLVED_AWAIT_NODE) { + const promise = node.debugInfo.deref(); + debugInfo = + promise === undefined || promise._debugInfo === undefined + ? null + : promise._debugInfo; + } else { + debugInfo = node.debugInfo; + } + if (debugInfo !== null) { + forwardDebugInfo(request, task.id, debugInfo); + } + return match; } default: { // eslint-disable-next-line react-internal/prod-error-codes @@ -4513,6 +4556,8 @@ function tryStreamTask(request: Request, task: Task): void { } function performWork(request: Request): void { + markAsyncSequenceRootTask(); + const prevDispatcher = ReactSharedInternals.H; ReactSharedInternals.H = HooksDispatcher; const prevRequest = currentRequest; diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 7a86231ed9a5c..3bf84962bbed0 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -11,10 +11,18 @@ import type { AsyncSequence, IONode, PromiseNode, + UnresolvedPromiseNode, AwaitNode, + UnresolvedAwaitNode, } from './ReactFlightAsyncSequence'; -import {IO_NODE, PROMISE_NODE, AWAIT_NODE} from './ReactFlightAsyncSequence'; +import { + IO_NODE, + PROMISE_NODE, + UNRESOLVED_PROMISE_NODE, + AWAIT_NODE, + UNRESOLVED_AWAIT_NODE, +} from './ReactFlightAsyncSequence'; import {resolveOwner} from './flight/ReactFlightCurrentOwner'; import {createHook, executionAsyncId} from 'async_hooks'; import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; @@ -30,7 +38,12 @@ const pendingOperations: Map = export function initAsyncDebugInfo(): void { if (__DEV__ && enableAsyncDebugInfo) { createHook({ - init(asyncId: number, type: string, triggerAsyncId: number): void { + init( + asyncId: number, + type: string, + triggerAsyncId: number, + resource: any, + ): void { const trigger = pendingOperations.get(triggerAsyncId); let node: AsyncSequence; if (type === 'PROMISE') { @@ -46,18 +59,20 @@ export function initAsyncDebugInfo(): void { // If the thing we're waiting on is another Await we still track that sequence // so that we can later pick the best stack trace in user space. node = ({ - tag: AWAIT_NODE, + tag: UNRESOLVED_AWAIT_NODE, owner: resolveOwner(), + debugInfo: new WeakRef((resource: Promise)), stack: new Error(), start: performance.now(), end: -1.1, // set when resolved. awaited: trigger, // The thing we're awaiting on. Might get overrriden when we resolve. previous: current === undefined ? null : current, // The path that led us here. - }: AwaitNode); + }: UnresolvedAwaitNode); } else { node = ({ - tag: PROMISE_NODE, + tag: UNRESOLVED_PROMISE_NODE, owner: resolveOwner(), + debugInfo: new WeakRef((resource: Promise)), stack: new Error(), start: performance.now(), end: -1.1, // Set when we resolve. @@ -66,7 +81,7 @@ export function initAsyncDebugInfo(): void { ? null // It might get overridden when we resolve. : trigger, previous: null, - }: PromiseNode); + }: UnresolvedPromiseNode); } } else if ( type !== 'Microtask' && @@ -78,17 +93,22 @@ export function initAsyncDebugInfo(): void { node = ({ tag: IO_NODE, owner: resolveOwner(), + debugInfo: null, stack: new Error(), // This is only used if no native promises are used. start: performance.now(), end: -1.1, // Only set when pinged. awaited: null, previous: null, }: IONode); - } else if (trigger.tag === AWAIT_NODE) { + } else if ( + trigger.tag === AWAIT_NODE || + trigger.tag === UNRESOLVED_AWAIT_NODE + ) { // We have begun a new I/O sequence after the await. node = ({ tag: IO_NODE, owner: resolveOwner(), + debugInfo: null, stack: new Error(), start: performance.now(), end: -1.1, // Only set when pinged. @@ -110,16 +130,41 @@ export function initAsyncDebugInfo(): void { pendingOperations.set(asyncId, node); }, promiseResolve(asyncId: number): void { - const resolvedNode = pendingOperations.get(asyncId); - if (resolvedNode !== undefined) { - if (resolvedNode.tag === IO_NODE) { - // eslint-disable-next-line react-internal/prod-error-codes - throw new Error( - 'A Promise should never be an IO_NODE. This is a bug in React.', - ); + const node = pendingOperations.get(asyncId); + if (node !== undefined) { + let resolvedNode: AwaitNode | PromiseNode; + switch (node.tag) { + case UNRESOLVED_AWAIT_NODE: { + const awaitNode: AwaitNode = (node: any); + awaitNode.tag = AWAIT_NODE; + resolvedNode = awaitNode; + break; + } + case UNRESOLVED_PROMISE_NODE: { + const promiseNode: PromiseNode = (node: any); + promiseNode.tag = PROMISE_NODE; + resolvedNode = promiseNode; + break; + } + case IO_NODE: + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'A Promise should never be an IO_NODE. This is a bug in React.', + ); + default: + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'A Promise should never be resolved twice. This is a bug in React or Node.js.', + ); } // Log the end time when we resolved the promise. resolvedNode.end = performance.now(); + // The Promise can be garbage collected after this so we should extract debugInfo first. + const promise = node.debugInfo.deref(); + resolvedNode.debugInfo = + promise === undefined || promise._debugInfo === undefined + ? null + : promise._debugInfo; const currentAsyncId = executionAsyncId(); if (asyncId !== currentAsyncId) { // If the promise was not resolved by itself, then that means that @@ -140,6 +185,15 @@ export function initAsyncDebugInfo(): void { } } +export function markAsyncSequenceRootTask(): void { + if (__DEV__ && enableAsyncDebugInfo) { + // Whatever Task we're running now is spawned by React itself to perform render work. + // Don't track any cause beyond this task. We may still track I/O that was started outside + // React but just not the cause of entering the render. + pendingOperations.delete(executionAsyncId()); + } +} + export function getCurrentAsyncSequence(): null | AsyncSequence { if (!__DEV__ || !enableAsyncDebugInfo) { return null; diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js index b0f71c9697a74..7418aaef18310 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js @@ -11,6 +11,7 @@ import type {AsyncSequence} from './ReactFlightAsyncSequence'; // Exported for runtimes that don't support Promise instrumentation for async debugging. export function initAsyncDebugInfo(): void {} +export function markAsyncSequenceRootTask(): void {} export function getCurrentAsyncSequence(): null | AsyncSequence { return null; } diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index da2fc898b93b5..d1c84f9cc272c 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -117,6 +117,23 @@ describe('ReactFlightAsyncDebugInfo', () => { }); } + function fetchThirdParty(Component) { + const stream = ReactServerDOMServer.renderToPipeableStream( + , + {}, + { + environmentName: 'third-party', + }, + ); + const readable = new Stream.PassThrough(streamOptions); + const result = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: {}, + moduleLoading: {}, + }); + stream.pipe(readable); + return result; + } + it('can track async information when awaited', async () => { async function getData() { await delay(1); @@ -163,9 +180,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 133, + 150, 109, - 120, + 137, 50, ], ], @@ -188,9 +205,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 133, + 150, 109, - 120, + 137, 50, ], ], @@ -207,17 +224,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 122, + 139, 13, - 121, + 138, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 129, + 146, 26, - 128, + 145, 5, ], ], @@ -234,9 +251,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 133, + 150, 109, - 120, + 137, 50, ], ], @@ -245,17 +262,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 122, + 139, 13, - 121, + 138, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 129, + 146, 26, - 128, + 145, 5, ], ], @@ -281,9 +298,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 133, + 150, 109, - 120, + 137, 50, ], ], @@ -300,17 +317,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 123, + 140, 21, - 121, + 138, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 129, + 146, 20, - 128, + 145, 5, ], ], @@ -327,9 +344,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 133, + 150, 109, - 120, + 137, 50, ], ], @@ -338,17 +355,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 124, + 141, 21, - 121, + 138, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 129, + 146, 20, - 128, + 145, 5, ], ], @@ -410,9 +427,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 380, + 397, 109, - 367, + 384, 67, ], ], @@ -435,9 +452,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 380, + 397, 109, - 367, + 384, 67, ], ], @@ -446,9 +463,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 370, + 387, 7, - 368, + 385, 5, ], ], @@ -466,4 +483,465 @@ describe('ReactFlightAsyncDebugInfo', () => { `); } }); + + it('can ingores the start of I/O when immediately resolved non-native promise is awaited', async () => { + async function Component() { + return await { + then(callback) { + callback('hi'); + }, + }; + } + + const stream = ReactServerDOMServer.renderToPipeableStream(); + + const readable = new Stream.PassThrough(streamOptions); + + const result = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: {}, + moduleLoading: {}, + }); + stream.pipe(readable); + + expect(await result).toBe('hi'); + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + expect(getDebugInfo(result)).toMatchInlineSnapshot(` + [ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Component", + "owner": null, + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 496, + 109, + 487, + 94, + ], + ], + }, + { + "time": 0, + }, + ] + `); + } + }); + + it('forwards debugInfo from awaited Promises', async () => { + async function Component() { + let resolve; + const promise = new Promise(r => (resolve = r)); + promise._debugInfo = [ + {time: performance.now()}, + { + name: 'Virtual Component', + }, + {time: performance.now()}, + ]; + const promise2 = promise.then(value => value); + promise2._debugInfo = [ + {time: performance.now()}, + { + name: 'Virtual Component2', + }, + {time: performance.now()}, + ]; + resolve('hi'); + const result = await promise2; + return result.toUpperCase(); + } + + const stream = ReactServerDOMServer.renderToPipeableStream(); + + const readable = new Stream.PassThrough(streamOptions); + + const result = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: {}, + moduleLoading: {}, + }); + stream.pipe(readable); + + expect(await result).toBe('HI'); + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + expect(getDebugInfo(result)).toMatchInlineSnapshot(` + [ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Component", + "owner": null, + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 568, + 109, + 544, + 50, + ], + ], + }, + { + "time": 0, + }, + { + "name": "Virtual Component", + }, + { + "time": 0, + }, + { + "time": 0, + }, + { + "name": "Virtual Component2", + }, + { + "time": 0, + }, + { + "time": 0, + }, + ] + `); + } + }); + + it('forwards async debug info one environment to the next', async () => { + async function getData() { + await delay(1); + await delay(2); + return 'hi'; + } + + async function ThirdPartyComponent() { + const data = await getData(); + return data; + } + + async function Component() { + const data = await fetchThirdParty(ThirdPartyComponent); + return data.toUpperCase(); + } + + const stream = ReactServerDOMServer.renderToPipeableStream(); + + const readable = new Stream.PassThrough(streamOptions); + + const result = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: {}, + moduleLoading: {}, + }); + stream.pipe(readable); + + expect(await result).toBe('HI'); + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + expect(getDebugInfo(result)).toMatchInlineSnapshot(` + [ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Component", + "owner": null, + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 651, + 109, + 634, + 63, + ], + ], + }, + { + "time": 0, + }, + { + "env": "third-party", + "key": null, + "name": "ThirdPartyComponent", + "owner": null, + "props": {}, + "stack": [ + [ + "fetchThirdParty", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 122, + 40, + 120, + 3, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 647, + 24, + 646, + 5, + ], + ], + }, + { + "time": 0, + }, + { + "awaited": { + "end": 0, + "env": "third-party", + "name": "delay", + "owner": { + "env": "third-party", + "key": null, + "name": "ThirdPartyComponent", + "owner": null, + "props": {}, + "stack": [ + [ + "fetchThirdParty", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 122, + 40, + 120, + 3, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 647, + 24, + 646, + 5, + ], + ], + }, + "stack": [ + [ + "delay", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 115, + 12, + 114, + 3, + ], + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 636, + 13, + 635, + 5, + ], + [ + "ThirdPartyComponent", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 642, + 24, + 641, + 5, + ], + ], + "start": 0, + }, + "env": "third-party", + "owner": { + "env": "third-party", + "key": null, + "name": "ThirdPartyComponent", + "owner": null, + "props": {}, + "stack": [ + [ + "fetchThirdParty", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 122, + 40, + 120, + 3, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 647, + 24, + 646, + 5, + ], + ], + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 636, + 13, + 635, + 5, + ], + [ + "ThirdPartyComponent", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 642, + 24, + 641, + 5, + ], + ], + }, + { + "time": 0, + }, + { + "time": 0, + }, + { + "awaited": { + "end": 0, + "env": "third-party", + "name": "delay", + "owner": { + "env": "third-party", + "key": null, + "name": "ThirdPartyComponent", + "owner": null, + "props": {}, + "stack": [ + [ + "fetchThirdParty", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 122, + 40, + 120, + 3, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 647, + 24, + 646, + 5, + ], + ], + }, + "stack": [ + [ + "delay", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 115, + 12, + 114, + 3, + ], + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 637, + 13, + 635, + 5, + ], + [ + "ThirdPartyComponent", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 642, + 18, + 641, + 5, + ], + ], + "start": 0, + }, + "env": "third-party", + "owner": { + "env": "third-party", + "key": null, + "name": "ThirdPartyComponent", + "owner": null, + "props": {}, + "stack": [ + [ + "fetchThirdParty", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 122, + 40, + 120, + 3, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 647, + 24, + 646, + 5, + ], + ], + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 637, + 13, + 635, + 5, + ], + [ + "ThirdPartyComponent", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 642, + 18, + 641, + 5, + ], + ], + }, + { + "time": 0, + }, + { + "time": 0, + }, + { + "time": 0, + }, + ] + `); + } + }); }); diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index 65fd6129904e6..e7e172599d0e6 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -14,6 +14,7 @@ module.exports = { Symbol: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + WeakRef: 'readonly', Int8Array: 'readonly', Uint8Array: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.cjs2015.js b/scripts/rollup/validate/eslintrc.cjs2015.js index fa0b471330f4a..5e4feb64da8e8 100644 --- a/scripts/rollup/validate/eslintrc.cjs2015.js +++ b/scripts/rollup/validate/eslintrc.cjs2015.js @@ -14,6 +14,7 @@ module.exports = { Symbol: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + WeakRef: 'readonly', Int8Array: 'readonly', Uint8Array: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.esm.js b/scripts/rollup/validate/eslintrc.esm.js index a5ea7afb972e7..bef26bbd67efb 100644 --- a/scripts/rollup/validate/eslintrc.esm.js +++ b/scripts/rollup/validate/eslintrc.esm.js @@ -14,6 +14,7 @@ module.exports = { Symbol: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + WeakRef: 'readonly', Int8Array: 'readonly', Uint8Array: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js index afee2f1199eb1..b8b556d8b7b2e 100644 --- a/scripts/rollup/validate/eslintrc.fb.js +++ b/scripts/rollup/validate/eslintrc.fb.js @@ -14,6 +14,7 @@ module.exports = { Proxy: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + WeakRef: 'readonly', Int8Array: 'readonly', Uint8Array: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.rn.js b/scripts/rollup/validate/eslintrc.rn.js index 2420898bebecd..2ee825dd77414 100644 --- a/scripts/rollup/validate/eslintrc.rn.js +++ b/scripts/rollup/validate/eslintrc.rn.js @@ -14,6 +14,7 @@ module.exports = { Proxy: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', + WeakRef: 'readonly', Int8Array: 'readonly', Uint8Array: 'readonly',