@samitouri / QOS-React / commits / 4a28227960

[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++;