Skip to content

Commit 4a28227

Browse files
authored
[DevTools] Inspect the Initial Paint when inspecting a Root (facebook#34454)
1 parent e4a27db commit 4a28227

18 files changed

Lines changed: 744 additions & 309 deletions

File tree

packages/react-devtools-shared/src/__tests__/store-test.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -974,12 +974,8 @@ describe('Store', () => {
974974
<Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
975975
`);
976976

977-
const rendererID = getRendererID();
978-
const rootID = store.getRootIDForElement(store.getElementIDAtIndex(0));
979977
await actAsync(() => {
980978
agent.overrideSuspenseMilestone({
981-
rendererID,
982-
rootID,
983979
suspendedSet: [
984980
store.getElementIDAtIndex(4),
985981
store.getElementIDAtIndex(8),
@@ -1009,8 +1005,6 @@ describe('Store', () => {
10091005

10101006
await actAsync(() => {
10111007
agent.overrideSuspenseMilestone({
1012-
rendererID,
1013-
rootID,
10141008
suspendedSet: [],
10151009
});
10161010
});

packages/react-devtools-shared/src/backend/agent.js

Lines changed: 263 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
*/
99

1010
import EventEmitter from '../events';
11-
import {SESSION_STORAGE_LAST_SELECTION_KEY, __DEBUG__} from '../constants';
11+
import {
12+
SESSION_STORAGE_LAST_SELECTION_KEY,
13+
UNKNOWN_SUSPENDERS_NONE,
14+
__DEBUG__,
15+
} from '../constants';
1216
import setupHighlighter from './views/Highlighter';
1317
import {
1418
initialize as setupTraceUpdates,
@@ -26,8 +30,13 @@ import type {
2630
RendererID,
2731
RendererInterface,
2832
DevToolsHookSettings,
33+
InspectedElement,
2934
} from './types';
30-
import type {ComponentFilter} from 'react-devtools-shared/src/frontend/types';
35+
import type {
36+
ComponentFilter,
37+
DehydratedData,
38+
ElementType,
39+
} from 'react-devtools-shared/src/frontend/types';
3140
import type {GroupItem} from './views/TraceUpdates/canvas';
3241
import {gte, isReactNativeEnvironment} from './utils';
3342
import {
@@ -73,6 +82,13 @@ type InspectElementParams = {
7382
requestID: number,
7483
};
7584

85+
type InspectScreenParams = {
86+
forceFullData: boolean,
87+
id: number,
88+
path: Array<string | number> | null,
89+
requestID: number,
90+
};
91+
7692
type OverrideHookParams = {
7793
id: number,
7894
hookID: number,
@@ -131,8 +147,6 @@ type OverrideSuspenseParams = {
131147
};
132148

133149
type OverrideSuspenseMilestoneParams = {
134-
rendererID: number,
135-
rootID: number,
136150
suspendedSet: Array<number>,
137151
};
138152

@@ -141,6 +155,111 @@ type PersistedSelection = {
141155
path: Array<PathFrame>,
142156
};
143157

158+
function createEmptyInspectedScreen(
159+
arbitraryRootID: number,
160+
type: ElementType,
161+
): InspectedElement {
162+
const suspendedBy: DehydratedData = {
163+
cleaned: [],
164+
data: [],
165+
unserializable: [],
166+
};
167+
return {
168+
// invariants
169+
id: arbitraryRootID,
170+
type: type,
171+
// Properties we merge
172+
isErrored: false,
173+
errors: [],
174+
warnings: [],
175+
suspendedBy,
176+
suspendedByRange: null,
177+
// TODO: How to merge these?
178+
unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
179+
// Properties where merging doesn't make sense so we ignore them entirely in the UI
180+
rootType: null,
181+
plugins: {stylex: null},
182+
nativeTag: null,
183+
env: null,
184+
source: null,
185+
stack: null,
186+
rendererPackageName: null,
187+
rendererVersion: null,
188+
// These don't make sense for a Root. They're just bottom values.
189+
key: null,
190+
canEditFunctionProps: false,
191+
canEditHooks: false,
192+
canEditFunctionPropsDeletePaths: false,
193+
canEditFunctionPropsRenamePaths: false,
194+
canEditHooksAndDeletePaths: false,
195+
canEditHooksAndRenamePaths: false,
196+
canToggleError: false,
197+
canToggleSuspense: false,
198+
isSuspended: false,
199+
hasLegacyContext: false,
200+
context: null,
201+
hooks: null,
202+
props: null,
203+
state: null,
204+
owners: null,
205+
};
206+
}
207+
208+
function mergeRoots(
209+
left: InspectedElement,
210+
right: InspectedElement,
211+
suspendedByOffset: number,
212+
): void {
213+
const leftSuspendedByRange = left.suspendedByRange;
214+
const rightSuspendedByRange = right.suspendedByRange;
215+
216+
if (right.isErrored) {
217+
left.isErrored = true;
218+
}
219+
for (let i = 0; i < right.errors.length; i++) {
220+
left.errors.push(right.errors[i]);
221+
}
222+
for (let i = 0; i < right.warnings.length; i++) {
223+
left.warnings.push(right.warnings[i]);
224+
}
225+
226+
const leftSuspendedBy: DehydratedData = left.suspendedBy;
227+
const {data, cleaned, unserializable} = (right.suspendedBy: DehydratedData);
228+
const leftSuspendedByData = ((leftSuspendedBy.data: any): Array<mixed>);
229+
const rightSuspendedByData = ((data: any): Array<mixed>);
230+
for (let i = 0; i < rightSuspendedByData.length; i++) {
231+
leftSuspendedByData.push(rightSuspendedByData[i]);
232+
}
233+
for (let i = 0; i < cleaned.length; i++) {
234+
leftSuspendedBy.cleaned.push(
235+
[suspendedByOffset + cleaned[i][0]].concat(cleaned[i].slice(1)),
236+
);
237+
}
238+
for (let i = 0; i < unserializable.length; i++) {
239+
leftSuspendedBy.unserializable.push(
240+
[suspendedByOffset + unserializable[i][0]].concat(
241+
unserializable[i].slice(1),
242+
),
243+
);
244+
}
245+
246+
if (rightSuspendedByRange !== null) {
247+
if (leftSuspendedByRange === null) {
248+
left.suspendedByRange = [
249+
rightSuspendedByRange[0],
250+
rightSuspendedByRange[1],
251+
];
252+
} else {
253+
if (rightSuspendedByRange[0] < leftSuspendedByRange[0]) {
254+
leftSuspendedByRange[0] = rightSuspendedByRange[0];
255+
}
256+
if (rightSuspendedByRange[1] > leftSuspendedByRange[1]) {
257+
leftSuspendedByRange[1] = rightSuspendedByRange[1];
258+
}
259+
}
260+
}
261+
}
262+
144263
export default class Agent extends EventEmitter<{
145264
hideNativeHighlight: [],
146265
showNativeHighlight: [HostInstance],
@@ -201,6 +320,7 @@ export default class Agent extends EventEmitter<{
201320
bridge.addListener('getProfilingStatus', this.getProfilingStatus);
202321
bridge.addListener('getOwnersList', this.getOwnersList);
203322
bridge.addListener('inspectElement', this.inspectElement);
323+
bridge.addListener('inspectScreen', this.inspectScreen);
204324
bridge.addListener('logElementToConsole', this.logElementToConsole);
205325
bridge.addListener('overrideError', this.overrideError);
206326
bridge.addListener('overrideSuspense', this.overrideSuspense);
@@ -531,6 +651,138 @@ export default class Agent extends EventEmitter<{
531651
}
532652
};
533653

654+
inspectScreen: InspectScreenParams => void = ({
655+
requestID,
656+
id,
657+
forceFullData,
658+
path: screenPath,
659+
}) => {
660+
let inspectedScreen: InspectedElement | null = null;
661+
let found = false;
662+
// the suspendedBy index will be from the previously merged roots.
663+
// We need to keep track of how many suspendedBy we've already seen to know
664+
// to which renderer the index belongs.
665+
let suspendedByOffset = 0;
666+
let suspendedByPathIndex: number | null = null;
667+
// The path to hydrate for a specific renderer
668+
let rendererPath: InspectElementParams['path'] = null;
669+
if (screenPath !== null && screenPath.length > 1) {
670+
const secondaryCategory = screenPath[0];
671+
if (secondaryCategory !== 'suspendedBy') {
672+
throw new Error(
673+
'Only hydrating suspendedBy paths is supported. This is a bug.',
674+
);
675+
}
676+
if (typeof screenPath[1] !== 'number') {
677+
throw new Error(
678+
`Expected suspendedBy index to be a number. Received '${screenPath[1]}' instead. This is a bug.`,
679+
);
680+
}
681+
suspendedByPathIndex = screenPath[1];
682+
rendererPath = screenPath.slice(2);
683+
}
684+
685+
for (const rendererID in this._rendererInterfaces) {
686+
const renderer = ((this._rendererInterfaces[
687+
(rendererID: any)
688+
]: any): RendererInterface);
689+
let path: InspectElementParams['path'] = null;
690+
if (suspendedByPathIndex !== null && rendererPath !== null) {
691+
const suspendedByPathRendererIndex =
692+
suspendedByPathIndex - suspendedByOffset;
693+
const rendererHasRequestedSuspendedByPath =
694+
renderer.getElementAttributeByPath(id, [
695+
'suspendedBy',
696+
suspendedByPathRendererIndex,
697+
]) !== undefined;
698+
if (rendererHasRequestedSuspendedByPath) {
699+
path = ['suspendedBy', suspendedByPathRendererIndex].concat(
700+
rendererPath,
701+
);
702+
}
703+
}
704+
705+
const inspectedRootsPayload = renderer.inspectElement(
706+
requestID,
707+
id,
708+
path,
709+
forceFullData,
710+
);
711+
switch (inspectedRootsPayload.type) {
712+
case 'hydrated-path':
713+
// The path will be relative to the Roots of this renderer. We adjust it
714+
// to be relative to all Roots of this implementation.
715+
inspectedRootsPayload.path[1] += suspendedByOffset;
716+
// TODO: Hydration logic is flawed since the Frontend path is not based
717+
// on the original backend data but rather its own representation of it (e.g. due to reorder).
718+
// So we can receive null here instead when hydration fails
719+
if (inspectedRootsPayload.value !== null) {
720+
for (
721+
let i = 0;
722+
i < inspectedRootsPayload.value.cleaned.length;
723+
i++
724+
) {
725+
inspectedRootsPayload.value.cleaned[i][1] += suspendedByOffset;
726+
}
727+
}
728+
this._bridge.send('inspectedScreen', inspectedRootsPayload);
729+
// If we hydrated a path, it must've been in a specific renderer so we can stop here.
730+
return;
731+
case 'full-data':
732+
const inspectedRoots = inspectedRootsPayload.value;
733+
if (inspectedScreen === null) {
734+
inspectedScreen = createEmptyInspectedScreen(
735+
inspectedRoots.id,
736+
inspectedRoots.type,
737+
);
738+
}
739+
mergeRoots(inspectedScreen, inspectedRoots, suspendedByOffset);
740+
const dehydratedSuspendedBy: DehydratedData =
741+
inspectedRoots.suspendedBy;
742+
const suspendedBy = ((dehydratedSuspendedBy.data: any): Array<mixed>);
743+
suspendedByOffset += suspendedBy.length;
744+
found = true;
745+
break;
746+
case 'no-change':
747+
found = true;
748+
const rootsSuspendedBy: Array<mixed> =
749+
(renderer.getElementAttributeByPath(id, ['suspendedBy']): any);
750+
suspendedByOffset += rootsSuspendedBy.length;
751+
break;
752+
case 'not-found':
753+
break;
754+
case 'error':
755+
// bail out and show the error
756+
// TODO: aggregate errors
757+
this._bridge.send('inspectedScreen', inspectedRootsPayload);
758+
return;
759+
}
760+
}
761+
762+
if (inspectedScreen === null) {
763+
if (found) {
764+
this._bridge.send('inspectedScreen', {
765+
type: 'no-change',
766+
responseID: requestID,
767+
id,
768+
});
769+
} else {
770+
this._bridge.send('inspectedScreen', {
771+
type: 'not-found',
772+
responseID: requestID,
773+
id,
774+
});
775+
}
776+
} else {
777+
this._bridge.send('inspectedScreen', {
778+
type: 'full-data',
779+
responseID: requestID,
780+
id,
781+
value: inspectedScreen,
782+
});
783+
}
784+
};
785+
534786
logElementToConsole: ElementAndRendererID => void = ({id, rendererID}) => {
535787
const renderer = this._rendererInterfaces[rendererID];
536788
if (renderer == null) {
@@ -567,17 +819,15 @@ export default class Agent extends EventEmitter<{
567819
};
568820

569821
overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({
570-
rendererID,
571-
rootID,
572822
suspendedSet,
573823
}) => {
574-
const renderer = this._rendererInterfaces[rendererID];
575-
if (renderer == null) {
576-
console.warn(
577-
`Invalid renderer id "${rendererID}" to override suspense milestone`,
578-
);
579-
} else {
580-
renderer.overrideSuspenseMilestone(rootID, suspendedSet);
824+
for (const rendererID in this._rendererInterfaces) {
825+
const renderer = ((this._rendererInterfaces[
826+
(rendererID: any)
827+
]: any): RendererInterface);
828+
if (renderer.supportsTogglingSuspense) {
829+
renderer.overrideSuspenseMilestone(suspendedSet);
830+
}
581831
}
582832
};
583833

0 commit comments

Comments
 (0)