[DevTools] Inspect the Initial Paint when inspecting a Root (#34454)
Sebastian "Sebbie" Silbermann committed
Oct 2, 2025 at 19:18 UTC
4a28227960202539f329338f62d33973e76b32d8
18 files changed
+744
-309
packages/react-devtools-shared/src/__tests__/store-test.js
-6
@@ -974,12 +974,8 @@ describe('Store', () => {
974
<Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
975
`);
976
977
- const rendererID = getRendererID();
978
- const rootID = store.getRootIDForElement(store.getElementIDAtIndex(0));
977
await actAsync(() => {
978
agent.overrideSuspenseMilestone({
981
- rendererID,
982
- rootID,
979
suspendedSet: [
980
store.getElementIDAtIndex(4),
981
store.getElementIDAtIndex(8),
@@ -1009,8 +1005,6 @@ describe('Store', () => {
1005
1006
await actAsync(() => {
1007
agent.overrideSuspenseMilestone({
1012
- rendererID,
1013
- rootID,
1008
suspendedSet: [],
1009
});
1010
});
packages/react-devtools-shared/src/backend/agent.js
+263
-13
@@ -8,7 +8,11 @@
8
*/
9
10
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';
16
import setupHighlighter from './views/Highlighter';
17
import {
18
initialize as setupTraceUpdates,
@@ -26,8 +30,13 @@ import type {
30
RendererID,
31
RendererInterface,
32
DevToolsHookSettings,
33
+ InspectedElement,
34
} 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';
40
import type {GroupItem} from './views/TraceUpdates/canvas';
41
import {gte, isReactNativeEnvironment} from './utils';
42
import {
@@ -73,6 +82,13 @@ type InspectElementParams = {
82
requestID: number,
83
};
84
85
+type InspectScreenParams = {
86
+ forceFullData: boolean,
87
+ id: number,
88
+ path: Array<string | number> | null,
89
+ requestID: number,
90
+};
91
+
92
type OverrideHookParams = {
93
id: number,
94
hookID: number,
@@ -131,8 +147,6 @@ type OverrideSuspenseParams = {
147
};
148
149
type OverrideSuspenseMilestoneParams = {
134
- rendererID: number,
135
- rootID: number,
150
suspendedSet: Array<number>,
151
};
152
@@ -141,6 +155,111 @@ type PersistedSelection = {
155
path: Array<PathFrame>,
156
};
157
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
+
263
export default class Agent extends EventEmitter<{
264
hideNativeHighlight: [],
265
showNativeHighlight: [HostInstance],
@@ -201,6 +320,7 @@ export default class Agent extends EventEmitter<{
320
bridge.addListener('getProfilingStatus', this.getProfilingStatus);
321
bridge.addListener('getOwnersList', this.getOwnersList);
322
bridge.addListener('inspectElement', this.inspectElement);
323
+ bridge.addListener('inspectScreen', this.inspectScreen);
324
bridge.addListener('logElementToConsole', this.logElementToConsole);
325
bridge.addListener('overrideError', this.overrideError);
326
bridge.addListener('overrideSuspense', this.overrideSuspense);
@@ -531,6 +651,138 @@ export default class Agent extends EventEmitter<{
651
}
652
};
653
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
+
786
logElementToConsole: ElementAndRendererID => void = ({id, rendererID}) => {
787
const renderer = this._rendererInterfaces[rendererID];
788
if (renderer == null) {
@@ -567,17 +819,15 @@ export default class Agent extends EventEmitter<{
819
};
820
821
overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({
570
- rendererID,
571
- rootID,
822
suspendedSet,
823
}) => {
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
+ }
831
}
832
};
833
packages/react-devtools-shared/src/backend/fiber/renderer.js
+123
-16
@@ -2420,7 +2420,6 @@ export function attach(
2420
!isProductionBuildOfRenderer && StrictModeBits !== 0 ? 1 : 0,
2421
);
2422
pushOperation(hasOwnerMetadata ? 1 : 0);
2423
- pushOperation(supportsTogglingSuspense ? 1 : 0);
2423
2424
if (isProfiling) {
2425
if (displayNamesByRootID !== null) {
@@ -4902,7 +4901,11 @@ export function attach(
4901
fiberInstance.data = nextFiber;
4902
if (
4903
mostRecentlyInspectedElement !== null &&
4905
- mostRecentlyInspectedElement.id === fiberInstance.id &&
4904
+ (mostRecentlyInspectedElement.id === fiberInstance.id ||
4905
+ // If we're inspecting a Root, we inspect the Screen.
4906
+ // Invalidating any Root invalidates the Screen too.
4907
+ (mostRecentlyInspectedElement.type === ElementTypeRoot &&
4908
+ nextFiber.tag === HostRoot)) &&
4909
didFiberRender(prevFiber, nextFiber)
4910
) {
4911
// If this Fiber has updated, clear cached inspected data.
@@ -6422,7 +6425,10 @@ export function attach(
6425
return inspectVirtualInstanceRaw(devtoolsInstance);
6426
}
6427
if (devtoolsInstance.kind === FIBER_INSTANCE) {
6425
- return inspectFiberInstanceRaw(devtoolsInstance);
6428
+ const isRoot = devtoolsInstance.parent === null;
6429
+ return isRoot
6430
+ ? inspectRootsRaw(devtoolsInstance.id)
6431
+ : inspectFiberInstanceRaw(devtoolsInstance);
6432
}
6433
(devtoolsInstance: FilteredFiberInstance); // assert exhaustive
6434
throw new Error('Unsupported instance kind');
@@ -6875,10 +6881,24 @@ export function attach(
6881
let currentlyInspectedPaths: Object = {};
6882
6883
function isMostRecentlyInspectedElement(id: number): boolean {
6878
- return (
6879
- mostRecentlyInspectedElement !== null &&
6880
- mostRecentlyInspectedElement.id === id
6881
- );
6884
+ if (mostRecentlyInspectedElement === null) {
6885
+ return false;
6886
+ }
6887
+ if (mostRecentlyInspectedElement.id === id) {
6888
+ return true;
6889
+ }
6890
+
6891
+ if (mostRecentlyInspectedElement.type === ElementTypeRoot) {
6892
+ // we inspected the screen recently. If we're inspecting another root, we're
6893
+ // still inspecting the screen.
6894
+ const instance = idToDevToolsInstanceMap.get(id);
6895
+ return (
6896
+ instance !== undefined &&
6897
+ instance.kind === FIBER_INSTANCE &&
6898
+ instance.parent === null
6899
+ );
6900
+ }
6901
+ return false;
6902
}
6903
6904
function isMostRecentlyInspectedElementCurrent(id: number): boolean {
@@ -7060,8 +7080,8 @@ export function attach(
7080
if (!hasElementUpdatedSinceLastInspected) {
7081
if (path !== null) {
7082
let secondaryCategory: 'suspendedBy' | 'hooks' | null = null;
7063
- if (path[0] === 'hooks') {
7064
- secondaryCategory = 'hooks';
7083
+ if (path[0] === 'hooks' || path[0] === 'suspendedBy') {
7084
+ secondaryCategory = path[0];
7085
}
7086
7087
// If this element has not been updated since it was last inspected,
@@ -7209,6 +7229,99 @@ export function attach(
7229
};
7230
}
7231
7232
+ function inspectRootsRaw(arbitraryRootID: number): InspectedElement | null {
7233
+ const roots = hook.getFiberRoots(rendererID);
7234
+ if (roots.size === 0) {
7235
+ return null;
7236
+ }
7237
+
7238
+ const inspectedRoots: InspectedElement = {
7239
+ // invariants
7240
+ id: arbitraryRootID,
7241
+ type: ElementTypeRoot,
7242
+ // Properties we merge
7243
+ isErrored: false,
7244
+ errors: [],
7245
+ warnings: [],
7246
+ suspendedBy: [],
7247
+ suspendedByRange: null,
7248
+ // TODO: How to merge these?
7249
+ unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
7250
+ // Properties where merging doesn't make sense so we ignore them entirely in the UI
7251
+ rootType: null,
7252
+ plugins: {stylex: null},
7253
+ nativeTag: null,
7254
+ env: null,
7255
+ source: null,
7256
+ stack: null,
7257
+ rendererPackageName: null,
7258
+ rendererVersion: null,
7259
+ // These don't make sense for a Root. They're just bottom values.
7260
+ key: null,
7261
+ canEditFunctionProps: false,
7262
+ canEditHooks: false,
7263
+ canEditFunctionPropsDeletePaths: false,
7264
+ canEditFunctionPropsRenamePaths: false,
7265
+ canEditHooksAndDeletePaths: false,
7266
+ canEditHooksAndRenamePaths: false,
7267
+ canToggleError: false,
7268
+ canToggleSuspense: false,
7269
+ isSuspended: false,
7270
+ hasLegacyContext: false,
7271
+ context: null,
7272
+ hooks: null,
7273
+ props: null,
7274
+ state: null,
7275
+ owners: null,
7276
+ };
7277
+
7278
+ let minSuspendedByRange = Infinity;
7279
+ let maxSuspendedByRange = -Infinity;
7280
+ roots.forEach(root => {
7281
+ const rootInstance = rootToFiberInstanceMap.get(root);
7282
+ if (rootInstance === undefined) {
7283
+ throw new Error(
7284
+ 'Expected a root instance to exist for this Fiber root',
7285
+ );
7286
+ }
7287
+ const inspectedRoot = inspectFiberInstanceRaw(rootInstance);
7288
+ if (inspectedRoot === null) {
7289
+ return;
7290
+ }
7291
+
7292
+ if (inspectedRoot.isErrored) {
7293
+ inspectedRoots.isErrored = true;
7294
+ }
7295
+ for (let i = 0; i < inspectedRoot.errors.length; i++) {
7296
+ inspectedRoots.errors.push(inspectedRoot.errors[i]);
7297
+ }
7298
+ for (let i = 0; i < inspectedRoot.warnings.length; i++) {
7299
+ inspectedRoots.warnings.push(inspectedRoot.warnings[i]);
7300
+ }
7301
+ for (let i = 0; i < inspectedRoot.suspendedBy.length; i++) {
7302
+ inspectedRoots.suspendedBy.push(inspectedRoot.suspendedBy[i]);
7303
+ }
7304
+ const suspendedByRange = inspectedRoot.suspendedByRange;
7305
+ if (suspendedByRange !== null) {
7306
+ if (suspendedByRange[0] < minSuspendedByRange) {
7307
+ minSuspendedByRange = suspendedByRange[0];
7308
+ }
7309
+ if (suspendedByRange[1] > maxSuspendedByRange) {
7310
+ maxSuspendedByRange = suspendedByRange[1];
7311
+ }
7312
+ }
7313
+ });
7314
+
7315
+ if (minSuspendedByRange !== Infinity || maxSuspendedByRange !== -Infinity) {
7316
+ inspectedRoots.suspendedByRange = [
7317
+ minSuspendedByRange,
7318
+ maxSuspendedByRange,
7319
+ ];
7320
+ }
7321
+
7322
+ return inspectedRoots;
7323
+ }
7324
+
7325
function logElementToConsole(id: number) {
7326
const result = isMostRecentlyInspectedElementCurrent(id)
7327
? mostRecentlyInspectedElement
@@ -7867,13 +7980,9 @@ export function attach(
7980
7981
/**
7982
* Resets the all other roots of this renderer.
7870
- * @param rootID The root that contains this milestone
7983
* @param suspendedSet List of IDs of SuspenseComponent Fibers
7984
*/
7873
- function overrideSuspenseMilestone(
7874
- rootID: FiberInstance['id'],
7875
- suspendedSet: Array<FiberInstance['id']>,
7876
- ) {
7985
+ function overrideSuspenseMilestone(suspendedSet: Array<FiberInstance['id']>) {
7986
if (
7987
typeof setSuspenseHandler !== 'function' ||
7988
typeof scheduleUpdate !== 'function'
@@ -7883,8 +7992,6 @@ export function attach(
7992
);
7993
}
7994
7886
- // TODO: Allow overriding the timeline for the specified root.
7887
-
7995
const unsuspendedSet: Set<Fiber> = new Set(forceFallbackForFibers);
7996
7997
let resuspended = false;
packages/react-devtools-shared/src/backend/legacy/renderer.js
+106
-1
@@ -412,7 +412,6 @@ export function attach(
412
pushOperation(0); // Profiling flag
413
pushOperation(0); // StrictMode supported?
414
pushOperation(hasOwnerMetadata ? 1 : 0);
415
- pushOperation(supportsTogglingSuspense ? 1 : 0);
415
416
pushOperation(SUSPENSE_TREE_OPERATION_ADD);
417
pushOperation(id);
@@ -800,6 +799,20 @@ export function attach(
799
return null;
800
}
801
802
+ const rootID = internalInstanceToRootIDMap.get(internalInstance);
803
+ if (rootID === undefined) {
804
+ throw new Error('Expected to find root ID.');
805
+ }
806
+ const isRoot = rootID === id;
807
+ return isRoot
808
+ ? inspectRootsRaw(rootID)
809
+ : inspectInternalInstanceRaw(id, internalInstance);
810
+ }
811
+
812
+ function inspectInternalInstanceRaw(
813
+ id: number,
814
+ internalInstance: InternalInstance,
815
+ ): InspectedElement | null {
816
const {key} = getData(internalInstance);
817
const type = getElementType(internalInstance);
818
@@ -903,6 +916,98 @@ export function attach(
916
};
917
}
918
919
+ function inspectRootsRaw(arbitraryRootID: number): InspectedElement | null {
920
+ const roots =
921
+ renderer.Mount._instancesByReactRootID ||
922
+ renderer.Mount._instancesByContainerID;
923
+
924
+ const inspectedRoots: InspectedElement = {
925
+ // invariants
926
+ id: arbitraryRootID,
927
+ type: ElementTypeRoot,
928
+ // Properties we merge
929
+ isErrored: false,
930
+ errors: [],
931
+ warnings: [],
932
+ suspendedBy: [],
933
+ suspendedByRange: null,
934
+ // TODO: How to merge these?
935
+ unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
936
+ // Properties where merging doesn't make sense so we ignore them entirely in the UI
937
+ rootType: null,
938
+ plugins: {stylex: null},
939
+ nativeTag: null,
940
+ env: null,
941
+ source: null,
942
+ stack: null,
943
+ // TODO: We could make the Frontend accept an array to display
944
+ // a list of unique renderers contributing to this Screen.
945
+ rendererPackageName: null,
946
+ rendererVersion: null,
947
+ // These don't make sense for a Root. They're just bottom values.
948
+ key: null,
949
+ canEditFunctionProps: false,
950
+ canEditHooks: false,
951
+ canEditFunctionPropsDeletePaths: false,
952
+ canEditFunctionPropsRenamePaths: false,
953
+ canEditHooksAndDeletePaths: false,
954
+ canEditHooksAndRenamePaths: false,
955
+ canToggleError: false,
956
+ canToggleSuspense: false,
957
+ isSuspended: false,
958
+ hasLegacyContext: false,
959
+ context: null,
960
+ hooks: null,
961
+ props: null,
962
+ state: null,
963
+ owners: null,
964
+ };
965
+
966
+ let minSuspendedByRange = Infinity;
967
+ let maxSuspendedByRange = -Infinity;
968
+
969
+ for (const rootKey in roots) {
970
+ const internalInstance = roots[rootKey];
971
+ const id = getID(internalInstance);
972
+ const inspectedRoot = inspectInternalInstanceRaw(id, internalInstance);
973
+
974
+ if (inspectedRoot === null) {
975
+ return null;
976
+ }
977
+
978
+ if (inspectedRoot.isErrored) {
979
+ inspectedRoots.isErrored = true;
980
+ }
981
+ for (let i = 0; i < inspectedRoot.errors.length; i++) {
982
+ inspectedRoots.errors.push(inspectedRoot.errors[i]);
983
+ }
984
+ for (let i = 0; i < inspectedRoot.warnings.length; i++) {
985
+ inspectedRoots.warnings.push(inspectedRoot.warnings[i]);
986
+ }
987
+ for (let i = 0; i < inspectedRoot.suspendedBy.length; i++) {
988
+ inspectedRoots.suspendedBy.push(inspectedRoot.suspendedBy[i]);
989
+ }
990
+ const suspendedByRange = inspectedRoot.suspendedByRange;
991
+ if (suspendedByRange !== null) {
992
+ if (suspendedByRange[0] < minSuspendedByRange) {
993
+ minSuspendedByRange = suspendedByRange[0];
994
+ }
995
+ if (suspendedByRange[1] > maxSuspendedByRange) {
996
+ maxSuspendedByRange = suspendedByRange[1];
997
+ }
998
+ }
999
+ }
1000
+
1001
+ if (minSuspendedByRange !== Infinity || maxSuspendedByRange !== -Infinity) {
1002
+ inspectedRoots.suspendedByRange = [
1003
+ minSuspendedByRange,
1004
+ maxSuspendedByRange,
1005
+ ];
1006
+ }
1007
+
1008
+ return inspectedRoots;
1009
+ }
1010
+
1011
function logElementToConsole(id: number): void {
1012
const result = inspectElementRaw(id);
1013
if (result === null) {
packages/react-devtools-shared/src/backend/types.js
+1
-4
@@ -450,10 +450,7 @@ export type RendererInterface = {
450
onErrorOrWarning?: OnErrorOrWarning,
451
overrideError: (id: number, forceError: boolean) => void,
452
overrideSuspense: (id: number, forceFallback: boolean) => void,
453
- overrideSuspenseMilestone: (
454
- rootID: number,
455
- suspendedSet: Array<number>,
456
- ) => void,
453
+ overrideSuspenseMilestone: (suspendedSet: Array<number>) => void,
454
overrideValueAtPath: (
455
type: Type,
456
id: number,
packages/react-devtools-shared/src/backend/views/Highlighter/index.js
+48
@@ -10,6 +10,7 @@
10
import Agent from 'react-devtools-shared/src/backend/agent';
11
import {hideOverlay, showOverlay} from './Highlighter';
12
13
+import type {HostInstance} from 'react-devtools-shared/src/backend/types';
14
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
15
import type {RendererInterface} from '../../types';
16
@@ -26,6 +27,7 @@ export default function setupHighlighter(
27
): void {
28
bridge.addListener('clearHostInstanceHighlight', clearHostInstanceHighlight);
29
bridge.addListener('highlightHostInstance', highlightHostInstance);
30
+ bridge.addListener('highlightHostInstances', highlightHostInstances);
31
bridge.addListener('scrollToHostInstance', scrollToHostInstance);
32
bridge.addListener('shutdown', stopInspectingHost);
33
bridge.addListener('startInspectingHost', startInspectingHost);
@@ -157,6 +159,52 @@ export default function setupHighlighter(
159
hideOverlay(agent);
160
}
161
162
+ function highlightHostInstances({
163
+ displayName,
164
+ hideAfterTimeout,
165
+ elements,
166
+ scrollIntoView,
167
+ }: {
168
+ displayName: string | null,
169
+ hideAfterTimeout: boolean,
170
+ elements: Array<{rendererID: number, id: number}>,
171
+ scrollIntoView: boolean,
172
+ }) {
173
+ const nodes: Array<HostInstance> = [];
174
+ for (let i = 0; i < elements.length; i++) {
175
+ const {id, rendererID} = elements[i];
176
+ const renderer = agent.rendererInterfaces[rendererID];
177
+ if (renderer == null) {
178
+ console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
179
+ continue;
180
+ }
181
+
182
+ // In some cases fiber may already be unmounted
183
+ if (!renderer.hasElementWithId(id)) {
184
+ continue;
185
+ }
186
+
187
+ const hostInstances = renderer.findHostInstancesForElementID(id);
188
+ if (hostInstances !== null) {
189
+ for (let j = 0; j < hostInstances.length; j++) {
190
+ nodes.push(hostInstances[j]);
191
+ }
192
+ }
193
+ }
194
+
195
+ if (nodes.length > 0) {
196
+ const node = nodes[0];
197
+ // $FlowFixMe[method-unbinding]
198
+ if (scrollIntoView && typeof node.scrollIntoView === 'function') {
199
+ // If the node isn't visible show it before highlighting it.
200
+ // We may want to reconsider this; it might be a little disruptive.
201
+ node.scrollIntoView({block: 'nearest', inline: 'nearest'});
202
+ }
203
+ }
204
+
205
+ showOverlay(nodes, displayName, agent, hideAfterTimeout);
206
+ }
207
+
208
function attemptScrollToHostInstance(
209
renderer: RendererInterface,
210
id: number,
packages/react-devtools-shared/src/backendAPI.js
+27
-1
@@ -95,7 +95,7 @@ export function inspectElement(
95
id: number,
96
path: InspectedElementPath | null,
97
rendererID: number,
98
- shouldListenToPauseEvents: boolean = false,
98
+ shouldListenToPauseEvents: boolean,
99
): Promise<InspectedElementPayload> {
100
const requestID = requestCounter++;
101
const promise = getPromiseForRequestID<InspectedElementPayload>(
@@ -117,6 +117,32 @@ export function inspectElement(
117
return promise;
118
}
119
120
+export function inspectScreen(
121
+ bridge: FrontendBridge,
122
+ forceFullData: boolean,
123
+ arbitraryRootID: number,
124
+ path: InspectedElementPath | null,
125
+ shouldListenToPauseEvents: boolean,
126
+): Promise<InspectedElementPayload> {
127
+ const requestID = requestCounter++;
128
+ const promise = getPromiseForRequestID<InspectedElementPayload>(
129
+ requestID,
130
+ 'inspectedScreen',
131
+ bridge,
132
+ `Timed out while inspecting screen.`,
133
+ shouldListenToPauseEvents,
134
+ );
135
+
136
+ bridge.send('inspectScreen', {
137
+ requestID,
138
+ id: arbitraryRootID,
139
+ path,
140
+ forceFullData,
141
+ });
142
+
143
+ return promise;
144
+}
145
+
146
let storeAsGlobalCount = 0;
147
148
export function storeAsGlobal({
packages/react-devtools-shared/src/bridge.js
+16
-8
@@ -65,12 +65,6 @@ export const BRIDGE_PROTOCOL: Array<BridgeProtocol> = [
65
{
66
version: 2,
67
minNpmVersion: '4.22.0',
68
- maxNpmVersion: '6.2.0',
69
- },
70
- // Version 3 adds supports-toggling-suspense bit to add-root
71
- {
72
- version: 3,
73
- minNpmVersion: '6.2.0',
68
maxNpmVersion: null,
69
},
70
];
@@ -92,6 +86,12 @@ type HighlightHostInstance = {
86
openBuiltinElementsPanel: boolean,
87
scrollIntoView: boolean,
88
};
89
+type HighlightHostInstances = {
90
+ elements: Array<ElementAndRendererID>,
91
+ displayName: string | null,
92
+ hideAfterTimeout: boolean,
93
+ scrollIntoView: boolean,
94
+};
95
96
type ScrollToHostInstance = {
97
...ElementAndRendererID,
@@ -145,8 +145,6 @@ type OverrideSuspense = {
145
};
146
147
type OverrideSuspenseMilestone = {
148
- rendererID: number,
149
- rootID: number,
148
suspendedSet: Array<number>,
149
};
150
@@ -167,6 +165,13 @@ type InspectElementParams = {
165
requestID: number,
166
};
167
168
+type InspectScreenParams = {
169
+ requestID: number,
170
+ id: number,
171
+ forceFullData: boolean,
172
+ path: Array<number | string> | null,
173
+};
174
+
175
type StoreAsGlobalParams = {
176
...ElementAndRendererID,
177
count: number,
@@ -199,6 +204,7 @@ export type BackendEvents = {
204
fastRefreshScheduled: [],
205
getSavedPreferences: [],
206
inspectedElement: [InspectedElementPayload],
207
+ inspectedScreen: [InspectedElementPayload],
208
isReloadAndProfileSupportedByBackend: [boolean],
209
operations: [Array<number>],
210
ownersList: [OwnersList],
@@ -243,7 +249,9 @@ type FrontendEvents = {
249
getProfilingData: [{rendererID: RendererID}],
250
getProfilingStatus: [],
251
highlightHostInstance: [HighlightHostInstance],
252
+ highlightHostInstances: [HighlightHostInstances],
253
inspectElement: [InspectElementParams],
254
+ inspectScreen: [InspectScreenParams],
255
logElementToConsole: [ElementAndRendererID],
256
overrideError: [OverrideError],
257
overrideSuspense: [OverrideSuspense],
packages/react-devtools-shared/src/devtools/store.js
+42
-41
@@ -96,7 +96,6 @@ export type Capabilities = {
96
supportsBasicProfiling: boolean,
97
hasOwnerMetadata: boolean,
98
supportsStrictMode: boolean,
99
- supportsTogglingSuspense: boolean,
99
supportsAdvancedProfiling: AdvancedProfiling,
100
};
101
@@ -506,14 +505,6 @@ export default class Store extends EventEmitter<{
505
);
506
}
507
509
- supportsTogglingSuspense(rootID: Element['id']): boolean {
510
- const capabilities = this._rootIDToCapabilities.get(rootID);
511
- if (capabilities === undefined) {
512
- throw new Error(`No capabilities registered for root ${rootID}`);
513
- }
514
- return capabilities.supportsTogglingSuspense;
515
- }
516
-
508
// This build of DevTools supports the Timeline profiler.
509
// This is a static flag, controlled by the Store config.
510
get supportsTimeline(): boolean {
@@ -898,38 +889,48 @@ export default class Store extends EventEmitter<{
889
* @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
890
*/
891
getSuspendableDocumentOrderSuspense(
901
- rootID: Element['id'] | void,
892
uniqueSuspendersOnly: boolean,
893
): $ReadOnlyArray<SuspenseNode['id']> {
904
- if (rootID === undefined) {
905
- return [];
906
- }
907
- const root = this.getElementByID(rootID);
908
- if (root === null) {
909
- return [];
910
- }
911
- if (!this.supportsTogglingSuspense(rootID)) {
894
+ const roots = this.roots;
895
+ if (roots.length === 0) {
896
return [];
897
}
898
+
899
const list: SuspenseNode['id'][] = [];
915
- const suspense = this.getSuspenseByID(rootID);
916
- if (suspense !== null) {
917
- const stack = [suspense];
918
- while (stack.length > 0) {
919
- const current = stack.pop();
920
- if (current === undefined) {
921
- continue;
922
- }
923
- // Include the root even if we won't show it suspended (because that's just blank).
924
- // You should be able to see what suspended the shell.
925
- if (!uniqueSuspendersOnly || current.hasUniqueSuspenders) {
926
- list.push(current.id);
900
+ for (let i = 0; i < roots.length; i++) {
901
+ const rootID = roots[i];
902
+ const root = this.getElementByID(rootID);
903
+ if (root === null) {
904
+ continue;
905
+ }
906
+ // TODO: This includes boundaries that can't be suspended due to no support from the renderer.
907
+
908
+ const suspense = this.getSuspenseByID(rootID);
909
+ if (suspense !== null) {
910
+ if (list.length === 0) {
911
+ // start with an arbitrary root that will allow inspection of the Screen
912
+ list.push(suspense.id);
913
}
928
- // Add children in reverse order to maintain document order
929
- for (let j = current.children.length - 1; j >= 0; j--) {
930
- const childSuspense = this.getSuspenseByID(current.children[j]);
931
- if (childSuspense !== null) {
932
- stack.push(childSuspense);
914
+
915
+ const stack = [suspense];
916
+ while (stack.length > 0) {
917
+ const current = stack.pop();
918
+ if (current === undefined) {
919
+ continue;
920
+ }
921
+ if (
922
+ (!uniqueSuspendersOnly || current.hasUniqueSuspenders) &&
923
+ // Roots are already included as part of the Screen
924
+ current.id !== rootID
925
+ ) {
926
+ list.push(current.id);
927
+ }
928
+ // Add children in reverse order to maintain document order
929
+ for (let j = current.children.length - 1; j >= 0; j--) {
930
+ const childSuspense = this.getSuspenseByID(current.children[j]);
931
+ if (childSuspense !== null) {
932
+ stack.push(childSuspense);
933
+ }
934
}
935
}
936
}
@@ -1191,7 +1192,6 @@ export default class Store extends EventEmitter<{
1192
1193
let supportsStrictMode = false;
1194
let hasOwnerMetadata = false;
1194
- let supportsTogglingSuspense = false;
1195
1196
// If we don't know the bridge protocol, guess that we're dealing with the latest.
1197
// If we do know it, we can take it into consideration when parsing operations.
@@ -1204,9 +1204,6 @@ export default class Store extends EventEmitter<{
1204
1205
hasOwnerMetadata = operations[i] > 0;
1206
i++;
1207
-
1208
- supportsTogglingSuspense = operations[i] > 0;
1209
- i++;
1207
}
1208
1209
this._roots = this._roots.concat(id);
@@ -1215,7 +1212,6 @@ export default class Store extends EventEmitter<{
1212
supportsBasicProfiling,
1213
hasOwnerMetadata,
1214
supportsStrictMode,
1218
- supportsTogglingSuspense,
1215
supportsAdvancedProfiling,
1216
});
1217
@@ -1561,7 +1557,12 @@ export default class Store extends EventEmitter<{
1557
if (name === null) {
1558
// The boundary isn't explicitly named.
1559
// Pick a sensible default.
1564
- name = this._guessSuspenseName(element);
1560
+ if (parentID === 0) {
1561
+ // For Roots we use their display name.
1562
+ name = element.displayName;
1563
+ } else {
1564
+ name = this._guessSuspenseName(element);
1565
+ }
1566
}
1567
}
1568
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
-1
@@ -209,7 +209,6 @@ function updateTree(
209
i++; // Profiling flag
210
i++; // supportsStrictMode flag
211
i++; // hasOwnerMetadata flag
212
- i++; // supportsTogglingSuspense flag
212
213
if (__DEBUG__) {
214
debug('Add', `new root fiber ${id}`);
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
+4
-4
@@ -25,7 +25,7 @@ export default function SuspenseBreadcrumbs(): React$Node {
25
const store = useContext(StoreContext);
26
const treeDispatch = useContext(TreeDispatcherContext);
27
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
28
- const {selectedSuspenseID, selectedRootID, lineage} = useContext(
28
+ const {selectedSuspenseID, lineage, roots} = useContext(
29
SuspenseTreeStateContext,
30
);
31
@@ -45,13 +45,13 @@ export default function SuspenseBreadcrumbs(): React$Node {
45
// that rendered the whole screen. In laymans terms this is really "Initial Paint".
46
// TODO: Once we add subtree selection, then the equivalent should be called
47
// "Transition" since in that case it's really about a Transition within the page.
48
- selectedRootID !== null ? (
48
+ roots.length > 0 ? (
49
<li
50
className={styles.SuspenseBreadcrumbsListItem}
51
- aria-current={selectedSuspenseID === selectedRootID}>
51
+ aria-current="true">
52
<button
53
className={styles.SuspenseBreadcrumbsButton}
54
- onClick={handleClick.bind(null, selectedRootID)}
54
+ onClick={handleClick.bind(null, roots[0])}
55
type="button">
56
Initial Paint
57
</button>
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+24
-7
@@ -278,11 +278,7 @@ function getDocumentBoundingRect(
278
};
279
}
280
281
-function SuspenseRectsShell({
282
- rootID,
283
-}: {
284
- rootID: SuspenseNode['id'],
285
-}): React$Node {
281
+function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
282
const store = useContext(StoreContext);
283
const root = store.getSuspenseByID(rootID);
284
if (root === null) {
@@ -299,6 +295,8 @@ const ViewBox = createContext<Rect>((null: any));
295
296
function SuspenseRectsContainer(): React$Node {
297
const store = useContext(StoreContext);
298
+ const treeDispatch = useContext(TreeDispatcherContext);
299
+ const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
300
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
301
const {roots} = useContext(SuspenseTreeStateContext);
302
@@ -312,14 +310,33 @@ function SuspenseRectsContainer(): React$Node {
310
const width = '100%';
311
const aspectRatio = `1 / ${heightScale}`;
312
313
+ function handleClick(event: SyntheticMouseEvent) {
314
+ if (event.defaultPrevented) {
315
+ // Already clicked on an inner rect
316
+ return;
317
+ }
318
+ if (roots.length === 0) {
319
+ // Nothing to select
320
+ return;
321
+ }
322
+ const arbitraryRootID = roots[0];
323
+
324
+ event.preventDefault();
325
+ treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: arbitraryRootID});
326
+ suspenseTreeDispatch({
327
+ type: 'SET_SUSPENSE_LINEAGE',
328
+ payload: arbitraryRootID,
329
+ });
330
+ }
331
+
332
return (
316
- <div className={styles.SuspenseRectsContainer}>
333
+ <div className={styles.SuspenseRectsContainer} onClick={handleClick}>
334
<ViewBox.Provider value={boundingBox}>
335
<div
336
className={styles.SuspenseRectsViewBox}
337
style={{aspectRatio, width}}>
338
{roots.map(rootID => {
322
- return <SuspenseRectsShell key={rootID} rootID={rootID} />;
339
+ return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
340
})}
341
</div>
342
</ViewBox.Provider>
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+6
-66
@@ -34,13 +34,9 @@ import {
34
SuspenseTreeStateContext,
35
} from './SuspenseTreeContext';
36
import {StoreContext, OptionsContext} from '../context';
37
-import {TreeDispatcherContext} from '../Components/TreeContext';
37
import Button from '../Button';
38
import Toggle from '../Toggle';
40
-import typeof {
41
- SyntheticEvent,
42
- SyntheticPointerEvent,
43
-} from 'react-dom-bindings/src/events/SyntheticEvent';
39
+import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
40
import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal';
41
import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
42
import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
@@ -71,20 +67,14 @@ function ToggleUniqueSuspenders() {
67
const store = useContext(StoreContext);
68
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
69
74
- const {selectedRootID: rootID, uniqueSuspendersOnly} = useContext(
75
- SuspenseTreeStateContext,
76
- );
70
+ const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext);
71
72
function handleToggleUniqueSuspenders() {
73
const nextUniqueSuspendersOnly = !uniqueSuspendersOnly;
80
- const nextTimeline =
81
- rootID === null
82
- ? []
83
- : // TODO: Handle different timeline modes (e.g. random order)
84
- store.getSuspendableDocumentOrderSuspense(
85
- rootID,
86
- nextUniqueSuspendersOnly,
87
- );
74
+ // TODO: Handle different timeline modes (e.g. random order)
75
+ const nextTimeline = store.getSuspendableDocumentOrderSuspense(
76
+ nextUniqueSuspendersOnly,
77
+ );
78
suspenseTreeDispatch({
79
type: 'SET_SUSPENSE_TIMELINE',
80
payload: [nextTimeline, null, nextUniqueSuspendersOnly],
@@ -101,55 +91,6 @@ function ToggleUniqueSuspenders() {
91
);
92
}
93
104
-function SelectRoot() {
105
- const store = useContext(StoreContext);
106
- const {roots, selectedRootID, uniqueSuspendersOnly} = useContext(
107
- SuspenseTreeStateContext,
108
- );
109
- const treeDispatch = useContext(TreeDispatcherContext);
110
- const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
111
-
112
- function handleChange(event: SyntheticEvent) {
113
- const newRootID = +event.currentTarget.value;
114
- // TODO: scrollIntoView both suspense rects and host instance.
115
- const nextTimeline = store.getSuspendableDocumentOrderSuspense(
116
- newRootID,
117
- uniqueSuspendersOnly,
118
- );
119
- suspenseTreeDispatch({
120
- type: 'SET_SUSPENSE_TIMELINE',
121
- payload: [nextTimeline, newRootID, uniqueSuspendersOnly],
122
- });
123
- if (nextTimeline.length > 0) {
124
- const milestone = nextTimeline[nextTimeline.length - 1];
125
- treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone});
126
- }
127
- }
128
- return (
129
- roots.length > 0 && (
130
- <select
131
- aria-label="Select Suspense Root"
132
- className={styles.SuspenseTimelineRootSwitcher}
133
- onChange={handleChange}
134
- value={selectedRootID === null ? -1 : selectedRootID}>
135
- <option disabled={true} value={-1}>
136
- ----
137
- </option>
138
- {roots.map(rootID => {
139
- // TODO: Use name
140
- const name = '#' + rootID;
141
- // TODO: Highlight host on hover
142
- return (
143
- <option key={rootID} value={rootID}>
144
- {name}
145
- </option>
146
- );
147
- })}
148
- </select>
149
- )
150
- );
151
-}
152
-
94
function ToggleTreeList({
95
dispatch,
96
state,
@@ -427,7 +368,6 @@ function SuspenseTab(_: {}) {
368
<div className={styles.SuspenseBreadcrumbs}>
369
<SuspenseBreadcrumbs />
370
</div>
430
- <SelectRoot />
371
<div className={styles.VRule} />
372
<ToggleUniqueSuspenders />
373
{!hideSettings && <SettingsModalContextToggle />}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+5
-37
@@ -9,7 +9,7 @@
9
10
import * as React from 'react';
11
import {useContext, useEffect, useRef} from 'react';
12
-import {BridgeContext, StoreContext} from '../context';
12
+import {BridgeContext} from '../context';
13
import {TreeDispatcherContext} from '../Components/TreeContext';
14
import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks';
15
import {
@@ -23,20 +23,15 @@ import ButtonIcon from '../ButtonIcon';
23
24
function SuspenseTimelineInput() {
25
const bridge = useContext(BridgeContext);
26
- const store = useContext(StoreContext);
26
const treeDispatch = useContext(TreeDispatcherContext);
27
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
28
const {highlightHostInstance, clearHighlightHostInstance} =
29
useHighlightHostInstance();
30
const scrollToHostInstance = useScrollToHostInstance();
31
33
- const {
34
- selectedRootID: rootID,
35
- timeline,
36
- timelineIndex,
37
- hoveredTimelineIndex,
38
- playing,
39
- } = useContext(SuspenseTreeStateContext);
32
+ const {timeline, timelineIndex, hoveredTimelineIndex, playing} = useContext(
33
+ SuspenseTreeStateContext,
34
+ );
35
36
const min = 0;
37
const max = timeline.length > 0 ? timeline.length - 1 : 0;
@@ -112,24 +107,12 @@ function SuspenseTimelineInput() {
107
// For now we just exclude it from deps since we don't lint those anyway.
108
function changeTimelineIndex(newIndex: number) {
109
// Synchronize timeline index with what is resuspended.
115
- if (rootID === null) {
116
- return;
117
- }
118
- const rendererID = store.getRendererIDForElement(rootID);
119
- if (rendererID === null) {
120
- console.error(
121
- `No renderer ID found for root element ${rootID} in suspense timeline.`,
122
- );
123
- return;
124
- }
110
// We suspend everything after the current selection. The root isn't showing
111
// anything suspended in the root. The step after that should have one less
112
// thing suspended. I.e. the first suspense boundary should be unsuspended
113
// when it's selected. This also lets you show everything in the last step.
114
const suspendedSet = timeline.slice(timelineIndex + 1);
115
bridge.send('overrideSuspenseMilestone', {
131
- rendererID,
132
- rootID,
116
suspendedSet,
117
});
118
if (isInitialMount.current) {
@@ -164,20 +147,6 @@ function SuspenseTimelineInput() {
147
};
148
}, [playing]);
149
167
- if (rootID === null) {
168
- return (
169
- <div className={styles.SuspenseTimelineInput}>No root selected.</div>
170
- );
171
- }
172
-
173
- if (!store.supportsTogglingSuspense(rootID)) {
174
- return (
175
- <div className={styles.SuspenseTimelineInput}>
176
- Can't step through Suspense in production apps.
177
- </div>
178
- );
179
- }
180
-
150
if (timeline.length === 0) {
151
return (
152
<div className={styles.SuspenseTimelineInput}>
@@ -226,10 +195,9 @@ function SuspenseTimelineInput() {
195
}
196
197
export default function SuspenseTimeline(): React$Node {
229
- const {selectedRootID} = useContext(SuspenseTreeStateContext);
198
return (
199
<div className={styles.SuspenseTimelineContainer}>
232
- <SuspenseTimelineInput key={selectedRootID} />
200
+ <SuspenseTimelineInput />
201
</div>
202
);
203
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+24
-83
@@ -7,10 +7,7 @@
7
* @flow
8
*/
9
import type {ReactContext} from 'shared/ReactTypes';
10
-import type {
11
- Element,
12
- SuspenseNode,
13
-} from 'react-devtools-shared/src/frontend/types';
10
+import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
11
import type Store from '../../store';
12
13
import * as React from 'react';
@@ -27,7 +24,6 @@ import {StoreContext} from '../context';
24
export type SuspenseTreeState = {
25
lineage: $ReadOnlyArray<SuspenseNode['id']> | null,
26
roots: $ReadOnlyArray<SuspenseNode['id']>,
30
- selectedRootID: SuspenseNode['id'] | null,
27
selectedSuspenseID: SuspenseNode['id'] | null,
28
timeline: $ReadOnlyArray<SuspenseNode['id']>,
29
timelineIndex: number | -1,
@@ -107,60 +103,27 @@ type Props = {
103
children: React$Node,
104
};
105
110
-function getDefaultRootID(store: Store): Element['id'] | null {
111
- const designatedRootID = store.roots.find(rootID => {
112
- const suspense = store.getSuspenseByID(rootID);
113
- return (
114
- store.supportsTogglingSuspense(rootID) &&
115
- suspense !== null &&
116
- suspense.children.length > 1
117
- );
118
- });
119
-
120
- return designatedRootID === undefined ? null : designatedRootID;
121
-}
122
-
106
function getInitialState(store: Store): SuspenseTreeState {
124
- let initialState: SuspenseTreeState;
107
const uniqueSuspendersOnly = true;
126
- const selectedRootID = getDefaultRootID(store);
127
- // TODO: Default to nearest from inspected
128
- if (selectedRootID === null) {
129
- initialState = {
130
- selectedSuspenseID: null,
131
- lineage: null,
132
- roots: store.roots,
133
- selectedRootID,
134
- timeline: [],
135
- timelineIndex: -1,
136
- hoveredTimelineIndex: -1,
137
- uniqueSuspendersOnly,
138
- playing: false,
139
- };
140
- } else {
141
- const timeline = store.getSuspendableDocumentOrderSuspense(
142
- selectedRootID,
143
- uniqueSuspendersOnly,
144
- );
145
- const timelineIndex = timeline.length - 1;
146
- const selectedSuspenseID =
147
- timelineIndex === -1 ? null : timeline[timelineIndex];
148
- const lineage =
149
- selectedSuspenseID !== null
150
- ? store.getSuspenseLineage(selectedSuspenseID)
151
- : [];
152
- initialState = {
153
- selectedSuspenseID,
154
- lineage,
155
- roots: store.roots,
156
- selectedRootID,
157
- timeline,
158
- timelineIndex,
159
- hoveredTimelineIndex: -1,
160
- uniqueSuspendersOnly,
161
- playing: false,
162
- };
163
- }
108
+ const timeline =
109
+ store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
110
+ const timelineIndex = timeline.length - 1;
111
+ const selectedSuspenseID =
112
+ timelineIndex === -1 ? null : timeline[timelineIndex];
113
+ const lineage =
114
+ selectedSuspenseID !== null
115
+ ? store.getSuspenseLineage(selectedSuspenseID)
116
+ : [];
117
+ const initialState: SuspenseTreeState = {
118
+ selectedSuspenseID,
119
+ lineage,
120
+ roots: store.roots,
121
+ timeline,
122
+ timelineIndex,
123
+ hoveredTimelineIndex: -1,
124
+ uniqueSuspendersOnly,
125
+ playing: false,
126
+ };
127
128
return initialState;
129
}
@@ -209,23 +172,10 @@ function SuspenseTreeContextController({children}: Props): React.Node {
172
selectedTimelineID = removedIDs.get(selectedTimelineID);
173
}
174
212
- let nextRootID = state.selectedRootID;
213
- if (selectedTimelineID !== null && selectedTimelineID !== 0) {
214
- nextRootID =
215
- store.getSuspenseRootIDForSuspense(selectedTimelineID);
216
- }
217
- if (nextRootID === null) {
218
- nextRootID = getDefaultRootID(store);
219
- }
220
-
221
- const nextTimeline =
222
- nextRootID === null
223
- ? []
224
- : // TODO: Handle different timeline modes (e.g. random order)
225
- store.getSuspendableDocumentOrderSuspense(
226
- nextRootID,
227
- state.uniqueSuspendersOnly,
228
- );
175
+ // TODO: Handle different timeline modes (e.g. random order)
176
+ const nextTimeline = store.getSuspendableDocumentOrderSuspense(
177
+ state.uniqueSuspendersOnly,
178
+ );
179
180
let nextTimelineIndex =
181
selectedTimelineID === null || nextTimeline.length === 0
@@ -250,7 +200,6 @@ function SuspenseTreeContextController({children}: Props): React.Node {
200
...state,
201
lineage: nextLineage,
202
roots: store.roots,
253
- selectedRootID: nextRootID,
203
selectedSuspenseID,
204
timeline: nextTimeline,
205
timelineIndex: nextTimelineIndex,
@@ -258,27 +207,21 @@ function SuspenseTreeContextController({children}: Props): React.Node {
207
}
208
case 'SELECT_SUSPENSE_BY_ID': {
209
const selectedSuspenseID = action.payload;
261
- const selectedRootID =
262
- store.getSuspenseRootIDForSuspense(selectedSuspenseID);
210
211
return {
212
...state,
213
selectedSuspenseID,
267
- selectedRootID,
214
playing: false, // pause
215
};
216
}
217
case 'SET_SUSPENSE_LINEAGE': {
218
const suspenseID = action.payload;
219
const lineage = store.getSuspenseLineage(suspenseID);
274
- const selectedRootID =
275
- store.getSuspenseRootIDForSuspense(suspenseID);
220
221
return {
222
...state,
223
lineage,
224
selectedSuspenseID: suspenseID,
281
- selectedRootID,
225
playing: false, // pause
226
};
227
}
@@ -316,8 +259,6 @@ function SuspenseTreeContextController({children}: Props): React.Node {
259
...state,
260
selectedSuspenseID: nextSelectedSuspenseID,
261
lineage: nextLineage,
319
- selectedRootID:
320
- nextRootID === null ? state.selectedRootID : nextRootID,
262
timeline: nextTimeline,
263
timelineIndex: nextMilestoneIndex,
264
uniqueSuspendersOnly: nextUniqueSuspendersOnly,
packages/react-devtools-shared/src/devtools/views/hooks.js
+34
-10
@@ -353,20 +353,44 @@ export function useHighlightHostInstance(): {
353
const highlightHostInstance = useCallback(
354
(id: number, scrollIntoView?: boolean = false) => {
355
const element = store.getElementByID(id);
356
- const rendererID = store.getRendererIDForElement(id);
357
- if (element !== null && rendererID !== null) {
356
+ if (element !== null) {
357
+ const isRoot = element.parentID === 0;
358
let displayName = element.displayName;
359
if (displayName !== null && element.nameProp !== null) {
360
displayName += ` name="${element.nameProp}"`;
361
}
362
- bridge.send('highlightHostInstance', {
363
- displayName,
364
- hideAfterTimeout: false,
365
- id,
366
- openBuiltinElementsPanel: false,
367
- rendererID,
368
- scrollIntoView: scrollIntoView,
369
- });
362
+ if (isRoot) {
363
+ // Inspect screen
364
+ const elements: Array<{rendererID: number, id: number}> = [];
365
+
366
+ for (let i = 0; i < store.roots.length; i++) {
367
+ const rootID = store.roots[i];
368
+ const rendererID = store.getRendererIDForElement(rootID);
369
+ if (rendererID === null) {
370
+ continue;
371
+ }
372
+ elements.push({rendererID, id: rootID});
373
+ }
374
+
375
+ bridge.send('highlightHostInstances', {
376
+ displayName,
377
+ hideAfterTimeout: false,
378
+ elements,
379
+ scrollIntoView: scrollIntoView,
380
+ });
381
+ } else {
382
+ const rendererID = store.getRendererIDForElement(id);
383
+ if (rendererID !== null) {
384
+ bridge.send('highlightHostInstance', {
385
+ displayName,
386
+ hideAfterTimeout: false,
387
+ id,
388
+ openBuiltinElementsPanel: false,
389
+ rendererID,
390
+ scrollIntoView: scrollIntoView,
391
+ });
392
+ }
393
+ }
394
}
395
},
396
[store, bridge],
packages/react-devtools-shared/src/inspectedElementMutableSource.js
+21
-10
@@ -12,6 +12,7 @@ import {
12
convertInspectedElementBackendToFrontend,
13
hydrateHelper,
14
inspectElement as inspectElementAPI,
15
+ inspectScreen as inspectScreenAPI,
16
} from 'react-devtools-shared/src/backendAPI';
17
import {fillInPath} from 'react-devtools-shared/src/hydration';
18
@@ -57,21 +58,31 @@ export function inspectElement(
58
rendererID: number,
59
shouldListenToPauseEvents: boolean = false,
60
): Promise<InspectElementReturnType> {
60
- const {id} = element;
61
+ const {id, parentID} = element;
62
63
// This could indicate that the DevTools UI has been closed and reopened.
64
// The in-memory cache will be clear but the backend still thinks we have cached data.
65
// In this case, we need to tell it to resend the full data.
66
const forceFullData = !inspectedElementCache.has(id);
66
-
67
- return inspectElementAPI(
68
- bridge,
69
- forceFullData,
70
- id,
71
- path,
72
- rendererID,
73
- shouldListenToPauseEvents,
74
- ).then((data: any) => {
67
+ const isRoot = parentID === 0;
68
+ const promisedElement = isRoot
69
+ ? inspectScreenAPI(
70
+ bridge,
71
+ forceFullData,
72
+ id,
73
+ path,
74
+ shouldListenToPauseEvents,
75
+ )
76
+ : inspectElementAPI(
77
+ bridge,
78
+ forceFullData,
79
+ id,
80
+ path,
81
+ rendererID,
82
+ shouldListenToPauseEvents,
83
+ );
84
+
85
+ return promisedElement.then((data: any) => {
86
const {type} = data;
87
88
let inspectedElement;
packages/react-devtools-shared/src/utils.js
-1
@@ -262,7 +262,6 @@ export function printOperationsArray(operations: Array<number>) {
262
i++; // supportsProfiling
263
i++; // supportsStrictMode
264
i++; // hasOwnerMetadata
265
- i++; // supportsTogglingSuspense
265
} else {
266
const parentID = ((operations[i]: any): number);
267
i++;