[DevTools] Inspect the Initial Paint when inspecting a Root (#34454)

This commit is contained in:
Sebastian "Sebbie" Silbermann 2025-10-02 19:18:15 +02:00 committed by GitHub
parent e4a27db283
commit 4a28227960
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 743 additions and 308 deletions

View File

@ -974,12 +974,8 @@ describe('Store', () => {
<Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
`);
const rendererID = getRendererID();
const rootID = store.getRootIDForElement(store.getElementIDAtIndex(0));
await actAsync(() => {
agent.overrideSuspenseMilestone({
rendererID,
rootID,
suspendedSet: [
store.getElementIDAtIndex(4),
store.getElementIDAtIndex(8),
@ -1009,8 +1005,6 @@ describe('Store', () => {
await actAsync(() => {
agent.overrideSuspenseMilestone({
rendererID,
rootID,
suspendedSet: [],
});
});

View File

@ -8,7 +8,11 @@
*/
import EventEmitter from '../events';
import {SESSION_STORAGE_LAST_SELECTION_KEY, __DEBUG__} from '../constants';
import {
SESSION_STORAGE_LAST_SELECTION_KEY,
UNKNOWN_SUSPENDERS_NONE,
__DEBUG__,
} from '../constants';
import setupHighlighter from './views/Highlighter';
import {
initialize as setupTraceUpdates,
@ -26,8 +30,13 @@ import type {
RendererID,
RendererInterface,
DevToolsHookSettings,
InspectedElement,
} from './types';
import type {ComponentFilter} from 'react-devtools-shared/src/frontend/types';
import type {
ComponentFilter,
DehydratedData,
ElementType,
} from 'react-devtools-shared/src/frontend/types';
import type {GroupItem} from './views/TraceUpdates/canvas';
import {gte, isReactNativeEnvironment} from './utils';
import {
@ -73,6 +82,13 @@ type InspectElementParams = {
requestID: number,
};
type InspectScreenParams = {
forceFullData: boolean,
id: number,
path: Array<string | number> | null,
requestID: number,
};
type OverrideHookParams = {
id: number,
hookID: number,
@ -131,8 +147,6 @@ type OverrideSuspenseParams = {
};
type OverrideSuspenseMilestoneParams = {
rendererID: number,
rootID: number,
suspendedSet: Array<number>,
};
@ -141,6 +155,111 @@ type PersistedSelection = {
path: Array<PathFrame>,
};
function createEmptyInspectedScreen(
arbitraryRootID: number,
type: ElementType,
): InspectedElement {
const suspendedBy: DehydratedData = {
cleaned: [],
data: [],
unserializable: [],
};
return {
// invariants
id: arbitraryRootID,
type: type,
// Properties we merge
isErrored: false,
errors: [],
warnings: [],
suspendedBy,
suspendedByRange: null,
// TODO: How to merge these?
unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
// Properties where merging doesn't make sense so we ignore them entirely in the UI
rootType: null,
plugins: {stylex: null},
nativeTag: null,
env: null,
source: null,
stack: null,
rendererPackageName: null,
rendererVersion: null,
// These don't make sense for a Root. They're just bottom values.
key: null,
canEditFunctionProps: false,
canEditHooks: false,
canEditFunctionPropsDeletePaths: false,
canEditFunctionPropsRenamePaths: false,
canEditHooksAndDeletePaths: false,
canEditHooksAndRenamePaths: false,
canToggleError: false,
canToggleSuspense: false,
isSuspended: false,
hasLegacyContext: false,
context: null,
hooks: null,
props: null,
state: null,
owners: null,
};
}
function mergeRoots(
left: InspectedElement,
right: InspectedElement,
suspendedByOffset: number,
): void {
const leftSuspendedByRange = left.suspendedByRange;
const rightSuspendedByRange = right.suspendedByRange;
if (right.isErrored) {
left.isErrored = true;
}
for (let i = 0; i < right.errors.length; i++) {
left.errors.push(right.errors[i]);
}
for (let i = 0; i < right.warnings.length; i++) {
left.warnings.push(right.warnings[i]);
}
const leftSuspendedBy: DehydratedData = left.suspendedBy;
const {data, cleaned, unserializable} = (right.suspendedBy: DehydratedData);
const leftSuspendedByData = ((leftSuspendedBy.data: any): Array<mixed>);
const rightSuspendedByData = ((data: any): Array<mixed>);
for (let i = 0; i < rightSuspendedByData.length; i++) {
leftSuspendedByData.push(rightSuspendedByData[i]);
}
for (let i = 0; i < cleaned.length; i++) {
leftSuspendedBy.cleaned.push(
[suspendedByOffset + cleaned[i][0]].concat(cleaned[i].slice(1)),
);
}
for (let i = 0; i < unserializable.length; i++) {
leftSuspendedBy.unserializable.push(
[suspendedByOffset + unserializable[i][0]].concat(
unserializable[i].slice(1),
),
);
}
if (rightSuspendedByRange !== null) {
if (leftSuspendedByRange === null) {
left.suspendedByRange = [
rightSuspendedByRange[0],
rightSuspendedByRange[1],
];
} else {
if (rightSuspendedByRange[0] < leftSuspendedByRange[0]) {
leftSuspendedByRange[0] = rightSuspendedByRange[0];
}
if (rightSuspendedByRange[1] > leftSuspendedByRange[1]) {
leftSuspendedByRange[1] = rightSuspendedByRange[1];
}
}
}
}
export default class Agent extends EventEmitter<{
hideNativeHighlight: [],
showNativeHighlight: [HostInstance],
@ -201,6 +320,7 @@ export default class Agent extends EventEmitter<{
bridge.addListener('getProfilingStatus', this.getProfilingStatus);
bridge.addListener('getOwnersList', this.getOwnersList);
bridge.addListener('inspectElement', this.inspectElement);
bridge.addListener('inspectScreen', this.inspectScreen);
bridge.addListener('logElementToConsole', this.logElementToConsole);
bridge.addListener('overrideError', this.overrideError);
bridge.addListener('overrideSuspense', this.overrideSuspense);
@ -531,6 +651,138 @@ export default class Agent extends EventEmitter<{
}
};
inspectScreen: InspectScreenParams => void = ({
requestID,
id,
forceFullData,
path: screenPath,
}) => {
let inspectedScreen: InspectedElement | null = null;
let found = false;
// the suspendedBy index will be from the previously merged roots.
// We need to keep track of how many suspendedBy we've already seen to know
// to which renderer the index belongs.
let suspendedByOffset = 0;
let suspendedByPathIndex: number | null = null;
// The path to hydrate for a specific renderer
let rendererPath: InspectElementParams['path'] = null;
if (screenPath !== null && screenPath.length > 1) {
const secondaryCategory = screenPath[0];
if (secondaryCategory !== 'suspendedBy') {
throw new Error(
'Only hydrating suspendedBy paths is supported. This is a bug.',
);
}
if (typeof screenPath[1] !== 'number') {
throw new Error(
`Expected suspendedBy index to be a number. Received '${screenPath[1]}' instead. This is a bug.`,
);
}
suspendedByPathIndex = screenPath[1];
rendererPath = screenPath.slice(2);
}
for (const rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
(rendererID: any)
]: any): RendererInterface);
let path: InspectElementParams['path'] = null;
if (suspendedByPathIndex !== null && rendererPath !== null) {
const suspendedByPathRendererIndex =
suspendedByPathIndex - suspendedByOffset;
const rendererHasRequestedSuspendedByPath =
renderer.getElementAttributeByPath(id, [
'suspendedBy',
suspendedByPathRendererIndex,
]) !== undefined;
if (rendererHasRequestedSuspendedByPath) {
path = ['suspendedBy', suspendedByPathRendererIndex].concat(
rendererPath,
);
}
}
const inspectedRootsPayload = renderer.inspectElement(
requestID,
id,
path,
forceFullData,
);
switch (inspectedRootsPayload.type) {
case 'hydrated-path':
// The path will be relative to the Roots of this renderer. We adjust it
// to be relative to all Roots of this implementation.
inspectedRootsPayload.path[1] += suspendedByOffset;
// TODO: Hydration logic is flawed since the Frontend path is not based
// on the original backend data but rather its own representation of it (e.g. due to reorder).
// So we can receive null here instead when hydration fails
if (inspectedRootsPayload.value !== null) {
for (
let i = 0;
i < inspectedRootsPayload.value.cleaned.length;
i++
) {
inspectedRootsPayload.value.cleaned[i][1] += suspendedByOffset;
}
}
this._bridge.send('inspectedScreen', inspectedRootsPayload);
// If we hydrated a path, it must've been in a specific renderer so we can stop here.
return;
case 'full-data':
const inspectedRoots = inspectedRootsPayload.value;
if (inspectedScreen === null) {
inspectedScreen = createEmptyInspectedScreen(
inspectedRoots.id,
inspectedRoots.type,
);
}
mergeRoots(inspectedScreen, inspectedRoots, suspendedByOffset);
const dehydratedSuspendedBy: DehydratedData =
inspectedRoots.suspendedBy;
const suspendedBy = ((dehydratedSuspendedBy.data: any): Array<mixed>);
suspendedByOffset += suspendedBy.length;
found = true;
break;
case 'no-change':
found = true;
const rootsSuspendedBy: Array<mixed> =
(renderer.getElementAttributeByPath(id, ['suspendedBy']): any);
suspendedByOffset += rootsSuspendedBy.length;
break;
case 'not-found':
break;
case 'error':
// bail out and show the error
// TODO: aggregate errors
this._bridge.send('inspectedScreen', inspectedRootsPayload);
return;
}
}
if (inspectedScreen === null) {
if (found) {
this._bridge.send('inspectedScreen', {
type: 'no-change',
responseID: requestID,
id,
});
} else {
this._bridge.send('inspectedScreen', {
type: 'not-found',
responseID: requestID,
id,
});
}
} else {
this._bridge.send('inspectedScreen', {
type: 'full-data',
responseID: requestID,
id,
value: inspectedScreen,
});
}
};
logElementToConsole: ElementAndRendererID => void = ({id, rendererID}) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
@ -567,17 +819,15 @@ export default class Agent extends EventEmitter<{
};
overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({
rendererID,
rootID,
suspendedSet,
}) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(
`Invalid renderer id "${rendererID}" to override suspense milestone`,
);
} else {
renderer.overrideSuspenseMilestone(rootID, suspendedSet);
for (const rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
(rendererID: any)
]: any): RendererInterface);
if (renderer.supportsTogglingSuspense) {
renderer.overrideSuspenseMilestone(suspendedSet);
}
}
};

View File

@ -2420,7 +2420,6 @@ export function attach(
!isProductionBuildOfRenderer && StrictModeBits !== 0 ? 1 : 0,
);
pushOperation(hasOwnerMetadata ? 1 : 0);
pushOperation(supportsTogglingSuspense ? 1 : 0);
if (isProfiling) {
if (displayNamesByRootID !== null) {
@ -4902,7 +4901,11 @@ export function attach(
fiberInstance.data = nextFiber;
if (
mostRecentlyInspectedElement !== null &&
mostRecentlyInspectedElement.id === fiberInstance.id &&
(mostRecentlyInspectedElement.id === fiberInstance.id ||
// If we're inspecting a Root, we inspect the Screen.
// Invalidating any Root invalidates the Screen too.
(mostRecentlyInspectedElement.type === ElementTypeRoot &&
nextFiber.tag === HostRoot)) &&
didFiberRender(prevFiber, nextFiber)
) {
// If this Fiber has updated, clear cached inspected data.
@ -6422,7 +6425,10 @@ export function attach(
return inspectVirtualInstanceRaw(devtoolsInstance);
}
if (devtoolsInstance.kind === FIBER_INSTANCE) {
return inspectFiberInstanceRaw(devtoolsInstance);
const isRoot = devtoolsInstance.parent === null;
return isRoot
? inspectRootsRaw(devtoolsInstance.id)
: inspectFiberInstanceRaw(devtoolsInstance);
}
(devtoolsInstance: FilteredFiberInstance); // assert exhaustive
throw new Error('Unsupported instance kind');
@ -6875,11 +6881,25 @@ export function attach(
let currentlyInspectedPaths: Object = {};
function isMostRecentlyInspectedElement(id: number): boolean {
if (mostRecentlyInspectedElement === null) {
return false;
}
if (mostRecentlyInspectedElement.id === id) {
return true;
}
if (mostRecentlyInspectedElement.type === ElementTypeRoot) {
// we inspected the screen recently. If we're inspecting another root, we're
// still inspecting the screen.
const instance = idToDevToolsInstanceMap.get(id);
return (
mostRecentlyInspectedElement !== null &&
mostRecentlyInspectedElement.id === id
instance !== undefined &&
instance.kind === FIBER_INSTANCE &&
instance.parent === null
);
}
return false;
}
function isMostRecentlyInspectedElementCurrent(id: number): boolean {
return (
@ -7060,8 +7080,8 @@ export function attach(
if (!hasElementUpdatedSinceLastInspected) {
if (path !== null) {
let secondaryCategory: 'suspendedBy' | 'hooks' | null = null;
if (path[0] === 'hooks') {
secondaryCategory = 'hooks';
if (path[0] === 'hooks' || path[0] === 'suspendedBy') {
secondaryCategory = path[0];
}
// If this element has not been updated since it was last inspected,
@ -7209,6 +7229,99 @@ export function attach(
};
}
function inspectRootsRaw(arbitraryRootID: number): InspectedElement | null {
const roots = hook.getFiberRoots(rendererID);
if (roots.size === 0) {
return null;
}
const inspectedRoots: InspectedElement = {
// invariants
id: arbitraryRootID,
type: ElementTypeRoot,
// Properties we merge
isErrored: false,
errors: [],
warnings: [],
suspendedBy: [],
suspendedByRange: null,
// TODO: How to merge these?
unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
// Properties where merging doesn't make sense so we ignore them entirely in the UI
rootType: null,
plugins: {stylex: null},
nativeTag: null,
env: null,
source: null,
stack: null,
rendererPackageName: null,
rendererVersion: null,
// These don't make sense for a Root. They're just bottom values.
key: null,
canEditFunctionProps: false,
canEditHooks: false,
canEditFunctionPropsDeletePaths: false,
canEditFunctionPropsRenamePaths: false,
canEditHooksAndDeletePaths: false,
canEditHooksAndRenamePaths: false,
canToggleError: false,
canToggleSuspense: false,
isSuspended: false,
hasLegacyContext: false,
context: null,
hooks: null,
props: null,
state: null,
owners: null,
};
let minSuspendedByRange = Infinity;
let maxSuspendedByRange = -Infinity;
roots.forEach(root => {
const rootInstance = rootToFiberInstanceMap.get(root);
if (rootInstance === undefined) {
throw new Error(
'Expected a root instance to exist for this Fiber root',
);
}
const inspectedRoot = inspectFiberInstanceRaw(rootInstance);
if (inspectedRoot === null) {
return;
}
if (inspectedRoot.isErrored) {
inspectedRoots.isErrored = true;
}
for (let i = 0; i < inspectedRoot.errors.length; i++) {
inspectedRoots.errors.push(inspectedRoot.errors[i]);
}
for (let i = 0; i < inspectedRoot.warnings.length; i++) {
inspectedRoots.warnings.push(inspectedRoot.warnings[i]);
}
for (let i = 0; i < inspectedRoot.suspendedBy.length; i++) {
inspectedRoots.suspendedBy.push(inspectedRoot.suspendedBy[i]);
}
const suspendedByRange = inspectedRoot.suspendedByRange;
if (suspendedByRange !== null) {
if (suspendedByRange[0] < minSuspendedByRange) {
minSuspendedByRange = suspendedByRange[0];
}
if (suspendedByRange[1] > maxSuspendedByRange) {
maxSuspendedByRange = suspendedByRange[1];
}
}
});
if (minSuspendedByRange !== Infinity || maxSuspendedByRange !== -Infinity) {
inspectedRoots.suspendedByRange = [
minSuspendedByRange,
maxSuspendedByRange,
];
}
return inspectedRoots;
}
function logElementToConsole(id: number) {
const result = isMostRecentlyInspectedElementCurrent(id)
? mostRecentlyInspectedElement
@ -7867,13 +7980,9 @@ export function attach(
/**
* Resets the all other roots of this renderer.
* @param rootID The root that contains this milestone
* @param suspendedSet List of IDs of SuspenseComponent Fibers
*/
function overrideSuspenseMilestone(
rootID: FiberInstance['id'],
suspendedSet: Array<FiberInstance['id']>,
) {
function overrideSuspenseMilestone(suspendedSet: Array<FiberInstance['id']>) {
if (
typeof setSuspenseHandler !== 'function' ||
typeof scheduleUpdate !== 'function'
@ -7883,8 +7992,6 @@ export function attach(
);
}
// TODO: Allow overriding the timeline for the specified root.
const unsuspendedSet: Set<Fiber> = new Set(forceFallbackForFibers);
let resuspended = false;

View File

@ -412,7 +412,6 @@ export function attach(
pushOperation(0); // Profiling flag
pushOperation(0); // StrictMode supported?
pushOperation(hasOwnerMetadata ? 1 : 0);
pushOperation(supportsTogglingSuspense ? 1 : 0);
pushOperation(SUSPENSE_TREE_OPERATION_ADD);
pushOperation(id);
@ -800,6 +799,20 @@ export function attach(
return null;
}
const rootID = internalInstanceToRootIDMap.get(internalInstance);
if (rootID === undefined) {
throw new Error('Expected to find root ID.');
}
const isRoot = rootID === id;
return isRoot
? inspectRootsRaw(rootID)
: inspectInternalInstanceRaw(id, internalInstance);
}
function inspectInternalInstanceRaw(
id: number,
internalInstance: InternalInstance,
): InspectedElement | null {
const {key} = getData(internalInstance);
const type = getElementType(internalInstance);
@ -903,6 +916,98 @@ export function attach(
};
}
function inspectRootsRaw(arbitraryRootID: number): InspectedElement | null {
const roots =
renderer.Mount._instancesByReactRootID ||
renderer.Mount._instancesByContainerID;
const inspectedRoots: InspectedElement = {
// invariants
id: arbitraryRootID,
type: ElementTypeRoot,
// Properties we merge
isErrored: false,
errors: [],
warnings: [],
suspendedBy: [],
suspendedByRange: null,
// TODO: How to merge these?
unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
// Properties where merging doesn't make sense so we ignore them entirely in the UI
rootType: null,
plugins: {stylex: null},
nativeTag: null,
env: null,
source: null,
stack: null,
// TODO: We could make the Frontend accept an array to display
// a list of unique renderers contributing to this Screen.
rendererPackageName: null,
rendererVersion: null,
// These don't make sense for a Root. They're just bottom values.
key: null,
canEditFunctionProps: false,
canEditHooks: false,
canEditFunctionPropsDeletePaths: false,
canEditFunctionPropsRenamePaths: false,
canEditHooksAndDeletePaths: false,
canEditHooksAndRenamePaths: false,
canToggleError: false,
canToggleSuspense: false,
isSuspended: false,
hasLegacyContext: false,
context: null,
hooks: null,
props: null,
state: null,
owners: null,
};
let minSuspendedByRange = Infinity;
let maxSuspendedByRange = -Infinity;
for (const rootKey in roots) {
const internalInstance = roots[rootKey];
const id = getID(internalInstance);
const inspectedRoot = inspectInternalInstanceRaw(id, internalInstance);
if (inspectedRoot === null) {
return null;
}
if (inspectedRoot.isErrored) {
inspectedRoots.isErrored = true;
}
for (let i = 0; i < inspectedRoot.errors.length; i++) {
inspectedRoots.errors.push(inspectedRoot.errors[i]);
}
for (let i = 0; i < inspectedRoot.warnings.length; i++) {
inspectedRoots.warnings.push(inspectedRoot.warnings[i]);
}
for (let i = 0; i < inspectedRoot.suspendedBy.length; i++) {
inspectedRoots.suspendedBy.push(inspectedRoot.suspendedBy[i]);
}
const suspendedByRange = inspectedRoot.suspendedByRange;
if (suspendedByRange !== null) {
if (suspendedByRange[0] < minSuspendedByRange) {
minSuspendedByRange = suspendedByRange[0];
}
if (suspendedByRange[1] > maxSuspendedByRange) {
maxSuspendedByRange = suspendedByRange[1];
}
}
}
if (minSuspendedByRange !== Infinity || maxSuspendedByRange !== -Infinity) {
inspectedRoots.suspendedByRange = [
minSuspendedByRange,
maxSuspendedByRange,
];
}
return inspectedRoots;
}
function logElementToConsole(id: number): void {
const result = inspectElementRaw(id);
if (result === null) {

View File

@ -450,10 +450,7 @@ export type RendererInterface = {
onErrorOrWarning?: OnErrorOrWarning,
overrideError: (id: number, forceError: boolean) => void,
overrideSuspense: (id: number, forceFallback: boolean) => void,
overrideSuspenseMilestone: (
rootID: number,
suspendedSet: Array<number>,
) => void,
overrideSuspenseMilestone: (suspendedSet: Array<number>) => void,
overrideValueAtPath: (
type: Type,
id: number,

View File

@ -10,6 +10,7 @@
import Agent from 'react-devtools-shared/src/backend/agent';
import {hideOverlay, showOverlay} from './Highlighter';
import type {HostInstance} from 'react-devtools-shared/src/backend/types';
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
import type {RendererInterface} from '../../types';
@ -26,6 +27,7 @@ export default function setupHighlighter(
): void {
bridge.addListener('clearHostInstanceHighlight', clearHostInstanceHighlight);
bridge.addListener('highlightHostInstance', highlightHostInstance);
bridge.addListener('highlightHostInstances', highlightHostInstances);
bridge.addListener('scrollToHostInstance', scrollToHostInstance);
bridge.addListener('shutdown', stopInspectingHost);
bridge.addListener('startInspectingHost', startInspectingHost);
@ -157,6 +159,52 @@ export default function setupHighlighter(
hideOverlay(agent);
}
function highlightHostInstances({
displayName,
hideAfterTimeout,
elements,
scrollIntoView,
}: {
displayName: string | null,
hideAfterTimeout: boolean,
elements: Array<{rendererID: number, id: number}>,
scrollIntoView: boolean,
}) {
const nodes: Array<HostInstance> = [];
for (let i = 0; i < elements.length; i++) {
const {id, rendererID} = elements[i];
const renderer = agent.rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
continue;
}
// In some cases fiber may already be unmounted
if (!renderer.hasElementWithId(id)) {
continue;
}
const hostInstances = renderer.findHostInstancesForElementID(id);
if (hostInstances !== null) {
for (let j = 0; j < hostInstances.length; j++) {
nodes.push(hostInstances[j]);
}
}
}
if (nodes.length > 0) {
const node = nodes[0];
// $FlowFixMe[method-unbinding]
if (scrollIntoView && typeof node.scrollIntoView === 'function') {
// If the node isn't visible show it before highlighting it.
// We may want to reconsider this; it might be a little disruptive.
node.scrollIntoView({block: 'nearest', inline: 'nearest'});
}
}
showOverlay(nodes, displayName, agent, hideAfterTimeout);
}
function attemptScrollToHostInstance(
renderer: RendererInterface,
id: number,

View File

@ -95,7 +95,7 @@ export function inspectElement(
id: number,
path: InspectedElementPath | null,
rendererID: number,
shouldListenToPauseEvents: boolean = false,
shouldListenToPauseEvents: boolean,
): Promise<InspectedElementPayload> {
const requestID = requestCounter++;
const promise = getPromiseForRequestID<InspectedElementPayload>(
@ -117,6 +117,32 @@ export function inspectElement(
return promise;
}
export function inspectScreen(
bridge: FrontendBridge,
forceFullData: boolean,
arbitraryRootID: number,
path: InspectedElementPath | null,
shouldListenToPauseEvents: boolean,
): Promise<InspectedElementPayload> {
const requestID = requestCounter++;
const promise = getPromiseForRequestID<InspectedElementPayload>(
requestID,
'inspectedScreen',
bridge,
`Timed out while inspecting screen.`,
shouldListenToPauseEvents,
);
bridge.send('inspectScreen', {
requestID,
id: arbitraryRootID,
path,
forceFullData,
});
return promise;
}
let storeAsGlobalCount = 0;
export function storeAsGlobal({

View File

@ -65,12 +65,6 @@ export const BRIDGE_PROTOCOL: Array<BridgeProtocol> = [
{
version: 2,
minNpmVersion: '4.22.0',
maxNpmVersion: '6.2.0',
},
// Version 3 adds supports-toggling-suspense bit to add-root
{
version: 3,
minNpmVersion: '6.2.0',
maxNpmVersion: null,
},
];
@ -92,6 +86,12 @@ type HighlightHostInstance = {
openBuiltinElementsPanel: boolean,
scrollIntoView: boolean,
};
type HighlightHostInstances = {
elements: Array<ElementAndRendererID>,
displayName: string | null,
hideAfterTimeout: boolean,
scrollIntoView: boolean,
};
type ScrollToHostInstance = {
...ElementAndRendererID,
@ -145,8 +145,6 @@ type OverrideSuspense = {
};
type OverrideSuspenseMilestone = {
rendererID: number,
rootID: number,
suspendedSet: Array<number>,
};
@ -167,6 +165,13 @@ type InspectElementParams = {
requestID: number,
};
type InspectScreenParams = {
requestID: number,
id: number,
forceFullData: boolean,
path: Array<number | string> | null,
};
type StoreAsGlobalParams = {
...ElementAndRendererID,
count: number,
@ -199,6 +204,7 @@ export type BackendEvents = {
fastRefreshScheduled: [],
getSavedPreferences: [],
inspectedElement: [InspectedElementPayload],
inspectedScreen: [InspectedElementPayload],
isReloadAndProfileSupportedByBackend: [boolean],
operations: [Array<number>],
ownersList: [OwnersList],
@ -243,7 +249,9 @@ type FrontendEvents = {
getProfilingData: [{rendererID: RendererID}],
getProfilingStatus: [],
highlightHostInstance: [HighlightHostInstance],
highlightHostInstances: [HighlightHostInstances],
inspectElement: [InspectElementParams],
inspectScreen: [InspectScreenParams],
logElementToConsole: [ElementAndRendererID],
overrideError: [OverrideError],
overrideSuspense: [OverrideSuspense],

View File

@ -96,7 +96,6 @@ export type Capabilities = {
supportsBasicProfiling: boolean,
hasOwnerMetadata: boolean,
supportsStrictMode: boolean,
supportsTogglingSuspense: boolean,
supportsAdvancedProfiling: AdvancedProfiling,
};
@ -506,14 +505,6 @@ export default class Store extends EventEmitter<{
);
}
supportsTogglingSuspense(rootID: Element['id']): boolean {
const capabilities = this._rootIDToCapabilities.get(rootID);
if (capabilities === undefined) {
throw new Error(`No capabilities registered for root ${rootID}`);
}
return capabilities.supportsTogglingSuspense;
}
// This build of DevTools supports the Timeline profiler.
// This is a static flag, controlled by the Store config.
get supportsTimeline(): boolean {
@ -898,31 +889,40 @@ export default class Store extends EventEmitter<{
* @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
*/
getSuspendableDocumentOrderSuspense(
rootID: Element['id'] | void,
uniqueSuspendersOnly: boolean,
): $ReadOnlyArray<SuspenseNode['id']> {
if (rootID === undefined) {
const roots = this.roots;
if (roots.length === 0) {
return [];
}
const list: SuspenseNode['id'][] = [];
for (let i = 0; i < roots.length; i++) {
const rootID = roots[i];
const root = this.getElementByID(rootID);
if (root === null) {
return [];
continue;
}
if (!this.supportsTogglingSuspense(rootID)) {
return [];
}
const list: SuspenseNode['id'][] = [];
// TODO: This includes boundaries that can't be suspended due to no support from the renderer.
const suspense = this.getSuspenseByID(rootID);
if (suspense !== null) {
if (list.length === 0) {
// start with an arbitrary root that will allow inspection of the Screen
list.push(suspense.id);
}
const stack = [suspense];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) {
continue;
}
// Include the root even if we won't show it suspended (because that's just blank).
// You should be able to see what suspended the shell.
if (!uniqueSuspendersOnly || current.hasUniqueSuspenders) {
if (
(!uniqueSuspendersOnly || current.hasUniqueSuspenders) &&
// Roots are already included as part of the Screen
current.id !== rootID
) {
list.push(current.id);
}
// Add children in reverse order to maintain document order
@ -934,6 +934,7 @@ export default class Store extends EventEmitter<{
}
}
}
}
return list;
}
@ -1191,7 +1192,6 @@ export default class Store extends EventEmitter<{
let supportsStrictMode = false;
let hasOwnerMetadata = false;
let supportsTogglingSuspense = false;
// If we don't know the bridge protocol, guess that we're dealing with the latest.
// If we do know it, we can take it into consideration when parsing operations.
@ -1204,9 +1204,6 @@ export default class Store extends EventEmitter<{
hasOwnerMetadata = operations[i] > 0;
i++;
supportsTogglingSuspense = operations[i] > 0;
i++;
}
this._roots = this._roots.concat(id);
@ -1215,7 +1212,6 @@ export default class Store extends EventEmitter<{
supportsBasicProfiling,
hasOwnerMetadata,
supportsStrictMode,
supportsTogglingSuspense,
supportsAdvancedProfiling,
});
@ -1561,9 +1557,14 @@ export default class Store extends EventEmitter<{
if (name === null) {
// The boundary isn't explicitly named.
// Pick a sensible default.
if (parentID === 0) {
// For Roots we use their display name.
name = element.displayName;
} else {
name = this._guessSuspenseName(element);
}
}
}
i += 5;
let rects: SuspenseNode['rects'];

View File

@ -209,7 +209,6 @@ function updateTree(
i++; // Profiling flag
i++; // supportsStrictMode flag
i++; // hasOwnerMetadata flag
i++; // supportsTogglingSuspense flag
if (__DEBUG__) {
debug('Add', `new root fiber ${id}`);

View File

@ -25,7 +25,7 @@ export default function SuspenseBreadcrumbs(): React$Node {
const store = useContext(StoreContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {selectedSuspenseID, selectedRootID, lineage} = useContext(
const {selectedSuspenseID, lineage, roots} = useContext(
SuspenseTreeStateContext,
);
@ -45,13 +45,13 @@ export default function SuspenseBreadcrumbs(): React$Node {
// that rendered the whole screen. In laymans terms this is really "Initial Paint".
// TODO: Once we add subtree selection, then the equivalent should be called
// "Transition" since in that case it's really about a Transition within the page.
selectedRootID !== null ? (
roots.length > 0 ? (
<li
className={styles.SuspenseBreadcrumbsListItem}
aria-current={selectedSuspenseID === selectedRootID}>
aria-current="true">
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={handleClick.bind(null, selectedRootID)}
onClick={handleClick.bind(null, roots[0])}
type="button">
Initial Paint
</button>

View File

@ -278,11 +278,7 @@ function getDocumentBoundingRect(
};
}
function SuspenseRectsShell({
rootID,
}: {
rootID: SuspenseNode['id'],
}): React$Node {
function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
const store = useContext(StoreContext);
const root = store.getSuspenseByID(rootID);
if (root === null) {
@ -299,6 +295,8 @@ const ViewBox = createContext<Rect>((null: any));
function SuspenseRectsContainer(): React$Node {
const store = useContext(StoreContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
const {roots} = useContext(SuspenseTreeStateContext);
@ -312,14 +310,33 @@ function SuspenseRectsContainer(): React$Node {
const width = '100%';
const aspectRatio = `1 / ${heightScale}`;
function handleClick(event: SyntheticMouseEvent) {
if (event.defaultPrevented) {
// Already clicked on an inner rect
return;
}
if (roots.length === 0) {
// Nothing to select
return;
}
const arbitraryRootID = roots[0];
event.preventDefault();
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: arbitraryRootID});
suspenseTreeDispatch({
type: 'SET_SUSPENSE_LINEAGE',
payload: arbitraryRootID,
});
}
return (
<div className={styles.SuspenseRectsContainer}>
<div className={styles.SuspenseRectsContainer} onClick={handleClick}>
<ViewBox.Provider value={boundingBox}>
<div
className={styles.SuspenseRectsViewBox}
style={{aspectRatio, width}}>
{roots.map(rootID => {
return <SuspenseRectsShell key={rootID} rootID={rootID} />;
return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
})}
</div>
</ViewBox.Provider>

View File

@ -34,13 +34,9 @@ import {
SuspenseTreeStateContext,
} from './SuspenseTreeContext';
import {StoreContext, OptionsContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import Button from '../Button';
import Toggle from '../Toggle';
import typeof {
SyntheticEvent,
SyntheticPointerEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal';
import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
@ -71,18 +67,12 @@ function ToggleUniqueSuspenders() {
const store = useContext(StoreContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {selectedRootID: rootID, uniqueSuspendersOnly} = useContext(
SuspenseTreeStateContext,
);
const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext);
function handleToggleUniqueSuspenders() {
const nextUniqueSuspendersOnly = !uniqueSuspendersOnly;
const nextTimeline =
rootID === null
? []
: // TODO: Handle different timeline modes (e.g. random order)
store.getSuspendableDocumentOrderSuspense(
rootID,
// TODO: Handle different timeline modes (e.g. random order)
const nextTimeline = store.getSuspendableDocumentOrderSuspense(
nextUniqueSuspendersOnly,
);
suspenseTreeDispatch({
@ -101,55 +91,6 @@ function ToggleUniqueSuspenders() {
);
}
function SelectRoot() {
const store = useContext(StoreContext);
const {roots, selectedRootID, uniqueSuspendersOnly} = useContext(
SuspenseTreeStateContext,
);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
function handleChange(event: SyntheticEvent) {
const newRootID = +event.currentTarget.value;
// TODO: scrollIntoView both suspense rects and host instance.
const nextTimeline = store.getSuspendableDocumentOrderSuspense(
newRootID,
uniqueSuspendersOnly,
);
suspenseTreeDispatch({
type: 'SET_SUSPENSE_TIMELINE',
payload: [nextTimeline, newRootID, uniqueSuspendersOnly],
});
if (nextTimeline.length > 0) {
const milestone = nextTimeline[nextTimeline.length - 1];
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone});
}
}
return (
roots.length > 0 && (
<select
aria-label="Select Suspense Root"
className={styles.SuspenseTimelineRootSwitcher}
onChange={handleChange}
value={selectedRootID === null ? -1 : selectedRootID}>
<option disabled={true} value={-1}>
----
</option>
{roots.map(rootID => {
// TODO: Use name
const name = '#' + rootID;
// TODO: Highlight host on hover
return (
<option key={rootID} value={rootID}>
{name}
</option>
);
})}
</select>
)
);
}
function ToggleTreeList({
dispatch,
state,
@ -427,7 +368,6 @@ function SuspenseTab(_: {}) {
<div className={styles.SuspenseBreadcrumbs}>
<SuspenseBreadcrumbs />
</div>
<SelectRoot />
<div className={styles.VRule} />
<ToggleUniqueSuspenders />
{!hideSettings && <SettingsModalContextToggle />}

View File

@ -9,7 +9,7 @@
import * as React from 'react';
import {useContext, useEffect, useRef} from 'react';
import {BridgeContext, StoreContext} from '../context';
import {BridgeContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks';
import {
@ -23,20 +23,15 @@ import ButtonIcon from '../ButtonIcon';
function SuspenseTimelineInput() {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const scrollToHostInstance = useScrollToHostInstance();
const {
selectedRootID: rootID,
timeline,
timelineIndex,
hoveredTimelineIndex,
playing,
} = useContext(SuspenseTreeStateContext);
const {timeline, timelineIndex, hoveredTimelineIndex, playing} = useContext(
SuspenseTreeStateContext,
);
const min = 0;
const max = timeline.length > 0 ? timeline.length - 1 : 0;
@ -112,24 +107,12 @@ function SuspenseTimelineInput() {
// For now we just exclude it from deps since we don't lint those anyway.
function changeTimelineIndex(newIndex: number) {
// Synchronize timeline index with what is resuspended.
if (rootID === null) {
return;
}
const rendererID = store.getRendererIDForElement(rootID);
if (rendererID === null) {
console.error(
`No renderer ID found for root element ${rootID} in suspense timeline.`,
);
return;
}
// We suspend everything after the current selection. The root isn't showing
// anything suspended in the root. The step after that should have one less
// thing suspended. I.e. the first suspense boundary should be unsuspended
// when it's selected. This also lets you show everything in the last step.
const suspendedSet = timeline.slice(timelineIndex + 1);
bridge.send('overrideSuspenseMilestone', {
rendererID,
rootID,
suspendedSet,
});
if (isInitialMount.current) {
@ -164,20 +147,6 @@ function SuspenseTimelineInput() {
};
}, [playing]);
if (rootID === null) {
return (
<div className={styles.SuspenseTimelineInput}>No root selected.</div>
);
}
if (!store.supportsTogglingSuspense(rootID)) {
return (
<div className={styles.SuspenseTimelineInput}>
Can't step through Suspense in production apps.
</div>
);
}
if (timeline.length === 0) {
return (
<div className={styles.SuspenseTimelineInput}>
@ -226,10 +195,9 @@ function SuspenseTimelineInput() {
}
export default function SuspenseTimeline(): React$Node {
const {selectedRootID} = useContext(SuspenseTreeStateContext);
return (
<div className={styles.SuspenseTimelineContainer}>
<SuspenseTimelineInput key={selectedRootID} />
<SuspenseTimelineInput />
</div>
);
}

View File

@ -7,10 +7,7 @@
* @flow
*/
import type {ReactContext} from 'shared/ReactTypes';
import type {
Element,
SuspenseNode,
} from 'react-devtools-shared/src/frontend/types';
import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
import type Store from '../../store';
import * as React from 'react';
@ -27,7 +24,6 @@ import {StoreContext} from '../context';
export type SuspenseTreeState = {
lineage: $ReadOnlyArray<SuspenseNode['id']> | null,
roots: $ReadOnlyArray<SuspenseNode['id']>,
selectedRootID: SuspenseNode['id'] | null,
selectedSuspenseID: SuspenseNode['id'] | null,
timeline: $ReadOnlyArray<SuspenseNode['id']>,
timelineIndex: number | -1,
@ -107,41 +103,10 @@ type Props = {
children: React$Node,
};
function getDefaultRootID(store: Store): Element['id'] | null {
const designatedRootID = store.roots.find(rootID => {
const suspense = store.getSuspenseByID(rootID);
return (
store.supportsTogglingSuspense(rootID) &&
suspense !== null &&
suspense.children.length > 1
);
});
return designatedRootID === undefined ? null : designatedRootID;
}
function getInitialState(store: Store): SuspenseTreeState {
let initialState: SuspenseTreeState;
const uniqueSuspendersOnly = true;
const selectedRootID = getDefaultRootID(store);
// TODO: Default to nearest from inspected
if (selectedRootID === null) {
initialState = {
selectedSuspenseID: null,
lineage: null,
roots: store.roots,
selectedRootID,
timeline: [],
timelineIndex: -1,
hoveredTimelineIndex: -1,
uniqueSuspendersOnly,
playing: false,
};
} else {
const timeline = store.getSuspendableDocumentOrderSuspense(
selectedRootID,
uniqueSuspendersOnly,
);
const timeline =
store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
const timelineIndex = timeline.length - 1;
const selectedSuspenseID =
timelineIndex === -1 ? null : timeline[timelineIndex];
@ -149,18 +114,16 @@ function getInitialState(store: Store): SuspenseTreeState {
selectedSuspenseID !== null
? store.getSuspenseLineage(selectedSuspenseID)
: [];
initialState = {
const initialState: SuspenseTreeState = {
selectedSuspenseID,
lineage,
roots: store.roots,
selectedRootID,
timeline,
timelineIndex,
hoveredTimelineIndex: -1,
uniqueSuspendersOnly,
playing: false,
};
}
return initialState;
}
@ -209,21 +172,8 @@ function SuspenseTreeContextController({children}: Props): React.Node {
selectedTimelineID = removedIDs.get(selectedTimelineID);
}
let nextRootID = state.selectedRootID;
if (selectedTimelineID !== null && selectedTimelineID !== 0) {
nextRootID =
store.getSuspenseRootIDForSuspense(selectedTimelineID);
}
if (nextRootID === null) {
nextRootID = getDefaultRootID(store);
}
const nextTimeline =
nextRootID === null
? []
: // TODO: Handle different timeline modes (e.g. random order)
store.getSuspendableDocumentOrderSuspense(
nextRootID,
// TODO: Handle different timeline modes (e.g. random order)
const nextTimeline = store.getSuspendableDocumentOrderSuspense(
state.uniqueSuspendersOnly,
);
@ -250,7 +200,6 @@ function SuspenseTreeContextController({children}: Props): React.Node {
...state,
lineage: nextLineage,
roots: store.roots,
selectedRootID: nextRootID,
selectedSuspenseID,
timeline: nextTimeline,
timelineIndex: nextTimelineIndex,
@ -258,27 +207,21 @@ function SuspenseTreeContextController({children}: Props): React.Node {
}
case 'SELECT_SUSPENSE_BY_ID': {
const selectedSuspenseID = action.payload;
const selectedRootID =
store.getSuspenseRootIDForSuspense(selectedSuspenseID);
return {
...state,
selectedSuspenseID,
selectedRootID,
playing: false, // pause
};
}
case 'SET_SUSPENSE_LINEAGE': {
const suspenseID = action.payload;
const lineage = store.getSuspenseLineage(suspenseID);
const selectedRootID =
store.getSuspenseRootIDForSuspense(suspenseID);
return {
...state,
lineage,
selectedSuspenseID: suspenseID,
selectedRootID,
playing: false, // pause
};
}
@ -316,8 +259,6 @@ function SuspenseTreeContextController({children}: Props): React.Node {
...state,
selectedSuspenseID: nextSelectedSuspenseID,
lineage: nextLineage,
selectedRootID:
nextRootID === null ? state.selectedRootID : nextRootID,
timeline: nextTimeline,
timelineIndex: nextMilestoneIndex,
uniqueSuspendersOnly: nextUniqueSuspendersOnly,

View File

@ -353,12 +353,34 @@ export function useHighlightHostInstance(): {
const highlightHostInstance = useCallback(
(id: number, scrollIntoView?: boolean = false) => {
const element = store.getElementByID(id);
const rendererID = store.getRendererIDForElement(id);
if (element !== null && rendererID !== null) {
if (element !== null) {
const isRoot = element.parentID === 0;
let displayName = element.displayName;
if (displayName !== null && element.nameProp !== null) {
displayName += ` name="${element.nameProp}"`;
}
if (isRoot) {
// Inspect screen
const elements: Array<{rendererID: number, id: number}> = [];
for (let i = 0; i < store.roots.length; i++) {
const rootID = store.roots[i];
const rendererID = store.getRendererIDForElement(rootID);
if (rendererID === null) {
continue;
}
elements.push({rendererID, id: rootID});
}
bridge.send('highlightHostInstances', {
displayName,
hideAfterTimeout: false,
elements,
scrollIntoView: scrollIntoView,
});
} else {
const rendererID = store.getRendererIDForElement(id);
if (rendererID !== null) {
bridge.send('highlightHostInstance', {
displayName,
hideAfterTimeout: false,
@ -368,6 +390,8 @@ export function useHighlightHostInstance(): {
scrollIntoView: scrollIntoView,
});
}
}
}
},
[store, bridge],
);

View File

@ -12,6 +12,7 @@ import {
convertInspectedElementBackendToFrontend,
hydrateHelper,
inspectElement as inspectElementAPI,
inspectScreen as inspectScreenAPI,
} from 'react-devtools-shared/src/backendAPI';
import {fillInPath} from 'react-devtools-shared/src/hydration';
@ -57,21 +58,31 @@ export function inspectElement(
rendererID: number,
shouldListenToPauseEvents: boolean = false,
): Promise<InspectElementReturnType> {
const {id} = element;
const {id, parentID} = element;
// This could indicate that the DevTools UI has been closed and reopened.
// The in-memory cache will be clear but the backend still thinks we have cached data.
// In this case, we need to tell it to resend the full data.
const forceFullData = !inspectedElementCache.has(id);
return inspectElementAPI(
const isRoot = parentID === 0;
const promisedElement = isRoot
? inspectScreenAPI(
bridge,
forceFullData,
id,
path,
shouldListenToPauseEvents,
)
: inspectElementAPI(
bridge,
forceFullData,
id,
path,
rendererID,
shouldListenToPauseEvents,
).then((data: any) => {
);
return promisedElement.then((data: any) => {
const {type} = data;
let inspectedElement;

View File

@ -262,7 +262,6 @@ export function printOperationsArray(operations: Array<number>) {
i++; // supportsProfiling
i++; // supportsStrictMode
i++; // hasOwnerMetadata
i++; // supportsTogglingSuspense
} else {
const parentID = ((operations[i]: any): number);
i++;