diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index f63f82333116e..a4b6e7b5f94db 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -10,6 +10,8 @@ 'use strict'; +const path = require('path'); + import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; global.ReadableStream = @@ -75,23 +77,60 @@ describe('ReactFlightDOMNode', () => { use = React.use; }); - function filterStackFrame(filename, functionName) { - return ( - filename !== '' && - !filename.startsWith('node:') && - !filename.includes('node_modules') && - // Filter out our own internal source code since it'll typically be in node_modules - (!filename.includes('/packages/') || filename.includes('/__tests__/')) && - !filename.includes('/build/') + function getLineNumber() { + const error = new Error(); + Error.captureStackTrace(error, getLineNumber); + const firstStackFrame = error.stack.split('\n')[1]; + + const lineNumber = firstStackFrame.slice( + firstStackFrame.indexOf(':') + 1, + firstStackFrame.lastIndexOf(':'), ); + + return parseInt(lineNumber, 10); + } + + function createFilterStackFrame( + userSpaceStart: number, + userSpaceEnd: number, + ) { + return (filename: string, functionName: string, lineNumber: number) => { + if (filename === __filename) { + return lineNumber >= userSpaceStart && lineNumber <= userSpaceEnd; + } + + return ( + filename !== '' && + !filename.startsWith('node:') && + !filename.includes('node_modules') && + // Filter out our own internal source code since it'll typically be in + // node_modules. This also includes the current test file. Only user space + // code that has been marked explicitly is included (see above). + !filename.includes('/packages/') && + !filename.includes('/build/') + ); + }; } - function normalizeCodeLocInfo(str) { + const repoRoot = path.resolve(__dirname, '../../../../'); + + function normalizeCodeLocInfo(str, {preserveLocation = false} = {}) { return ( str && - str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { - return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); - }) + str.replace( + /^ +(?:at|in) ([\S]+) ([^\n]*)/gm, + function (m, name, location) { + return ( + ' in ' + + name + + (/\d/.test(m) + ? preserveLocation + ? ' ' + location.replace(repoRoot, '') + : ' (at **)' + : '') + ); + }, + ) ); } @@ -751,6 +790,8 @@ describe('ReactFlightDOMNode', () => { // @gate enableHalt it('includes deeper location for aborted stacks', async () => { + const userSpaceStart = getLineNumber(); + async function getData() { const signal = ReactServer.cacheSignal(); await new Promise((resolve, reject) => { @@ -789,6 +830,8 @@ describe('ReactFlightDOMNode', () => { ); } + const userSpaceEnd = getLineNumber(); + const errors = []; const serverAbortController = new AbortController(); const {pendingResult} = await serverAct(async () => { @@ -802,7 +845,10 @@ describe('ReactFlightDOMNode', () => { onError(error) { errors.push(error); }, - filterStackFrame, + filterStackFrame: createFilterStackFrame( + userSpaceStart, + userSpaceEnd, + ), }, ), }; @@ -907,6 +953,558 @@ describe('ReactFlightDOMNode', () => { } }); + // @gate enableHalt + describe.each(['setImmediate', 'setTimeout'])( + 'when scheduling prerendering and aborting in successive tasks using %s', + timerFunctionName => { + let scheduleTask; + + beforeEach(() => { + // These tests rely on tasks resolving exactly as they would in a real + // environment, which is not the case when using fake timers and + // serverAct. + jest.useRealTimers(); + scheduleTask = globalThis[timerFunctionName]; + }); + + afterEach(() => { + jest.useFakeTimers(); + }); + + function createHangingPromise(signal) { + const promise = new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason)); + }); + promise.displayName = 'hanging'; + return promise; + } + + function ClientRoot({response}) { + return use(response); + } + + it('includes deeper location for hanging promises', async () => { + const userSpaceStart = getLineNumber(); + + async function Component({promise}) { + await promise; + return null; + } + + function App({promise}) { + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement(Component, {promise}), + ), + ); + } + + const userSpaceEnd = getLineNumber(); + + const serverRenderAbortController = new AbortController(); + const serverCleanupAbortController = new AbortController(); + const errors = []; + + const promise = createHangingPromise( + serverCleanupAbortController.signal, + ); + + // destructure trick to avoid the act scope from awaiting the returned value + const {prelude} = await new Promise((resolve, reject) => { + let result; + + scheduleTask(() => { + result = ReactServerDOMStaticServer.prerender( + ReactServer.createElement(App, {promise}), + webpackMap, + { + signal: serverRenderAbortController.signal, + onError(error) { + errors.push(error); + }, + filterStackFrame: createFilterStackFrame( + userSpaceStart, + userSpaceEnd, + ), + }, + ); + + serverRenderAbortController.signal.addEventListener('abort', () => { + serverCleanupAbortController.abort(); + }); + }); + + scheduleTask(() => { + serverRenderAbortController.abort(); + resolve(result); + }); + }); + + expect(errors).toEqual([]); + + const prerenderResponse = ReactServerDOMClient.createFromReadableStream( + await createBufferedUnclosingStream(prelude), + {serverConsumerManifest: {moduleMap: null, moduleLoading: null}}, + ); + + let componentStack; + let ownerStack; + + const clientAbortController = new AbortController(); + + const fizzPrerenderStream = await new Promise(resolve => { + let result; + + scheduleTask(() => { + result = ReactDOMFizzStatic.prerender( + React.createElement(ClientRoot, {response: prerenderResponse}), + { + signal: clientAbortController.signal, + onError(error, errorInfo) { + componentStack = errorInfo.componentStack; + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ); + }); + + scheduleTask(() => { + clientAbortController.abort(); + resolve(result); + }); + }); + + const prerenderHTML = await readWebResult(fizzPrerenderStream.prelude); + + expect(prerenderHTML).toBe(''); + + const normalizedComponentStack = normalizeCodeLocInfo(componentStack, { + preserveLocation: true, + }); + + if (__DEV__) { + if (gate(flags => flags.enableAsyncDebugInfo)) { + expect(normalizedComponentStack).toMatchInlineSnapshot(` + " + in Component (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:990:11) + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1001:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)" + `); + } else { + expect(normalizedComponentStack).toMatchInlineSnapshot(` + " + in Component + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1001:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)" + `); + } + } else { + expect(normalizedComponentStack).toMatchInlineSnapshot(` + " + in body + in html + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)" + `); + } + + const normalizedOwnerStack = normalizeCodeLocInfo(ownerStack, { + preserveLocation: true, + }); + + if (__DEV__) { + if (gate(flags => flags.enableAsyncDebugInfo)) { + expect(normalizedOwnerStack).toMatchInlineSnapshot(` + " + in Component (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:990:11) + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1001:27)" + `); + } else { + expect(normalizedOwnerStack).toMatchInlineSnapshot(` + " + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1001:27)" + `); + } + } else { + expect(normalizedOwnerStack).toBeNull(); + } + }); + + it('includes deeper location for hanging promises in ignore-listed components', async () => { + async function IgnoreListedComponent({signal}) { + return createHangingPromise(signal); + } + + const userSpaceStart = getLineNumber(); + + function App({signal}) { + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement(IgnoreListedComponent, {signal}), + ), + ); + } + + const userSpaceEnd = getLineNumber(); + + const serverRenderAbortController = new AbortController(); + const serverCleanupAbortController = new AbortController(); + const errors = []; + + // destructure trick to avoid the act scope from awaiting the returned value + const {prelude} = await new Promise((resolve, reject) => { + let result; + + scheduleTask(() => { + result = ReactServerDOMStaticServer.prerender( + ReactServer.createElement(App, { + signal: serverCleanupAbortController.signal, + }), + webpackMap, + { + signal: serverRenderAbortController.signal, + onError(error) { + errors.push(error); + }, + filterStackFrame: createFilterStackFrame( + userSpaceStart, + userSpaceEnd, + ), + }, + ); + + serverRenderAbortController.signal.addEventListener('abort', () => { + serverCleanupAbortController.abort(); + }); + }); + + scheduleTask(() => { + serverRenderAbortController.abort(); + resolve(result); + }); + }); + + expect(errors).toEqual([]); + + const prerenderResponse = ReactServerDOMClient.createFromReadableStream( + await createBufferedUnclosingStream(prelude), + {serverConsumerManifest: {moduleMap: null, moduleLoading: null}}, + ); + + let componentStack; + let ownerStack; + + const clientAbortController = new AbortController(); + + const fizzPrerenderStream = await new Promise(resolve => { + let result; + + scheduleTask(() => { + result = ReactDOMFizzStatic.prerender( + React.createElement(ClientRoot, {response: prerenderResponse}), + { + signal: clientAbortController.signal, + onError(error, errorInfo) { + componentStack = errorInfo.componentStack; + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ); + }); + + scheduleTask(() => { + clientAbortController.abort(); + resolve(result); + }); + }); + + const prerenderHTML = await readWebResult(fizzPrerenderStream.prelude); + + expect(prerenderHTML).toBe(''); + + const normalizedComponentStack = normalizeCodeLocInfo(componentStack, { + preserveLocation: true, + }); + + if (__DEV__) { + expect(normalizedComponentStack).toMatchInlineSnapshot(` + " + in IgnoreListedComponent + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1156:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)" + `); + } else { + expect(normalizedComponentStack).toMatchInlineSnapshot(` + " + in body + in html + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)" + `); + } + + const normalizedOwnerStack = normalizeCodeLocInfo(ownerStack, { + preserveLocation: true, + }); + + if (__DEV__) { + expect(normalizedOwnerStack).toMatchInlineSnapshot(` + " + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1156:27)" + `); + } else { + expect(normalizedOwnerStack).toBeNull(); + } + }); + + it('includes deeper location for unresolved I/O', async () => { + const userSpaceStart = getLineNumber(); + + async function ComponentA() { + await Promise.resolve(); + await new Promise(r => setTimeout(r)); + return null; + } + + async function ComponentB() { + await new Promise(r => setTimeout(r)); + return null; + } + + async function ComponentC({promise}) { + await promise; + return null; + } + + function ComponentD() { + const promise = new Promise(r => setTimeout(r, 10)); + return ReactServer.createElement(ComponentC, {promise}); + } + + function App() { + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement(ComponentA), + ReactServer.createElement(ComponentB), + ReactServer.createElement(ComponentD), + ), + ); + } + + const userSpaceEnd = getLineNumber(); + + const serverRenderAbortController = new AbortController(); + const errors = []; + + // destructure trick to avoid the act scope from awaiting the returned value + const {prelude} = await new Promise((resolve, reject) => { + let result; + + scheduleTask(() => { + result = ReactServerDOMStaticServer.prerender( + ReactServer.createElement(App), + webpackMap, + { + signal: serverRenderAbortController.signal, + onError(error) { + errors.push(error); + }, + filterStackFrame: createFilterStackFrame( + userSpaceStart, + userSpaceEnd, + ), + }, + ); + }); + + scheduleTask(() => { + serverRenderAbortController.abort(); + resolve(result); + }); + }); + + expect(errors).toEqual([]); + + const prerenderResponse = ReactServerDOMClient.createFromReadableStream( + await createBufferedUnclosingStream(prelude), + {serverConsumerManifest: {moduleMap: null, moduleLoading: null}}, + ); + + const componentStacks = []; + const ownerStacks = []; + + const clientAbortController = new AbortController(); + + const fizzPrerenderStream = await new Promise(resolve => { + let result; + + scheduleTask(() => { + result = ReactDOMFizzStatic.prerender( + React.createElement(ClientRoot, {response: prerenderResponse}), + { + signal: clientAbortController.signal, + onError(error, errorInfo) { + componentStacks.push(errorInfo.componentStack); + if (React.captureOwnerStack) { + ownerStacks.push(React.captureOwnerStack()); + } + }, + }, + ); + }); + + scheduleTask(() => { + clientAbortController.abort(); + resolve(result); + }); + }); + + const prerenderHTML = await readWebResult(fizzPrerenderStream.prelude); + + expect(prerenderHTML).toBe(''); + + const normalizedComponentStacks = componentStacks.map(stack => + normalizeCodeLocInfo(stack, { + preserveLocation: true, + }), + ); + + if (__DEV__) { + // TODO: The location for App is not quite right. It's the "implied + // location of the owner" that always points at where the first child + // is created (see initializeFakeStack in ReactFlightClient). This + // frame is not that important thought, so we accept that for now. + + if (gate(flags => flags.enableAsyncDebugInfo)) { + expect(normalizedComponentStacks).toMatchInlineSnapshot(` + [ + " + in ComponentA (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1281:17) + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1307:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + " + in ComponentB (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1286:17) + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1307:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + " + in ComponentC (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1291:11) + in ComponentD (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1297:30) + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1307:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + ] + `); + } else { + expect(normalizedComponentStacks).toMatchInlineSnapshot(` + [ + " + in ComponentA + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1307:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + " + in ComponentB + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1307:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + " + in ComponentC + in ComponentD (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1297:30) + in body + in html + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1307:27) + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + ] + `); + } + } else { + expect(normalizedComponentStacks).toMatchInlineSnapshot(` + [ + " + in body + in html + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + " + in body + in html + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + " + in body + in html + in ClientRoot (/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:982:56)", + ] + `); + } + + const normalizedOwnerStacks = ownerStacks.map(stack => + normalizeCodeLocInfo(stack, { + preserveLocation: true, + }), + ); + + if (__DEV__) { + if (gate(flags => flags.enableAsyncDebugInfo)) { + expect(normalizedOwnerStacks).toMatchInlineSnapshot(` + [ + " + in ComponentA (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1281:17) + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1307:27)", + " + in ComponentB (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1286:17) + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1308:27)", + " + in ComponentC (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1291:11) + in ComponentD (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1297:30) + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1309:27)", + ] + `); + } else { + expect(normalizedOwnerStacks).toMatchInlineSnapshot(` + [ + " + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1307:27)", + " + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1308:27)", + " + in ComponentD (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1297:30) + in App (file:///packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js:1309:27)", + ] + `); + } + } else { + expect(normalizedOwnerStacks).toEqual([]); + } + }); + }, + ); + // @gate enableHalt || enablePostpone // @gate enableHalt it('can handle an empty prelude when prerendering', async () => { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 84008f6757a79..d131e80ea8daf 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -75,6 +75,7 @@ import type { AsyncSequence, IONode, PromiseNode, + UnresolvedAwaitNode, UnresolvedPromiseNode, } from './ReactFlightAsyncSequence'; @@ -95,6 +96,7 @@ import { markAsyncSequenceRootTask, getCurrentAsyncSequence, getAsyncSequenceFromPromise, + getInternalAwaitNode, parseStackTrace, parseStackTracePrivate, supportsComponentStorage, @@ -4511,7 +4513,7 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void { function serializeIONode( request: Request, - ioNode: IONode | PromiseNode | UnresolvedPromiseNode, + ioNode: IONode | PromiseNode | UnresolvedPromiseNode | UnresolvedAwaitNode, promiseRef: null | WeakRef>, ): string { const existingRef = request.writtenDebugObjects.get(ioNode); @@ -5452,9 +5454,16 @@ function forwardDebugInfoFromCurrentContext( } } if (enableProfilerTimer && enableAsyncDebugInfo) { - const sequence = getCurrentAsyncSequence(); - if (sequence !== null) { - emitAsyncSequence(request, task, sequence, debugInfo, null, null); + if (request.status === ABORTING) { + // When aborting, skip forwarding debug info here. + // forwardDebugInfoFromAbortedTask will handle it more accurately. + } else { + // For normal resolve/reject, use the current execution context, as it + // shows what actually caused the Promise to settle. + const sequence = getCurrentAsyncSequence(); + if (sequence !== null) { + emitAsyncSequence(request, task, sequence, debugInfo, null, null); + } } } } @@ -5492,11 +5501,18 @@ function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void { // See if any of the dependencies are resolved yet. node = node.awaited; } + // For unresolved promise nodes, check if we have an internal await node + // that shows what the async function is blocked on. if (node.tag === UNRESOLVED_PROMISE_NODE) { - // We don't know what Promise will eventually end up resolving this Promise and if it - // was I/O at all. However, we assume that it was some kind of I/O since it didn't - // complete in time before aborting. - // The best we can do is try to emit the stack of where this Promise was created. + const internalAwait = getInternalAwaitNode(node); + if (internalAwait !== null) { + node = internalAwait; + } + } + if ( + node.tag === UNRESOLVED_PROMISE_NODE || + node.tag === UNRESOLVED_AWAIT_NODE + ) { serializeIONode(request, node, null); request.pendingChunks++; const env = (0, request.environmentName)(); diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index a78297ad83f73..429030001c7fb 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactStackTrace} from 'shared/ReactTypes'; +import type {ReactStackTrace, ReactComponentInfo} from 'shared/ReactTypes'; import type { AsyncSequence, @@ -40,6 +40,28 @@ const pendingOperations: Map = // Keep the last resolved await as a workaround for async functions missing data. let lastRanAwait: null | AwaitNode = null; +// These two maps work together to track what async functions are blocked on when aborting: +// +// 1. unresolvedPromiseNodesByOwner: Maps owner -> Promise (to find the Promise to link) +// When a Promise is created, we track it by its owner. This typically captures async +// function return Promises. Sync components may also have Promises tracked here, but +// they won't be linked since sync functions can't have awaits with matching owners. +// +// 2. internalAwaitNodesByPromise: Maps Promise -> await (stores the actual link) +// When an await happens with the same owner as a tracked Promise, we link that Promise +// to the await. This shows what the async function is currently blocked on. +// +// By storing the links separately from the regular awaited field, we can use this information +// only during abort scenarios without affecting normal rendering. +const unresolvedPromiseNodesByOwner: WeakMap< + ReactComponentInfo, + UnresolvedPromiseNode, +> = new WeakMap(); +const internalAwaitNodesByPromise: WeakMap< + UnresolvedPromiseNode | PromiseNode, + UnresolvedAwaitNode | AwaitNode, +> = new WeakMap(); + function resolvePromiseOrAwaitNode( unresolvedNode: UnresolvedAwaitNode | UnresolvedPromiseNode, endTime: number, @@ -114,9 +136,10 @@ export function initAsyncDebugInfo(): void { } } const current = pendingOperations.get(currentAsyncId); + const owner = resolveOwner(); node = ({ tag: UNRESOLVED_AWAIT_NODE, - owner: resolveOwner(), + owner: owner, stack: stack, start: performance.now(), end: -1.1, // set when resolved. @@ -124,6 +147,16 @@ export function initAsyncDebugInfo(): void { 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. }: UnresolvedAwaitNode); + // Link the owner's Promise to this await so we can track what it's blocked on. + // This only links when the await and Promise have the same owner (i.e., async functions + // awaiting within themselves). Promises from sync components won't match any awaits. + // We store this in a separate WeakMap to avoid affecting normal rendering. + if (owner !== null) { + const ownerPromiseNode = unresolvedPromiseNodesByOwner.get(owner); + if (ownerPromiseNode !== undefined) { + internalAwaitNodesByPromise.set(ownerPromiseNode, node); + } + } } else { const owner = resolveOwner(); node = ({ @@ -140,6 +173,17 @@ export function initAsyncDebugInfo(): void { : trigger, previous: null, }: UnresolvedPromiseNode); + // Track Promises by owner so awaits with matching owners can link to them. + // Only track the first Promise per owner. This typically captures async function + // return Promises, but may also track Promises from sync components - those won't + // be linked since sync functions can't have awaits with matching owners. + if ( + owner !== null && + trigger === undefined && + !unresolvedPromiseNodesByOwner.has(owner) + ) { + unresolvedPromiseNodesByOwner.set(owner, node); + } } } else if ( type !== 'Microtask' && @@ -356,3 +400,10 @@ export function getAsyncSequenceFromPromise( } return node; } + +export function getInternalAwaitNode( + promiseNode: UnresolvedPromiseNode | PromiseNode, +): null | UnresolvedAwaitNode | AwaitNode { + const awaitNode = internalAwaitNodesByPromise.get(promiseNode); + return awaitNode === undefined ? null : awaitNode; +} diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js index e435929114b57..a42109165b00f 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js @@ -7,7 +7,13 @@ * @flow */ -import type {AsyncSequence} from './ReactFlightAsyncSequence'; +import type { + AsyncSequence, + AwaitNode, + PromiseNode, + UnresolvedAwaitNode, + UnresolvedPromiseNode, +} from './ReactFlightAsyncSequence'; // Exported for runtimes that don't support Promise instrumentation for async debugging. export function initAsyncDebugInfo(): void {} @@ -20,3 +26,8 @@ export function getAsyncSequenceFromPromise( ): null | AsyncSequence { return null; } +export function getInternalAwaitNode( + promiseNode: UnresolvedPromiseNode | PromiseNode, +): null | UnresolvedAwaitNode | AwaitNode { + return null; +}