diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 3bafdcf40bc9..58275380d58b 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -92,6 +92,7 @@ import { markAsyncSequenceRootTask, getCurrentAsyncSequence, getAsyncSequenceFromPromise, + cleanupAsyncDebugInfo, parseStackTrace, parseStackTracePrivate, supportsComponentStorage, @@ -863,6 +864,10 @@ export function resolveRequest(): null | Request { return null; } +export function isRequestClosingOrClosed(request: Request): boolean { + return request.status >= CLOSING; +} + function isTypedArray(value: any): boolean { if (value instanceof ArrayBuffer) { return true; @@ -4140,6 +4145,9 @@ function fatalError(request: Request, error: mixed): void { request.status = CLOSING; request.fatalError = error; } + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } const abortReason = new Error( 'The render was aborted due to a fatal error.', { @@ -6078,14 +6086,18 @@ function flushCompletedChunks(request: Request): void { // We'll continue writing on this stream so nothing closes. return; } else { - // We'll close the main stream but keep the debug stream open. - // TODO: If this destination is not currently flowing we'll not close it when it resumes flowing. + // We'll mark the main stream as closed while keeping the debug stream open. + // If the main stream is currently flowing, close it immediately. + // TODO: If this destination is not currently flowing we won't close it when flow resumes. // We should keep a separate status for this. + request.status = CLOSED; if (request.destination !== null) { - request.status = CLOSED; close(request.destination); request.destination = null; } + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } return; } } @@ -6109,6 +6121,11 @@ function flushCompletedChunks(request: Request): void { close(request.debugDestination); request.debugDestination = null; } + if (__DEV__) { + // Clean up async debug tracking after all close/abort side effects, + // so any async work spawned during abort doesn't re-populate tracking. + cleanupAsyncDebugInfo(request); + } } } @@ -6158,6 +6175,9 @@ function callOnAllReadyIfReady(request: Request): void { export function startFlowing(request: Request, destination: Destination): void { if (request.status === CLOSING) { request.status = CLOSED; + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } closeWithError(destination, request.fatalError); return; } @@ -6183,6 +6203,9 @@ export function startFlowingDebug( ): void { if (request.status === CLOSING) { request.status = CLOSED; + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } closeWithError(debugDestination, request.fatalError); return; } @@ -6212,6 +6235,9 @@ function finishHalt(request: Request, abortedTasks: Set): void { const onAllReady = request.onAllReady; onAllReady(); flushCompletedChunks(request); + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } } catch (error) { logRecoverableError(request, error, null); fatalError(request, error); @@ -6228,6 +6254,9 @@ function finishAbort( const onAllReady = request.onAllReady; onAllReady(); flushCompletedChunks(request); + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } } catch (error) { logRecoverableError(request, error, null); fatalError(request, error); @@ -6282,6 +6311,9 @@ export function abort(request: Request, reason: mixed): void { const onAllReady = request.onAllReady; onAllReady(); flushCompletedChunks(request); + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } } } catch (error) { logRecoverableError(request, error, null); diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 9bb521be4065..23f1bc4e1d48 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -26,7 +26,11 @@ import { UNRESOLVED_AWAIT_NODE, } from './ReactFlightAsyncSequence'; import {resolveOwner} from './flight/ReactFlightCurrentOwner'; -import {resolveRequest, isAwaitInUserspace} from './ReactFlightServer'; +import { + resolveRequest, + isAwaitInUserspace, + isRequestClosingOrClosed, +} from './ReactFlightServer'; import {createHook, executionAsyncId, AsyncResource} from 'async_hooks'; import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; import {parseStackTracePrivate} from './ReactFlightServerConfig'; @@ -37,6 +41,25 @@ const getAsyncId = AsyncResource.prototype.asyncId; const pendingOperations: Map = __DEV__ && enableAsyncDebugInfo ? new Map() : (null: any); +// Tracks which asyncIds belong to each request, for cleanup when the request closes. +// Explicit cleanup is required because asyncIdToRequests holds strong references +// to request owners until ownership is removed. +const requestAsyncIds: WeakMap> = __DEV__ && +enableAsyncDebugInfo + ? new WeakMap() + : (null: any); + +// Tracks request ownership for each asyncId. +const asyncIdToRequests: Map> = __DEV__ && enableAsyncDebugInfo + ? new Map() + : (null: any); + +// Tracks requests that have already been cleaned so we can avoid re-adding +// ownership in windows where status has not yet transitioned to CLOSING/CLOSED. +const cleanedRequests: WeakSet = __DEV__ && enableAsyncDebugInfo + ? new WeakSet() + : (null: any); + // Keep the last resolved await as a workaround for async functions missing data. let lastRanAwait: null | AwaitNode = null; @@ -54,6 +77,55 @@ function resolvePromiseOrAwaitNode( const emptyStack: ReactStackTrace = []; +function addRequestOwnership(request: any, asyncId: number): void { + let ids = requestAsyncIds.get(request); + if (ids === undefined) { + ids = new Set(); + requestAsyncIds.set(request, ids); + } + ids.add(asyncId); + + let owners = asyncIdToRequests.get(asyncId); + if (owners === undefined) { + owners = new Set(); + asyncIdToRequests.set(asyncId, owners); + } + owners.add(request); +} + +function removeAsyncIdFromRequest(request: any, asyncId: number): void { + const ids = requestAsyncIds.get(request); + if (ids !== undefined) { + ids.delete(asyncId); + if (ids.size === 0) { + requestAsyncIds.delete(request); + } + } +} + +function removeAllRequestOwnership(asyncId: number): void { + const owners = asyncIdToRequests.get(asyncId); + if (owners !== undefined) { + owners.forEach(request => { + removeAsyncIdFromRequest(request, asyncId); + }); + asyncIdToRequests.delete(asyncId); + } +} + +function removeRequestOwnership(request: any, asyncId: number): boolean { + const owners = asyncIdToRequests.get(asyncId); + if (owners === undefined) { + return false; + } + owners.delete(request); + if (owners.size === 0) { + asyncIdToRequests.delete(asyncId); + return true; + } + return false; +} + // 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. @@ -68,6 +140,15 @@ export function initAsyncDebugInfo(): void { triggerAsyncId: number, resource: any, ): void { + const request = resolveRequest(); + if ( + request !== null && + (isRequestClosingOrClosed(request) || cleanedRequests.has(request)) + ) { + // Avoid creating any new tracking nodes once cleanup has run for this request. + return; + } + const trigger = pendingOperations.get(triggerAsyncId); let node: AsyncSequence; if (type === 'PROMISE') { @@ -100,7 +181,6 @@ export function initAsyncDebugInfo(): void { } } else { promiseRef = new WeakRef((resource: Promise)); - const request = resolveRequest(); if (request === null) { // We don't collect stacks for awaits that weren't in the scope of a specific render. } else { @@ -200,7 +280,29 @@ export function initAsyncDebugInfo(): void { node = trigger; } } + // Keep operation tracking for both request-scoped and external async work. pendingOperations.set(asyncId, node); + + if (request !== null) { + // Keep ownership request-local here. We intentionally don't claim + // unowned trigger nodes so async debug info from external promises can + // still be reused by later requests. + addRequestOwnership(request, asyncId); + } else { + // If this async resource was spawned from request-owned work, preserve all + // owners so cleanup only deletes entries when the last owner closes. + const triggerOwners = asyncIdToRequests.get(triggerAsyncId); + if (triggerOwners !== undefined) { + triggerOwners.forEach(triggerRequest => { + if ( + !isRequestClosingOrClosed(triggerRequest) && + !cleanedRequests.has(triggerRequest) + ) { + addRequestOwnership(triggerRequest, asyncId); + } + }); + } + } }, before(asyncId: number): void { const node = pendingOperations.get(asyncId); @@ -335,6 +437,7 @@ export function initAsyncDebugInfo(): void { // 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); + removeAllRequestOwnership(asyncId); }, }).enable(); } @@ -345,7 +448,9 @@ export function markAsyncSequenceRootTask(): void { // 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()); + const asyncId = executionAsyncId(); + pendingOperations.delete(asyncId); + removeAllRequestOwnership(asyncId); } } @@ -386,3 +491,24 @@ export function getAsyncSequenceFromPromise( } return node; } + +export function cleanupAsyncDebugInfo(request: any): void { + if (__DEV__ && enableAsyncDebugInfo) { + cleanedRequests.add(request); + // Ensure we don't retain chains through the process-global fallback pointer. + lastRanAwait = null; + + // Sweep all asyncIds tracked for this request from the global pendingOperations map. + // Delete an asyncId only when this was its final request owner so sharing across + // active requests does not lose lineage. + const ids = requestAsyncIds.get(request); + if (ids !== undefined) { + ids.forEach(id => { + if (removeRequestOwnership(request, id)) { + pendingOperations.delete(id); + } + }); + requestAsyncIds.delete(request); + } + } +} diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js index e435929114b5..90a6e9675f83 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js @@ -20,3 +20,4 @@ export function getAsyncSequenceFromPromise( ): null | AsyncSequence { return null; } +export function cleanupAsyncDebugInfo(request: any): void {}