[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"
/>
This commit is contained in:
Sebastian Markbåge
2025-07-28 12:05:56 -04:00
committed by GitHub
parent cc015840ef
commit 4a58b63865
16 changed files with 678 additions and 159 deletions

View File

@@ -7,7 +7,11 @@
* @flow
*/
import type {ReactComponentInfo, ReactDebugInfo} from 'shared/ReactTypes';
import type {
ReactComponentInfo,
ReactDebugInfo,
ReactAsyncInfo,
} from 'shared/ReactTypes';
import {
ComponentFilterDisplayName,
@@ -135,6 +139,7 @@ import type {
ReactRenderer,
RendererInterface,
SerializedElement,
SerializedAsyncInfo,
WorkTagMap,
CurrentDispatcherRef,
LegacyDispatcherRef,
@@ -165,6 +170,7 @@ type FiberInstance = {
source: null | string | Error | ReactFunctionLocation, // source location of this component function, or owned child stack
logCount: number, // total number of errors/warnings last seen
treeBaseDuration: number, // the profiled time of the last render of this subtree
suspendedBy: null | Array<ReactAsyncInfo>, // things that suspended in the children position of this component
data: Fiber, // one of a Fiber pair
};
@@ -178,6 +184,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance {
source: null,
logCount: 0,
treeBaseDuration: 0,
suspendedBy: null,
data: fiber,
};
}
@@ -193,6 +200,7 @@ type FilteredFiberInstance = {
source: null | string | Error | ReactFunctionLocation, // always null here.
logCount: number, // total number of errors/warnings last seen
treeBaseDuration: number, // the profiled time of the last render of this subtree
suspendedBy: null | Array<ReactAsyncInfo>, // not used
data: Fiber, // one of a Fiber pair
};
@@ -207,6 +215,7 @@ function createFilteredFiberInstance(fiber: Fiber): FilteredFiberInstance {
source: null,
logCount: 0,
treeBaseDuration: 0,
suspendedBy: null,
data: fiber,
}: any);
}
@@ -225,6 +234,7 @@ type VirtualInstance = {
source: null | string | Error | ReactFunctionLocation, // source location of this server component, or owned child stack
logCount: number, // total number of errors/warnings last seen
treeBaseDuration: number, // the profiled time of the last render of this subtree
suspendedBy: null | Array<ReactAsyncInfo>, // things that blocked the server component's child from rendering
// The latest info for this instance. This can be updated over time and the
// same info can appear in more than once ServerComponentInstance.
data: ReactComponentInfo,
@@ -242,6 +252,7 @@ function createVirtualInstance(
source: null,
logCount: 0,
treeBaseDuration: 0,
suspendedBy: null,
data: debugEntry,
};
}
@@ -2354,6 +2365,21 @@ export function attach(
// the current parent here as well.
let reconcilingParent: null | DevToolsInstance = null;
function insertSuspendedBy(asyncInfo: ReactAsyncInfo): void {
const parentInstance = reconcilingParent;
if (parentInstance === null) {
// Suspending at the root is not attributed to any particular component
// TODO: It should be attributed to the shell.
return;
}
const suspendedBy = parentInstance.suspendedBy;
if (suspendedBy === null) {
parentInstance.suspendedBy = [asyncInfo];
} else if (suspendedBy.indexOf(asyncInfo) === -1) {
suspendedBy.push(asyncInfo);
}
}
function insertChild(instance: DevToolsInstance): void {
const parentInstance = reconcilingParent;
if (parentInstance === null) {
@@ -2515,6 +2541,17 @@ export function attach(
if (fiber._debugInfo) {
for (let i = 0; i < fiber._debugInfo.length; i++) {
const debugEntry = fiber._debugInfo[i];
if (debugEntry.awaited) {
// Async Info
const asyncInfo: ReactAsyncInfo = (debugEntry: any);
if (level === virtualLevel) {
// Track any async info between the previous virtual instance up until to this
// instance and add it to the parent. This can add the same set multiple times
// so we assume insertSuspendedBy dedupes.
insertSuspendedBy(asyncInfo);
}
if (previousVirtualInstance) continue;
}
if (typeof debugEntry.name !== 'string') {
// Not a Component. Some other Debug Info.
continue;
@@ -2768,6 +2805,7 @@ export function attach(
// Move all the children of this instance to the remaining set.
remainingReconcilingChildren = instance.firstChild;
instance.firstChild = null;
instance.suspendedBy = null;
try {
// Unmount the remaining set.
unmountRemainingChildren();
@@ -2968,6 +3006,7 @@ export function attach(
// We'll move them back one by one, and anything that remains is deleted.
remainingReconcilingChildren = virtualInstance.firstChild;
virtualInstance.firstChild = null;
virtualInstance.suspendedBy = null;
try {
if (
updateVirtualChildrenRecursively(
@@ -3019,6 +3058,17 @@ export function attach(
if (nextChild._debugInfo) {
for (let i = 0; i < nextChild._debugInfo.length; i++) {
const debugEntry = nextChild._debugInfo[i];
if (debugEntry.awaited) {
// Async Info
const asyncInfo: ReactAsyncInfo = (debugEntry: any);
if (level === virtualLevel) {
// Track any async info between the previous virtual instance up until to this
// instance and add it to the parent. This can add the same set multiple times
// so we assume insertSuspendedBy dedupes.
insertSuspendedBy(asyncInfo);
}
if (previousVirtualInstance) continue;
}
if (typeof debugEntry.name !== 'string') {
// Not a Component. Some other Debug Info.
continue;
@@ -3343,6 +3393,7 @@ export function attach(
// We'll move them back one by one, and anything that remains is deleted.
remainingReconcilingChildren = fiberInstance.firstChild;
fiberInstance.firstChild = null;
fiberInstance.suspendedBy = null;
}
try {
if (
@@ -4051,6 +4102,42 @@ export function attach(
return null;
}
function serializeAsyncInfo(
asyncInfo: ReactAsyncInfo,
index: number,
parentInstance: DevToolsInstance,
): SerializedAsyncInfo {
const ioInfo = asyncInfo.awaited;
const ioOwnerInstance = findNearestOwnerInstance(
parentInstance,
ioInfo.owner,
);
const awaitOwnerInstance = findNearestOwnerInstance(
parentInstance,
asyncInfo.owner,
);
return {
awaited: {
name: ioInfo.name,
start: ioInfo.start,
end: ioInfo.end,
value: ioInfo.value == null ? null : ioInfo.value,
env: ioInfo.env == null ? null : ioInfo.env,
owner:
ioOwnerInstance === null
? null
: instanceToSerializedElement(ioOwnerInstance),
stack: ioInfo.stack == null ? null : ioInfo.stack,
},
env: asyncInfo.env == null ? null : asyncInfo.env,
owner:
awaitOwnerInstance === null
? null
: instanceToSerializedElement(awaitOwnerInstance),
stack: asyncInfo.stack == null ? null : asyncInfo.stack,
};
}
// Fast path props lookup for React Native style editor.
// Could use inspectElementRaw() but that would require shallow rendering hooks components,
// and could also mess with memoization.
@@ -4342,6 +4429,13 @@ export function attach(
nativeTag = getNativeTag(fiber.stateNode);
}
// This set is an edge case where if you pass a promise to a Client Component into a children
// position without a Server Component as the direct parent. E.g. <div>{promise}</div>
// In this case, this becomes associated with the Client/Host Component where as normally
// you'd expect these to be associated with the Server Component that awaited the data.
// TODO: Prepend other suspense sources like css, images and use().
const suspendedBy = fiberInstance.suspendedBy;
return {
id: fiberInstance.id,
@@ -4398,6 +4492,13 @@ export function attach(
? []
: Array.from(componentLogsEntry.warnings.entries()),
suspendedBy:
suspendedBy === null
? []
: suspendedBy.map((info, index) =>
serializeAsyncInfo(info, index, fiberInstance),
),
// List of owners
owners,
@@ -4451,6 +4552,9 @@ export function attach(
const componentLogsEntry =
componentInfoToComponentLogsMap.get(componentInfo);
// Things that Suspended this Server Component (use(), awaits and direct child promises)
const suspendedBy = virtualInstance.suspendedBy;
return {
id: virtualInstance.id,
@@ -4490,6 +4594,14 @@ export function attach(
componentLogsEntry === undefined
? []
: Array.from(componentLogsEntry.warnings.entries()),
suspendedBy:
suspendedBy === null
? []
: suspendedBy.map((info, index) =>
serializeAsyncInfo(info, index, virtualInstance),
),
// List of owners
owners,
@@ -4534,7 +4646,7 @@ export function attach(
function createIsPathAllowed(
key: string | null,
secondaryCategory: 'hooks' | null,
secondaryCategory: 'suspendedBy' | 'hooks' | null,
) {
// This function helps prevent previously-inspected paths from being dehydrated in updates.
// This is important to avoid a bad user experience where expanded toggles collapse on update.
@@ -4566,6 +4678,13 @@ export function attach(
return true;
}
break;
case 'suspendedBy':
if (path.length < 5) {
// Never dehydrate anything above suspendedBy[index].awaited.value
// Those are part of the internal meta data. We only dehydrate inside the Promise.
return true;
}
break;
default:
break;
}
@@ -4789,36 +4908,42 @@ export function attach(
type: 'not-found',
};
}
const inspectedElement = mostRecentlyInspectedElement;
// Any time an inspected element has an update,
// we should update the selected $r value as wel.
// Do this before dehydration (cleanForBridge).
updateSelectedElement(mostRecentlyInspectedElement);
updateSelectedElement(inspectedElement);
// Clone before cleaning so that we preserve the full data.
// This will enable us to send patches without re-inspecting if hydrated paths are requested.
// (Reducing how often we shallow-render is a better DX for function components that use hooks.)
const cleanedInspectedElement = {...mostRecentlyInspectedElement};
const cleanedInspectedElement = {...inspectedElement};
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.context = cleanForBridge(
cleanedInspectedElement.context,
inspectedElement.context,
createIsPathAllowed('context', null),
);
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.hooks = cleanForBridge(
cleanedInspectedElement.hooks,
inspectedElement.hooks,
createIsPathAllowed('hooks', 'hooks'),
);
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.props = cleanForBridge(
cleanedInspectedElement.props,
inspectedElement.props,
createIsPathAllowed('props', null),
);
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.state = cleanForBridge(
cleanedInspectedElement.state,
inspectedElement.state,
createIsPathAllowed('state', null),
);
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.suspendedBy = cleanForBridge(
inspectedElement.suspendedBy,
createIsPathAllowed('suspendedBy', 'suspendedBy'),
);
return {
id,

View File

@@ -755,6 +755,10 @@ export function attach(
inspectedElement.state,
createIsPathAllowed('state'),
);
inspectedElement.suspendedBy = cleanForBridge(
inspectedElement.suspendedBy,
createIsPathAllowed('suspendedBy'),
);
return {
id,
@@ -847,6 +851,9 @@ export function attach(
errors,
warnings,
// Not supported in legacy renderers.
suspendedBy: [],
// List of owners
owners,

View File

@@ -32,7 +32,7 @@ import type {
import type {InitBackend} from 'react-devtools-shared/src/backend';
import type {TimelineDataExport} from 'react-devtools-timeline/src/types';
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
import type Agent from './agent';
type BundleType =
@@ -232,6 +232,25 @@ export type PathMatch = {
isFullMatch: boolean,
};
// Serialized version of ReactIOInfo
export type SerializedIOInfo = {
name: string,
start: number,
end: number,
value: null | Promise<mixed>,
env: null | string,
owner: null | SerializedElement,
stack: null | ReactStackTrace,
};
// Serialized version of ReactAsyncInfo
export type SerializedAsyncInfo = {
awaited: SerializedIOInfo,
env: null | string,
owner: null | SerializedElement,
stack: null | ReactStackTrace,
};
export type SerializedElement = {
displayName: string | null,
id: number,
@@ -268,14 +287,17 @@ export type InspectedElement = {
hasLegacyContext: boolean,
// Inspectable properties.
context: Object | null,
hooks: Object | null,
props: Object | null,
state: Object | null,
context: Object | null, // DehydratedData or {[string]: mixed}
hooks: Object | null, // DehydratedData or {[string]: mixed}
props: Object | null, // DehydratedData or {[string]: mixed}
state: Object | null, // DehydratedData or {[string]: mixed}
key: number | string | null,
errors: Array<[string, number]>,
warnings: Array<[string, number]>,
// Things that suspended this Instances
suspendedBy: Object, // DehydratedData or Array<SerializedAsyncInfo>
// List of owners
owners: Array<SerializedElement> | null,
source: ReactFunctionLocation | null,

View File

@@ -16,6 +16,7 @@ import ElementPollingCancellationError from 'react-devtools-shared/src/errors/El
import type {
InspectedElement as InspectedElementBackend,
InspectedElementPayload,
SerializedAsyncInfo as SerializedAsyncInfoBackend,
} from 'react-devtools-shared/src/backend/types';
import type {
BackendEvents,
@@ -24,6 +25,7 @@ import type {
import type {
DehydratedData,
InspectedElement as InspectedElementFrontend,
SerializedAsyncInfo as SerializedAsyncInfoFrontend,
} from 'react-devtools-shared/src/frontend/types';
import type {InspectedElementPath} from 'react-devtools-shared/src/frontend/types';
@@ -209,6 +211,32 @@ export function cloneInspectedElementWithPath(
return clonedInspectedElement;
}
function backendToFrontendSerializedAsyncInfo(
asyncInfo: SerializedAsyncInfoBackend,
): SerializedAsyncInfoFrontend {
const ioInfo = asyncInfo.awaited;
return {
awaited: {
name: ioInfo.name,
start: ioInfo.start,
end: ioInfo.end,
value: ioInfo.value,
env: ioInfo.env,
owner:
ioInfo.owner === null
? null
: backendToFrontendSerializedElementMapper(ioInfo.owner),
stack: ioInfo.stack,
},
env: asyncInfo.env,
owner:
asyncInfo.owner === null
? null
: backendToFrontendSerializedElementMapper(asyncInfo.owner),
stack: asyncInfo.stack,
};
}
export function convertInspectedElementBackendToFrontend(
inspectedElementBackend: InspectedElementBackend,
): InspectedElementFrontend {
@@ -238,9 +266,13 @@ export function convertInspectedElementBackendToFrontend(
key,
errors,
warnings,
suspendedBy,
nativeTag,
} = inspectedElementBackend;
const hydratedSuspendedBy: null | Array<SerializedAsyncInfoBackend> =
hydrateHelper(suspendedBy);
const inspectedElement: InspectedElementFrontend = {
canEditFunctionProps,
canEditFunctionPropsDeletePaths,
@@ -272,6 +304,10 @@ export function convertInspectedElementBackendToFrontend(
state: hydrateHelper(state),
errors,
warnings,
suspendedBy:
hydratedSuspendedBy == null // backwards compat
? []
: hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo),
nativeTag,
};

View File

@@ -51,3 +51,41 @@
.EditableValue {
min-width: 1rem;
}
.CollapsableRow {
border-top: 1px solid var(--color-border);
}
.CollapsableRow:last-child {
margin-bottom: -0.25rem;
}
.CollapsableHeader {
width: 100%;
padding: 0.25rem;
display: flex;
}
.CollapsableHeaderIcon {
flex: 0 0 1rem;
margin-left: -0.25rem;
width: 1rem;
height: 1rem;
padding: 0;
color: var(--color-expand-collapse-toggle);
}
.CollapsableHeaderTitle {
flex: 1 1 auto;
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
text-align: left;
}
.CollapsableContent {
padding: 0.25rem 0;
}
.PreviewContainer {
padding: 0 0.25rem 0.25rem 0.25rem;
}

View File

@@ -9,7 +9,6 @@
import * as React from 'react';
import {copy} from 'clipboard-js';
import {toNormalUrl} from 'jsc-safe-url';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
@@ -21,6 +20,8 @@ import useOpenResource from '../useOpenResource';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import styles from './InspectedElementSourcePanel.css';
import formatLocationForDisplay from './formatLocationForDisplay';
type Props = {
source: ReactFunctionLocation,
symbolicatedSourcePromise: Promise<ReactFunctionLocation | null>,
@@ -95,52 +96,21 @@ function FormattedSourceString({source, symbolicatedSourcePromise}: Props) {
symbolicatedSource,
);
const [, sourceURL, line] =
const [, sourceURL, line, column] =
symbolicatedSource == null ? source : symbolicatedSource;
return (
<div
className={styles.SourceOneLiner}
data-testname="InspectedElementView-FormattedSourceString">
{linkIsEnabled ? (
<span className={styles.Link} onClick={viewSource}>
{formatSourceForDisplay(sourceURL, line)}
</span>
) : (
formatSourceForDisplay(sourceURL, line)
)}
<span
className={linkIsEnabled ? styles.Link : null}
title={sourceURL + ':' + line}
onClick={viewSource}>
{formatLocationForDisplay(sourceURL, line, column)}
</span>
</div>
);
}
// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame
function formatSourceForDisplay(sourceURL: string, line: number) {
// Metro can return JSC-safe URLs, which have `//&` as a delimiter
// https://www.npmjs.com/package/jsc-safe-url
const sanitizedSourceURL = sourceURL.includes('//&')
? toNormalUrl(sourceURL)
: sourceURL;
// Note: this RegExp doesn't work well with URLs from Metro,
// which provides bundle URL with query parameters prefixed with /&
const BEFORE_SLASH_RE = /^(.*)[\\\/]/;
let nameOnly = sanitizedSourceURL.replace(BEFORE_SLASH_RE, '');
// In DEV, include code for a common special case:
// prefer "folder/index.js" instead of just "index.js".
if (/^index\./.test(nameOnly)) {
const match = sanitizedSourceURL.match(BEFORE_SLASH_RE);
if (match) {
const pathBeforeSlash = match[1];
if (pathBeforeSlash) {
const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, '');
nameOnly = folderName + '/' + nameOnly;
}
}
}
return `${nameOnly}:${line}`;
}
export default InspectedElementSourcePanel;

View File

@@ -0,0 +1,153 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {copy} from 'clipboard-js';
import * as React from 'react';
import {useState} from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import KeyValue from './KeyValue';
import {serializeDataForCopy} from '../utils';
import Store from '../../store';
import styles from './InspectedElementSharedStyles.css';
import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
import StackTraceView from './StackTraceView';
import OwnerView from './OwnerView';
import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {SerializedAsyncInfo} from 'react-devtools-shared/src/frontend/types';
type RowProps = {
bridge: FrontendBridge,
element: Element,
inspectedElement: InspectedElement,
store: Store,
asyncInfo: SerializedAsyncInfo,
index: number,
};
function SuspendedByRow({
bridge,
element,
inspectedElement,
store,
asyncInfo,
index,
}: RowProps) {
const [isOpen, setIsOpen] = useState(false);
const name = asyncInfo.awaited.name;
let stack;
let owner;
if (asyncInfo.stack === null || asyncInfo.stack.length === 0) {
stack = asyncInfo.awaited.stack;
owner = asyncInfo.awaited.owner;
} else {
stack = asyncInfo.stack;
owner = asyncInfo.owner;
}
return (
<div className={styles.CollapsableRow}>
<Button
className={styles.CollapsableHeader}
onClick={() => setIsOpen(prevIsOpen => !prevIsOpen)}
title={`${isOpen ? 'Collapse' : 'Expand'}`}>
<ButtonIcon
className={styles.CollapsableHeaderIcon}
type={isOpen ? 'expanded' : 'collapsed'}
/>
<span className={styles.CollapsableHeaderTitle}>{name}</span>
</Button>
{isOpen && (
<div className={styles.CollapsableContent}>
<div className={styles.PreviewContainer}>
<KeyValue
alphaSort={true}
bridge={bridge}
canDeletePaths={false}
canEditValues={false}
canRenamePaths={false}
depth={1}
element={element}
hidden={false}
inspectedElement={inspectedElement}
name={'Promise'}
path={[index, 'awaited', 'value']}
pathRoot="suspendedBy"
store={store}
value={asyncInfo.awaited.value}
/>
</div>
{stack !== null && stack.length > 0 && (
<StackTraceView stack={stack} />
)}
{owner !== null && owner.id !== inspectedElement.id ? (
<OwnerView
key={owner.id}
displayName={owner.displayName || 'Anonymous'}
hocDisplayNames={owner.hocDisplayNames}
compiledWithForget={owner.compiledWithForget}
id={owner.id}
isInStore={store.containsElement(owner.id)}
type={owner.type}
/>
) : null}
</div>
)}
</div>
);
}
type Props = {
bridge: FrontendBridge,
element: Element,
inspectedElement: InspectedElement,
store: Store,
};
export default function InspectedElementSuspendedBy({
bridge,
element,
inspectedElement,
store,
}: Props): React.Node {
const {suspendedBy} = inspectedElement;
// Skip the section if nothing suspended this component.
if (suspendedBy == null || suspendedBy.length === 0) {
return null;
}
const handleCopy = withPermissionsCheck(
{permissions: ['clipboardWrite']},
() => copy(serializeDataForCopy(suspendedBy)),
);
return (
<div>
<div className={styles.HeaderRow}>
<div className={styles.Header}>suspended by</div>
<Button onClick={handleCopy} title="Copy to clipboard">
<ButtonIcon type="copy" />
</Button>
</div>
{suspendedBy.map((asyncInfo, index) => (
<SuspendedByRow
key={index}
index={index}
asyncInfo={asyncInfo}
bridge={bridge}
element={element}
inspectedElement={inspectedElement}
store={store}
/>
))}
</div>
);
}

View File

@@ -2,16 +2,6 @@
font-family: var(--font-family-sans);
}
.Owner {
color: var(--color-component-name);
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.InspectedElement {
overflow-x: hidden;
overflow-y: auto;
@@ -28,41 +18,6 @@
}
}
.Owner {
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
background: none;
border: none;
display: block;
}
.Owner:focus {
outline: none;
background-color: var(--color-button-background-focus);
}
.NotInStore {
color: var(--color-dim);
cursor: default;
}
.OwnerButton {
cursor: pointer;
width: 100%;
padding: 0;
}
.OwnerContent {
display: flex;
align-items: center;
padding-left: 1rem;
width: 100%;
border-radius: 0.25rem;
}
.OwnerContent:hover {
background-color: var(--color-background-hover);
}
.OwnersMetaField {
padding-left: 1.25rem;
white-space: nowrap;

View File

@@ -8,10 +8,8 @@
*/
import * as React from 'react';
import {Fragment, useCallback, useContext} from 'react';
import {TreeDispatcherContext} from './TreeContext';
import {Fragment, useContext} from 'react';
import {BridgeContext, StoreContext} from '../context';
import Button from '../Button';
import InspectedElementBadges from './InspectedElementBadges';
import InspectedElementContextTree from './InspectedElementContextTree';
import InspectedElementErrorsAndWarningsTree from './InspectedElementErrorsAndWarningsTree';
@@ -20,12 +18,11 @@ import InspectedElementPropsTree from './InspectedElementPropsTree';
import InspectedElementStateTree from './InspectedElementStateTree';
import InspectedElementStyleXPlugin from './InspectedElementStyleXPlugin';
import InspectedElementSuspenseToggle from './InspectedElementSuspenseToggle';
import InspectedElementSuspendedBy from './InspectedElementSuspendedBy';
import NativeStyleEditor from './NativeStyleEditor';
import ElementBadges from './ElementBadges';
import {useHighlightHostInstance} from '../hooks';
import {enableStyleXFeatures} from 'react-devtools-feature-flags';
import {logEvent} from 'react-devtools-shared/src/Logger';
import InspectedElementSourcePanel from './InspectedElementSourcePanel';
import OwnerView from './OwnerView';
import styles from './InspectedElementView.css';
@@ -156,6 +153,15 @@ export default function InspectedElementView({
<NativeStyleEditor />
</div>
<div className={styles.InspectedElementSection}>
<InspectedElementSuspendedBy
bridge={bridge}
element={element}
inspectedElement={inspectedElement}
store={store}
/>
</div>
{showRenderedBy && (
<div
className={styles.InspectedElementSection}
@@ -196,57 +202,3 @@ export default function InspectedElementView({
</Fragment>
);
}
type OwnerViewProps = {
displayName: string,
hocDisplayNames: Array<string> | null,
compiledWithForget: boolean,
id: number,
isInStore: boolean,
};
function OwnerView({
displayName,
hocDisplayNames,
compiledWithForget,
id,
isInStore,
}: OwnerViewProps) {
const dispatch = useContext(TreeDispatcherContext);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const handleClick = useCallback(() => {
logEvent({
event_name: 'select-element',
metadata: {source: 'owner-view'},
});
dispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: id,
});
}, [dispatch, id]);
return (
<Button
key={id}
className={styles.OwnerButton}
disabled={!isInStore}
onClick={handleClick}
onMouseEnter={() => highlightHostInstance(id)}
onMouseLeave={clearHighlightHostInstance}>
<span className={styles.OwnerContent}>
<span
className={`${styles.Owner} ${isInStore ? '' : styles.NotInStore}`}
title={displayName}>
{displayName}
</span>
<ElementBadges
hocDisplayNames={hocDisplayNames}
compiledWithForget={compiledWithForget}
/>
</span>
</Button>
);
}

View File

@@ -0,0 +1,41 @@
.Owner {
color: var(--color-component-name);
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
background: none;
border: none;
display: block;
}
.Owner:focus {
outline: none;
background-color: var(--color-button-background-focus);
}
.OwnerButton {
cursor: pointer;
width: 100%;
padding: 0;
}
.OwnerContent {
display: flex;
align-items: center;
padding-left: 1rem;
width: 100%;
border-radius: 0.25rem;
}
.OwnerContent:hover {
background-color: var(--color-background-hover);
}
.NotInStore {
color: var(--color-dim);
cursor: default;
}

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import {useCallback, useContext} from 'react';
import {TreeDispatcherContext} from './TreeContext';
import Button from '../Button';
import ElementBadges from './ElementBadges';
import {useHighlightHostInstance} from '../hooks';
import {logEvent} from 'react-devtools-shared/src/Logger';
import styles from './OwnerView.css';
type OwnerViewProps = {
displayName: string,
hocDisplayNames: Array<string> | null,
compiledWithForget: boolean,
id: number,
isInStore: boolean,
};
export default function OwnerView({
displayName,
hocDisplayNames,
compiledWithForget,
id,
isInStore,
}: OwnerViewProps): React.Node {
const dispatch = useContext(TreeDispatcherContext);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const handleClick = useCallback(() => {
logEvent({
event_name: 'select-element',
metadata: {source: 'owner-view'},
});
dispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: id,
});
}, [dispatch, id]);
return (
<Button
key={id}
className={styles.OwnerButton}
disabled={!isInStore}
onClick={handleClick}
onMouseEnter={() => highlightHostInstance(id)}
onMouseLeave={clearHighlightHostInstance}>
<span className={styles.OwnerContent}>
<span
className={`${styles.Owner} ${isInStore ? '' : styles.NotInStore}`}
title={displayName}>
{displayName}
</span>
<ElementBadges
hocDisplayNames={hocDisplayNames}
compiledWithForget={compiledWithForget}
/>
</span>
</Button>
);
}

View File

@@ -0,0 +1,24 @@
.StackTraceView {
padding: 0.25rem;
}
.CallSite {
display: block;
padding-left: 1rem;
}
.Link {
color: var(--color-link);
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
cursor: pointer;
border-radius: 0.125rem;
padding: 0px 2px;
}
.Link:hover {
background-color: var(--color-background-hover);
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import useOpenResource from '../useOpenResource';
import styles from './StackTraceView.css';
import type {ReactStackTrace, ReactCallSite} from 'shared/ReactTypes';
import formatLocationForDisplay from './formatLocationForDisplay';
type CallSiteViewProps = {
callSite: ReactCallSite,
};
export function CallSiteView({callSite}: CallSiteViewProps): React.Node {
const symbolicatedCallSite: null | ReactCallSite = null; // TODO
const [linkIsEnabled, viewSource] = useOpenResource(
callSite,
symbolicatedCallSite,
);
const [functionName, url, line, column] =
symbolicatedCallSite !== null ? symbolicatedCallSite : callSite;
return (
<div className={styles.CallSite}>
{functionName}
{' @ '}
<span
className={linkIsEnabled ? styles.Link : null}
onClick={viewSource}
title={url + ':' + line}>
{formatLocationForDisplay(url, line, column)}
</span>
</div>
);
}
type Props = {
stack: ReactStackTrace,
};
export default function StackTraceView({stack}: Props): React.Node {
return (
<div className={styles.StackTraceView}>
{stack.map((callSite, index) => (
<CallSiteView key={index} callSite={callSite} />
))}
</div>
);
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {toNormalUrl} from 'jsc-safe-url';
// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame
export default function formatLocationForDisplay(
sourceURL: string,
line: number,
column: number,
): string {
// Metro can return JSC-safe URLs, which have `//&` as a delimiter
// https://www.npmjs.com/package/jsc-safe-url
const sanitizedSourceURL = sourceURL.includes('//&')
? toNormalUrl(sourceURL)
: sourceURL;
// Note: this RegExp doesn't work well with URLs from Metro,
// which provides bundle URL with query parameters prefixed with /&
const BEFORE_SLASH_RE = /^(.*)[\\\/]/;
let nameOnly = sanitizedSourceURL.replace(BEFORE_SLASH_RE, '');
// In DEV, include code for a common special case:
// prefer "folder/index.js" instead of just "index.js".
if (/^index\./.test(nameOnly)) {
const match = sanitizedSourceURL.match(BEFORE_SLASH_RE);
if (match) {
const pathBeforeSlash = match[1];
if (pathBeforeSlash) {
const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, '');
nameOnly = folderName + '/' + nameOnly;
}
}
}
return `${nameOnly}:${line}`;
}

View File

@@ -121,7 +121,7 @@ function sanitize(data: Object): void {
}
export function serializeDataForCopy(props: Object): string {
const cloned = Object.assign({}, props);
const cloned = isArray(props) ? props.slice(0) : Object.assign({}, props);
sanitize(cloned);

View File

@@ -18,7 +18,7 @@ import type {
Dehydrated,
Unserializable,
} from 'react-devtools-shared/src/hydration';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
export type BrowserTheme = 'dark' | 'light';
@@ -184,6 +184,25 @@ export type Element = {
compiledWithForget: boolean,
};
// Serialized version of ReactIOInfo
export type SerializedIOInfo = {
name: string,
start: number,
end: number,
value: null | Promise<mixed>,
env: null | string,
owner: null | SerializedElement,
stack: null | ReactStackTrace,
};
// Serialized version of ReactAsyncInfo
export type SerializedAsyncInfo = {
awaited: SerializedIOInfo,
env: null | string,
owner: null | SerializedElement,
stack: null | ReactStackTrace,
};
export type SerializedElement = {
displayName: string | null,
id: number,
@@ -239,6 +258,9 @@ export type InspectedElement = {
errors: Array<[string, number]>,
warnings: Array<[string, number]>,
// Things that suspended this Instances
suspendedBy: Object,
// List of owners
owners: Array<SerializedElement> | null,