From ec7d9a7249e84e841fbe1e4c22e1be2c0c15dae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 19 Oct 2025 11:56:25 -0700 Subject: [PATCH 1/5] Resolve the .default export of a React.lazy as the canonical value (#34906) For debug purposes this is the value that the `React.lazy` resolves to. It also lets us look at that value for descriptions like its name. --- packages/react/src/ReactLazy.js | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/react/src/ReactLazy.js b/packages/react/src/ReactLazy.js index 55b1690b7ca..47f37afbae2 100644 --- a/packages/react/src/ReactLazy.js +++ b/packages/react/src/ReactLazy.js @@ -20,6 +20,8 @@ import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; import {REACT_LAZY_TYPE} from 'shared/ReactSymbols'; +import noop from 'shared/noop'; + const Uninitialized = -1; const Pending = 0; const Resolved = 1; @@ -67,12 +69,20 @@ export type LazyComponent = { function lazyInitializer(payload: Payload): T { if (payload._status === Uninitialized) { + let resolveDebugValue: (void | T) => void = (null: any); + let rejectDebugValue: mixed => void = (null: any); if (__DEV__ && enableAsyncDebugInfo) { const ioInfo = payload._ioInfo; if (ioInfo != null) { // Mark when we first kicked off the lazy request. // $FlowFixMe[cannot-write] ioInfo.start = ioInfo.end = performance.now(); + // Stash a Promise for introspection of the value later. + // $FlowFixMe[cannot-write] + ioInfo.value = new Promise((resolve, reject) => { + resolveDebugValue = resolve; + rejectDebugValue = reject; + }); } } const ctor = payload._result; @@ -92,12 +102,20 @@ function lazyInitializer(payload: Payload): T { const resolved: ResolvedPayload = (payload: any); resolved._status = Resolved; resolved._result = moduleObject; - if (__DEV__) { + if (__DEV__ && enableAsyncDebugInfo) { const ioInfo = payload._ioInfo; if (ioInfo != null) { // Mark the end time of when we resolved. // $FlowFixMe[cannot-write] ioInfo.end = performance.now(); + // Surface the default export as the resolved "value" for debug purposes. + const debugValue = + moduleObject == null ? undefined : moduleObject.default; + resolveDebugValue(debugValue); + // $FlowFixMe + ioInfo.value.status = 'fulfilled'; + // $FlowFixMe + ioInfo.value.value = debugValue; } // Make the thenable introspectable if (thenable.status === undefined) { @@ -124,6 +142,14 @@ function lazyInitializer(payload: Payload): T { // Mark the end time of when we rejected. // $FlowFixMe[cannot-write] ioInfo.end = performance.now(); + // Hide unhandled rejections. + // $FlowFixMe + ioInfo.value.then(noop, noop); + rejectDebugValue(error); + // $FlowFixMe + ioInfo.value.status = 'rejected'; + // $FlowFixMe + ioInfo.value.reason = error; } // Make the thenable introspectable if (thenable.status === undefined) { @@ -139,9 +165,6 @@ function lazyInitializer(payload: Payload): T { if (__DEV__ && enableAsyncDebugInfo) { const ioInfo = payload._ioInfo; if (ioInfo != null) { - // Stash the thenable for introspection of the value later. - // $FlowFixMe[cannot-write] - ioInfo.value = thenable; const displayName = thenable.displayName; if (typeof displayName === 'string') { // $FlowFixMe[cannot-write] From bf11d2fb2f01174974b7e1fa5b1c01d34936724b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 19 Oct 2025 11:56:40 -0700 Subject: [PATCH 2/5] [DevTools] Infer name from stack if it's the generic "lazy" name (#34907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34906. Infer name from stack if it's the generic "lazy" name. It might be wrapped in an abstraction. E.g. `next/dynamic`. Also use the function name as a description of a resolved function value. Screenshot 2025-10-18 at 10 42 05 AM --- .../react-devtools-shared/src/devtools/views/useInferredName.js | 2 +- packages/shared/ReactIODescription.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/devtools/views/useInferredName.js b/packages/react-devtools-shared/src/devtools/views/useInferredName.js index f0822e126d7..2c93ded4691 100644 --- a/packages/react-devtools-shared/src/devtools/views/useInferredName.js +++ b/packages/react-devtools-shared/src/devtools/views/useInferredName.js @@ -16,7 +16,7 @@ export default function useInferredName( const fetchFileWithCaching = useContext(FetchFileWithCachingContext); const name = asyncInfo.awaited.name; let inferNameFromStack = null; - if (!name || name === 'Promise') { + if (!name || name === 'Promise' || name === 'lazy') { // If all we have is a generic name, we can try to infer a better name from // the stack. We only do this if the stack has more than one frame since // otherwise it's likely to just be the name of the component which isn't better. diff --git a/packages/shared/ReactIODescription.js b/packages/shared/ReactIODescription.js index e1a0fce2c4d..6d7bf648fc8 100644 --- a/packages/shared/ReactIODescription.js +++ b/packages/shared/ReactIODescription.js @@ -13,6 +13,8 @@ export function getIODescription(value: mixed): string { } try { switch (typeof value) { + case 'function': + return value.name || ''; case 'object': // Test the object for a bunch of common property names that are useful identifiers. // While we only have the return value here, it should ideally be a name that From 58bdc0bb967098f14562cd76af0668f2056459a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 19 Oct 2025 11:56:56 -0700 Subject: [PATCH 3/5] [Flight] Ignore bound-anonymous-fn resources as they're not considered I/O (#34911) When you create a snapshot from an AsyncLocalStorage in Node.js, that creates a new bound AsyncResource which everything runs inside of. https://github.com/nodejs/node/blob/3437e1c4bd529e51d96ea581b6435bbeb77ef524/lib/internal/async_local_storage/async_hooks.js#L61-L67 This resource is itself tracked by our async debug tracking as I/O. We can't really distinguish these in general from other AsyncResources which are I/O. However, by default they're given the name `"bound-anonymous-fn"` if you pass it an anonymous function or in the case of a snapshot, that's built-in: https://github.com/nodejs/node/blob/3437e1c4bd529e51d96ea581b6435bbeb77ef524/lib/async_hooks.js#L262-L263 We can at least assume that these are non-I/O. If you want to ensure that a bound resource is not considered I/O, you can ensure your function isn't assigned a name or give it this explicit name. The other issue here is that, the sequencing here is that we track the callsite of the `.snapshot()` or `.bind()` call as the trigger. So if that was outside of render for example, then it would be considered non-I/O. However, this might miss stuff if you resolve promises inside the `.run()` of the snapshot if the `.run()` call itself was spawned by I/O which should be tracked. Time will tell if those patterns appear. However, in cases like nested renders (e.g. Next.js's "use cache") then restoring it as if it was outside the parent render is what you do want. --- .../src/ReactFlightServerConfigDebugNode.js | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index a78297ad83f..e79c19cc73a 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -142,10 +142,28 @@ export function initAsyncDebugInfo(): void { }: UnresolvedPromiseNode); } } else if ( - type !== 'Microtask' && - type !== 'TickObject' && - type !== 'Immediate' + // bound-anonymous-fn is the default name for snapshots and .bind() without a name. + // This isn't I/O by itself but likely just a continuation. If the bound function + // has a name, we might treat it as I/O but we can't tell the difference. + type === 'bound-anonymous-fn' || + // queueMicroTask, process.nextTick and setImmediate aren't considered new I/O + // for our purposes but just continuation of existing I/O. + type === 'Microtask' || + type === 'TickObject' || + type === 'Immediate' ) { + // Treat the trigger as the node to carry along the sequence. + // For "bound-anonymous-fn" this will be the callsite of the .bind() which may not + // be the best if the callsite of the .run() call is within I/O which should be + // tracked. It might be better to track the execution context of "before()" as the + // execution context for anything spawned from within the run(). Basically as if + // it wasn't an AsyncResource at all. + if (trigger === undefined) { + return; + } + node = trigger; + } else { + // New I/O if (trigger === undefined) { // We have begun a new I/O sequence. const owner = resolveOwner(); @@ -181,13 +199,6 @@ export function initAsyncDebugInfo(): void { // 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); }, From 2cfb221937eac48209d01d5dda5664de473b1953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 19 Oct 2025 13:38:33 -0700 Subject: [PATCH 4/5] [Flight] Allow passing DEV only startTime as an option (#34912) When you use the `createFromFetch` API we assume that the start time of the request is the same time as when you call `createFromFetch` but in principle you could use it with a Promise that starts earlier and just happens to resolve to a `Response`. When you use `createFromReadableStream` that is almost definitely the case. E.g. you might have started it way earlier and you don't call `createFromReadableStream` until you get the headers back (the fetch promise resolves). This adds an option to pass in the start time for debug purposes if you started the request before starting to parse it. --- packages/react-client/src/ReactFlightClient.js | 6 +++++- packages/react-markup/src/ReactMarkupServer.js | 3 +++ .../src/client/ReactFlightDOMClientBrowser.js | 4 ++++ .../src/client/ReactFlightDOMClientNode.js | 4 ++++ .../src/client/ReactFlightDOMClientBrowser.js | 4 ++++ .../src/client/ReactFlightDOMClientEdge.js | 4 ++++ .../src/client/ReactFlightDOMClientNode.js | 4 ++++ .../src/client/ReactFlightDOMClientBrowser.js | 4 ++++ .../src/client/ReactFlightDOMClientEdge.js | 4 ++++ .../src/client/ReactFlightDOMClientNode.js | 4 ++++ .../src/client/ReactFlightDOMClientBrowser.js | 4 ++++ .../src/client/ReactFlightDOMClientEdge.js | 4 ++++ .../src/client/ReactFlightDOMClientNode.js | 4 ++++ 13 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 1c2151362d8..8bcabfb5563 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -2561,6 +2561,7 @@ function ResponseInstance( findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only replayConsole: boolean, // DEV-only environmentName: void | string, // DEV-only + debugStartTime: void | number, // DEV-only debugChannel: void | DebugChannel, // DEV-only ) { const chunks: Map> = new Map(); @@ -2621,7 +2622,8 @@ function ResponseInstance( // Note: createFromFetch allows this to be marked at the start of the fetch // where as if you use createFromReadableStream from the body of the fetch // then the start time is when the headers resolved. - this._debugStartTime = performance.now(); + this._debugStartTime = + debugStartTime == null ? performance.now() : debugStartTime; this._debugIOStarted = false; // We consider everything before the first setTimeout task to be cached data // and is not considered I/O required to load the stream. @@ -2669,6 +2671,7 @@ export function createResponse( findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only replayConsole: boolean, // DEV-only environmentName: void | string, // DEV-only + debugStartTime: void | number, // DEV-only debugChannel: void | DebugChannel, // DEV-only ): WeakResponse { return getWeakResponse( @@ -2684,6 +2687,7 @@ export function createResponse( findSourceMapURL, replayConsole, environmentName, + debugStartTime, debugChannel, ), ); diff --git a/packages/react-markup/src/ReactMarkupServer.js b/packages/react-markup/src/ReactMarkupServer.js index 0b35404cb80..6f1e35e6156 100644 --- a/packages/react-markup/src/ReactMarkupServer.js +++ b/packages/react-markup/src/ReactMarkupServer.js @@ -91,6 +91,9 @@ export function experimental_renderToHTML( undefined, undefined, false, + undefined, + undefined, + undefined, ); const streamState = createFlightStreamState(flightResponse, null); const flightDestination = { diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js index cfc8dcf5f1b..371f08abc9a 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js @@ -52,6 +52,7 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, }; function createDebugCallbackFromWritableStream( @@ -103,6 +104,9 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js index 2bf32729472..78dce936158 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js @@ -57,6 +57,7 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, // For the Node.js client we only support a single-direction debug channel. debugChannel?: Readable, }; @@ -112,6 +113,9 @@ function createFromNodeStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js index 2f71ce2fa95..0f0141e6409 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js @@ -129,6 +129,9 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); } @@ -205,6 +208,7 @@ export type Options = { temporaryReferences?: TemporaryReferenceSet, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, }; export function createFromReadableStream( diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js index f9c63ccbc33..5c8d1023b23 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js @@ -79,6 +79,7 @@ export type Options = { temporaryReferences?: TemporaryReferenceSet, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, // For the Edge client we only support a single-direction debug channel. debugChannel?: {readable?: ReadableStream, ...}, }; @@ -107,6 +108,9 @@ function createResponseFromOptions(options?: Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js index b874bcd7d44..fbc633a1753 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js @@ -52,6 +52,7 @@ export type Options = { encodeFormAction?: EncodeFormActionCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, // For the Node.js client we only support a single-direction debug channel. debugChannel?: Readable, }; @@ -103,6 +104,9 @@ export function createFromNodeStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js index b4b84f1c41b..b3d31bd1bbb 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js @@ -51,6 +51,7 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, }; function createDebugCallbackFromWritableStream( @@ -102,6 +103,9 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js index b994841be18..5517e0f73cf 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js @@ -79,6 +79,7 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, // For the Edge client we only support a single-direction debug channel. debugChannel?: {readable?: ReadableStream, ...}, }; @@ -109,6 +110,9 @@ function createResponseFromOptions(options: Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js index f174f10c0b8..6d117929df0 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js @@ -60,6 +60,7 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, // For the Node.js client we only support a single-direction debug channel. debugChannel?: Readable, }; @@ -114,6 +115,9 @@ function createFromNodeStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js index b4b84f1c41b..b3d31bd1bbb 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js @@ -51,6 +51,7 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, }; function createDebugCallbackFromWritableStream( @@ -102,6 +103,9 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js index 63dae495455..bc4caac767f 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js @@ -79,6 +79,7 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, // For the Edge client we only support a single-direction debug channel. debugChannel?: {readable?: ReadableStream, ...}, }; @@ -109,6 +110,9 @@ function createResponseFromOptions(options: Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js index f174f10c0b8..6d117929df0 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js @@ -60,6 +60,7 @@ export type Options = { findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, + startTime?: number, // For the Node.js client we only support a single-direction debug channel. debugChannel?: Readable, }; @@ -114,6 +115,9 @@ function createFromNodeStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, debugChannel, ); From b485f7cf64118fc8729181f46fe5e2edd47bea43 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 20 Oct 2025 00:47:27 +0200 Subject: [PATCH 5/5] [DevTools] Don't attach filtered IO to grandparent Suspense (#34916) --- packages/react-devtools-shared/src/backend/fiber/renderer.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 4dd4a619cb4..4a61fba6525 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2862,7 +2862,10 @@ export function attach( let parentInstance = reconcilingParent; while ( parentInstance.kind === FILTERED_FIBER_INSTANCE && - parentInstance.parent !== null + parentInstance.parent !== null && + // We can't move past the parent Suspense node. + // The Suspense node holding async info must be a parent of the devtools instance (or the instance itself) + parentInstance !== parentSuspenseNode.instance ) { parentInstance = parentInstance.parent; }