Skip to content

Commit 43497d9

Browse files
committed
Merge Roots from different renderers in the agent
1 parent 9100846 commit 43497d9

3 files changed

Lines changed: 318 additions & 26 deletions

File tree

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

Lines changed: 218 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,130 @@ 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 suspendedBy: DehydratedData = inspectedRoots.suspendedBy;
741+
suspendedByOffset += suspendedBy.data.length;
742+
found = true;
743+
break;
744+
case 'no-change':
745+
found = true;
746+
suspendedByOffset += renderer.getElementAttributeByPath(id, [
747+
'suspendedBy',
748+
]).length;
749+
break;
571750
case 'not-found':
572-
continue;
751+
break;
573752
case 'error':
574753
// bail out and show the error
575754
// TODO: aggregate errors
576-
this._bridge.send('inspectedScreen', inspectedRoots);
755+
this._bridge.send('inspectedScreen', inspectedRootsPayload);
577756
return;
578757
}
579758
}
580759

581-
this._bridge.send('inspectedScreen', payload);
760+
if (inspectedScreen === null) {
761+
if (found) {
762+
this._bridge.send('inspectedScreen', {
763+
type: 'no-change',
764+
responseID: requestID,
765+
id,
766+
});
767+
} else {
768+
this._bridge.send('inspectedScreen', {
769+
type: 'not-found',
770+
responseID: requestID,
771+
id,
772+
});
773+
}
774+
} else {
775+
this._bridge.send('inspectedScreen', {
776+
type: 'full-data',
777+
responseID: requestID,
778+
id,
779+
value: inspectedScreen,
780+
});
781+
}
582782
};
583783

584784
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
@@ -7043,8 +7043,8 @@ export function attach(
70437043
if (!hasElementUpdatedSinceLastInspected) {
70447044
if (path !== null) {
70457045
let secondaryCategory: 'suspendedBy' | 'hooks' | null = null;
7046-
if (path[0] === 'hooks') {
7047-
secondaryCategory = 'hooks';
7046+
if (path[0] === 'hooks' || path[0] === 'suspendedBy') {
7047+
secondaryCategory = path[0];
70487048
}
70497049
70507050
// If this element has not been updated since it was last inspected,
@@ -7193,9 +7193,8 @@ export function attach(
71937193
}
71947194
71957195
function inspectRootsRaw(arbitraryRootID: number): InspectedElement | null {
7196-
// Merges roots of all known roots. The agent is supposed to only use the result
7197-
// of single renderer implementation.
7198-
if (rootToFiberInstanceMap.size === 0) {
7196+
const roots = hook.getFiberRoots(rendererID);
7197+
if (roots.size === 0) {
71997198
return null;
72007199
}
72017200
@@ -7241,7 +7240,13 @@ export function attach(
72417240
72427241
let minSuspendedByRange = Infinity;
72437242
let maxSuspendedByRange = -Infinity;
7244-
rootToFiberInstanceMap.forEach(rootInstance => {
7243+
roots.forEach(root => {
7244+
const rootInstance = rootToFiberInstanceMap.get(root);
7245+
if (rootInstance === undefined) {
7246+
throw new Error(
7247+
'Expected a root instance to exist for this Fiber root',
7248+
);
7249+
}
72457250
const inspectedRoot = inspectFiberInstanceRaw(rootInstance);
72467251
if (inspectedRoot === null) {
72477252
return;

0 commit comments

Comments
 (0)