Skip to content

Commit e078537

Browse files
committed
Merge Roots from different renderers in the agent
1 parent 09bf018 commit e078537

File tree

3 files changed

+320
-26
lines changed

3 files changed

+320
-26
lines changed

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

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

1010
import EventEmitter from '../events';
11-
import {SESSION_STORAGE_LAST_SELECTION_KEY, __DEBUG__} from '../constants';
11+
import {
12+
SESSION_STORAGE_LAST_SELECTION_KEY,
13+
UNKNOWN_SUSPENDERS_NONE,
14+
__DEBUG__,
15+
} from '../constants';
1216
import setupHighlighter from './views/Highlighter';
1317
import {
1418
initialize as setupTraceUpdates,
@@ -26,9 +30,13 @@ import type {
2630
RendererID,
2731
RendererInterface,
2832
DevToolsHookSettings,
29-
InspectedElementPayload,
33+
InspectedElement,
3034
} from './types';
31-
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';
3240
import type {GroupItem} from './views/TraceUpdates/canvas';
3341
import {gte, isReactNativeEnvironment} from './utils';
3442
import {
@@ -147,6 +155,111 @@ type PersistedSelection = {
147155
path: Array<PathFrame>,
148156
};
149157

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+
150263
export default class Agent extends EventEmitter<{
151264
hideNativeHighlight: [],
152265
showNativeHighlight: [HostInstance],
@@ -542,43 +655,132 @@ export default class Agent extends EventEmitter<{
542655
requestID,
543656
id,
544657
forceFullData,
545-
path,
658+
path: screenPath,
546659
}) => {
547-
const payload: InspectedElementPayload = {
548-
type: 'no-change',
549-
id,
550-
responseID: requestID,
551-
};
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+
552685
for (const rendererID in this._rendererInterfaces) {
553686
const renderer = ((this._rendererInterfaces[
554687
(rendererID: any)
555688
]: any): RendererInterface);
556-
const inspectedRoots = renderer.inspectElement(
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(
557706
requestID,
558707
id,
559708
path,
560709
forceFullData,
561710
);
562-
switch (inspectedRoots.type) {
711+
switch (inspectedRootsPayload.type) {
563712
case 'hydrated-path':
564-
this._bridge.send('inspectedScreen', inspectedRoots);
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);
565729
// If we hydrated a path, it must've been in a specific renderer so we can stop here.
566730
return;
567731
case 'full-data':
568-
// TODO: Handle merging of roots from different renderer implementations.
569-
this._bridge.send('inspectedScreen', inspectedRoots);
570-
return;
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;
571752
case 'not-found':
572-
continue;
753+
break;
573754
case 'error':
574755
// bail out and show the error
575756
// TODO: aggregate errors
576-
this._bridge.send('inspectedScreen', inspectedRoots);
757+
this._bridge.send('inspectedScreen', inspectedRootsPayload);
577758
return;
578759
}
579760
}
580761

581-
this._bridge.send('inspectedScreen', payload);
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+
}
582784
};
583785

584786
logElementToConsole: ElementAndRendererID => void = ({id, rendererID}) => {

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7075,8 +7075,8 @@ export function attach(
70757075
if (!hasElementUpdatedSinceLastInspected) {
70767076
if (path !== null) {
70777077
let secondaryCategory: 'suspendedBy' | 'hooks' | null = null;
7078-
if (path[0] === 'hooks') {
7079-
secondaryCategory = 'hooks';
7078+
if (path[0] === 'hooks' || path[0] === 'suspendedBy') {
7079+
secondaryCategory = path[0];
70807080
}
70817081
70827082
// If this element has not been updated since it was last inspected,
@@ -7225,9 +7225,8 @@ export function attach(
72257225
}
72267226
72277227
function inspectRootsRaw(arbitraryRootID: number): InspectedElement | null {
7228-
// Merges roots of all known roots. The agent is supposed to only use the result
7229-
// of single renderer implementation.
7230-
if (rootToFiberInstanceMap.size === 0) {
7228+
const roots = hook.getFiberRoots(rendererID);
7229+
if (roots.size === 0) {
72317230
return null;
72327231
}
72337232
@@ -7273,7 +7272,13 @@ export function attach(
72737272
72747273
let minSuspendedByRange = Infinity;
72757274
let maxSuspendedByRange = -Infinity;
7276-
rootToFiberInstanceMap.forEach(rootInstance => {
7275+
roots.forEach(root => {
7276+
const rootInstance = rootToFiberInstanceMap.get(root);
7277+
if (rootInstance === undefined) {
7278+
throw new Error(
7279+
'Expected a root instance to exist for this Fiber root',
7280+
);
7281+
}
72777282
const inspectedRoot = inspectFiberInstanceRaw(rootInstance);
72787283
if (inspectedRoot === null) {
72797284
return;

0 commit comments

Comments
 (0)