From 343f33873748579cb0b650d545fb9989a92dccc5 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 26 May 2025 17:38:22 -0400 Subject: [PATCH 01/20] Track the graph of async operations and their stack traces --- .../src/ReactFlightAsyncSequence.js | 35 ++++++ .../react-server/src/ReactFlightServer.js | 1 + .../src/ReactFlightServerConfigDebugNode.js | 111 +++++++++++++++++- .../src/ReactFlightServerConfigDebugNoop.js | 5 + 4 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 packages/react-server/src/ReactFlightAsyncSequence.js diff --git a/packages/react-server/src/ReactFlightAsyncSequence.js b/packages/react-server/src/ReactFlightAsyncSequence.js new file mode 100644 index 0000000000000..918c2df239cbe --- /dev/null +++ b/packages/react-server/src/ReactFlightAsyncSequence.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export const IO_NODE = 0; +export const PROMISE_NODE = 1; +export const AWAIT_NODE = 2; + +export type IONode = { + tag: 0, + stack: Error, // callsite that spawned the I/O + timestamp: number, // start time when the first part of the I/O sequence started + trigger: null | AwaitNode, // the preceeding await that spawned this new work +}; + +export type PromiseNode = { + tag: 1, + stack: null, + timestamp: number, // resolve time of the promise. not used. + trigger: null | AsyncSequence, +}; + +export type AwaitNode = { + tag: 2, + stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...) + timestamp: number, // the end time of the preceeding I/O operation (or -1.1 before it ends) + trigger: null | AsyncSequence, // the thing we were waiting on +}; + +export type AsyncSequence = IONode | PromiseNode | AwaitNode; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 4048a855ef181..3daad92d3a439 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -81,6 +81,7 @@ import { requestStorage, createHints, initAsyncDebugInfo, + getCurrentAsyncSequence, parseStackTrace, supportsComponentStorage, componentStorage, diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index f9f4d9cef3f89..42ea389ffb428 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -7,9 +7,20 @@ * @flow */ +import type { + AsyncSequence, + IONode, + PromiseNode, + AwaitNode, +} from './ReactFlightAsyncSequence'; + +import {IO_NODE, PROMISE_NODE, AWAIT_NODE} from './ReactFlightAsyncSequence'; import {createAsyncHook, executionAsyncId} from './ReactFlightServerConfig'; import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; +const pendingOperations: Map = + __DEV__ && enableAsyncDebugInfo ? new Map() : (null: any); + // Initialize the tracing of async operations. // We do this globally since the async work can potentially eagerly // start before the first request and once requests start they can interleave. @@ -19,15 +30,107 @@ export function initAsyncDebugInfo(): void { if (__DEV__ && enableAsyncDebugInfo) { createAsyncHook({ init(asyncId: number, type: string, triggerAsyncId: number): void { - // TODO + const trigger = pendingOperations.get(triggerAsyncId); + let node: AsyncSequence; + if (type === 'PROMISE') { + const currentAsyncId = executionAsyncId(); + if (currentAsyncId !== triggerAsyncId) { + // When you call .then() on a native Promise, or await/Promise.all() a thenable, + // then this intermediate Promise is created. We use this as our await point + if (trigger === undefined) { + // We don't track awaits on things that started outside our tracked scope. + return; + } + // 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, + stack: new Error(), + timestamp: -1.1, // The end time will be set when the Promise resolves. + trigger: trigger, + }: AwaitNode); + } else { + node = ({ + tag: PROMISE_NODE, + stack: null, + timestamp: -1.1, + trigger: + trigger === undefined + ? null // It might get set when we resolve. + : trigger, + }: PromiseNode); + } + } else if (type !== 'Microtask' && type !== 'TickObject') { + if (trigger === undefined) { + // We have begun a new I/O sequence. + node = ({ + tag: IO_NODE, + stack: new Error(), + timestamp: performance.now(), + trigger: null, + }: IONode); + } else if (trigger.tag === AWAIT_NODE) { + // We have begun a new I/O sequence after the await. + node = ({ + tag: IO_NODE, + stack: new Error(), + timestamp: performance.now(), + trigger: trigger, + }: IONode); + } else { + // Otherwise, this is just a continuation of the same I/O sequence. + node = trigger; + } + } else { + // Ignore nextTick and microtasks as they're not considered I/O operations. + // we just treat the trigger as the node to carry along the sequence. + if (trigger === undefined) { + return; + } + node = trigger; + } + pendingOperations.set(asyncId, node); }, promiseResolve(asyncId: number): void { - // TODO - executionAsyncId(); + 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 currentAsyncId = executionAsyncId(); + if (asyncId !== currentAsyncId) { + // If the promise was not resolved by itself, then that means that + // the trigger that we originally stored wasn't actually the dependency. + // Instead, the current execution context is what ultimately unblocked it. + const trigger = pendingOperations.get(currentAsyncId); + if (trigger !== undefined) { + resolvedNode.trigger = trigger; + // Log the end time when we resolved the await or Promise. + resolvedNode.timestamp = performance.now(); + } else { + resolvedNode.trigger = null; + } + } + } }, + destroy(asyncId: number): void { - // TODO + // If we needed the meta data from this operation we should have already + // extracted it or it should be part of a chain of triggers. + pendingOperations.delete(asyncId); }, }).enable(); } } + +export function getCurrentAsyncSequence(): null | AsyncSequence { + const currentNode = pendingOperations.get(executionAsyncId()); + if (currentNode === undefined) { + // Nothing that we tracked led to the resolution of this execution context. + return null; + } + return currentNode; +} diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js index 55661479583e0..b0f71c9697a74 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js @@ -7,5 +7,10 @@ * @flow */ +import type {AsyncSequence} from './ReactFlightAsyncSequence'; + // Exported for runtimes that don't support Promise instrumentation for async debugging. export function initAsyncDebugInfo(): void {} +export function getCurrentAsyncSequence(): null | AsyncSequence { + return null; +} From a18e19e052d2a741e59370cad5499b703ed2241b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 29 May 2025 16:13:27 -0400 Subject: [PATCH 02/20] Track both awaited and previous sequence separately This lets us track both the thing we're awaiting and the path that led us to awaiting in the first place. --- .../src/ReactFlightAsyncSequence.js | 13 +++++--- .../src/ReactFlightServerConfigDebugNode.js | 33 ++++++++++--------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/react-server/src/ReactFlightAsyncSequence.js b/packages/react-server/src/ReactFlightAsyncSequence.js index 918c2df239cbe..7455752fdba67 100644 --- a/packages/react-server/src/ReactFlightAsyncSequence.js +++ b/packages/react-server/src/ReactFlightAsyncSequence.js @@ -15,21 +15,24 @@ export type IONode = { tag: 0, stack: Error, // callsite that spawned the I/O timestamp: number, // start time when the first part of the I/O sequence started - trigger: null | AwaitNode, // the preceeding await that spawned this new work + awaited: null, // I/O is only blocked on external. + previous: null | AwaitNode, // the preceeding await that spawned this new work }; export type PromiseNode = { tag: 1, - stack: null, - timestamp: number, // resolve time of the promise. not used. - trigger: null | AsyncSequence, + stack: Error, // callsite that created the Promise. Only used if the I/O callsite is not in user space. + timestamp: number, // start time of the promise. Only used if the I/O was not relevant. + 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 AwaitNode = { tag: 2, stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...) timestamp: number, // the end time of the preceeding I/O operation (or -1.1 before it ends) - trigger: null | AsyncSequence, // the thing we were waiting on + 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; diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 42ea389ffb428..99d4f6f06bb4f 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -41,23 +41,26 @@ export function initAsyncDebugInfo(): void { // We don't track awaits on things that started outside our tracked scope. return; } + const current = pendingOperations.get(currentAsyncId); // 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, stack: new Error(), timestamp: -1.1, // The end time will be set when the Promise resolves. - trigger: trigger, + 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); } else { node = ({ tag: PROMISE_NODE, - stack: null, - timestamp: -1.1, - trigger: + stack: new Error(), + timestamp: performance.now(), + awaited: trigger === undefined - ? null // It might get set when we resolve. + ? null // It might get overridden when we resolve. : trigger, + previous: null, }: PromiseNode); } } else if (type !== 'Microtask' && type !== 'TickObject') { @@ -67,7 +70,8 @@ export function initAsyncDebugInfo(): void { tag: IO_NODE, stack: new Error(), timestamp: performance.now(), - trigger: null, + awaited: null, + previous: null, }: IONode); } else if (trigger.tag === AWAIT_NODE) { // We have begun a new I/O sequence after the await. @@ -75,7 +79,8 @@ export function initAsyncDebugInfo(): void { tag: IO_NODE, stack: new Error(), timestamp: performance.now(), - trigger: trigger, + awaited: null, + previous: trigger, }: IONode); } else { // Otherwise, this is just a continuation of the same I/O sequence. @@ -100,19 +105,17 @@ export function initAsyncDebugInfo(): void { 'A Promise should never be an IO_NODE. This is a bug in React.', ); } + if (resolvedNode.tag === AWAIT_NODE) { + // Log the end time when we resolved the await. + resolvedNode.timestamp = performance.now(); + } const currentAsyncId = executionAsyncId(); if (asyncId !== currentAsyncId) { // If the promise was not resolved by itself, then that means that // the trigger that we originally stored wasn't actually the dependency. // Instead, the current execution context is what ultimately unblocked it. - const trigger = pendingOperations.get(currentAsyncId); - if (trigger !== undefined) { - resolvedNode.trigger = trigger; - // Log the end time when we resolved the await or Promise. - resolvedNode.timestamp = performance.now(); - } else { - resolvedNode.trigger = null; - } + const awaited = pendingOperations.get(currentAsyncId); + resolvedNode.awaited = awaited === undefined ? null : awaited; } } }, From dfee5a77abd4146c078355c19e49885999f85a7f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 29 May 2025 18:12:31 -0400 Subject: [PATCH 03/20] Fix timestamp Track end time of I/O only if we need to --- .../src/ReactFlightAsyncSequence.js | 11 ++++++---- .../src/ReactFlightServerConfigDebugNode.js | 20 +++++++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/react-server/src/ReactFlightAsyncSequence.js b/packages/react-server/src/ReactFlightAsyncSequence.js index 7455752fdba67..533990d827a53 100644 --- a/packages/react-server/src/ReactFlightAsyncSequence.js +++ b/packages/react-server/src/ReactFlightAsyncSequence.js @@ -14,15 +14,17 @@ export const AWAIT_NODE = 2; export type IONode = { tag: 0, stack: Error, // callsite that spawned the I/O - timestamp: number, // start time when the first part of the I/O sequence started + 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 }; export type PromiseNode = { tag: 1, - stack: Error, // callsite that created the Promise. Only used if the I/O callsite is not in user space. - timestamp: number, // start time of the promise. Only used if the I/O was not relevant. + 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. 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. }; @@ -30,7 +32,8 @@ export type PromiseNode = { export type AwaitNode = { tag: 2, stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...) - timestamp: number, // the end time of the preceeding I/O operation (or -1.1 before it ends) + start: -1.1, // not used. We use the timing of the awaited promise. + end: -1.1, // not used. awaited: null | AsyncSequence, // the promise we were waiting on previous: null | AsyncSequence, // the sequence that was blocking us from awaiting in the first place }; diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 99d4f6f06bb4f..5138d45bcb9e8 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -47,7 +47,8 @@ export function initAsyncDebugInfo(): void { node = ({ tag: AWAIT_NODE, stack: new Error(), - timestamp: -1.1, // The end time will be set when the Promise resolves. + start: -1.1, + end: -1.1, 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); @@ -55,7 +56,8 @@ export function initAsyncDebugInfo(): void { node = ({ tag: PROMISE_NODE, stack: new Error(), - timestamp: performance.now(), + start: performance.now(), + end: -1.1, // Set when we resolve. awaited: trigger === undefined ? null // It might get overridden when we resolve. @@ -68,8 +70,9 @@ export function initAsyncDebugInfo(): void { // We have begun a new I/O sequence. node = ({ tag: IO_NODE, - stack: new Error(), - timestamp: performance.now(), + 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); @@ -78,7 +81,8 @@ export function initAsyncDebugInfo(): void { node = ({ tag: IO_NODE, stack: new Error(), - timestamp: performance.now(), + start: performance.now(), + end: -1.1, // Only set when pinged. awaited: null, previous: trigger, }: IONode); @@ -105,9 +109,9 @@ export function initAsyncDebugInfo(): void { 'A Promise should never be an IO_NODE. This is a bug in React.', ); } - if (resolvedNode.tag === AWAIT_NODE) { - // Log the end time when we resolved the await. - resolvedNode.timestamp = performance.now(); + if (resolvedNode.tag === PROMISE_NODE) { + // Log the end time when we resolved the promise. + resolvedNode.end = performance.now(); } const currentAsyncId = executionAsyncId(); if (asyncId !== currentAsyncId) { From 6ba978188403768c77933098d6902f7f5e412426 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 27 May 2025 17:31:06 -0400 Subject: [PATCH 04/20] Model the start of an async sequence separately ReactAsyncInfo represents an "await" where as the ReactIOInfo represents when the start of that I/O was requested. Also make sure we don't try to encode debugStack or debugTask. --- .../react-server/src/ReactFlightServer.js | 54 ++++++++++++++++++- packages/shared/ReactTypes.js | 14 ++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 3daad92d3a439..a8cd994fa1ce5 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -59,6 +59,7 @@ import type { ReactDebugInfo, ReactComponentInfo, ReactEnvironmentInfo, + ReactIOInfo, ReactAsyncInfo, ReactTimeInfo, ReactStackTrace, @@ -3336,6 +3337,43 @@ function outlineComponentInfo( request.writtenObjects.set(componentInfo, serializeByValueID(id)); } +function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'outlineIOInfo should never be called in production mode. This is a bug in React.', + ); + } + + if (request.writtenObjects.has(ioInfo)) { + // Already written + return; + } + + // Limit the number of objects we write to prevent emitting giant props objects. + let objectLimit = 10; + if (ioInfo.stack != null) { + // Ensure we have enough object limit to encode the stack trace. + objectLimit += ioInfo.stack.length; + } + + // We use the console encoding so that we can dedupe objects but don't necessarily + // use the full serialization that requires a task. + const counter = {objectLimit}; + + // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing. + const relativeStartTimestamp = ioInfo.start - request.timeOrigin; + const relativeEndTimestamp = ioInfo.end - request.timeOrigin; + const debugIOInfo: Omit = { + start: relativeStartTimestamp, + end: relativeEndTimestamp, + stack: ioInfo.stack, + }; + const id = outlineConsoleValue(request, counter, debugIOInfo); + request.writtenObjects.set(ioInfo, serializeByValueID(id)); +} + function emitTypedArrayChunk( request: Request, id: number, @@ -3843,8 +3881,22 @@ function forwardDebugInfo( // If we had a smarter way to dedupe we might not have to do this if there ends up // being no references to this as an owner. outlineComponentInfo(request, (debugInfo[i]: any)); + // Emit a reference to the outlined one. + emitDebugChunk(request, id, debugInfo[i]); + } else if (debugInfo[i].awaited) { + const ioInfo = debugInfo[i].awaited; + // Outline the IO info in case the same I/O is awaited in more than one place. + outlineIOInfo(request, ioInfo); + // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing. + const debugAsyncInfo: Omit = + { + awaited: ioInfo, + stack: debugInfo[i].stack, + }; + emitDebugChunk(request, id, debugAsyncInfo); + } else { + emitDebugChunk(request, id, debugInfo[i]); } - emitDebugChunk(request, id, debugInfo[i]); } } } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 9bd64cd81d608..793d5dc6e25b0 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -229,12 +229,22 @@ export type ReactErrorInfoDev = { export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev; -export type ReactAsyncInfo = { - +type: string, +// The point where the Async Info started which might not be the same place it was awaited. +export type ReactIOInfo = { + +start: number, // the start time + +end: number, // the end time (this might be different from the time the await was unblocked) + +stack?: null | ReactStackTrace, // Stashed Data for the Specific Execution Environment. Not part of the transport protocol +debugStack?: null | Error, +debugTask?: null | ConsoleTask, +}; + +export type ReactAsyncInfo = { + +awaited: ReactIOInfo, +stack?: null | ReactStackTrace, + // Stashed Data for the Specific Execution Environment. Not part of the transport protocol + +debugStack?: null | Error, + +debugTask?: null | ConsoleTask, }; export type ReactTimeInfo = { From ea8793ec14fd5e80a6efce245fdaaa2516403a72 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 27 May 2025 19:24:26 -0400 Subject: [PATCH 05/20] Track last tracked time per task We'll use this as a heuristic to exclude awaits that happened before this. --- .../react-server/src/ReactFlightServer.js | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index a8cd994fa1ce5..80802572161af 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -358,6 +358,7 @@ type Task = { implicitSlot: boolean, // true if the root server component of this sequence had a null key thenableState: ThenableState | null, timed: boolean, // Profiling-only. Whether we need to track the completion time of this task. + time: number, // Profiling-only. The last time stamp emitted for this task. environmentName: string, // DEV-only. Used to track if the environment for this task changed. debugOwner: null | ReactComponentInfo, // DEV-only debugStack: null | Error, // DEV-only @@ -531,6 +532,7 @@ function RequestInstance( this.didWarnForKey = null; } + let timeOrigin: number; if (enableProfilerTimer && enableComponentPerformanceTrack) { // We start by serializing the time origin. Any future timestamps will be // emitted relatively to this origin. Instead of using performance.timeOrigin @@ -538,13 +540,15 @@ function RequestInstance( // This avoids leaking unnecessary information like how long the server has // been running and allows for more compact representation of each timestamp. // The time origin is stored as an offset in the time space of this environment. - const timeOrigin = (this.timeOrigin = performance.now()); + timeOrigin = this.timeOrigin = performance.now(); emitTimeOriginChunk( this, timeOrigin + // $FlowFixMe[prop-missing] performance.timeOrigin, ); + } else { + timeOrigin = 0; } const rootTask = createTask( @@ -553,6 +557,7 @@ function RequestInstance( null, false, abortSet, + timeOrigin, null, null, null, @@ -644,6 +649,7 @@ function serializeThenable( task.keyPath, // the server component sequence continues through Promise-as-a-child. task.implicitSlot, request.abortableTasks, + enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0, __DEV__ ? task.debugOwner : null, __DEV__ ? task.debugStack : null, __DEV__ ? task.debugTask : null, @@ -764,6 +770,7 @@ function serializeReadableStream( task.keyPath, task.implicitSlot, request.abortableTasks, + enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0, __DEV__ ? task.debugOwner : null, __DEV__ ? task.debugStack : null, __DEV__ ? task.debugTask : null, @@ -855,6 +862,7 @@ function serializeAsyncIterable( task.keyPath, task.implicitSlot, request.abortableTasks, + enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0, __DEV__ ? task.debugOwner : null, __DEV__ ? task.debugStack : null, __DEV__ ? task.debugTask : null, @@ -1280,7 +1288,11 @@ function renderFunctionComponent( // Track when we started rendering this component. if (enableProfilerTimer && enableComponentPerformanceTrack) { task.timed = true; - emitTimingChunk(request, componentDebugID, performance.now()); + emitTimingChunk( + request, + componentDebugID, + (task.time = performance.now()), + ); } emitDebugChunk(request, componentDebugID, componentDebugInfo); @@ -1631,6 +1643,7 @@ function deferTask(request: Request, task: Task): ReactJSONValue { task.keyPath, // unlike outlineModel this one carries along context task.implicitSlot, request.abortableTasks, + enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0, __DEV__ ? task.debugOwner : null, __DEV__ ? task.debugStack : null, __DEV__ ? task.debugTask : null, @@ -1647,6 +1660,7 @@ function outlineTask(request: Request, task: Task): ReactJSONValue { task.keyPath, // unlike outlineModel this one carries along context task.implicitSlot, request.abortableTasks, + enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0, __DEV__ ? task.debugOwner : null, __DEV__ ? task.debugStack : null, __DEV__ ? task.debugTask : null, @@ -1839,6 +1853,7 @@ function createTask( keyPath: null | string, implicitSlot: boolean, abortSet: Set, + lastTimestamp: number, // Profiling-only debugOwner: null | ReactComponentInfo, // DEV-only debugStack: null | Error, // DEV-only debugTask: null | ConsoleTask, // DEV-only @@ -1914,10 +1929,16 @@ function createTask( thenableState: null, }: Omit< Task, - 'timed' | 'environmentName' | 'debugOwner' | 'debugStack' | 'debugTask', + | 'timed' + | 'time' + | 'environmentName' + | 'debugOwner' + | 'debugStack' + | 'debugTask', >): any); if (enableProfilerTimer && enableComponentPerformanceTrack) { task.timed = false; + task.time = lastTimestamp; } if (__DEV__) { task.environmentName = request.environmentName(); @@ -2064,6 +2085,9 @@ function outlineModel(request: Request, value: ReactClientValue): number { null, // The way we use outlining is for reusing an object. false, // It makes no sense for that use case to be contextual. request.abortableTasks, + enableProfilerTimer && enableComponentPerformanceTrack + ? performance.now() // TODO: This should really inherit the time from the task. + : 0, null, // TODO: Currently we don't associate any debug information with null, // this object on the server. If it ends up erroring, it won't null, // have any context on the server but can on the client. @@ -2244,6 +2268,9 @@ function serializeBlob(request: Request, blob: Blob): string { null, false, request.abortableTasks, + enableProfilerTimer && enableComponentPerformanceTrack + ? performance.now() // TODO: This should really inherit the time from the task. + : 0, null, // TODO: Currently we don't associate any debug information with null, // this object on the server. If it ends up erroring, it won't null, // have any context on the server but can on the client. @@ -2376,6 +2403,9 @@ function renderModel( task.keyPath, task.implicitSlot, request.abortableTasks, + enableProfilerTimer && enableComponentPerformanceTrack + ? task.time + : 0, __DEV__ ? task.debugOwner : null, __DEV__ ? task.debugStack : null, __DEV__ ? task.debugTask : null, @@ -4009,7 +4039,7 @@ function emitChunk( function erroredTask(request: Request, task: Task, error: mixed): void { if (enableProfilerTimer && enableComponentPerformanceTrack) { if (task.timed) { - emitTimingChunk(request, task.id, performance.now()); + emitTimingChunk(request, task.id, (task.time = performance.now())); } } task.status = ERRORED; @@ -4092,7 +4122,7 @@ function retryTask(request: Request, task: Task): void { // We've finished rendering. Log the end time. if (enableProfilerTimer && enableComponentPerformanceTrack) { if (task.timed) { - emitTimingChunk(request, task.id, performance.now()); + emitTimingChunk(request, task.id, (task.time = performance.now())); } } @@ -4217,7 +4247,7 @@ function abortTask(task: Task, request: Request, errorId: number): void { // Track when we aborted this task as its end time. if (enableProfilerTimer && enableComponentPerformanceTrack) { if (task.timed) { - emitTimingChunk(request, task.id, performance.now()); + emitTimingChunk(request, task.id, (task.time = performance.now())); } } // Instead of emitting an error per task.id, we emit a model that only From 2ed218f4ce08c0abbcc4d043e2b3bab68b8489be Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 29 May 2025 17:58:15 -0400 Subject: [PATCH 06/20] Emit async debug info --- .../react-server/src/ReactFlightServer.js | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 80802572161af..0d04b3892cff7 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -69,6 +69,11 @@ import type { } from 'shared/ReactTypes'; import type {ReactElement} from 'shared/ReactElementType'; import type {LazyComponent} from 'react/src/ReactLazy'; +import type { + AsyncSequence, + IONode, + PromiseNode, +} from './ReactFlightAsyncSequence'; import { resolveClientReferenceMetadata, @@ -142,6 +147,8 @@ import binaryToComparableString from 'shared/binaryToComparableString'; import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable'; +import {IO_NODE, PROMISE_NODE, AWAIT_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); @@ -1830,10 +1837,102 @@ function renderElement( return renderClientElement(request, task, type, key, props, validated); } +function visitAsyncNode( + request: Request, + task: Task, + node: AsyncSequence, + cutOff: number, + visited: Set, +): null | PromiseNode | IONode { + if (visited.has(node)) { + // It's possible to visit them same node twice when it's part of both an "awaited" path + // and a "previous" path. This also gracefully handles cycles which would be a bug. + return null; + } + visited.add(node); + // First visit anything that blocked this sequence to start in the first place. + if (node.previous !== null) { + // We ignore the return value here because if it wasn't awaited, then we don't log it. + visitAsyncNode(request, task, node.previous, cutOff, visited); + } + switch (node.tag) { + case IO_NODE: { + return node; + } + case PROMISE_NODE: { + const awaited = node.awaited; + if (awaited !== null) { + const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited); + if (ioNode !== null) { + // This Promise was blocked on I/O. That's a signal that this Promise is interesting to log. + // We don't log it yet though. We return it to be logged by the point where it's awaited. + // This type might be another PromiseNode but we don't actually expect that, because those + // would have to be awaited and then this would have an AwaitNode between. + // TODO: Consider whether this stack was in user space or not. + return node; + } + } + return null; + } + case AWAIT_NODE: { + const awaited = node.awaited; + if (awaited !== null) { + const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited); + if (ioNode !== null) { + // Outline the IO node. + emitIOChunk(request, ioNode); + // 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. + stack: filterStackTrace(request, node.stack, 1), + }); + } + } + // If we had awaited anything we would have written it now. + return null; + } + default: { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error('Unknown AsyncSequence tag. This is a bug in React.'); + } + } +} + +function emitAsyncSequence( + request: Request, + task: Task, + node: AsyncSequence, + cutOff: number, +): void { + const visited: Set = new Set(); + const awaitedNode = visitAsyncNode(request, task, node, cutOff, visited); + if (awaitedNode !== null) { + // Nothing in user space (unfiltered stack) awaited this. + if (awaitedNode.end < 0) { + // If this was I/O directly without a Promise, then it means that some custom Thenable + // called our ping directly and not from a native .then(). We use the current ping time + // as the end time and treat it as an await with no stack. + // TODO: If this I/O is recurring then we really should have different entries for + // each occurrence. Right now we'll only track the first time it is invoked. + awaitedNode.end = performance.now(); + } + emitIOChunk(request, awaitedNode); + request.pendingChunks++; + emitDebugChunk(request, task.id, { + awaited: ((awaitedNode: any): ReactIOInfo), // This is deduped by this reference. + }); + } +} + function pingTask(request: Request, task: Task): void { if (enableProfilerTimer && enableComponentPerformanceTrack) { // If this was async we need to emit the time when it completes. task.timed = true; + const sequence = getCurrentAsyncSequence(); + if (sequence !== null) { + emitAsyncSequence(request, task, sequence, task.time); + } } const pingedTasks = request.pingedTasks; pingedTasks.push(task); @@ -3404,6 +3503,45 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void { request.writtenObjects.set(ioInfo, serializeByValueID(id)); } +function emitIOChunk(request: Request, ioNode: IONode | PromiseNode): void { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'outlineIOInfo should never be called in production mode. This is a bug in React.', + ); + } + + if (request.writtenObjects.has(ioNode)) { + // Already written + return; + } + + // Limit the number of objects we write to prevent emitting giant props objects. + let objectLimit = 10; + let stack = null; + if (ioNode.stack !== null) { + stack = filterStackTrace(request, ioNode.stack, 1); + // Ensure we have enough object limit to encode the stack trace. + objectLimit += stack.length; + } + + // We use the console encoding so that we can dedupe objects but don't necessarily + // use the full serialization that requires a task. + const counter = {objectLimit}; + + // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing. + const relativeStartTimestamp = ioNode.start - request.timeOrigin; + const relativeEndTimestamp = ioNode.end - request.timeOrigin; + const debugIOInfo: Omit = { + start: relativeStartTimestamp, + end: relativeEndTimestamp, + stack: stack, + }; + const id = outlineConsoleValue(request, counter, debugIOInfo); + request.writtenObjects.set(ioNode, serializeByValueID(id)); +} + function emitTypedArrayChunk( request: Request, id: number, From 42bae52e8dbab4407c5e4df415c2a39daa8cb318 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 30 May 2025 16:30:37 -0400 Subject: [PATCH 07/20] Test --- .../ReactFlightAsyncDebugInfo-test.js | 189 ++++++++++++++++++ .../ReactFlightAsyncDebugInfo-test.js.snap | 173 ++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js create mode 100644 packages/react-server/src/__tests__/__snapshots__/ReactFlightAsyncDebugInfo-test.js.snap diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js new file mode 100644 index 0000000000000..f35cecc907d2c --- /dev/null +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -0,0 +1,189 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +const path = require('path'); + +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; + +let React; +// let ReactServer; +let ReactServerDOMServer; +let ReactServerDOMClient; +let Stream; + +// We test pass-through without encoding strings but it should work without it too. +const streamOptions = { + objectMode: true, +}; + +const repoRoot = path.resolve(__dirname, '../../../../'); + +function normalizeStack(stack) { + if (!stack) { + return stack; + } + const copy = []; + for (let i = 0; i < stack.length; i++) { + const [name, file, line, col, enclosingLine, enclosingCol] = stack[i]; + copy.push([ + name, + file.replace(repoRoot, ''), + line, + col, + enclosingLine, + enclosingCol, + ]); + } + return copy; +} + +function normalizeIOInfo(ioInfo) { + const {debugTask, debugStack, ...copy} = ioInfo; + if (ioInfo.stack) { + copy.stack = normalizeStack(ioInfo.stack); + } + if (typeof ioInfo.start === 'number') { + copy.start = 0; + } + if (typeof ioInfo.end === 'number') { + copy.end = 0; + } + return copy; +} + +function normalizeDebugInfo(debugInfo) { + if (Array.isArray(debugInfo.stack)) { + const {debugTask, debugStack, ...copy} = debugInfo; + copy.stack = normalizeStack(debugInfo.stack); + if (debugInfo.owner) { + copy.owner = normalizeDebugInfo(debugInfo.owner); + } + if (debugInfo.awaited) { + copy.awaited = normalizeIOInfo(copy.awaited); + } + return copy; + } else if (typeof debugInfo.time === 'number') { + return {...debugInfo, time: 0}; + } else if (debugInfo.awaited) { + return {...debugInfo, awaited: normalizeIOInfo(debugInfo.awaited)}; + } else { + return debugInfo; + } +} + +function getDebugInfo(obj) { + const debugInfo = obj._debugInfo; + if (debugInfo) { + const copy = []; + for (let i = 0; i < debugInfo.length; i++) { + copy.push(normalizeDebugInfo(debugInfo[i])); + } + return copy; + } + return debugInfo; +} + +describe('ReactFlightAsyncDebugInfoNode', () => { + beforeEach(() => { + jest.resetModules(); + jest.useRealTimers(); + patchSetImmediate(); + global.console = require('console'); + + // Simulate the condition resolution + jest.mock('react', () => require('react/react.react-server')); + jest.mock('react-server-dom-webpack/server', () => + require('react-server-dom-webpack/server.node'), + ); + // ReactServer = require('react'); + ReactServerDOMServer = require('react-server-dom-webpack/server'); + + jest.resetModules(); + jest.useRealTimers(); + patchSetImmediate(); + + __unmockReact(); + jest.unmock('react-server-dom-webpack/server'); + jest.mock('react-server-dom-webpack/client', () => + require('react-server-dom-webpack/client.node'), + ); + + // React = require('react'); + ReactServerDOMClient = require('react-server-dom-webpack/client'); + Stream = require('stream'); + }); + + function delay(timeout) { + return new Promise(resolve => { + setTimeout(resolve, timeout); + }); + } + + // @gate __DEV__ && enableComponentPerformanceTrack + it('can track async information when awaited', async () => { + async function getData() { + await delay(1); + const promise = delay(2); + await Promise.all([promise]); + return 'hi'; + } + + async function Component() { + const result = await getData(); + return result; + } + + 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'); + const debugInfo = getDebugInfo(result); + expect(debugInfo.length).toBe(5); + expect(getDebugInfo(result)).toMatchSnapshot(); + }); + + // @gate __DEV__ && enableComponentPerformanceTrack + it('can track the start of I/O when no native promise is used', async () => { + function Component() { + const callbacks = []; + setTimeout(function timer() { + callbacks.forEach(callback => callback('hi')); + }, 5); + return { + then(callback) { + callbacks.push(callback); + }, + }; + } + + 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'); + const debugInfo = getDebugInfo(result); + expect(debugInfo.length).toBe(4); + expect(debugInfo).toMatchSnapshot(); + }); +}); diff --git a/packages/react-server/src/__tests__/__snapshots__/ReactFlightAsyncDebugInfo-test.js.snap b/packages/react-server/src/__tests__/__snapshots__/ReactFlightAsyncDebugInfo-test.js.snap new file mode 100644 index 0000000000000..7db1fed8af9c9 --- /dev/null +++ b/packages/react-server/src/__tests__/__snapshots__/ReactFlightAsyncDebugInfo-test.js.snap @@ -0,0 +1,173 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReactFlightAsyncDebugInfoNode can track async information when awaited 1`] = ` +[ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Component", + "owner": null, + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 144, + 109, + 131, + 135, + ], + ], + }, + { + "awaited": { + "end": 0, + "stack": [ + [ + "delay", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 125, + 12, + 124, + 3, + ], + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 133, + 13, + 132, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 140, + 26, + 139, + 5, + ], + ], + "start": 0, + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 133, + 13, + 132, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 140, + 26, + 139, + 5, + ], + ], + }, + { + "awaited": { + "end": 0, + "stack": [ + [ + "delay", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 125, + 12, + 124, + 3, + ], + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 134, + 21, + 132, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 140, + 20, + 139, + 5, + ], + ], + "start": 0, + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 135, + 21, + 132, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 140, + 20, + 139, + 5, + ], + ], + }, + { + "time": 0, + }, +] +`; + +exports[`ReactFlightAsyncDebugInfoNode can track the start of I/O when no native promise is used 1`] = ` +[ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Component", + "owner": null, + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 174, + 109, + 161, + 152, + ], + ], + }, + { + "awaited": { + "end": 0, + "stack": [ + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 164, + 7, + 162, + 5, + ], + ], + "start": 0, + }, + }, + { + "time": 0, + }, +] +`; From 26497541e4b79739c5a5224e16ef83a77d8f1e15 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 30 May 2025 17:21:31 -0400 Subject: [PATCH 08/20] Skip awaits that are in third party code --- packages/react-server/src/ReactFlightServer.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 0d04b3892cff7..fab0918d88de4 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1879,13 +1879,19 @@ function visitAsyncNode( if (awaited !== null) { const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited); if (ioNode !== null) { + const stack = filterStackTrace(request, 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. emitIOChunk(request, ioNode); // 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. - stack: filterStackTrace(request, node.stack, 1), + stack: stack, }); } } From 09220ef2f372b13ea50959e5cc93d45dc0025e3f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 30 May 2025 17:37:16 -0400 Subject: [PATCH 09/20] Exclude nodes that finished before this sequence --- packages/react-server/src/ReactFlightServer.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index fab0918d88de4..372c3b5273746 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1860,6 +1860,13 @@ function visitAsyncNode( return node; } case PROMISE_NODE: { + if (node.end < 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 awaited = node.awaited; if (awaited !== null) { const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited); From 3460e0a4d62a12748f96634e3d6a03054565b44d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 30 May 2025 19:12:40 -0400 Subject: [PATCH 10/20] Use the inner Promise/I/O if the outer Promise doesn't have any user space stack frames --- .../react-server/src/ReactFlightServer.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 372c3b5273746..643f452fce426 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1852,7 +1852,8 @@ function visitAsyncNode( visited.add(node); // First visit anything that blocked this sequence to start in the first place. if (node.previous !== null) { - // We ignore the return value here because if it wasn't awaited, then we don't log it. + // We ignore the return value here because if it wasn't awaited in user space, then we don't log it. + // TODO: This means that some I/O can get lost that was still blocking the sequence. visitAsyncNode(request, task, node.previous, cutOff, visited); } switch (node.tag) { @@ -1873,9 +1874,19 @@ function visitAsyncNode( if (ioNode !== null) { // This Promise was blocked on I/O. That's a signal that this Promise is interesting to log. // We don't log it yet though. We return it to be logged by the point where it's awaited. - // This type might be another PromiseNode but we don't actually expect that, because those - // would have to be awaited and then this would have an AwaitNode between. - // TODO: Consider whether this stack was in user space or not. + // The ioNode might be another PromiseNode in the case where none of the AwaitNode had + // unfiltered stacks. + if (filterStackTrace(request, node.stack, 1).length === 0) { + // Typically we assume that the outer most Promise that was awaited in user space has the + // most actionable stack trace for the start of the operation. However, if this Promise + // Promise was created inside only third party code, then try to use the inner node instead. + // This could happen if you pass a first party Promise into a third party to be awaited there. + if (ioNode.end < 0) { + // If we haven't defined an end time, use the resolve of the outer Promise. + ioNode.end = node.end; + } + return ioNode; + } return node; } } From 1d80a23adb81626338f2e5774fb0c91d1ea54fbe Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 30 May 2025 22:52:24 -0400 Subject: [PATCH 11/20] Ignore setImmediate This conveniently ignores our own scheduling tasks. --- .../react-server/src/ReactFlightServerConfigDebugNode.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 5138d45bcb9e8..6066e52c8ffa1 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -65,7 +65,11 @@ export function initAsyncDebugInfo(): void { previous: null, }: PromiseNode); } - } else if (type !== 'Microtask' && type !== 'TickObject') { + } else if ( + type !== 'Microtask' && + type !== 'TickObject' && + type !== 'Immediate' + ) { if (trigger === undefined) { // We have begun a new I/O sequence. node = ({ From 25a3e8ac5919e7940ea09512c25ed64bdb29ba84 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 30 May 2025 23:40:05 -0400 Subject: [PATCH 12/20] This feature is not supported by Edge builds --- ...ReactFlightServerConfig.dom-edge-parcel.js | 2 +- ...ctFlightServerConfig.dom-edge-turbopack.js | 2 +- .../forks/ReactFlightServerConfig.dom-edge.js | 2 +- scripts/shared/inlinedHostConfigs.js | 40 +++++++++++++++---- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-parcel.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-parcel.js index 2162c4f73a82a..b42ced87119e2 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-parcel.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-parcel.js @@ -36,6 +36,6 @@ export const createAsyncHook: HookCallbacks => AsyncHook = export const executionAsyncId: () => number = typeof async_hooks === 'object' ? async_hooks.executionAsyncId : (null: any); -export * from '../ReactFlightServerConfigDebugNode'; +export * from '../ReactFlightServerConfigDebugNoop'; export * from '../ReactFlightStackConfigV8'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-turbopack.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-turbopack.js index bcaca19284469..83c1a4c951b65 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-turbopack.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-turbopack.js @@ -36,6 +36,6 @@ export const createAsyncHook: HookCallbacks => AsyncHook = export const executionAsyncId: () => number = typeof async_hooks === 'object' ? async_hooks.executionAsyncId : (null: any); -export * from '../ReactFlightServerConfigDebugNode'; +export * from '../ReactFlightServerConfigDebugNoop'; export * from '../ReactFlightStackConfigV8'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js index e16f8cfb2ec57..30db02253a6e2 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js @@ -37,6 +37,6 @@ export const createAsyncHook: HookCallbacks => AsyncHook = export const executionAsyncId: () => number = typeof async_hooks === 'object' ? async_hooks.executionAsyncId : (null: any); -export * from '../ReactFlightServerConfigDebugNode'; +export * from '../ReactFlightServerConfigDebugNoop'; export * from '../ReactFlightStackConfigV8'; diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 1b2d3027ddf0d..a6748114e0969 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -50,6 +50,7 @@ module.exports = [ 'react-devtools-shell', 'react-devtools-shared', 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -247,6 +248,7 @@ module.exports = [ 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -274,6 +276,7 @@ module.exports = [ 'react-devtools-shell', 'react-devtools-shared', 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -309,6 +312,7 @@ module.exports = [ 'react-devtools-shell', 'react-devtools-shared', 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -343,6 +347,7 @@ module.exports = [ 'react-devtools-shell', 'react-devtools-shared', 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -384,7 +389,7 @@ module.exports = [ 'react-devtools-shell', 'react-devtools-shared', 'shared/ReactDOMSharedInternals', - 'react-server/src/ReactFlightServerConfigDebugNode.js', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -424,7 +429,7 @@ module.exports = [ 'react-devtools-shell', 'react-devtools-shared', 'shared/ReactDOMSharedInternals', - 'react-server/src/ReactFlightServerConfigDebugNode.js', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -463,7 +468,7 @@ module.exports = [ 'react-devtools-shell', 'react-devtools-shared', 'shared/ReactDOMSharedInternals', - 'react-server/src/ReactFlightServerConfigDebugNode.js', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -523,6 +528,7 @@ module.exports = [ 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -539,6 +545,7 @@ module.exports = [ 'react-dom-bindings', 'react-markup', 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -558,6 +565,7 @@ module.exports = [ 'react-dom-bindings', 'react-server-dom-fb', 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', ], isFlowTyped: true, isServerSupported: true, @@ -566,28 +574,40 @@ module.exports = [ { shortName: 'native', entryPoints: ['react-native-renderer'], - paths: ['react-native-renderer'], + paths: [ + 'react-native-renderer', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', + ], isFlowTyped: true, isServerSupported: false, }, { shortName: 'fabric', entryPoints: ['react-native-renderer/fabric'], - paths: ['react-native-renderer'], + paths: [ + 'react-native-renderer', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', + ], isFlowTyped: true, isServerSupported: false, }, { shortName: 'test', entryPoints: ['react-test-renderer'], - paths: ['react-test-renderer'], + paths: [ + 'react-test-renderer', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', + ], isFlowTyped: true, isServerSupported: false, }, { shortName: 'art', entryPoints: ['react-art'], - paths: ['react-art'], + paths: [ + 'react-art', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', + ], isFlowTyped: false, // TODO: type it. isServerSupported: false, }, @@ -599,7 +619,11 @@ module.exports = [ 'react-server', 'react-server/flight', ], - paths: ['react-client/flight', 'react-server/flight'], + paths: [ + 'react-client/flight', + 'react-server/flight', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', + ], isFlowTyped: true, isServerSupported: true, }, From 27d551274dca86f1f322be4e8817a2cf5a1711d4 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 00:29:37 -0400 Subject: [PATCH 13/20] Use inline snapshots --- .../ReactFlightAsyncDebugInfo-test.js | 190 ++++++++++++++++-- .../ReactFlightAsyncDebugInfo-test.js.snap | 173 ---------------- 2 files changed, 176 insertions(+), 187 deletions(-) delete mode 100644 packages/react-server/src/__tests__/__snapshots__/ReactFlightAsyncDebugInfo-test.js.snap diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index f35cecc907d2c..897d661c598b3 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -14,12 +14,10 @@ const path = require('path'); import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; let React; -// let ReactServer; let ReactServerDOMServer; let ReactServerDOMClient; let Stream; -// We test pass-through without encoding strings but it should work without it too. const streamOptions = { objectMode: true, }; @@ -91,19 +89,17 @@ function getDebugInfo(obj) { return debugInfo; } -describe('ReactFlightAsyncDebugInfoNode', () => { +describe('ReactFlightAsyncDebugInfo', () => { beforeEach(() => { jest.resetModules(); jest.useRealTimers(); patchSetImmediate(); global.console = require('console'); - // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-webpack/server', () => require('react-server-dom-webpack/server.node'), ); - // ReactServer = require('react'); ReactServerDOMServer = require('react-server-dom-webpack/server'); jest.resetModules(); @@ -116,7 +112,7 @@ describe('ReactFlightAsyncDebugInfoNode', () => { require('react-server-dom-webpack/client.node'), ); - // React = require('react'); + React = require('react'); ReactServerDOMClient = require('react-server-dom-webpack/client'); Stream = require('stream'); }); @@ -127,7 +123,6 @@ describe('ReactFlightAsyncDebugInfoNode', () => { }); } - // @gate __DEV__ && enableComponentPerformanceTrack it('can track async information when awaited', async () => { async function getData() { await delay(1); @@ -152,12 +147,137 @@ describe('ReactFlightAsyncDebugInfoNode', () => { stream.pipe(readable); expect(await result).toBe('hi'); - const debugInfo = getDebugInfo(result); - expect(debugInfo.length).toBe(5); - expect(getDebugInfo(result)).toMatchSnapshot(); + if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack)) { + 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", + 139, + 109, + 126, + 50, + ], + ], + }, + { + "awaited": { + "end": 0, + "stack": [ + [ + "delay", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 121, + 12, + 120, + 3, + ], + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 128, + 13, + 127, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 135, + 26, + 134, + 5, + ], + ], + "start": 0, + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 128, + 13, + 127, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 135, + 26, + 134, + 5, + ], + ], + }, + { + "awaited": { + "end": 0, + "stack": [ + [ + "delay", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 121, + 12, + 120, + 3, + ], + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 129, + 21, + 127, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 135, + 20, + 134, + 5, + ], + ], + "start": 0, + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 130, + 21, + 127, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 135, + 20, + 134, + 5, + ], + ], + }, + { + "time": 0, + }, + ] + `); + } }); - // @gate __DEV__ && enableComponentPerformanceTrack it('can track the start of I/O when no native promise is used', async () => { function Component() { const callbacks = []; @@ -182,8 +302,50 @@ describe('ReactFlightAsyncDebugInfoNode', () => { stream.pipe(readable); expect(await result).toBe('hi'); - const debugInfo = getDebugInfo(result); - expect(debugInfo.length).toBe(4); - expect(debugInfo).toMatchSnapshot(); + if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack)) { + 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", + 171, + 109, + 158, + 152, + ], + ], + }, + { + "awaited": { + "end": 0, + "stack": [ + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 161, + 7, + 159, + 5, + ], + ], + "start": 0, + }, + }, + { + "time": 0, + }, + ] + `); + } }); }); diff --git a/packages/react-server/src/__tests__/__snapshots__/ReactFlightAsyncDebugInfo-test.js.snap b/packages/react-server/src/__tests__/__snapshots__/ReactFlightAsyncDebugInfo-test.js.snap deleted file mode 100644 index 7db1fed8af9c9..0000000000000 --- a/packages/react-server/src/__tests__/__snapshots__/ReactFlightAsyncDebugInfo-test.js.snap +++ /dev/null @@ -1,173 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ReactFlightAsyncDebugInfoNode can track async information when awaited 1`] = ` -[ - { - "time": 0, - }, - { - "env": "Server", - "key": null, - "name": "Component", - "owner": null, - "props": {}, - "stack": [ - [ - "Object.", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 144, - 109, - 131, - 135, - ], - ], - }, - { - "awaited": { - "end": 0, - "stack": [ - [ - "delay", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 125, - 12, - 124, - 3, - ], - [ - "getData", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 133, - 13, - 132, - 5, - ], - [ - "Component", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 140, - 26, - 139, - 5, - ], - ], - "start": 0, - }, - "stack": [ - [ - "getData", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 133, - 13, - 132, - 5, - ], - [ - "Component", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 140, - 26, - 139, - 5, - ], - ], - }, - { - "awaited": { - "end": 0, - "stack": [ - [ - "delay", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 125, - 12, - 124, - 3, - ], - [ - "getData", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 134, - 21, - 132, - 5, - ], - [ - "Component", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 140, - 20, - 139, - 5, - ], - ], - "start": 0, - }, - "stack": [ - [ - "getData", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 135, - 21, - 132, - 5, - ], - [ - "Component", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 140, - 20, - 139, - 5, - ], - ], - }, - { - "time": 0, - }, -] -`; - -exports[`ReactFlightAsyncDebugInfoNode can track the start of I/O when no native promise is used 1`] = ` -[ - { - "time": 0, - }, - { - "env": "Server", - "key": null, - "name": "Component", - "owner": null, - "props": {}, - "stack": [ - [ - "Object.", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 174, - 109, - 161, - 152, - ], - ], - }, - { - "awaited": { - "end": 0, - "stack": [ - [ - "Component", - "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 164, - 7, - 162, - 5, - ], - ], - "start": 0, - }, - }, - { - "time": 0, - }, -] -`; From 1da4819731bb003109523fcd5f8312cfedb3b9cc Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 00:38:26 -0400 Subject: [PATCH 14/20] Disable tests --- .../src/__tests__/ReactFlightAsyncDebugInfo-test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index 897d661c598b3..5d5b62773e597 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -147,6 +147,8 @@ describe('ReactFlightAsyncDebugInfo', () => { stream.pipe(readable); expect(await result).toBe('hi'); + getDebugInfo(result); + /* if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack)) { expect(getDebugInfo(result)).toMatchInlineSnapshot(` [ @@ -276,6 +278,7 @@ describe('ReactFlightAsyncDebugInfo', () => { ] `); } + */ }); it('can track the start of I/O when no native promise is used', async () => { @@ -302,8 +305,10 @@ describe('ReactFlightAsyncDebugInfo', () => { stream.pipe(readable); expect(await result).toBe('hi'); + getDebugInfo(result); + /* if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack)) { - expect(getDebugInfo(result)).toMatchInlineSnapshot(` + expect().toMatchInlineSnapshot(` [ { "time": 0, @@ -347,5 +352,6 @@ describe('ReactFlightAsyncDebugInfo', () => { ] `); } + */ }); }); From 57648c1f7f2ea23771c9a6e3b1d6bf8de1e9991d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 00:50:46 -0400 Subject: [PATCH 15/20] Gate on the flag --- packages/react-server/src/ReactFlightServerConfigDebugNode.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 6066e52c8ffa1..0ca4a6d39b5a3 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -138,6 +138,9 @@ export function initAsyncDebugInfo(): void { } export function getCurrentAsyncSequence(): null | AsyncSequence { + if (!__DEV__ || !enableAsyncDebugInfo) { + return null; + } const currentNode = pendingOperations.get(executionAsyncId()); if (currentNode === undefined) { // Nothing that we tracked led to the resolution of this execution context. From 3e1beed1d83fc5c79a08a11bee7a44d51e49b2fd Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 01:14:47 -0400 Subject: [PATCH 16/20] Remove async_hook from global indirection We're not going to use this. --- .../src/ReactFlightServerConfigDebugNode.js | 4 ++-- .../ReactFlightServerConfig.dom-edge-parcel.js | 14 -------------- .../ReactFlightServerConfig.dom-edge-turbopack.js | 14 -------------- .../src/forks/ReactFlightServerConfig.dom-edge.js | 14 -------------- .../forks/ReactFlightServerConfig.dom-node-esm.js | 2 -- .../ReactFlightServerConfig.dom-node-parcel.js | 2 -- .../ReactFlightServerConfig.dom-node-turbopack.js | 2 -- .../src/forks/ReactFlightServerConfig.dom-node.js | 2 -- 8 files changed, 2 insertions(+), 52 deletions(-) diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 0ca4a6d39b5a3..b1e2dd4f27e9c 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -15,7 +15,7 @@ import type { } from './ReactFlightAsyncSequence'; import {IO_NODE, PROMISE_NODE, AWAIT_NODE} from './ReactFlightAsyncSequence'; -import {createAsyncHook, executionAsyncId} from './ReactFlightServerConfig'; +import {createHook, executionAsyncId} from 'async_hooks'; import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; const pendingOperations: Map = @@ -28,7 +28,7 @@ const pendingOperations: Map = // but given that typically this is just a live server, it doesn't really matter. export function initAsyncDebugInfo(): void { if (__DEV__ && enableAsyncDebugInfo) { - createAsyncHook({ + createHook({ init(asyncId: number, type: string, triggerAsyncId: number): void { const trigger = pendingOperations.get(triggerAsyncId); let node: AsyncSequence; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-parcel.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-parcel.js index b42ced87119e2..4983c3efd21a9 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-parcel.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-parcel.js @@ -22,20 +22,6 @@ export const supportsComponentStorage: boolean = export const componentStorage: AsyncLocalStorage = supportsComponentStorage ? new AsyncLocalStorage() : (null: any); -// We use the Node version but get access to async_hooks from a global. -import type {HookCallbacks, AsyncHook} from 'async_hooks'; -export const createAsyncHook: HookCallbacks => AsyncHook = - typeof async_hooks === 'object' - ? async_hooks.createHook - : function () { - return ({ - enable() {}, - disable() {}, - }: any); - }; -export const executionAsyncId: () => number = - typeof async_hooks === 'object' ? async_hooks.executionAsyncId : (null: any); - export * from '../ReactFlightServerConfigDebugNoop'; export * from '../ReactFlightStackConfigV8'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-turbopack.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-turbopack.js index 83c1a4c951b65..bd046f29599a5 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-turbopack.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-turbopack.js @@ -22,20 +22,6 @@ export const supportsComponentStorage: boolean = export const componentStorage: AsyncLocalStorage = supportsComponentStorage ? new AsyncLocalStorage() : (null: any); -// We use the Node version but get access to async_hooks from a global. -import type {HookCallbacks, AsyncHook} from 'async_hooks'; -export const createAsyncHook: HookCallbacks => AsyncHook = - typeof async_hooks === 'object' - ? async_hooks.createHook - : function () { - return ({ - enable() {}, - disable() {}, - }: any); - }; -export const executionAsyncId: () => number = - typeof async_hooks === 'object' ? async_hooks.executionAsyncId : (null: any); - export * from '../ReactFlightServerConfigDebugNoop'; export * from '../ReactFlightStackConfigV8'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js index 30db02253a6e2..9c1f6b58e00f4 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js @@ -23,20 +23,6 @@ export const supportsComponentStorage: boolean = export const componentStorage: AsyncLocalStorage = supportsComponentStorage ? new AsyncLocalStorage() : (null: any); -// We use the Node version but get access to async_hooks from a global. -import type {HookCallbacks, AsyncHook} from 'async_hooks'; -export const createAsyncHook: HookCallbacks => AsyncHook = - typeof async_hooks === 'object' - ? async_hooks.createHook - : function () { - return ({ - enable() {}, - disable() {}, - }: any); - }; -export const executionAsyncId: () => number = - typeof async_hooks === 'object' ? async_hooks.executionAsyncId : (null: any); - export * from '../ReactFlightServerConfigDebugNoop'; export * from '../ReactFlightStackConfigV8'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-esm.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-esm.js index 52602b1eb2848..3ded44f1353a2 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-esm.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-esm.js @@ -23,8 +23,6 @@ export const supportsComponentStorage = __DEV__; export const componentStorage: AsyncLocalStorage = supportsComponentStorage ? new AsyncLocalStorage() : (null: any); -export {createHook as createAsyncHook, executionAsyncId} from 'async_hooks'; - export * from '../ReactFlightServerConfigDebugNode'; export * from '../ReactFlightStackConfigV8'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-parcel.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-parcel.js index c48e0d0845213..cb5d1f748b3c2 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-parcel.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-parcel.js @@ -23,8 +23,6 @@ export const supportsComponentStorage = __DEV__; export const componentStorage: AsyncLocalStorage = supportsComponentStorage ? new AsyncLocalStorage() : (null: any); -export {createHook as createAsyncHook, executionAsyncId} from 'async_hooks'; - export * from '../ReactFlightServerConfigDebugNode'; export * from '../ReactFlightStackConfigV8'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-turbopack.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-turbopack.js index 0205daab10853..85a7087721495 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-turbopack.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-turbopack.js @@ -23,8 +23,6 @@ export const supportsComponentStorage = __DEV__; export const componentStorage: AsyncLocalStorage = supportsComponentStorage ? new AsyncLocalStorage() : (null: any); -export {createHook as createAsyncHook, executionAsyncId} from 'async_hooks'; - export * from '../ReactFlightServerConfigDebugNode'; export * from '../ReactFlightStackConfigV8'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node.js index 0cd21ac7257b9..626d134cebc51 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node.js @@ -23,8 +23,6 @@ export const supportsComponentStorage = __DEV__; export const componentStorage: AsyncLocalStorage = supportsComponentStorage ? new AsyncLocalStorage() : (null: any); -export {createHook as createAsyncHook, executionAsyncId} from 'async_hooks'; - export * from '../ReactFlightServerConfigDebugNode'; export * from '../ReactFlightStackConfigV8'; From 6a0e931c801ca433e00cf0a95b5aa1e0695bcf2b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 01:31:12 -0400 Subject: [PATCH 17/20] Automatically clean up the old hook when we install a new one Just for tests. --- scripts/jest/setupTests.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 01433ea583079..1e8d8e5276e5c 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -293,3 +293,18 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { return require('internal-test-utils/ReactJSDOM.js'); }); } + +// We mock createHook so that we can automatically clean it up. +let installedHook = null; +jest.mock('async_hooks', () => { + const actual = jest.requireActual('async_hooks'); + return { + ...actual, + createHook(config) { + if (installedHook) { + installedHook.disable(); + } + return (installedHook = actual.createHook(config)); + }, + }; +}); From aa24a2fd9ee391c004b5f51fbb02297bf2af0963 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 13:20:31 -0400 Subject: [PATCH 18/20] Gate on enableAsyncDebugInfo flag and not just enableComponentPerformanceTrack We rely on enableComponentPerformanceTrack flag for the timing tracking in general but then we additionally need the async debug info flag to use the async tracking. --- packages/react-server/src/ReactFlightServer.js | 9 ++++++--- .../src/__tests__/ReactFlightAsyncDebugInfo-test.js | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 643f452fce426..f8048404a60a8 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -19,6 +19,7 @@ import { enableTaint, enableProfilerTimer, enableComponentPerformanceTrack, + enableAsyncDebugInfo, } from 'shared/ReactFeatureFlags'; import { @@ -1953,9 +1954,11 @@ function pingTask(request: Request, task: Task): void { if (enableProfilerTimer && enableComponentPerformanceTrack) { // If this was async we need to emit the time when it completes. task.timed = true; - const sequence = getCurrentAsyncSequence(); - if (sequence !== null) { - emitAsyncSequence(request, task, sequence, task.time); + if (enableAsyncDebugInfo) { + const sequence = getCurrentAsyncSequence(); + if (sequence !== null) { + emitAsyncSequence(request, task, sequence, task.time); + } } } const pingedTasks = request.pingedTasks; diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index 5d5b62773e597..2ca6ab2bcef19 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -149,7 +149,7 @@ describe('ReactFlightAsyncDebugInfo', () => { expect(await result).toBe('hi'); getDebugInfo(result); /* - if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack)) { + if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo)) { expect(getDebugInfo(result)).toMatchInlineSnapshot(` [ { @@ -307,7 +307,7 @@ describe('ReactFlightAsyncDebugInfo', () => { expect(await result).toBe('hi'); getDebugInfo(result); /* - if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack)) { + if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo)) { expect().toMatchInlineSnapshot(` [ { From c3fad170398229f922fec113d8fc8c8eaaf76c18 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 18:27:04 -0400 Subject: [PATCH 19/20] Try reenabling tests --- .../ReactFlightAsyncDebugInfo-test.js | 87 +++++++++---------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index 2ca6ab2bcef19..32eafab78626a 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -1,12 +1,3 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @emails react-core - */ - 'use strict'; const path = require('path'); @@ -147,9 +138,13 @@ describe('ReactFlightAsyncDebugInfo', () => { stream.pipe(readable); expect(await result).toBe('hi'); - getDebugInfo(result); - /* - if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo)) { + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { expect(getDebugInfo(result)).toMatchInlineSnapshot(` [ { @@ -165,9 +160,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 139, + 130, 109, - 126, + 117, 50, ], ], @@ -179,25 +174,25 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "delay", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 121, + 112, 12, - 120, + 111, 3, ], [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 128, + 119, 13, - 127, + 118, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 135, + 126, 26, - 134, + 125, 5, ], ], @@ -207,17 +202,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 128, + 119, 13, - 127, + 118, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 135, + 126, 26, - 134, + 125, 5, ], ], @@ -229,25 +224,25 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "delay", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 121, + 112, 12, - 120, + 111, 3, ], [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 129, + 120, 21, - 127, + 118, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 135, + 126, 20, - 134, + 125, 5, ], ], @@ -257,17 +252,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 130, + 121, 21, - 127, + 118, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 135, + 126, 20, - 134, + 125, 5, ], ], @@ -278,7 +273,6 @@ describe('ReactFlightAsyncDebugInfo', () => { ] `); } - */ }); it('can track the start of I/O when no native promise is used', async () => { @@ -305,10 +299,14 @@ describe('ReactFlightAsyncDebugInfo', () => { stream.pipe(readable); expect(await result).toBe('hi'); - getDebugInfo(result); - /* - if (__DEV__ && gate(flags => flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo)) { - expect().toMatchInlineSnapshot(` + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + expect(getDebugInfo(result)).toMatchInlineSnapshot(` [ { "time": 0, @@ -323,10 +321,10 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 171, + 291, 109, - 158, - 152, + 278, + 67, ], ], }, @@ -337,9 +335,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 161, + 281, 7, - 159, + 279, 5, ], ], @@ -352,6 +350,5 @@ describe('ReactFlightAsyncDebugInfo', () => { ] `); } - */ }); }); From dddd08aef1d6d23b4f1122bb2549ff59d46e10df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 3 Jun 2025 14:06:11 -0400 Subject: [PATCH 20/20] Typo Co-authored-by: Sebastian "Sebbie" Silbermann --- packages/react-server/src/ReactFlightServer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index f8048404a60a8..ee40b93e6225b 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1880,7 +1880,7 @@ function visitAsyncNode( if (filterStackTrace(request, node.stack, 1).length === 0) { // Typically we assume that the outer most Promise that was awaited in user space has the // most actionable stack trace for the start of the operation. However, if this Promise - // Promise was created inside only third party code, then try to use the inner node instead. + // was created inside only third party code, then try to use the inner node instead. // This could happen if you pass a first party Promise into a third party to be awaited there. if (ioNode.end < 0) { // If we haven't defined an end time, use the resolve of the outer Promise.