From 72965f361547da79fcd4310ae13e22d6abb274a6 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 11 Aug 2025 17:12:39 +0200 Subject: [PATCH 01/13] [DevTools] Restore reconciling Suspense stack after fallback was reconciled (#34168) --- .../src/backend/fiber/renderer.js | 70 ++++++++++--------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 236b31a3d9ef4..bf28c22728103 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -4162,7 +4162,7 @@ export function attach( const stashedSuspenseParent = reconcilingParentSuspenseNode; const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode; const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes; - let shouldPopSuspenseNode = false; + let shouldMeasureSuspenseNode = false; let previousSuspendedBy = null; if (fiberInstance !== null) { previousSuspendedBy = fiberInstance.suspendedBy; @@ -4192,7 +4192,7 @@ export function attach( previouslyReconciledSiblingSuspenseNode = null; remainingReconcilingChildrenSuspenseNodes = suspenseNode.firstChild; suspenseNode.firstChild = null; - shouldPopSuspenseNode = true; + shouldMeasureSuspenseNode = true; } } try { @@ -4379,38 +4379,40 @@ export function attach( 0, ); - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll - // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. - // Since the fallback conceptually blocks the parent. - reconcilingParentSuspenseNode = stashedSuspenseParent; - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; - shouldPopSuspenseNode = false; + shouldMeasureSuspenseNode = false; if (nextFallbackFiber !== null) { - updateFlags |= updateVirtualChildrenRecursively( - nextFallbackFiber, - null, - prevFallbackFiber, - traceNearestHostComponentUpdate, - 0, - ); - } else if ( - nextFiber.memoizedState === null && - fiberInstance.suspenseNode !== null - ) { - if (!isInDisconnectedSubtree) { - // Measure this Suspense node in case it changed. We don't update the rect while - // we're inside a disconnected subtree nor if we are the Suspense boundary that - // is suspended. This lets us keep the rectangle of the displayed content while - // we're suspended to visualize the resulting state. - const suspenseNode = fiberInstance.suspenseNode; - const prevRects = suspenseNode.rects; - const nextRects = measureInstance(fiberInstance); - if (!areEqualRects(prevRects, nextRects)) { - suspenseNode.rects = nextRects; - recordSuspenseResize(suspenseNode); - } + const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode; + const fallbackStashedSuspensePrevious = + previouslyReconciledSiblingSuspenseNode; + const fallbackStashedSuspenseRemaining = + remainingReconcilingChildrenSuspenseNodes; + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. + // Since the fallback conceptually blocks the parent. + reconcilingParentSuspenseNode = stashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; + try { + updateFlags |= updateVirtualChildrenRecursively( + nextFallbackFiber, + null, + prevFallbackFiber, + traceNearestHostComponentUpdate, + 0, + ); + } finally { + reconcilingParentSuspenseNode = fallbackStashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = + fallbackStashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = + fallbackStashedSuspenseRemaining; } + } else if (nextFiber.memoizedState === null) { + // Measure this Suspense node in case it changed. We don't update the rect while + // we're inside a disconnected subtree nor if we are the Suspense boundary that + // is suspended. This lets us keep the rectangle of the displayed content while + // we're suspended to visualize the resulting state. + shouldMeasureSuspenseNode = !isInDisconnectedSubtree; } } else { // Common case: Primary -> Primary. @@ -4519,7 +4521,7 @@ export function attach( reconcilingParent = stashedParent; previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; - if (shouldPopSuspenseNode) { + if (shouldMeasureSuspenseNode) { if ( !isInDisconnectedSubtree && reconcilingParentSuspenseNode !== null @@ -4535,6 +4537,8 @@ export function attach( recordSuspenseResize(suspenseNode); } } + } + if (fiberInstance.suspenseNode !== null) { reconcilingParentSuspenseNode = stashedSuspenseParent; previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; From 59ef3c4baf5fa107955eb72c3ee0f6e01a9923be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 11:41:14 -0400 Subject: [PATCH 02/13] [DevTools] Allow Introspection of React Elements and React.lazy (#34129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With RSC it's common to get React.lazy objects in the children position. This first formats them nicely. Then it adds introspection support for both lazy and elements. Unfortunately because of quirks with the hydration mechanism we have to expose it under the name `_payload` instead of something direct. Also because the name "type" is taken we can't expose the type field on an element neither. That whole algorithm could use a rewrite. Screenshot 2025-08-07 at 11 37 03 PM Screenshot 2025-08-07 at 11 36 36 PM For JSX an alternative or additional feature might be instead to jump to the first Instance that was rendered using that JSX. We know that based on the equality of the memoizedProps on the Fiber. It's just a matter of whether we do that eagerly or more lazily when you click but you may not have a match so would be nice to indicate that before you click. --- .../src/__tests__/inspectedElement-test.js | 30 ++++-- .../__tests__/legacy/inspectElement-test.js | 10 +- .../react-devtools-shared/src/hydration.js | 102 ++++++++++++++++-- packages/react-devtools-shared/src/utils.js | 68 +++++++++++- 4 files changed, 185 insertions(+), 25 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js index 522d211aeb06f..1136dd29281cb 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js @@ -682,6 +682,7 @@ describe('InspectedElement', () => { object_with_symbol={objectWithSymbol} proxy={proxyInstance} react_element={} + react_lazy={React.lazy(async () => ({default: 'foo'}))} regexp={/abc/giu} set={setShallow} set_of_sets={setOfSets} @@ -780,9 +781,18 @@ describe('InspectedElement', () => { "preview_short": () => {}, "preview_long": () => {}, }, - "react_element": Dehydrated { - "preview_short": , - "preview_long": , + "react_element": { + "key": null, + "props": Dehydrated { + "preview_short": {…}, + "preview_long": {}, + }, + }, + "react_lazy": { + "_payload": Dehydrated { + "preview_short": {…}, + "preview_long": {_result: () => {}, _status: -1}, + }, }, "regexp": Dehydrated { "preview_short": /abc/giu, @@ -930,13 +940,13 @@ describe('InspectedElement', () => { const inspectedElement = await inspectElementAtIndex(0); expect(inspectedElement.props).toMatchInlineSnapshot(` - { - "unusedPromise": Dehydrated { - "preview_short": Promise, - "preview_long": Promise, - }, - } - `); + { + "unusedPromise": Dehydrated { + "preview_short": Promise, + "preview_long": Promise, + }, + } + `); }); it('should not consume iterables while inspecting', async () => { diff --git a/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js b/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js index cf1ce1ffa3e38..f306ab97093d9 100644 --- a/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js @@ -289,9 +289,13 @@ describe('InspectedElementContext', () => { "preview_long": {boolean: true, number: 123, string: "abc"}, }, }, - "react_element": Dehydrated { - "preview_short": , - "preview_long": , + "react_element": { + "key": null, + "props": Dehydrated { + "preview_short": {…}, + "preview_long": {}, + }, + "ref": null, }, "regexp": Dehydrated { "preview_short": /abc/giu, diff --git a/packages/react-devtools-shared/src/hydration.js b/packages/react-devtools-shared/src/hydration.js index 7ce5a8ec6ab38..ecadad7ab3fe2 100644 --- a/packages/react-devtools-shared/src/hydration.js +++ b/packages/react-devtools-shared/src/hydration.js @@ -16,6 +16,8 @@ import { setInObject, } from 'react-devtools-shared/src/utils'; +import {REACT_LEGACY_ELEMENT_TYPE} from 'shared/ReactSymbols'; + import type { DehydratedData, InspectedElementPath, @@ -188,18 +190,103 @@ export function dehydrate( type, }; - // React Elements aren't very inspector-friendly, - // and often contain private fields or circular references. - case 'react_element': - cleaned.push(path); - return { - inspectable: false, + case 'react_element': { + isPathAllowedCheck = isPathAllowed(path); + + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + cleaned.push(path); + return { + inspectable: true, + preview_short: formatDataForPreview(data, false), + preview_long: formatDataForPreview(data, true), + name: getDisplayNameForReactElement(data) || 'Unknown', + type, + }; + } + + const unserializableValue: Unserializable = { + unserializable: true, + type, + readonly: true, preview_short: formatDataForPreview(data, false), preview_long: formatDataForPreview(data, true), name: getDisplayNameForReactElement(data) || 'Unknown', - type, }; + // TODO: We can't expose type because that name is already taken on Unserializable. + unserializableValue.key = dehydrate( + data.key, + cleaned, + unserializable, + path.concat(['key']), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + if (data.$$typeof === REACT_LEGACY_ELEMENT_TYPE) { + unserializableValue.ref = dehydrate( + data.ref, + cleaned, + unserializable, + path.concat(['ref']), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + } + unserializableValue.props = dehydrate( + data.props, + cleaned, + unserializable, + path.concat(['props']), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + unserializable.push(path); + return unserializableValue; + } + case 'react_lazy': { + isPathAllowedCheck = isPathAllowed(path); + + const payload = data._payload; + + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + cleaned.push(path); + const inspectable = + payload !== null && + typeof payload === 'object' && + (payload._status === 1 || + payload._status === 2 || + payload.status === 'fulfilled' || + payload.status === 'rejected'); + return { + inspectable, + preview_short: formatDataForPreview(data, false), + preview_long: formatDataForPreview(data, true), + name: 'lazy()', + type, + }; + } + + const unserializableValue: Unserializable = { + unserializable: true, + type: type, + preview_short: formatDataForPreview(data, false), + preview_long: formatDataForPreview(data, true), + name: 'lazy()', + }; + // Ideally we should alias these properties to something more readable but + // unfortunately because of how the hydration algorithm uses a single concept of + // "path" we can't alias the path. + unserializableValue._payload = dehydrate( + payload, + cleaned, + unserializable, + path.concat(['_payload']), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + unserializable.push(path); + return unserializableValue; + } // ArrayBuffers error if you try to inspect them. case 'array_buffer': case 'data_view': @@ -309,6 +396,7 @@ export function dehydrate( isPathAllowedCheck = isPathAllowed(path); if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + cleaned.push(path); return { inspectable: data.status === 'fulfilled' || data.status === 'rejected', diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index ef5e7450acdfb..2c6d026cd847b 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -633,6 +633,7 @@ export type DataType = | 'thenable' | 'object' | 'react_element' + | 'react_lazy' | 'regexp' | 'string' | 'symbol' @@ -686,11 +687,12 @@ export function getDataType(data: Object): DataType { return 'number'; } case 'object': - if ( - data.$$typeof === REACT_ELEMENT_TYPE || - data.$$typeof === REACT_LEGACY_ELEMENT_TYPE - ) { - return 'react_element'; + switch (data.$$typeof) { + case REACT_ELEMENT_TYPE: + case REACT_LEGACY_ELEMENT_TYPE: + return 'react_element'; + case REACT_LAZY_TYPE: + return 'react_lazy'; } if (isArray(data)) { return 'array'; @@ -906,6 +908,62 @@ export function formatDataForPreview( return `<${truncateForDisplay( getDisplayNameForReactElement(data) || 'Unknown', )} />`; + case 'react_lazy': + // To avoid actually initialize a lazy to cause a side-effect we make some assumptions + // about the structure of the payload even though that's not really part of the contract. + // In practice, this is really just coming from React.lazy helper or Flight. + const payload = data._payload; + if (payload !== null && typeof payload === 'object') { + if (payload._status === 0) { + // React.lazy constructor pending + return `pending lazy()`; + } + if (payload._status === 1 && payload._result != null) { + // React.lazy constructor fulfilled + if (showFormattedValue) { + const formatted = formatDataForPreview( + payload._result.default, + false, + ); + return `fulfilled lazy() {${truncateForDisplay(formatted)}}`; + } else { + return `fulfilled lazy() {…}`; + } + } + if (payload._status === 2) { + // React.lazy constructor rejected + if (showFormattedValue) { + const formatted = formatDataForPreview(payload._result, false); + return `rejected lazy() {${truncateForDisplay(formatted)}}`; + } else { + return `rejected lazy() {…}`; + } + } + if (payload.status === 'pending' || payload.status === 'blocked') { + // React Flight pending + return `pending lazy()`; + } + if (payload.status === 'fulfilled') { + // React Flight fulfilled + if (showFormattedValue) { + const formatted = formatDataForPreview(payload.value, false); + return `fulfilled lazy() {${truncateForDisplay(formatted)}}`; + } else { + return `fulfilled lazy() {…}`; + } + } + if (payload.status === 'rejected') { + // React Flight rejected + if (showFormattedValue) { + const formatted = formatDataForPreview(payload.reason, false); + return `rejected lazy() {${truncateForDisplay(formatted)}}`; + } else { + return `rejected lazy() {…}`; + } + } + } + // Some form of uninitialized + return 'lazy()'; case 'array_buffer': return `ArrayBuffer(${data.byteLength})`; case 'data_view': From 7a934a16b861366282e297ee61611f9bd8c524cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 11:41:30 -0400 Subject: [PATCH 03/13] [DevTools] Show Owner Stacks in "rendered by" View (#34130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This shows the stack trace of the JSX at each level so now you can also jump to the code location for the JSX callsite. The visual is similar to the owner stacks with `createTask` except when you click the `<...>` you jump to the Instance in the Components panel. Screenshot 2025-08-08 at 12 19 21 AM I'm not sure it's really necessary to have all the JSX stacks of every owner. We could just have it for the current component and then the rest of the owners you could get to if you just click that owner instance. As a bonus, I also use the JSX callsite as the fallback for the "View Source" button. This is primarily useful for built-ins like `
` and `` that don't have any implementation to jump to anyway. It's useful to be able to jump to where a boundary was defined. --- .../__tests__/__e2e__/devtools-utils.js | 15 ++++++-- .../src/__tests__/profilingCache-test.js | 1 + .../src/backend/fiber/renderer.js | 18 ++++++++++ .../src/backend/legacy/renderer.js | 3 ++ .../src/backend/types.js | 4 +++ .../react-devtools-shared/src/backendAPI.js | 2 ++ .../views/Components/InspectedElement.js | 20 +++++++---- .../views/Components/InspectedElementView.js | 35 ++++++++++++------- .../devtools/views/Components/OwnerView.js | 3 +- .../src/frontend/types.js | 4 +++ 10 files changed, 82 insertions(+), 23 deletions(-) diff --git a/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js b/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js index fe2bb3f6f222e..c39f63dc5bb4c 100644 --- a/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js +++ b/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js @@ -64,11 +64,22 @@ async function selectElement( createTestNameSelector('InspectedElementView-Owners'), ])[0]; + if (!ownersList) { + return false; + } + + const owners = findAllNodes(ownersList, [ + createTestNameSelector('OwnerView'), + ]); + return ( title && title.innerText.includes(titleText) && - ownersList && - ownersList.innerText.includes(ownersListText) + owners && + owners + .map(node => node.innerText) + .join('\n') + .includes(ownersListText) ); }, {titleText: displayName, ownersListText: waitForOwnersText} diff --git a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js index 795f37183a81f..d16062c69f488 100644 --- a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js @@ -949,6 +949,7 @@ describe('ProfilingCache', () => { "hocDisplayNames": null, "id": 1, "key": null, + "stack": null, "type": 11, }, ], diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index bf28c22728103..5510100f0d113 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -4991,6 +4991,10 @@ export function attach( id: instance.id, key: fiber.key, env: null, + stack: + fiber._debugOwner == null || fiber._debugStack == null + ? null + : parseStackTrace(fiber._debugStack, 1), type: getElementTypeForFiber(fiber), }; } else { @@ -5000,6 +5004,10 @@ export function attach( id: instance.id, key: componentInfo.key == null ? null : componentInfo.key, env: componentInfo.env == null ? null : componentInfo.env, + stack: + componentInfo.owner == null || componentInfo.debugStack == null + ? null + : parseStackTrace(componentInfo.debugStack, 1), type: ElementTypeVirtual, }; } @@ -5598,6 +5606,11 @@ export function attach( source, + stack: + fiber._debugOwner == null || fiber._debugStack == null + ? null + : parseStackTrace(fiber._debugStack, 1), + // Does the component have legacy context attached to it. hasLegacyContext, @@ -5698,6 +5711,11 @@ export function attach( source, + stack: + componentInfo.owner == null || componentInfo.debugStack == null + ? null + : parseStackTrace(componentInfo.debugStack, 1), + // Does the component have legacy context attached to it. hasLegacyContext: false, diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 6153e08832a11..faceec35a12b5 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -796,6 +796,7 @@ export function attach( id: getID(owner), key: element.key, env: null, + stack: null, type: getElementType(owner), }); if (owner._currentElement) { @@ -837,6 +838,8 @@ export function attach( source: null, + stack: null, + // Only legacy context exists in legacy versions. hasLegacyContext: true, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 585654252da20..55a1bc6532e22 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -257,6 +257,7 @@ export type SerializedElement = { id: number, key: number | string | null, env: null | string, + stack: null | ReactStackTrace, type: ElementType, }; @@ -308,6 +309,9 @@ export type InspectedElement = { source: ReactFunctionLocation | null, + // The location of the JSX creation. + stack: ReactStackTrace | null, + type: ElementType, // Meta information about the root this element belongs to. diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index a27e70c26d008..db22606377da1 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -257,6 +257,7 @@ export function convertInspectedElementBackendToFrontend( owners, env, source, + stack, context, hooks, plugins, @@ -295,6 +296,7 @@ export function convertInspectedElementBackendToFrontend( // Previous backend implementations (<= 6.1.5) have a different interface for Source. // This gates the source features for only compatible backends: >= 6.1.6 source: Array.isArray(source) ? source : null, + stack: stack, type, owners: owners === null diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index cc37953f4d271..7b19908cc8c4a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -51,12 +51,19 @@ export default function InspectedElementWrapper(_: Props): React.Node { const fetchFileWithCaching = useContext(FetchFileWithCachingContext); + const source = + inspectedElement == null + ? null + : inspectedElement.source != null + ? inspectedElement.source + : inspectedElement.stack != null && inspectedElement.stack.length > 0 + ? inspectedElement.stack[0] + : null; + const symbolicatedSourcePromise: null | Promise = React.useMemo(() => { - if (inspectedElement == null) return null; if (fetchFileWithCaching == null) return Promise.resolve(null); - const {source} = inspectedElement; if (source == null) return Promise.resolve(null); const [, sourceURL, line, column] = source; @@ -66,7 +73,7 @@ export default function InspectedElementWrapper(_: Props): React.Node { line, column, ); - }, [inspectedElement]); + }, [source]); const element = inspectedElementID !== null @@ -223,13 +230,12 @@ export default function InspectedElementWrapper(_: Props): React.Node { {!alwaysOpenInEditor && !!editorURL && - inspectedElement != null && - inspectedElement.source != null && + source != null && symbolicatedSourcePromise != null && ( }> @@ -276,7 +282,7 @@ export default function InspectedElementWrapper(_: Props): React.Node { {!hideViewSourceAction && ( )} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js index 95f7aee68da03..1318e96c30d0d 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js @@ -22,6 +22,7 @@ import InspectedElementSuspendedBy from './InspectedElementSuspendedBy'; import NativeStyleEditor from './NativeStyleEditor'; import {enableStyleXFeatures} from 'react-devtools-feature-flags'; import InspectedElementSourcePanel from './InspectedElementSourcePanel'; +import StackTraceView from './StackTraceView'; import OwnerView from './OwnerView'; import styles from './InspectedElementView.css'; @@ -52,6 +53,7 @@ export default function InspectedElementView({ symbolicatedSourcePromise, }: Props): React.Node { const { + stack, owners, rendererPackageName, rendererVersion, @@ -68,8 +70,9 @@ export default function InspectedElementView({ ? `${rendererPackageName}@${rendererVersion}` : null; const showOwnersList = owners !== null && owners.length > 0; + const showStack = stack != null && stack.length > 0; const showRenderedBy = - showOwnersList || rendererLabel !== null || rootType !== null; + showStack || showOwnersList || rendererLabel !== null || rootType !== null; return ( @@ -168,20 +171,26 @@ export default function InspectedElementView({ data-testname="InspectedElementView-Owners">
rendered by
+ {showStack ? : null} {showOwnersList && owners?.map(owner => ( - + <> + + {owner.stack != null && owner.stack.length > 0 ? ( + + ) : null} + ))} {rootType !== null && ( diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js index ac848484378bb..2b0f4b035a261 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js @@ -60,7 +60,8 @@ export default function OwnerView({ + title={displayName} + data-testname="OwnerView"> {'<' + displayName + '>'} diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 3fff08877ce92..e9bd9158b63e7 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -216,6 +216,7 @@ export type SerializedElement = { id: number, key: number | string | null, env: null | string, + stack: null | ReactStackTrace, hocDisplayNames: Array | null, compiledWithForget: boolean, type: ElementType, @@ -279,6 +280,9 @@ export type InspectedElement = { // Location of component in source code. source: ReactFunctionLocation | null, + // The location of the JSX creation. + stack: ReactStackTrace | null, + type: ElementType, // Meta information about the root this element belongs to. From ab5238d5a40a4a4a68b351d345ab26f8ec5785e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 11:41:46 -0400 Subject: [PATCH 04/13] [DevTools] Show name prop of Suspense / Activity in the Components Tree view (#34135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name prop will be used in the Suspense tab to help identity a boundary. Activity will also allow names. A custom component can be identified by the name of the component but built-ins doesn't have that. This PR adds it to the Components Tree View as well since otherwise you only have the key to go on. Normally we don't add all the props to avoid making this view too noisy but this is an exception along with key to help identify a boundary quickly in the tree. Unlike the SuspenseNode store, this wouldn't ever have a name inferred by owner since that kind of context already exists in this view. Screenshot 2025-08-08 at 1 20 36 PM I also made both the key and name prop searchable. Screenshot 2025-08-08 at 1 32 27 PM --- .../src/backend/fiber/renderer.js | 12 +++++++++++ .../src/backend/legacy/renderer.js | 1 + .../src/devtools/store.js | 6 ++++++ .../src/devtools/views/Components/Element.js | 20 ++++++++++++++++++- .../devtools/views/Components/TreeContext.js | 13 +++++++++++- .../views/Profiler/CommitTreeBuilder.js | 3 +++ .../src/frontend/types.js | 1 + packages/react-devtools-shared/src/utils.js | 1 + packages/shared/ReactTypes.js | 1 + 9 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 5510100f0d113..3a3c254f5cd4b 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2369,6 +2369,15 @@ export function attach( const keyString = key === null ? null : String(key); const keyStringID = getStringID(keyString); + const nameProp = + fiber.tag === SuspenseComponent + ? fiber.memoizedProps.name + : fiber.tag === ActivityComponent + ? fiber.memoizedProps.name + : null; + const namePropString = nameProp == null ? null : String(nameProp); + const namePropStringID = getStringID(namePropString); + pushOperation(TREE_OPERATION_ADD); pushOperation(id); pushOperation(elementType); @@ -2376,6 +2385,7 @@ export function attach( pushOperation(ownerID); pushOperation(displayNameStringID); pushOperation(keyStringID); + pushOperation(namePropStringID); // If this subtree has a new mode, let the frontend know. if ((fiber.mode & StrictModeBits) !== 0) { @@ -2478,6 +2488,7 @@ export function attach( // in such a way as to bypass the default stringification of the "key" property. const keyString = key === null ? null : String(key); const keyStringID = getStringID(keyString); + const namePropStringID = getStringID(null); const id = instance.id; @@ -2488,6 +2499,7 @@ export function attach( pushOperation(ownerID); pushOperation(displayNameStringID); pushOperation(keyStringID); + pushOperation(namePropStringID); const componentLogsEntry = componentInfoToComponentLogsMap.get(componentInfo); diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index faceec35a12b5..c2c278393602a 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -426,6 +426,7 @@ export function attach( pushOperation(ownerID); pushOperation(displayNameStringID); pushOperation(keyStringID); + pushOperation(getStringID(null)); // name prop } } diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 622c9a475419c..2d6b67ef12cc8 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -1116,6 +1116,7 @@ export default class Store extends EventEmitter<{ isCollapsed: false, // Never collapse roots; it would hide the entire tree. isStrictModeNonCompliant, key: null, + nameProp: null, ownerID: 0, parentID: 0, type, @@ -1139,6 +1140,10 @@ export default class Store extends EventEmitter<{ const key = stringTable[keyStringID]; i++; + const namePropStringID = operations[i]; + const nameProp = stringTable[namePropStringID]; + i++; + if (__DEBUG__) { debug( 'Add', @@ -1180,6 +1185,7 @@ export default class Store extends EventEmitter<{ isCollapsed: this._collapseNodesByDefault, isStrictModeNonCompliant: parentElement.isStrictModeNonCompliant, key, + nameProp, ownerID, parentID, type, diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.js b/packages/react-devtools-shared/src/devtools/views/Components/Element.js index c3ddf1da07518..25e5208ce9b6f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.js @@ -119,6 +119,7 @@ export default function Element({data, index, style}: Props): React.Node { hocDisplayNames, isStrictModeNonCompliant, key, + nameProp, compiledWithForget, } = element; const { @@ -179,7 +180,24 @@ export default function Element({data, index, style}: Props): React.Node { className={styles.KeyValue} title={key} onDoubleClick={handleKeyDoubleClick}> -
{key}
+
+                
+              
+
+ " +
+ )} + + {nameProp && ( + +  name=" + +
+                
+              
"
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index f43ced82447ef..72556543f4b33 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -995,7 +995,14 @@ function recursivelySearchTree( return; } - const {children, displayName, hocDisplayNames, compiledWithForget} = element; + const { + children, + displayName, + hocDisplayNames, + compiledWithForget, + key, + nameProp, + } = element; if (displayName != null && regExp.test(displayName) === true) { searchResults.push(elementID); } else if ( @@ -1006,6 +1013,10 @@ function recursivelySearchTree( searchResults.push(elementID); } else if (compiledWithForget && regExp.test('Forget')) { searchResults.push(elementID); + } else if (typeof key === 'string' && regExp.test(key)) { + searchResults.push(elementID); + } else if (typeof nameProp === 'string' && regExp.test(nameProp)) { + searchResults.push(elementID); } children.forEach(childID => diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index dfa515fffa1d1..d685263a22603 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -239,6 +239,9 @@ function updateTree( const key = stringTable[keyStringID]; i++; + // skip name prop + i++; + if (__DEBUG__) { debug( 'Add', diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index e9bd9158b63e7..0089059df9d7a 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -157,6 +157,7 @@ export type Element = { type: ElementType, displayName: string | null, key: number | string | null, + nameProp: null | string, hocDisplayNames: null | Array, diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 2c6d026cd847b..c585d90500dff 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -271,6 +271,7 @@ export function printOperationsArray(operations: Array) { i++; i++; // key + i++; // name logs.push( `Add node ${id} (${displayName || 'null'}) as child of ${parentID}`, diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 5c7af1d1b305f..af514a95109e3 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -298,6 +298,7 @@ export type ViewTransitionProps = { export type ActivityProps = { mode?: 'hidden' | 'visible' | null | void, children?: ReactNodeList, + name?: string, }; export type SuspenseProps = { From 6445b3154ee60c2b2aa15d8be437a3f07feeb8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 11:42:23 -0400 Subject: [PATCH 05/13] [Fiber] Add additional debugInfo to React.lazy constructors in DEV (#34137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This creates a debug info object for the React.lazy call when it's called on the client. We have some additional information we can track for these since they're created by React earlier. We can track the stack trace where `React.lazy` was called to associate it back to something useful. We can track the start time when we initialized it for the first time and the end time when it resolves. The name from the promise if available. This data is currently only picked up in child position and not component position. The component position is in a follow up. Screenshot 2025-08-08 at 2 49 33 PM This begs for ignore listing in the front end since these stacks aren't filtered on the server. --- .../src/__tests__/ReactFlight-test.js | 18 ++-- packages/react/src/ReactLazy.js | 87 ++++++++++++++++++- packages/shared/ReactTypes.js | 1 + 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 9a60c3bd66b29..0fd9b869c6141 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -2822,7 +2822,7 @@ describe('ReactFlight', () => { expect(getDebugInfo(promise)).toEqual( __DEV__ ? [ - {time: 20}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 22 : 20}, { name: 'ServerComponent', env: 'Server', @@ -2832,7 +2832,7 @@ describe('ReactFlight', () => { transport: expect.arrayContaining([]), }, }, - {time: 21}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 23 : 21}, ] : undefined, ); @@ -2843,7 +2843,7 @@ describe('ReactFlight', () => { expect(getDebugInfo(thirdPartyChildren[0])).toEqual( __DEV__ ? [ - {time: 22}, // Clamped to the start + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, // Clamped to the start { name: 'ThirdPartyComponent', env: 'third-party', @@ -2851,15 +2851,15 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: 22}, - {time: 23}, // This last one is when the promise resolved into the first party. + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 25 : 23}, // This last one is when the promise resolved into the first party. ] : undefined, ); expect(getDebugInfo(thirdPartyChildren[1])).toEqual( __DEV__ ? [ - {time: 22}, // Clamped to the start + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, // Clamped to the start { name: 'ThirdPartyLazyComponent', env: 'third-party', @@ -2867,14 +2867,14 @@ describe('ReactFlight', () => { stack: ' in myLazy (at **)\n in lazyInitializer (at **)', props: {}, }, - {time: 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, ] : undefined, ); expect(getDebugInfo(thirdPartyChildren[2])).toEqual( __DEV__ ? [ - {time: 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, { name: 'ThirdPartyFragmentComponent', env: 'third-party', @@ -2882,7 +2882,7 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, ] : undefined, ); diff --git a/packages/react/src/ReactLazy.js b/packages/react/src/ReactLazy.js index 2ac29c87774ec..69b35b58cc8bd 100644 --- a/packages/react/src/ReactLazy.js +++ b/packages/react/src/ReactLazy.js @@ -7,7 +7,16 @@ * @flow */ -import type {Wakeable, Thenable, ReactDebugInfo} from 'shared/ReactTypes'; +import type { + Wakeable, + Thenable, + FulfilledThenable, + RejectedThenable, + ReactDebugInfo, + ReactIOInfo, +} from 'shared/ReactTypes'; + +import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; import {REACT_LAZY_TYPE} from 'shared/ReactSymbols'; @@ -19,21 +28,25 @@ const Rejected = 2; type UninitializedPayload = { _status: -1, _result: () => Thenable<{default: T, ...}>, + _ioInfo?: ReactIOInfo, // DEV-only }; type PendingPayload = { _status: 0, _result: Wakeable, + _ioInfo?: ReactIOInfo, // DEV-only }; type ResolvedPayload = { _status: 1, _result: {default: T, ...}, + _ioInfo?: ReactIOInfo, // DEV-only }; type RejectedPayload = { _status: 2, _result: mixed, + _ioInfo?: ReactIOInfo, // DEV-only }; type Payload = @@ -51,6 +64,14 @@ export type LazyComponent = { function lazyInitializer(payload: Payload): T { if (payload._status === Uninitialized) { + 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(); + } + } const ctor = payload._result; const thenable = ctor(); // Transition to the next state. @@ -68,6 +89,21 @@ function lazyInitializer(payload: Payload): T { const resolved: ResolvedPayload = (payload: any); resolved._status = Resolved; resolved._result = moduleObject; + if (__DEV__) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + // Mark the end time of when we resolved. + // $FlowFixMe[cannot-write] + ioInfo.end = performance.now(); + } + // Make the thenable introspectable + if (thenable.status === undefined) { + const fulfilledThenable: FulfilledThenable<{default: T, ...}> = + (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = moduleObject; + } + } } }, error => { @@ -79,9 +115,37 @@ function lazyInitializer(payload: Payload): T { const rejected: RejectedPayload = (payload: any); rejected._status = Rejected; rejected._result = error; + if (__DEV__ && enableAsyncDebugInfo) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + // Mark the end time of when we rejected. + // $FlowFixMe[cannot-write] + ioInfo.end = performance.now(); + } + // Make the thenable introspectable + if (thenable.status === undefined) { + const rejectedThenable: RejectedThenable<{default: T, ...}> = + (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + } } }, ); + 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] + ioInfo.name = displayName; + } + } + } if (payload._status === Uninitialized) { // In case, we're still uninitialized, then we're waiting for the thenable // to resolve. Set it as pending in the meantime. @@ -140,5 +204,26 @@ export function lazy( _init: lazyInitializer, }; + if (__DEV__ && enableAsyncDebugInfo) { + // TODO: We should really track the owner here but currently ReactIOInfo + // can only contain ReactComponentInfo and not a Fiber. It's unusual to + // create a lazy inside an owner though since they should be in module scope. + const owner = null; + const ioInfo: ReactIOInfo = { + name: 'lazy', + start: -1, + end: -1, + value: null, + owner: owner, + debugStack: new Error('react-stack-top-frame'), + // eslint-disable-next-line react-internal/no-production-logging + debugTask: console.createTask ? console.createTask('lazy()') : null, + }; + payload._ioInfo = ioInfo; + // Add debug info to the lazy, but this doesn't have an await stack yet. + // That will be inferred by later usage. + lazyType._debugInfo = [{awaited: ioInfo}]; + } + return lazyType; } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index af514a95109e3..ff2649a23dc0d 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -108,6 +108,7 @@ interface ThenableImpl { onFulfill: (value: T) => mixed, onReject: (error: mixed) => mixed, ): void | Wakeable; + displayName?: string; } interface UntrackedThenable extends ThenableImpl { status?: void; From 34ce3acafdcfb6830250043b64d8af2450c730bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 11:42:59 -0400 Subject: [PATCH 06/13] [DevTools] Pick up suspended by info from React.lazy in type position (#34144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normally, we pick up debug info from instrumented Promise or React.Lazy while we're reconciling in ReactChildFiber when they appear in the child position. We add those to the `_debugInfo` of the Fiber. However, we don't do that for for Lazy in the Component type position. Instead, we have to pick up the debug info from it explicitly in DevTools. Likely this is the info added by #34137. Older versions wouldn't be covered by this particular mechanism but more generally from throwing a Promise. Screenshot 2025-08-08 at 11 32 33 PM --- .../src/backend/fiber/renderer.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 3a3c254f5cd4b..6fb5a66c7d28d 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -104,6 +104,7 @@ import { MEMO_NUMBER, MEMO_SYMBOL_STRING, SERVER_CONTEXT_SYMBOL_STRING, + LAZY_SYMBOL_STRING, } from '../shared/ReactSymbols'; import {enableStyleXFeatures} from 'react-devtools-feature-flags'; @@ -3161,6 +3162,25 @@ export function attach( return null; } + function trackDebugInfoFromLazyType(fiber: Fiber): void { + // The debugInfo from a Lazy isn't propagated onto _debugInfo of the parent Fiber the way + // it is when used in child position. So we need to pick it up explicitly. + const type = fiber.elementType; + const typeSymbol = getTypeSymbol(type); // The elementType might be have been a LazyComponent. + if (typeSymbol === LAZY_SYMBOL_STRING) { + const debugInfo: ?ReactDebugInfo = type._debugInfo; + if (debugInfo) { + for (let i = 0; i < debugInfo.length; i++) { + const debugEntry = debugInfo[i]; + if (debugEntry.awaited) { + const asyncInfo: ReactAsyncInfo = (debugEntry: any); + insertSuspendedBy(asyncInfo); + } + } + } + } + } + function mountVirtualChildrenRecursively( firstChild: Fiber, lastChild: null | Fiber, // non-inclusive @@ -3379,6 +3399,8 @@ export function attach( // because we don't want to highlight every host node inside of a newly mounted subtree. } + trackDebugInfoFromLazyType(fiber); + if (fiber.tag === HostHoistable) { const nearestInstance = reconcilingParent; if (nearestInstance === null) { @@ -4208,6 +4230,8 @@ export function attach( } } try { + trackDebugInfoFromLazyType(nextFiber); + if ( nextFiber.tag === HostHoistable && prevFiber.memoizedState !== nextFiber.memoizedState From 53d07944df70781b929f733e2059df43ca82edd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 11:44:05 -0400 Subject: [PATCH 07/13] [Fiber] Assign implicit debug info to used thenables (#34146) Similar to #34137 but for Promises. This lets us pick up the debug info from a raw Promise as a child which is not covered by `_debugThenables`. Currently ChildFiber doesn't stash its thenables so we can't pick them up from devtools after the fact without some debug info added to the parent. It also lets us track some approximate start/end time of use():ed promises based on the first time we saw this particular Promise. --- .../src/ReactFiberThenable.js | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/react-reconciler/src/ReactFiberThenable.js b/packages/react-reconciler/src/ReactFiberThenable.js index f4ae1d45b271e..643be63ffa1c9 100644 --- a/packages/react-reconciler/src/ReactFiberThenable.js +++ b/packages/react-reconciler/src/ReactFiberThenable.js @@ -12,6 +12,7 @@ import type { PendingThenable, FulfilledThenable, RejectedThenable, + ReactIOInfo, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; @@ -22,6 +23,8 @@ import {getWorkInProgressRoot} from './ReactFiberWorkLoop'; import ReactSharedInternals from 'shared/ReactSharedInternals'; +import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; + import noop from 'shared/noop'; opaque type ThenableStateDev = { @@ -154,6 +157,33 @@ export function trackUsedThenable( } } + if (__DEV__ && enableAsyncDebugInfo && thenable._debugInfo === undefined) { + // In DEV mode if the thenable that we observed had no debug info, then we add + // an inferred debug info so that we're able to track its potential I/O uniquely. + // We don't know the real start time since the I/O could have started much + // earlier and this could even be a cached Promise. Could be misleading. + const startTime = performance.now(); + const displayName = thenable.displayName; + const ioInfo: ReactIOInfo = { + name: typeof displayName === 'string' ? displayName : 'Promise', + start: startTime, + end: startTime, + value: (thenable: any), + // We don't know the requesting owner nor stack. + }; + // We can infer the await owner/stack lazily from where this promise ends up + // used. It can be used in more than one place so we can't assign it here. + thenable._debugInfo = [{awaited: ioInfo}]; + // Track when we resolved the Promise as the approximate end time. + if (thenable.status !== 'fulfilled' && thenable.status !== 'rejected') { + const trackEndTime = () => { + // $FlowFixMe[cannot-write] + ioInfo.end = performance.now(); + }; + thenable.then(trackEndTime, trackEndTime); + } + } + // We use an expando to track the status and result of a thenable so that we // can synchronously unwrap the value. Think of this as an extension of the // Promise API, or a custom interface that is a superset of Thenable. From 62a634b9722aa77d7ddb1fe8017db7d3a0925389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 11:46:27 -0400 Subject: [PATCH 08/13] [DebugTools] Use thenables from the _debugThenableState if available (#34161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the case where a Promise is not cached, then the thenable state might contain an older version. This version is the one that was actually observed by the committed render, so that's the version we'll want to inspect. We used to not store the thenable state but now we have it on `_debugThenableState` in DEV. Screenshot 2025-08-10 at 8 26 04 PM --- .../react-debug-tools/src/ReactDebugHooks.js | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 8242b27d4e5be..54a6dd3e43a33 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -147,6 +147,8 @@ function getPrimitiveStackCache(): Map> { let currentFiber: null | Fiber = null; let currentHook: null | Hook = null; let currentContextDependency: null | ContextDependency = null; +let currentThenableIndex: number = 0; +let currentThenableState: null | Array> = null; function nextHook(): null | Hook { const hook = currentHook; @@ -201,7 +203,15 @@ function use(usable: Usable): T { if (usable !== null && typeof usable === 'object') { // $FlowFixMe[method-unbinding] if (typeof usable.then === 'function') { - const thenable: Thenable = (usable: any); + const thenable: Thenable = + // If we have thenable state, then the actually used thenable will be the one + // stashed in it. It's possible for uncached Promises to be new each render + // and in that case the one we're inspecting is the in the thenable state. + currentThenableState !== null && + currentThenableIndex < currentThenableState.length + ? currentThenableState[currentThenableIndex++] + : (usable: any); + switch (thenable.status) { case 'fulfilled': { const fulfilledValue: T = thenable.value; @@ -1285,6 +1295,14 @@ export function inspectHooksOfFiber( // current state from them. currentHook = (fiber.memoizedState: Hook); currentFiber = fiber; + const thenableState = + fiber.dependencies && fiber.dependencies._debugThenableState; + // In DEV the thenableState is an inner object. + const usedThenables: any = thenableState + ? thenableState.thenables || thenableState + : null; + currentThenableState = Array.isArray(usedThenables) ? usedThenables : null; + currentThenableIndex = 0; if (hasOwnProperty.call(currentFiber, 'dependencies')) { // $FlowFixMe[incompatible-use]: Flow thinks hasOwnProperty might have nulled `currentFiber` @@ -1339,6 +1357,8 @@ export function inspectHooksOfFiber( currentFiber = null; currentHook = null; currentContextDependency = null; + currentThenableState = null; + currentThenableIndex = 0; restoreContexts(contextMap); } From ca292f7a57e8c5950cda51f1aa00509dbb07dbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 11:48:09 -0400 Subject: [PATCH 09/13] [DevTools] Don't show "awaited by" if there's nothing to show (#34163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E.g. if the owner is null or the same as current component and no stack. This happens for example when you return a plain Promise in the child position and inspect the component it was returned in since there's no hook stack and the owner is the same as the instance itself so there's nothing new to link to. Before: Screenshot 2025-08-10 at 10 28 32 PM After: Screenshot 2025-08-10 at 10 29 04 PM --- .../views/Components/InspectedElementSuspendedBy.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index c24dd881e9891..3608cff85ce55 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -104,11 +104,15 @@ function SuspendedByRow({ // Only show the awaited stack if the I/O started in a different owner // than where it was awaited. If it's started by the same component it's // probably easy enough to infer and less noise in the common case. + const canShowAwaitStack = + (asyncInfo.stack !== null && asyncInfo.stack.length > 0) || + (asyncOwner !== null && asyncOwner.id !== inspectedElement.id); const showAwaitStack = - !showIOStack || - (ioOwner === null - ? asyncOwner !== null - : asyncOwner === null || ioOwner.id !== asyncOwner.id); + canShowAwaitStack && + (!showIOStack || + (ioOwner === null + ? asyncOwner !== null + : asyncOwner === null || ioOwner.id !== asyncOwner.id)); const value: any = ioInfo.value; const metaName = From d587434c350a9ba317285ce7b535add47ab3c205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 12:10:05 -0400 Subject: [PATCH 10/13] [DevTools] Pick up suspended by info from use() (#34148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Similar to #34144 but for `use()`. `use()` dependencies don't get added to the `fiber._debugInfo` set because that just models the things blocking the children, and not the Fiber component itself. This picks up any debug info from the thenable state that we stashed onto `_debugThenableState` so that we know it used `use()`. Screenshot 2025-08-09 at 4 03 40 PM Without #34146 this doesn't pick up uninstrumented promises but after it, it'll pick those up as well. An instrumented promise that doesn't have anything in its debug info is not picked up. For example, if it didn't depend on any I/O on the server. This doesn't yet pick up the stack trace of the `use()` call. That information is in the Hooks information but needs a follow up to extract it. --- .../src/__tests__/inspectedElement-test.js | 2 +- .../src/backend/fiber/renderer.js | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js index 1136dd29281cb..09f811172f30d 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js @@ -791,7 +791,7 @@ describe('InspectedElement', () => { "react_lazy": { "_payload": Dehydrated { "preview_short": {…}, - "preview_long": {_result: () => {}, _status: -1}, + "preview_long": {_ioInfo: {…}, _result: () => {}, _status: -1}, }, }, "regexp": Dehydrated { diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 6fb5a66c7d28d..41436e8a6e9c9 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -8,6 +8,7 @@ */ import type { + Thenable, ReactComponentInfo, ReactDebugInfo, ReactAsyncInfo, @@ -3181,6 +3182,39 @@ export function attach( } } + function trackDebugInfoFromUsedThenables(fiber: Fiber): void { + // If a Fiber called use() in DEV mode then we may have collected _debugThenableState on + // the dependencies. If so, then this will contain the thenables passed to use(). + // These won't have their debug info picked up by fiber._debugInfo since that just + // contains things suspending the children. We have to collect use() separately. + const dependencies = fiber.dependencies; + if (dependencies == null) { + return; + } + const thenableState = dependencies._debugThenableState; + if (thenableState == null) { + return; + } + // In DEV the thenableState is an inner object. + const usedThenables: any = thenableState.thenables || thenableState; + if (!Array.isArray(usedThenables)) { + return; + } + for (let i = 0; i < usedThenables.length; i++) { + const thenable: Thenable = usedThenables[i]; + const debugInfo = thenable._debugInfo; + if (debugInfo) { + for (let j = 0; j < debugInfo.length; j++) { + const debugEntry = debugInfo[i]; + if (debugEntry.awaited) { + const asyncInfo: ReactAsyncInfo = (debugEntry: any); + insertSuspendedBy(asyncInfo); + } + } + } + } + } + function mountVirtualChildrenRecursively( firstChild: Fiber, lastChild: null | Fiber, // non-inclusive @@ -3400,6 +3434,7 @@ export function attach( } trackDebugInfoFromLazyType(fiber); + trackDebugInfoFromUsedThenables(fiber); if (fiber.tag === HostHoistable) { const nearestInstance = reconcilingParent; @@ -4231,6 +4266,7 @@ export function attach( } try { trackDebugInfoFromLazyType(nextFiber); + trackDebugInfoFromUsedThenables(nextFiber); if ( nextFiber.tag === HostHoistable && From f1e70b5e0aeffeba634f05a1524bf083f0340d5a Mon Sep 17 00:00:00 2001 From: Jan Kassens Date: Mon, 11 Aug 2025 12:13:33 -0400 Subject: [PATCH 11/13] [easy] remove leftover reference to disableDefaultPropsExceptForClasses (#34169) Noticed that I missed this in some earlier cleanup diff. Test Plan: grep for disableDefaultPropsExceptForClasses --- .../shared/forks/ReactFeatureFlags.test-renderer.native-fb.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 52a85eec8c61c..9cd9ac4ab5700 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -13,7 +13,6 @@ import typeof * as ExportsType from './ReactFeatureFlags.test-renderer'; export const alwaysThrottleRetries = false; export const disableClientCache = true; export const disableCommentsAsDOMContainers = true; -export const disableDefaultPropsExceptForClasses = true; export const disableInputAttributeSyncing = false; export const disableLegacyContext = false; export const disableLegacyContextForFunctionComponents = false; From 2c9a42dfd7dc6f26be9694ffc6cb2ebf8ef8472b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 12:28:10 -0400 Subject: [PATCH 12/13] [DevTools] If the await doesn't have a stack use the stack from use() if any (#34162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34148. This picks up the stack for the await from the `use()` Hook if one was used to get this async info. When you select a component that used hooks, we already collect this information. If you select a Suspense boundary, this lazily invokes the first component that awaited this data to inspects its hooks and produce a stack trace for the use(). When all we have for the name is "Promise" I also use the name of the first callsite in the stack trace if there's more than one. Which in practice will be the name of the custom Hook that called it. Ideally we'd use source mapping and ignore listing for this but that would require suspending the display. We could maybe make the SuspendedByRow wrapped in a Suspense boundary for this case. Screenshot 2025-08-10 at 10 07 55 PM --- .../src/backend/fiber/renderer.js | 162 ++++++++++++++---- .../Components/InspectedElementSuspendedBy.js | 16 +- 2 files changed, 146 insertions(+), 32 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 41436e8a6e9c9..7634c6c472da1 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -13,8 +13,12 @@ import type { ReactDebugInfo, ReactAsyncInfo, ReactIOInfo, + ReactStackTrace, + ReactCallSite, } from 'shared/ReactTypes'; +import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; + import { ComponentFilterDisplayName, ComponentFilterElementType, @@ -5187,6 +5191,32 @@ export function attach( return null; } + function inspectHooks(fiber: Fiber): HooksTree { + const originalConsoleMethods: {[string]: $FlowFixMe} = {}; + + // Temporarily disable all console logging before re-running the hook. + for (const method in console) { + try { + // $FlowFixMe[invalid-computed-prop] + originalConsoleMethods[method] = console[method]; + // $FlowFixMe[prop-missing] + console[method] = () => {}; + } catch (error) {} + } + + try { + return inspectHooksOfFiber(fiber, getDispatcherRef(renderer)); + } finally { + // Restore original console functionality. + for (const method in originalConsoleMethods) { + try { + // $FlowFixMe[prop-missing] + console[method] = originalConsoleMethods[method]; + } catch (error) {} + } + } + } + function getSuspendedByOfSuspenseNode( suspenseNode: SuspenseNode, ): Array { @@ -5196,6 +5226,11 @@ export function attach( if (!suspenseNode.hasUniqueSuspenders) { return result; } + // Cache the inspection of Hooks in case we need it for multiple entries. + // We don't need a full map here since it's likely that every ioInfo that's unique + // to a specific instance will have those appear in order of when that instance was discovered. + let hooksCacheKey: null | DevToolsInstance = null; + let hooksCache: null | HooksTree = null; suspenseNode.suspendedBy.forEach((set, ioInfo) => { let parentNode = suspenseNode.parent; while (parentNode !== null) { @@ -5217,18 +5252,100 @@ export function attach( ioInfo, ); if (asyncInfo !== null) { - const index = result.length; - result.push(serializeAsyncInfo(asyncInfo, index, firstInstance)); + let hooks: null | HooksTree = null; + if (asyncInfo.stack == null && asyncInfo.owner == null) { + if (hooksCacheKey === firstInstance) { + hooks = hooksCache; + } else if (firstInstance.kind !== VIRTUAL_INSTANCE) { + const fiber = firstInstance.data; + if ( + fiber.dependencies && + fiber.dependencies._debugThenableState + ) { + // This entry had no stack nor owner but this Fiber used Hooks so we might + // be able to get the stack from the Hook. + hooksCacheKey = firstInstance; + hooksCache = hooks = inspectHooks(fiber); + } + } + } + result.push(serializeAsyncInfo(asyncInfo, firstInstance, hooks)); } } }); return result; } + function getAwaitStackFromHooks( + hooks: HooksTree, + asyncInfo: ReactAsyncInfo, + ): null | ReactStackTrace { + // TODO: We search through the hooks tree generated by inspectHooksOfFiber so that we can + // use the information already extracted but ideally this search would be faster since we + // could know which index to extract from the debug state. + for (let i = 0; i < hooks.length; i++) { + const node = hooks[i]; + const debugInfo = node.debugInfo; + if (debugInfo != null && debugInfo.indexOf(asyncInfo) !== -1) { + // Found a matching Hook. We'll now use its source location to construct a stack. + const source = node.hookSource; + if ( + source != null && + source.functionName !== null && + source.fileName !== null && + source.lineNumber !== null && + source.columnNumber !== null + ) { + // Unfortunately this is in a slightly different format. TODO: Unify HookNode with ReactCallSite. + const callSite: ReactCallSite = [ + source.functionName, + source.fileName, + source.lineNumber, + source.columnNumber, + 0, + 0, + false, + ]; + // As we return we'll add any custom hooks parent stacks to the array. + return [callSite]; + } else { + return []; + } + } + // Otherwise, search the sub hooks of any custom hook. + const matchedStack = getAwaitStackFromHooks(node.subHooks, asyncInfo); + if (matchedStack !== null) { + // Append this custom hook to the stack trace since it must have been called inside of it. + const source = node.hookSource; + if ( + source != null && + source.functionName !== null && + source.fileName !== null && + source.lineNumber !== null && + source.columnNumber !== null + ) { + // Unfortunately this is in a slightly different format. TODO: Unify HookNode with ReactCallSite. + const callSite: ReactCallSite = [ + source.functionName, + source.fileName, + source.lineNumber, + source.columnNumber, + 0, + 0, + false, + ]; + matchedStack.push(callSite); + } + return matchedStack; + } + } + return null; + } + function serializeAsyncInfo( asyncInfo: ReactAsyncInfo, - index: number, parentInstance: DevToolsInstance, + hooks: null | HooksTree, ): SerializedAsyncInfo { const ioInfo = asyncInfo.awaited; const ioOwnerInstance = findNearestOwnerInstance( @@ -5268,6 +5385,11 @@ export function attach( // If we awaited in the child position of a component, then the best stack would be the // return callsite but we don't have that available so instead we skip. The callsite of // the JSX would be misleading in this case. The same thing happens with throw-a-Promise. + if (hooks !== null) { + // If this component used Hooks we might be able to instead infer the stack from the + // use() callsite if this async info came from a hook. Let's search the tree to find it. + awaitStack = getAwaitStackFromHooks(hooks, asyncInfo); + } break; default: // If we awaited by passing a Promise to a built-in element, then the JSX callsite is a @@ -5538,31 +5660,9 @@ export function attach( const owners: null | Array = getOwnersListFromInstance(fiberInstance); - let hooks = null; + let hooks: null | HooksTree = null; if (usesHooks) { - const originalConsoleMethods: {[string]: $FlowFixMe} = {}; - - // Temporarily disable all console logging before re-running the hook. - for (const method in console) { - try { - // $FlowFixMe[invalid-computed-prop] - originalConsoleMethods[method] = console[method]; - // $FlowFixMe[prop-missing] - console[method] = () => {}; - } catch (error) {} - } - - try { - hooks = inspectHooksOfFiber(fiber, getDispatcherRef(renderer)); - } finally { - // Restore original console functionality. - for (const method in originalConsoleMethods) { - try { - // $FlowFixMe[prop-missing] - console[method] = originalConsoleMethods[method]; - } catch (error) {} - } - } + hooks = inspectHooks(fiber); } let rootType = null; @@ -5641,8 +5741,8 @@ export function attach( // TODO: Prepend other suspense sources like css, images and use(). fiberInstance.suspendedBy === null ? [] - : fiberInstance.suspendedBy.map((info, index) => - serializeAsyncInfo(info, index, fiberInstance), + : fiberInstance.suspendedBy.map(info => + serializeAsyncInfo(info, fiberInstance, hooks), ); return { id: fiberInstance.id, @@ -5813,8 +5913,8 @@ export function attach( suspendedBy: suspendedBy === null ? [] - : suspendedBy.map((info, index) => - serializeAsyncInfo(info, index, virtualInstance), + : suspendedBy.map(info => + serializeAsyncInfo(info, virtualInstance, null), ), // List of owners diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index 3608cff85ce55..da74bc579e415 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -81,7 +81,21 @@ function SuspendedByRow({ }: RowProps) { const [isOpen, setIsOpen] = useState(false); const ioInfo = asyncInfo.awaited; - const name = ioInfo.name; + let name = ioInfo.name; + if (name === '' || name === 'Promise') { + // 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. + const bestStack = ioInfo.stack || asyncInfo.stack; + if (bestStack !== null && bestStack.length > 1) { + // TODO: Ideally we'd get the name from the last ignore listed frame before the + // first visible frame since this is the same algorithm as the Flight server uses. + // Ideally, we'd also get the name from the source mapped entry instead of the + // original entry. However, that would require suspending the immediate display + // of these rows to first do source mapping before we can show the name. + name = bestStack[0][0]; + } + } const description = ioInfo.description; const longName = description === '' ? name : name + ' (' + description + ')'; const shortDescription = getShortDescription(name, description); From 3c67bbe5f90dbe78d0cd4db198db41978da4284e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 12:28:32 -0400 Subject: [PATCH 13/13] [DevTools] Track suspensey CSS on "suspended by" (#34166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need to track that Suspensey CSS (Host Resources) can contribute to the loading state. We can pick up the start/end time from the Performance Observer API since we know which resource was loaded. If DOM nodes are not filtered there's a link to the `` instance. The `"awaited by"` stack is the callsite of the JSX creating the ``. Screenshot 2025-08-11 at 1 35 21 AM Inspecting the link itself: Screenshot 2025-08-11 at 1 31 43 AM In this approach I only include it if the page currently matches the media query. It might contribute in some other scenario but we're not showing every possible state but every possible scenario that might suspend if timing changes in the current state. --- .../src/backend/fiber/renderer.js | 90 +++++++++++++++++++ .../Components/InspectedElementSuspendedBy.js | 9 +- packages/shared/ReactIODescription.js | 2 + 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 7634c6c472da1..1d4541253f7a2 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -3219,6 +3219,94 @@ export function attach( } } + const hostAsyncInfoCache: WeakMap<{...}, ReactAsyncInfo> = new WeakMap(); + + function trackDebugInfoFromHostResource( + devtoolsInstance: DevToolsInstance, + fiber: Fiber, + ): void { + const resource: ?{ + type: 'stylesheet' | 'style' | 'script' | 'void', + instance?: null | HostInstance, + ... + } = fiber.memoizedState; + if (resource == null) { + return; + } + + // Use a cached entry based on the resource. This ensures that if we use the same + // resource in multiple places, it gets deduped and inner boundaries don't consider it + // as contributing to those boundaries. + const existingEntry = hostAsyncInfoCache.get(resource); + if (existingEntry !== undefined) { + insertSuspendedBy(existingEntry); + return; + } + + const props: { + href?: string, + media?: string, + ... + } = fiber.memoizedProps; + + // Stylesheet resources may suspend. We need to track that. + const mayResourceSuspendCommit = + resource.type === 'stylesheet' && + // If it doesn't match the currently debugged media, then it doesn't count. + (typeof props.media !== 'string' || + typeof matchMedia !== 'function' || + matchMedia(props.media)); + if (!mayResourceSuspendCommit) { + return; + } + + const instance = resource.instance; + if (instance == null) { + return; + } + + // Unlike props.href, this href will be fully qualified which we need for comparison below. + const href = instance.href; + if (typeof href !== 'string') { + return; + } + let start = -1; + let end = -1; + // $FlowFixMe[method-unbinding] + if (typeof performance.getEntriesByType === 'function') { + // We may be able to collect the start and end time of this resource from Performance Observer. + const resourceEntries = performance.getEntriesByType('resource'); + for (let i = 0; i < resourceEntries.length; i++) { + const resourceEntry = resourceEntries[i]; + if (resourceEntry.name === href) { + start = resourceEntry.startTime; + end = start + resourceEntry.duration; + } + } + } + const value = instance.sheet; + const promise = Promise.resolve(value); + (promise: any).status = 'fulfilled'; + (promise: any).value = value; + const ioInfo: ReactIOInfo = { + name: 'stylesheet', + start, + end, + value: promise, + // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. + owner: fiber, // Allow linking to the if it's not filtered. + }; + const asyncInfo: ReactAsyncInfo = { + awaited: ioInfo, + // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. + owner: fiber._debugOwner == null ? null : fiber._debugOwner, + debugStack: fiber._debugStack == null ? null : fiber._debugStack, + debugTask: fiber._debugTask == null ? null : fiber._debugTask, + }; + hostAsyncInfoCache.set(resource, asyncInfo); + insertSuspendedBy(asyncInfo); + } + function mountVirtualChildrenRecursively( firstChild: Fiber, lastChild: null | Fiber, // non-inclusive @@ -3446,6 +3534,7 @@ export function attach( throw new Error('Did not expect a host hoistable to be the root'); } aquireHostResource(nearestInstance, fiber.memoizedState); + trackDebugInfoFromHostResource(nearestInstance, fiber); } else if ( fiber.tag === HostComponent || fiber.tag === HostText || @@ -4282,6 +4371,7 @@ export function attach( } releaseHostResource(nearestInstance, prevFiber.memoizedState); aquireHostResource(nearestInstance, nextFiber.memoizedState); + trackDebugInfoFromHostResource(nearestInstance, nextFiber); } else if ( (nextFiber.tag === HostComponent || nextFiber.tag === HostText || diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index da74bc579e415..e5e094955887a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -178,9 +178,12 @@ function SuspendedByRow({ } /> )} - {(showIOStack || !showAwaitStack) && - ioOwner !== null && - ioOwner.id !== inspectedElement.id ? ( + {ioOwner !== null && + ioOwner.id !== inspectedElement.id && + (showIOStack || + !showAwaitStack || + asyncOwner === null || + ioOwner.id !== asyncOwner.id) ? (