@samitouri / QOS-React-1 / commits / 4a58b63865

[DevTools] Add "suspended by" Section to Component Inspector Sidebar (#34012)

This collects the ReactAsyncInfo between instances. It associates it with the parent. Typically this would be a Server Component's Promise return value but it can also be Promises in a fragment. It can also be associated with a client component when you pass a Promise into the child position e.g. `<div>{promise}</div>` then it's associated with the div. If an instance is filtered, then it gets associated with the parent of that's unfiltered. The stack trace currently isn't source mapped. I'll do that in a follow up. We also need to add a "short name" from the Promise for the description (e.g. url). I'll also add a little marker showing the relative time span of each entry. <img width="447" height="591" alt="Screenshot 2025-07-26 at 7 56 00 PM" src="https://github.com/user-attachments/assets/7c966540-7b1b-4568-8cb9-f25cefd5a918" /> <img width="446" height="570" alt="Screenshot 2025-07-26 at 7 55 23 PM" src="https://github.com/user-attachments/assets/4eac235b-e735-41e8-9c6e-a7633af64e4b" />

Sebastian Markbåge committed Jul 28, 2025 at 12:05 UTC 4a58b63865c5c732012fe4746e2b54fdc165990e
16 files changed +678 -159
packages/react-devtools-shared/src/backend/fiber/renderer.js
+133 -8
@@ -7,7 +7,11 @@
7 * @flow
8 */
9
10 -import type {ReactComponentInfo, ReactDebugInfo} from 'shared/ReactTypes';
10 +import type {
11 + ReactComponentInfo,
12 + ReactDebugInfo,
13 + ReactAsyncInfo,
14 +} from 'shared/ReactTypes';
15
16 import {
17 ComponentFilterDisplayName,
@@ -135,6 +139,7 @@ import type {
139 ReactRenderer,
140 RendererInterface,
141 SerializedElement,
142 + SerializedAsyncInfo,
143 WorkTagMap,
144 CurrentDispatcherRef,
145 LegacyDispatcherRef,
@@ -165,6 +170,7 @@ type FiberInstance = {
170 source: null | string | Error | ReactFunctionLocation, // source location of this component function, or owned child stack
171 logCount: number, // total number of errors/warnings last seen
172 treeBaseDuration: number, // the profiled time of the last render of this subtree
173 + suspendedBy: null | Array<ReactAsyncInfo>, // things that suspended in the children position of this component
174 data: Fiber, // one of a Fiber pair
175 };
176
@@ -178,6 +184,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance {
184 source: null,
185 logCount: 0,
186 treeBaseDuration: 0,
187 + suspendedBy: null,
188 data: fiber,
189 };
190 }
@@ -193,6 +200,7 @@ type FilteredFiberInstance = {
200 source: null | string | Error | ReactFunctionLocation, // always null here.
201 logCount: number, // total number of errors/warnings last seen
202 treeBaseDuration: number, // the profiled time of the last render of this subtree
203 + suspendedBy: null | Array<ReactAsyncInfo>, // not used
204 data: Fiber, // one of a Fiber pair
205 };
206
@@ -207,6 +215,7 @@ function createFilteredFiberInstance(fiber: Fiber): FilteredFiberInstance {
215 source: null,
216 logCount: 0,
217 treeBaseDuration: 0,
218 + suspendedBy: null,
219 data: fiber,
220 }: any);
221 }
@@ -225,6 +234,7 @@ type VirtualInstance = {
234 source: null | string | Error | ReactFunctionLocation, // source location of this server component, or owned child stack
235 logCount: number, // total number of errors/warnings last seen
236 treeBaseDuration: number, // the profiled time of the last render of this subtree
237 + suspendedBy: null | Array<ReactAsyncInfo>, // things that blocked the server component's child from rendering
238 // The latest info for this instance. This can be updated over time and the
239 // same info can appear in more than once ServerComponentInstance.
240 data: ReactComponentInfo,
@@ -242,6 +252,7 @@ function createVirtualInstance(
252 source: null,
253 logCount: 0,
254 treeBaseDuration: 0,
255 + suspendedBy: null,
256 data: debugEntry,
257 };
258 }
@@ -2354,6 +2365,21 @@ export function attach(
2365 // the current parent here as well.
2366 let reconcilingParent: null | DevToolsInstance = null;
2367
2368 + function insertSuspendedBy(asyncInfo: ReactAsyncInfo): void {
2369 + const parentInstance = reconcilingParent;
2370 + if (parentInstance === null) {
2371 + // Suspending at the root is not attributed to any particular component
2372 + // TODO: It should be attributed to the shell.
2373 + return;
2374 + }
2375 + const suspendedBy = parentInstance.suspendedBy;
2376 + if (suspendedBy === null) {
2377 + parentInstance.suspendedBy = [asyncInfo];
2378 + } else if (suspendedBy.indexOf(asyncInfo) === -1) {
2379 + suspendedBy.push(asyncInfo);
2380 + }
2381 + }
2382 +
2383 function insertChild(instance: DevToolsInstance): void {
2384 const parentInstance = reconcilingParent;
2385 if (parentInstance === null) {
@@ -2515,6 +2541,17 @@ export function attach(
2541 if (fiber._debugInfo) {
2542 for (let i = 0; i < fiber._debugInfo.length; i++) {
2543 const debugEntry = fiber._debugInfo[i];
2544 + if (debugEntry.awaited) {
2545 + // Async Info
2546 + const asyncInfo: ReactAsyncInfo = (debugEntry: any);
2547 + if (level === virtualLevel) {
2548 + // Track any async info between the previous virtual instance up until to this
2549 + // instance and add it to the parent. This can add the same set multiple times
2550 + // so we assume insertSuspendedBy dedupes.
2551 + insertSuspendedBy(asyncInfo);
2552 + }
2553 + if (previousVirtualInstance) continue;
2554 + }
2555 if (typeof debugEntry.name !== 'string') {
2556 // Not a Component. Some other Debug Info.
2557 continue;
@@ -2768,6 +2805,7 @@ export function attach(
2805 // Move all the children of this instance to the remaining set.
2806 remainingReconcilingChildren = instance.firstChild;
2807 instance.firstChild = null;
2808 + instance.suspendedBy = null;
2809 try {
2810 // Unmount the remaining set.
2811 unmountRemainingChildren();
@@ -2968,6 +3006,7 @@ export function attach(
3006 // We'll move them back one by one, and anything that remains is deleted.
3007 remainingReconcilingChildren = virtualInstance.firstChild;
3008 virtualInstance.firstChild = null;
3009 + virtualInstance.suspendedBy = null;
3010 try {
3011 if (
3012 updateVirtualChildrenRecursively(
@@ -3019,6 +3058,17 @@ export function attach(
3058 if (nextChild._debugInfo) {
3059 for (let i = 0; i < nextChild._debugInfo.length; i++) {
3060 const debugEntry = nextChild._debugInfo[i];
3061 + if (debugEntry.awaited) {
3062 + // Async Info
3063 + const asyncInfo: ReactAsyncInfo = (debugEntry: any);
3064 + if (level === virtualLevel) {
3065 + // Track any async info between the previous virtual instance up until to this
3066 + // instance and add it to the parent. This can add the same set multiple times
3067 + // so we assume insertSuspendedBy dedupes.
3068 + insertSuspendedBy(asyncInfo);
3069 + }
3070 + if (previousVirtualInstance) continue;
3071 + }
3072 if (typeof debugEntry.name !== 'string') {
3073 // Not a Component. Some other Debug Info.
3074 continue;
@@ -3343,6 +3393,7 @@ export function attach(
3393 // We'll move them back one by one, and anything that remains is deleted.
3394 remainingReconcilingChildren = fiberInstance.firstChild;
3395 fiberInstance.firstChild = null;
3396 + fiberInstance.suspendedBy = null;
3397 }
3398 try {
3399 if (
@@ -4051,6 +4102,42 @@ export function attach(
4102 return null;
4103 }
4104
4105 + function serializeAsyncInfo(
4106 + asyncInfo: ReactAsyncInfo,
4107 + index: number,
4108 + parentInstance: DevToolsInstance,
4109 + ): SerializedAsyncInfo {
4110 + const ioInfo = asyncInfo.awaited;
4111 + const ioOwnerInstance = findNearestOwnerInstance(
4112 + parentInstance,
4113 + ioInfo.owner,
4114 + );
4115 + const awaitOwnerInstance = findNearestOwnerInstance(
4116 + parentInstance,
4117 + asyncInfo.owner,
4118 + );
4119 + return {
4120 + awaited: {
4121 + name: ioInfo.name,
4122 + start: ioInfo.start,
4123 + end: ioInfo.end,
4124 + value: ioInfo.value == null ? null : ioInfo.value,
4125 + env: ioInfo.env == null ? null : ioInfo.env,
4126 + owner:
4127 + ioOwnerInstance === null
4128 + ? null
4129 + : instanceToSerializedElement(ioOwnerInstance),
4130 + stack: ioInfo.stack == null ? null : ioInfo.stack,
4131 + },
4132 + env: asyncInfo.env == null ? null : asyncInfo.env,
4133 + owner:
4134 + awaitOwnerInstance === null
4135 + ? null
4136 + : instanceToSerializedElement(awaitOwnerInstance),
4137 + stack: asyncInfo.stack == null ? null : asyncInfo.stack,
4138 + };
4139 + }
4140 +
4141 // Fast path props lookup for React Native style editor.
4142 // Could use inspectElementRaw() but that would require shallow rendering hooks components,
4143 // and could also mess with memoization.
@@ -4342,6 +4429,13 @@ export function attach(
4429 nativeTag = getNativeTag(fiber.stateNode);
4430 }
4431
4432 + // This set is an edge case where if you pass a promise to a Client Component into a children
4433 + // position without a Server Component as the direct parent. E.g. <div>{promise}</div>
4434 + // In this case, this becomes associated with the Client/Host Component where as normally
4435 + // you'd expect these to be associated with the Server Component that awaited the data.
4436 + // TODO: Prepend other suspense sources like css, images and use().
4437 + const suspendedBy = fiberInstance.suspendedBy;
4438 +
4439 return {
4440 id: fiberInstance.id,
4441
@@ -4398,6 +4492,13 @@ export function attach(
4492 ? []
4493 : Array.from(componentLogsEntry.warnings.entries()),
4494
4495 + suspendedBy:
4496 + suspendedBy === null
4497 + ? []
4498 + : suspendedBy.map((info, index) =>
4499 + serializeAsyncInfo(info, index, fiberInstance),
4500 + ),
4501 +
4502 // List of owners
4503 owners,
4504
@@ -4451,6 +4552,9 @@ export function attach(
4552 const componentLogsEntry =
4553 componentInfoToComponentLogsMap.get(componentInfo);
4554
4555 + // Things that Suspended this Server Component (use(), awaits and direct child promises)
4556 + const suspendedBy = virtualInstance.suspendedBy;
4557 +
4558 return {
4559 id: virtualInstance.id,
4560
@@ -4490,6 +4594,14 @@ export function attach(
4594 componentLogsEntry === undefined
4595 ? []
4596 : Array.from(componentLogsEntry.warnings.entries()),
4597 +
4598 + suspendedBy:
4599 + suspendedBy === null
4600 + ? []
4601 + : suspendedBy.map((info, index) =>
4602 + serializeAsyncInfo(info, index, virtualInstance),
4603 + ),
4604 +
4605 // List of owners
4606 owners,
4607
@@ -4534,7 +4646,7 @@ export function attach(
4646
4647 function createIsPathAllowed(
4648 key: string | null,
4537 - secondaryCategory: 'hooks' | null,
4649 + secondaryCategory: 'suspendedBy' | 'hooks' | null,
4650 ) {
4651 // This function helps prevent previously-inspected paths from being dehydrated in updates.
4652 // This is important to avoid a bad user experience where expanded toggles collapse on update.
@@ -4566,6 +4678,13 @@ export function attach(
4678 return true;
4679 }
4680 break;
4681 + case 'suspendedBy':
4682 + if (path.length < 5) {
4683 + // Never dehydrate anything above suspendedBy[index].awaited.value
4684 + // Those are part of the internal meta data. We only dehydrate inside the Promise.
4685 + return true;
4686 + }
4687 + break;
4688 default:
4689 break;
4690 }
@@ -4789,36 +4908,42 @@ export function attach(
4908 type: 'not-found',
4909 };
4910 }
4911 + const inspectedElement = mostRecentlyInspectedElement;
4912
4913 // Any time an inspected element has an update,
4914 // we should update the selected $r value as wel.
4915 // Do this before dehydration (cleanForBridge).
4796 - updateSelectedElement(mostRecentlyInspectedElement);
4916 + updateSelectedElement(inspectedElement);
4917
4918 // Clone before cleaning so that we preserve the full data.
4919 // This will enable us to send patches without re-inspecting if hydrated paths are requested.
4920 // (Reducing how often we shallow-render is a better DX for function components that use hooks.)
4801 - const cleanedInspectedElement = {...mostRecentlyInspectedElement};
4921 + const cleanedInspectedElement = {...inspectedElement};
4922 // $FlowFixMe[prop-missing] found when upgrading Flow
4923 cleanedInspectedElement.context = cleanForBridge(
4804 - cleanedInspectedElement.context,
4924 + inspectedElement.context,
4925 createIsPathAllowed('context', null),
4926 );
4927 // $FlowFixMe[prop-missing] found when upgrading Flow
4928 cleanedInspectedElement.hooks = cleanForBridge(
4809 - cleanedInspectedElement.hooks,
4929 + inspectedElement.hooks,
4930 createIsPathAllowed('hooks', 'hooks'),
4931 );
4932 // $FlowFixMe[prop-missing] found when upgrading Flow
4933 cleanedInspectedElement.props = cleanForBridge(
4814 - cleanedInspectedElement.props,
4934 + inspectedElement.props,
4935 createIsPathAllowed('props', null),
4936 );
4937 // $FlowFixMe[prop-missing] found when upgrading Flow
4938 cleanedInspectedElement.state = cleanForBridge(
4819 - cleanedInspectedElement.state,
4939 + inspectedElement.state,
4940 createIsPathAllowed('state', null),
4941 );
4942 + // $FlowFixMe[prop-missing] found when upgrading Flow
4943 + cleanedInspectedElement.suspendedBy = cleanForBridge(
4944 + inspectedElement.suspendedBy,
4945 + createIsPathAllowed('suspendedBy', 'suspendedBy'),
4946 + );
4947
4948 return {
4949 id,
packages/react-devtools-shared/src/backend/legacy/renderer.js
+7
@@ -755,6 +755,10 @@ export function attach(
755 inspectedElement.state,
756 createIsPathAllowed('state'),
757 );
758 + inspectedElement.suspendedBy = cleanForBridge(
759 + inspectedElement.suspendedBy,
760 + createIsPathAllowed('suspendedBy'),
761 + );
762
763 return {
764 id,
@@ -847,6 +851,9 @@ export function attach(
851 errors,
852 warnings,
853
854 + // Not supported in legacy renderers.
855 + suspendedBy: [],
856 +
857 // List of owners
858 owners,
859
packages/react-devtools-shared/src/backend/types.js
+27 -5
@@ -32,7 +32,7 @@ import type {
32 import type {InitBackend} from 'react-devtools-shared/src/backend';
33 import type {TimelineDataExport} from 'react-devtools-timeline/src/types';
34 import type {BackendBridge} from 'react-devtools-shared/src/bridge';
35 -import type {ReactFunctionLocation} from 'shared/ReactTypes';
35 +import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
36 import type Agent from './agent';
37
38 type BundleType =
@@ -232,6 +232,25 @@ export type PathMatch = {
232 isFullMatch: boolean,
233 };
234
235 +// Serialized version of ReactIOInfo
236 +export type SerializedIOInfo = {
237 + name: string,
238 + start: number,
239 + end: number,
240 + value: null | Promise<mixed>,
241 + env: null | string,
242 + owner: null | SerializedElement,
243 + stack: null | ReactStackTrace,
244 +};
245 +
246 +// Serialized version of ReactAsyncInfo
247 +export type SerializedAsyncInfo = {
248 + awaited: SerializedIOInfo,
249 + env: null | string,
250 + owner: null | SerializedElement,
251 + stack: null | ReactStackTrace,
252 +};
253 +
254 export type SerializedElement = {
255 displayName: string | null,
256 id: number,
@@ -268,14 +287,17 @@ export type InspectedElement = {
287 hasLegacyContext: boolean,
288
289 // Inspectable properties.
271 - context: Object | null,
272 - hooks: Object | null,
273 - props: Object | null,
274 - state: Object | null,
290 + context: Object | null, // DehydratedData or {[string]: mixed}
291 + hooks: Object | null, // DehydratedData or {[string]: mixed}
292 + props: Object | null, // DehydratedData or {[string]: mixed}
293 + state: Object | null, // DehydratedData or {[string]: mixed}
294 key: number | string | null,
295 errors: Array<[string, number]>,
296 warnings: Array<[string, number]>,
297
298 + // Things that suspended this Instances
299 + suspendedBy: Object, // DehydratedData or Array<SerializedAsyncInfo>
300 +
301 // List of owners
302 owners: Array<SerializedElement> | null,
303 source: ReactFunctionLocation | null,
packages/react-devtools-shared/src/backendAPI.js
+36
@@ -16,6 +16,7 @@ import ElementPollingCancellationError from 'react-devtools-shared/src/errors/El
16 import type {
17 InspectedElement as InspectedElementBackend,
18 InspectedElementPayload,
19 + SerializedAsyncInfo as SerializedAsyncInfoBackend,
20 } from 'react-devtools-shared/src/backend/types';
21 import type {
22 BackendEvents,
@@ -24,6 +25,7 @@ import type {
25 import type {
26 DehydratedData,
27 InspectedElement as InspectedElementFrontend,
28 + SerializedAsyncInfo as SerializedAsyncInfoFrontend,
29 } from 'react-devtools-shared/src/frontend/types';
30 import type {InspectedElementPath} from 'react-devtools-shared/src/frontend/types';
31
@@ -209,6 +211,32 @@ export function cloneInspectedElementWithPath(
211 return clonedInspectedElement;
212 }
213
214 +function backendToFrontendSerializedAsyncInfo(
215 + asyncInfo: SerializedAsyncInfoBackend,
216 +): SerializedAsyncInfoFrontend {
217 + const ioInfo = asyncInfo.awaited;
218 + return {
219 + awaited: {
220 + name: ioInfo.name,
221 + start: ioInfo.start,
222 + end: ioInfo.end,
223 + value: ioInfo.value,
224 + env: ioInfo.env,
225 + owner:
226 + ioInfo.owner === null
227 + ? null
228 + : backendToFrontendSerializedElementMapper(ioInfo.owner),
229 + stack: ioInfo.stack,
230 + },
231 + env: asyncInfo.env,
232 + owner:
233 + asyncInfo.owner === null
234 + ? null
235 + : backendToFrontendSerializedElementMapper(asyncInfo.owner),
236 + stack: asyncInfo.stack,
237 + };
238 +}
239 +
240 export function convertInspectedElementBackendToFrontend(
241 inspectedElementBackend: InspectedElementBackend,
242 ): InspectedElementFrontend {
@@ -238,9 +266,13 @@ export function convertInspectedElementBackendToFrontend(
266 key,
267 errors,
268 warnings,
269 + suspendedBy,
270 nativeTag,
271 } = inspectedElementBackend;
272
273 + const hydratedSuspendedBy: null | Array<SerializedAsyncInfoBackend> =
274 + hydrateHelper(suspendedBy);
275 +
276 const inspectedElement: InspectedElementFrontend = {
277 canEditFunctionProps,
278 canEditFunctionPropsDeletePaths,
@@ -272,6 +304,10 @@ export function convertInspectedElementBackendToFrontend(
304 state: hydrateHelper(state),
305 errors,
306 warnings,
307 + suspendedBy:
308 + hydratedSuspendedBy == null // backwards compat
309 + ? []
310 + : hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo),
311 nativeTag,
312 };
313
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css
+38
@@ -51,3 +51,41 @@
51 .EditableValue {
52 min-width: 1rem;
53 }
54 +
55 +.CollapsableRow {
56 + border-top: 1px solid var(--color-border);
57 +}
58 +
59 +.CollapsableRow:last-child {
60 + margin-bottom: -0.25rem;
61 +}
62 +
63 +.CollapsableHeader {
64 + width: 100%;
65 + padding: 0.25rem;
66 + display: flex;
67 +}
68 +
69 +.CollapsableHeaderIcon {
70 + flex: 0 0 1rem;
71 + margin-left: -0.25rem;
72 + width: 1rem;
73 + height: 1rem;
74 + padding: 0;
75 + color: var(--color-expand-collapse-toggle);
76 +}
77 +
78 +.CollapsableHeaderTitle {
79 + flex: 1 1 auto;
80 + font-family: var(--font-family-monospace);
81 + font-size: var(--font-size-monospace-normal);
82 + text-align: left;
83 +}
84 +
85 +.CollapsableContent {
86 + padding: 0.25rem 0;
87 +}
88 +
89 +.PreviewContainer {
90 + padding: 0 0.25rem 0.25rem 0.25rem;
91 +}
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js
+9 -39
@@ -9,7 +9,6 @@
9
10 import * as React from 'react';
11 import {copy} from 'clipboard-js';
12 -import {toNormalUrl} from 'jsc-safe-url';
12
13 import Button from '../Button';
14 import ButtonIcon from '../ButtonIcon';
@@ -21,6 +20,8 @@ import useOpenResource from '../useOpenResource';
20 import type {ReactFunctionLocation} from 'shared/ReactTypes';
21 import styles from './InspectedElementSourcePanel.css';
22
23 +import formatLocationForDisplay from './formatLocationForDisplay';
24 +
25 type Props = {
26 source: ReactFunctionLocation,
27 symbolicatedSourcePromise: Promise<ReactFunctionLocation | null>,
@@ -95,52 +96,21 @@ function FormattedSourceString({source, symbolicatedSourcePromise}: Props) {
96 symbolicatedSource,
97 );
98
98 - const [, sourceURL, line] =
99 + const [, sourceURL, line, column] =
100 symbolicatedSource == null ? source : symbolicatedSource;
101
102 return (
103 <div
104 className={styles.SourceOneLiner}
105 data-testname="InspectedElementView-FormattedSourceString">
105 - {linkIsEnabled ? (
106 - <span className={styles.Link} onClick={viewSource}>
107 - {formatSourceForDisplay(sourceURL, line)}
108 - </span>
109 - ) : (
110 - formatSourceForDisplay(sourceURL, line)
111 - )}
106 + <span
107 + className={linkIsEnabled ? styles.Link : null}
108 + title={sourceURL + ':' + line}
109 + onClick={viewSource}>
110 + {formatLocationForDisplay(sourceURL, line, column)}
111 + </span>
112 </div>
113 );
114 }
115
116 -// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame
117 -function formatSourceForDisplay(sourceURL: string, line: number) {
118 - // Metro can return JSC-safe URLs, which have `//&` as a delimiter
119 - // https://www.npmjs.com/package/jsc-safe-url
120 - const sanitizedSourceURL = sourceURL.includes('//&')
121 - ? toNormalUrl(sourceURL)
122 - : sourceURL;
123 -
124 - // Note: this RegExp doesn't work well with URLs from Metro,
125 - // which provides bundle URL with query parameters prefixed with /&
126 - const BEFORE_SLASH_RE = /^(.*)[\\\/]/;
127 -
128 - let nameOnly = sanitizedSourceURL.replace(BEFORE_SLASH_RE, '');
129 -
130 - // In DEV, include code for a common special case:
131 - // prefer "folder/index.js" instead of just "index.js".
132 - if (/^index\./.test(nameOnly)) {
133 - const match = sanitizedSourceURL.match(BEFORE_SLASH_RE);
134 - if (match) {
135 - const pathBeforeSlash = match[1];
136 - if (pathBeforeSlash) {
137 - const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, '');
138 - nameOnly = folderName + '/' + nameOnly;
139 - }
140 - }
141 - }
142 -
143 - return `${nameOnly}:${line}`;
144 -}
145 -
116 export default InspectedElementSourcePanel;
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js new
+153
@@ -0,0 +1,153 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {copy} from 'clipboard-js';
11 +import * as React from 'react';
12 +import {useState} from 'react';
13 +import Button from '../Button';
14 +import ButtonIcon from '../ButtonIcon';
15 +import KeyValue from './KeyValue';
16 +import {serializeDataForCopy} from '../utils';
17 +import Store from '../../store';
18 +import styles from './InspectedElementSharedStyles.css';
19 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
20 +import StackTraceView from './StackTraceView';
21 +import OwnerView from './OwnerView';
22 +
23 +import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
24 +import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
25 +import type {SerializedAsyncInfo} from 'react-devtools-shared/src/frontend/types';
26 +
27 +type RowProps = {
28 + bridge: FrontendBridge,
29 + element: Element,
30 + inspectedElement: InspectedElement,
31 + store: Store,
32 + asyncInfo: SerializedAsyncInfo,
33 + index: number,
34 +};
35 +
36 +function SuspendedByRow({
37 + bridge,
38 + element,
39 + inspectedElement,
40 + store,
41 + asyncInfo,
42 + index,
43 +}: RowProps) {
44 + const [isOpen, setIsOpen] = useState(false);
45 + const name = asyncInfo.awaited.name;
46 + let stack;
47 + let owner;
48 + if (asyncInfo.stack === null || asyncInfo.stack.length === 0) {
49 + stack = asyncInfo.awaited.stack;
50 + owner = asyncInfo.awaited.owner;
51 + } else {
52 + stack = asyncInfo.stack;
53 + owner = asyncInfo.owner;
54 + }
55 + return (
56 + <div className={styles.CollapsableRow}>
57 + <Button
58 + className={styles.CollapsableHeader}
59 + onClick={() => setIsOpen(prevIsOpen => !prevIsOpen)}
60 + title={`${isOpen ? 'Collapse' : 'Expand'}`}>
61 + <ButtonIcon
62 + className={styles.CollapsableHeaderIcon}
63 + type={isOpen ? 'expanded' : 'collapsed'}
64 + />
65 + <span className={styles.CollapsableHeaderTitle}>{name}</span>
66 + </Button>
67 + {isOpen && (
68 + <div className={styles.CollapsableContent}>
69 + <div className={styles.PreviewContainer}>
70 + <KeyValue
71 + alphaSort={true}
72 + bridge={bridge}
73 + canDeletePaths={false}
74 + canEditValues={false}
75 + canRenamePaths={false}
76 + depth={1}
77 + element={element}
78 + hidden={false}
79 + inspectedElement={inspectedElement}
80 + name={'Promise'}
81 + path={[index, 'awaited', 'value']}
82 + pathRoot="suspendedBy"
83 + store={store}
84 + value={asyncInfo.awaited.value}
85 + />
86 + </div>
87 + {stack !== null && stack.length > 0 && (
88 + <StackTraceView stack={stack} />
89 + )}
90 + {owner !== null && owner.id !== inspectedElement.id ? (
91 + <OwnerView
92 + key={owner.id}
93 + displayName={owner.displayName || 'Anonymous'}
94 + hocDisplayNames={owner.hocDisplayNames}
95 + compiledWithForget={owner.compiledWithForget}
96 + id={owner.id}
97 + isInStore={store.containsElement(owner.id)}
98 + type={owner.type}
99 + />
100 + ) : null}
101 + </div>
102 + )}
103 + </div>
104 + );
105 +}
106 +
107 +type Props = {
108 + bridge: FrontendBridge,
109 + element: Element,
110 + inspectedElement: InspectedElement,
111 + store: Store,
112 +};
113 +
114 +export default function InspectedElementSuspendedBy({
115 + bridge,
116 + element,
117 + inspectedElement,
118 + store,
119 +}: Props): React.Node {
120 + const {suspendedBy} = inspectedElement;
121 +
122 + // Skip the section if nothing suspended this component.
123 + if (suspendedBy == null || suspendedBy.length === 0) {
124 + return null;
125 + }
126 +
127 + const handleCopy = withPermissionsCheck(
128 + {permissions: ['clipboardWrite']},
129 + () => copy(serializeDataForCopy(suspendedBy)),
130 + );
131 +
132 + return (
133 + <div>
134 + <div className={styles.HeaderRow}>
135 + <div className={styles.Header}>suspended by</div>
136 + <Button onClick={handleCopy} title="Copy to clipboard">
137 + <ButtonIcon type="copy" />
138 + </Button>
139 + </div>
140 + {suspendedBy.map((asyncInfo, index) => (
141 + <SuspendedByRow
142 + key={index}
143 + index={index}
144 + asyncInfo={asyncInfo}
145 + bridge={bridge}
146 + element={element}
147 + inspectedElement={inspectedElement}
148 + store={store}
149 + />
150 + ))}
151 + </div>
152 + );
153 +}
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css
-45
@@ -2,16 +2,6 @@
2 font-family: var(--font-family-sans);
3 }
4
5 -.Owner {
6 - color: var(--color-component-name);
7 - font-family: var(--font-family-monospace);
8 - font-size: var(--font-size-monospace-normal);
9 - white-space: nowrap;
10 - overflow: hidden;
11 - text-overflow: ellipsis;
12 - max-width: 100%;
13 -}
14 -
5 .InspectedElement {
6 overflow-x: hidden;
7 overflow-y: auto;
@@ -28,41 +18,6 @@
18 }
19 }
20
31 -.Owner {
32 - border-radius: 0.25rem;
33 - padding: 0.125rem 0.25rem;
34 - background: none;
35 - border: none;
36 - display: block;
37 -}
38 -.Owner:focus {
39 - outline: none;
40 - background-color: var(--color-button-background-focus);
41 -}
42 -
43 -.NotInStore {
44 - color: var(--color-dim);
45 - cursor: default;
46 -}
47 -
48 -.OwnerButton {
49 - cursor: pointer;
50 - width: 100%;
51 - padding: 0;
52 -}
53 -
54 -.OwnerContent {
55 - display: flex;
56 - align-items: center;
57 - padding-left: 1rem;
58 - width: 100%;
59 - border-radius: 0.25rem;
60 -}
61 -
62 -.OwnerContent:hover {
63 - background-color: var(--color-background-hover);
64 -}
65 -
21 .OwnersMetaField {
22 padding-left: 1.25rem;
23 white-space: nowrap;
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js
+12 -60
@@ -8,10 +8,8 @@
8 */
9
10 import * as React from 'react';
11 -import {Fragment, useCallback, useContext} from 'react';
12 -import {TreeDispatcherContext} from './TreeContext';
11 +import {Fragment, useContext} from 'react';
12 import {BridgeContext, StoreContext} from '../context';
14 -import Button from '../Button';
13 import InspectedElementBadges from './InspectedElementBadges';
14 import InspectedElementContextTree from './InspectedElementContextTree';
15 import InspectedElementErrorsAndWarningsTree from './InspectedElementErrorsAndWarningsTree';
@@ -20,12 +18,11 @@ import InspectedElementPropsTree from './InspectedElementPropsTree';
18 import InspectedElementStateTree from './InspectedElementStateTree';
19 import InspectedElementStyleXPlugin from './InspectedElementStyleXPlugin';
20 import InspectedElementSuspenseToggle from './InspectedElementSuspenseToggle';
21 +import InspectedElementSuspendedBy from './InspectedElementSuspendedBy';
22 import NativeStyleEditor from './NativeStyleEditor';
24 -import ElementBadges from './ElementBadges';
25 -import {useHighlightHostInstance} from '../hooks';
23 import {enableStyleXFeatures} from 'react-devtools-feature-flags';
27 -import {logEvent} from 'react-devtools-shared/src/Logger';
24 import InspectedElementSourcePanel from './InspectedElementSourcePanel';
25 +import OwnerView from './OwnerView';
26
27 import styles from './InspectedElementView.css';
28
@@ -156,6 +153,15 @@ export default function InspectedElementView({
153 <NativeStyleEditor />
154 </div>
155
156 + <div className={styles.InspectedElementSection}>
157 + <InspectedElementSuspendedBy
158 + bridge={bridge}
159 + element={element}
160 + inspectedElement={inspectedElement}
161 + store={store}
162 + />
163 + </div>
164 +
165 {showRenderedBy && (
166 <div
167 className={styles.InspectedElementSection}
@@ -196,57 +202,3 @@ export default function InspectedElementView({
202 </Fragment>
203 );
204 }
199 -
200 -type OwnerViewProps = {
201 - displayName: string,
202 - hocDisplayNames: Array<string> | null,
203 - compiledWithForget: boolean,
204 - id: number,
205 - isInStore: boolean,
206 -};
207 -
208 -function OwnerView({
209 - displayName,
210 - hocDisplayNames,
211 - compiledWithForget,
212 - id,
213 - isInStore,
214 -}: OwnerViewProps) {
215 - const dispatch = useContext(TreeDispatcherContext);
216 - const {highlightHostInstance, clearHighlightHostInstance} =
217 - useHighlightHostInstance();
218 -
219 - const handleClick = useCallback(() => {
220 - logEvent({
221 - event_name: 'select-element',
222 - metadata: {source: 'owner-view'},
223 - });
224 - dispatch({
225 - type: 'SELECT_ELEMENT_BY_ID',
226 - payload: id,
227 - });
228 - }, [dispatch, id]);
229 -
230 - return (
231 - <Button
232 - key={id}
233 - className={styles.OwnerButton}
234 - disabled={!isInStore}
235 - onClick={handleClick}
236 - onMouseEnter={() => highlightHostInstance(id)}
237 - onMouseLeave={clearHighlightHostInstance}>
238 - <span className={styles.OwnerContent}>
239 - <span
240 - className={`${styles.Owner} ${isInStore ? '' : styles.NotInStore}`}
241 - title={displayName}>
242 - {displayName}
243 - </span>
244 -
245 - <ElementBadges
246 - hocDisplayNames={hocDisplayNames}
247 - compiledWithForget={compiledWithForget}
248 - />
249 - </span>
250 - </Button>
251 - );
252 -}
packages/react-devtools-shared/src/devtools/views/Components/OwnerView.css new
+41
@@ -0,0 +1,41 @@
1 +.Owner {
2 + color: var(--color-component-name);
3 + font-family: var(--font-family-monospace);
4 + font-size: var(--font-size-monospace-normal);
5 + white-space: nowrap;
6 + overflow: hidden;
7 + text-overflow: ellipsis;
8 + max-width: 100%;
9 + border-radius: 0.25rem;
10 + padding: 0.125rem 0.25rem;
11 + background: none;
12 + border: none;
13 + display: block;
14 +}
15 +.Owner:focus {
16 + outline: none;
17 + background-color: var(--color-button-background-focus);
18 +}
19 +
20 +.OwnerButton {
21 + cursor: pointer;
22 + width: 100%;
23 + padding: 0;
24 +}
25 +
26 +.OwnerContent {
27 + display: flex;
28 + align-items: center;
29 + padding-left: 1rem;
30 + width: 100%;
31 + border-radius: 0.25rem;
32 +}
33 +
34 +.OwnerContent:hover {
35 + background-color: var(--color-background-hover);
36 +}
37 +
38 +.NotInStore {
39 + color: var(--color-dim);
40 + cursor: default;
41 +}
packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js new
+72
@@ -0,0 +1,72 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import * as React from 'react';
11 +import {useCallback, useContext} from 'react';
12 +import {TreeDispatcherContext} from './TreeContext';
13 +import Button from '../Button';
14 +import ElementBadges from './ElementBadges';
15 +import {useHighlightHostInstance} from '../hooks';
16 +import {logEvent} from 'react-devtools-shared/src/Logger';
17 +
18 +import styles from './OwnerView.css';
19 +
20 +type OwnerViewProps = {
21 + displayName: string,
22 + hocDisplayNames: Array<string> | null,
23 + compiledWithForget: boolean,
24 + id: number,
25 + isInStore: boolean,
26 +};
27 +
28 +export default function OwnerView({
29 + displayName,
30 + hocDisplayNames,
31 + compiledWithForget,
32 + id,
33 + isInStore,
34 +}: OwnerViewProps): React.Node {
35 + const dispatch = useContext(TreeDispatcherContext);
36 + const {highlightHostInstance, clearHighlightHostInstance} =
37 + useHighlightHostInstance();
38 +
39 + const handleClick = useCallback(() => {
40 + logEvent({
41 + event_name: 'select-element',
42 + metadata: {source: 'owner-view'},
43 + });
44 + dispatch({
45 + type: 'SELECT_ELEMENT_BY_ID',
46 + payload: id,
47 + });
48 + }, [dispatch, id]);
49 +
50 + return (
51 + <Button
52 + key={id}
53 + className={styles.OwnerButton}
54 + disabled={!isInStore}
55 + onClick={handleClick}
56 + onMouseEnter={() => highlightHostInstance(id)}
57 + onMouseLeave={clearHighlightHostInstance}>
58 + <span className={styles.OwnerContent}>
59 + <span
60 + className={`${styles.Owner} ${isInStore ? '' : styles.NotInStore}`}
61 + title={displayName}>
62 + {displayName}
63 + </span>
64 +
65 + <ElementBadges
66 + hocDisplayNames={hocDisplayNames}
67 + compiledWithForget={compiledWithForget}
68 + />
69 + </span>
70 + </Button>
71 + );
72 +}
packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.css new
+24
@@ -0,0 +1,24 @@
1 +.StackTraceView {
2 + padding: 0.25rem;
3 +}
4 +
5 +.CallSite {
6 + display: block;
7 + padding-left: 1rem;
8 +}
9 +
10 +.Link {
11 + color: var(--color-link);
12 + white-space: pre;
13 + overflow: hidden;
14 + text-overflow: ellipsis;
15 + flex: 1;
16 + cursor: pointer;
17 + border-radius: 0.125rem;
18 + padding: 0px 2px;
19 +}
20 +
21 +.Link:hover {
22 + background-color: var(--color-background-hover);
23 +}
24 +
packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js new
+58
@@ -0,0 +1,58 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import * as React from 'react';
11 +
12 +import useOpenResource from '../useOpenResource';
13 +
14 +import styles from './StackTraceView.css';
15 +
16 +import type {ReactStackTrace, ReactCallSite} from 'shared/ReactTypes';
17 +
18 +import formatLocationForDisplay from './formatLocationForDisplay';
19 +
20 +type CallSiteViewProps = {
21 + callSite: ReactCallSite,
22 +};
23 +
24 +export function CallSiteView({callSite}: CallSiteViewProps): React.Node {
25 + const symbolicatedCallSite: null | ReactCallSite = null; // TODO
26 + const [linkIsEnabled, viewSource] = useOpenResource(
27 + callSite,
28 + symbolicatedCallSite,
29 + );
30 + const [functionName, url, line, column] =
31 + symbolicatedCallSite !== null ? symbolicatedCallSite : callSite;
32 + return (
33 + <div className={styles.CallSite}>
34 + {functionName}
35 + {' @ '}
36 + <span
37 + className={linkIsEnabled ? styles.Link : null}
38 + onClick={viewSource}
39 + title={url + ':' + line}>
40 + {formatLocationForDisplay(url, line, column)}
41 + </span>
42 + </div>
43 + );
44 +}
45 +
46 +type Props = {
47 + stack: ReactStackTrace,
48 +};
49 +
50 +export default function StackTraceView({stack}: Props): React.Node {
51 + return (
52 + <div className={styles.StackTraceView}>
53 + {stack.map((callSite, index) => (
54 + <CallSiteView key={index} callSite={callSite} />
55 + ))}
56 + </div>
57 + );
58 +}
packages/react-devtools-shared/src/devtools/views/Components/formatLocationForDisplay.js new
+44
@@ -0,0 +1,44 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {toNormalUrl} from 'jsc-safe-url';
11 +
12 +// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame
13 +export default function formatLocationForDisplay(
14 + sourceURL: string,
15 + line: number,
16 + column: number,
17 +): string {
18 + // Metro can return JSC-safe URLs, which have `//&` as a delimiter
19 + // https://www.npmjs.com/package/jsc-safe-url
20 + const sanitizedSourceURL = sourceURL.includes('//&')
21 + ? toNormalUrl(sourceURL)
22 + : sourceURL;
23 +
24 + // Note: this RegExp doesn't work well with URLs from Metro,
25 + // which provides bundle URL with query parameters prefixed with /&
26 + const BEFORE_SLASH_RE = /^(.*)[\\\/]/;
27 +
28 + let nameOnly = sanitizedSourceURL.replace(BEFORE_SLASH_RE, '');
29 +
30 + // In DEV, include code for a common special case:
31 + // prefer "folder/index.js" instead of just "index.js".
32 + if (/^index\./.test(nameOnly)) {
33 + const match = sanitizedSourceURL.match(BEFORE_SLASH_RE);
34 + if (match) {
35 + const pathBeforeSlash = match[1];
36 + if (pathBeforeSlash) {
37 + const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, '');
38 + nameOnly = folderName + '/' + nameOnly;
39 + }
40 + }
41 + }
42 +
43 + return `${nameOnly}:${line}`;
44 +}
packages/react-devtools-shared/src/devtools/views/utils.js
+1 -1
@@ -121,7 +121,7 @@ function sanitize(data: Object): void {
121 }
122
123 export function serializeDataForCopy(props: Object): string {
124 - const cloned = Object.assign({}, props);
124 + const cloned = isArray(props) ? props.slice(0) : Object.assign({}, props);
125
126 sanitize(cloned);
127
packages/react-devtools-shared/src/frontend/types.js
+23 -1
@@ -18,7 +18,7 @@ import type {
18 Dehydrated,
19 Unserializable,
20 } from 'react-devtools-shared/src/hydration';
21 -import type {ReactFunctionLocation} from 'shared/ReactTypes';
21 +import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
22
23 export type BrowserTheme = 'dark' | 'light';
24
@@ -184,6 +184,25 @@ export type Element = {
184 compiledWithForget: boolean,
185 };
186
187 +// Serialized version of ReactIOInfo
188 +export type SerializedIOInfo = {
189 + name: string,
190 + start: number,
191 + end: number,
192 + value: null | Promise<mixed>,
193 + env: null | string,
194 + owner: null | SerializedElement,
195 + stack: null | ReactStackTrace,
196 +};
197 +
198 +// Serialized version of ReactAsyncInfo
199 +export type SerializedAsyncInfo = {
200 + awaited: SerializedIOInfo,
201 + env: null | string,
202 + owner: null | SerializedElement,
203 + stack: null | ReactStackTrace,
204 +};
205 +
206 export type SerializedElement = {
207 displayName: string | null,
208 id: number,
@@ -239,6 +258,9 @@ export type InspectedElement = {
258 errors: Array<[string, number]>,
259 warnings: Array<[string, number]>,
260
261 + // Things that suspended this Instances
262 + suspendedBy: Object,
263 +
264 // List of owners
265 owners: Array<SerializedElement> | null,
266