main
js 1,206 lines 37.7 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import EventEmitter from '../events';
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,
19 toggleEnabled as setTraceUpdatesEnabled,
20 } from './views/TraceUpdates';
21 import {currentBridgeProtocol} from 'react-devtools-shared/src/bridge';
22
23 import type {BackendBridge} from 'react-devtools-shared/src/bridge';
24 import type {
25 InstanceAndStyle,
26 HostInstance,
27 OwnersList,
28 PathFrame,
29 PathMatch,
30 RendererID,
31 RendererInterface,
32 DevToolsHookSettings,
33 InspectedElement,
34 } from './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 {isReactNativeEnvironment} from './utils';
42 import {
43 sessionStorageGetItem,
44 sessionStorageRemoveItem,
45 sessionStorageSetItem,
46 } from '../storage';
47
48 const debug = (methodName: string, ...args: Array<string>) => {
49 // $FlowFixMe[constant-condition]
50 if (__DEBUG__) {
51 console.log(
52 `%cAgent %c${methodName}`,
53 'color: purple; font-weight: bold;',
54 'font-weight: bold;',
55 ...args,
56 );
57 }
58 };
59
60 type ElementAndRendererID = {
61 id: number,
62 rendererID: number,
63 };
64
65 type StoreAsGlobalParams = {
66 count: number,
67 id: number,
68 path: Array<string | number>,
69 rendererID: number,
70 };
71
72 type CopyElementParams = {
73 id: number,
74 path: Array<string | number>,
75 rendererID: number,
76 };
77
78 type InspectElementParams = {
79 forceFullData: boolean,
80 id: number,
81 path: Array<string | number> | null,
82 rendererID: number,
83 requestID: number,
84 };
85
86 type InspectScreenParams = {
87 forceFullData: boolean,
88 id: number,
89 path: Array<string | number> | null,
90 requestID: number,
91 };
92
93 type OverrideHookParams = {
94 id: number,
95 hookID: number,
96 path: Array<string | number>,
97 rendererID: number,
98 wasForwarded?: boolean,
99 value: any,
100 };
101
102 type SetInParams = {
103 id: number,
104 path: Array<string | number>,
105 rendererID: number,
106 wasForwarded?: boolean,
107 value: any,
108 };
109
110 type PathType = 'props' | 'hooks' | 'state' | 'context';
111
112 type DeletePathParams = {
113 type: PathType,
114 hookID?: ?number,
115 id: number,
116 path: Array<string | number>,
117 rendererID: number,
118 };
119
120 type RenamePathParams = {
121 type: PathType,
122 hookID?: ?number,
123 id: number,
124 oldPath: Array<string | number>,
125 newPath: Array<string | number>,
126 rendererID: number,
127 };
128
129 type OverrideValueAtPathParams = {
130 type: PathType,
131 hookID?: ?number,
132 id: number,
133 path: Array<string | number>,
134 rendererID: number,
135 value: any,
136 };
137
138 type OverrideErrorParams = {
139 id: number,
140 rendererID: number,
141 forceError: boolean,
142 };
143
144 type OverrideSuspenseParams = {
145 id: number,
146 rendererID: number,
147 forceFallback: boolean,
148 };
149
150 type OverrideSuspenseMilestoneParams = {
151 rendererID: number,
152 suspendedSet: Array<number>,
153 };
154
155 type PersistedSelection = {
156 rendererID: number,
157 path: Array<PathFrame>,
158 };
159
160 function createEmptyInspectedScreen(
161 arbitraryRootID: number,
162 type: ElementType,
163 ): InspectedElement {
164 const suspendedBy: DehydratedData = {
165 cleaned: [],
166 data: [],
167 unserializable: [],
168 };
169 return {
170 // invariants
171 id: arbitraryRootID,
172 type: type,
173 // Properties we merge
174 isErrored: false,
175 errors: [],
176 warnings: [],
177 suspendedBy,
178 suspendedByRange: null,
179 // TODO: How to merge these?
180 unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
181 // Properties where merging doesn't make sense so we ignore them entirely in the UI
182 rootType: null,
183 plugins: {stylex: null},
184 nativeTag: null,
185 env: null,
186 source: null,
187 stack: null,
188 rendererPackageName: null,
189 rendererVersion: null,
190 // These don't make sense for a Root. They're just bottom values.
191 key: null,
192 canEditFunctionProps: false,
193 canEditHooks: false,
194 canEditFunctionPropsDeletePaths: false,
195 canEditFunctionPropsRenamePaths: false,
196 canEditHooksAndDeletePaths: false,
197 canEditHooksAndRenamePaths: false,
198 canToggleError: false,
199 canToggleSuspense: false,
200 isSuspended: false,
201 hasLegacyContext: false,
202 context: null,
203 hooks: null,
204 props: null,
205 state: null,
206 owners: null,
207 };
208 }
209
210 function mergeRoots(
211 left: InspectedElement,
212 right: InspectedElement,
213 suspendedByOffset: number,
214 ): void {
215 const leftSuspendedByRange = left.suspendedByRange;
216 const rightSuspendedByRange = right.suspendedByRange;
217
218 if (right.isErrored) {
219 left.isErrored = true;
220 }
221 for (let i = 0; i < right.errors.length; i++) {
222 left.errors.push(right.errors[i]);
223 }
224 for (let i = 0; i < right.warnings.length; i++) {
225 left.warnings.push(right.warnings[i]);
226 }
227
228 const leftSuspendedBy: DehydratedData = left.suspendedBy;
229 const {data, cleaned, unserializable} = right.suspendedBy as DehydratedData;
230 const leftSuspendedByData = leftSuspendedBy.data as any as Array<mixed>;
231 const rightSuspendedByData = data as any as Array<mixed>;
232 for (let i = 0; i < rightSuspendedByData.length; i++) {
233 leftSuspendedByData.push(rightSuspendedByData[i]);
234 }
235 for (let i = 0; i < cleaned.length; i++) {
236 leftSuspendedBy.cleaned.push(
237 [suspendedByOffset + cleaned[i][0]].concat(cleaned[i].slice(1)),
238 );
239 }
240 for (let i = 0; i < unserializable.length; i++) {
241 leftSuspendedBy.unserializable.push(
242 [suspendedByOffset + unserializable[i][0]].concat(
243 unserializable[i].slice(1),
244 ),
245 );
246 }
247
248 if (rightSuspendedByRange !== null) {
249 if (leftSuspendedByRange === null) {
250 left.suspendedByRange = [
251 rightSuspendedByRange[0],
252 rightSuspendedByRange[1],
253 ];
254 } else {
255 if (rightSuspendedByRange[0] < leftSuspendedByRange[0]) {
256 leftSuspendedByRange[0] = rightSuspendedByRange[0];
257 }
258 if (rightSuspendedByRange[1] > leftSuspendedByRange[1]) {
259 leftSuspendedByRange[1] = rightSuspendedByRange[1];
260 }
261 }
262 }
263 }
264
265 export default class Agent extends EventEmitter<{
266 hideNativeHighlight: [],
267 showNativeHighlight: [HostInstance],
268 startInspectingNative: [],
269 stopInspectingNative: [],
270 shutdown: [],
271 traceUpdates: [Set<HostInstance>],
272 drawTraceUpdates: [Array<HostInstance>],
273 drawGroupedTraceUpdatesWithNames: [Array<Array<GroupItem>>],
274 disableTraceUpdates: [],
275 getIfHasUnsupportedRendererVersion: [],
276 updateHookSettings: [$ReadOnly<DevToolsHookSettings>],
277 getHookSettings: [],
278 }> {
279 _bridge: BackendBridge;
280 _isProfiling: boolean = false;
281 _rendererInterfaces: {[key: RendererID]: RendererInterface, ...} = {};
282 _persistedSelection: PersistedSelection | null = null;
283 _persistedSelectionMatch: PathMatch | null = null;
284 _traceUpdatesEnabled: boolean = false;
285 _onReloadAndProfile: ((recordChangeDescriptions: boolean) => void) | void;
286
287 constructor(
288 bridge: BackendBridge,
289 isProfiling: boolean = false,
290 onReloadAndProfile?: (recordChangeDescriptions: boolean) => void,
291 ) {
292 super();
293
294 this._isProfiling = isProfiling;
295 this._onReloadAndProfile = onReloadAndProfile;
296
297 const persistedSelectionString = sessionStorageGetItem(
298 SESSION_STORAGE_LAST_SELECTION_KEY,
299 );
300 if (persistedSelectionString != null) {
301 this._persistedSelection = JSON.parse(persistedSelectionString);
302 }
303
304 this._bridge = bridge;
305
306 bridge.addListener('clearErrorsAndWarnings', this.clearErrorsAndWarnings);
307 bridge.addListener('clearErrorsForElementID', this.clearErrorsForElementID);
308 bridge.addListener(
309 'clearWarningsForElementID',
310 this.clearWarningsForElementID,
311 );
312 bridge.addListener('copyElementPath', this.copyElementPath);
313 bridge.addListener('deletePath', this.deletePath);
314 bridge.addListener('getBackendVersion', this.getBackendVersion);
315 bridge.addListener('getBridgeProtocol', this.getBridgeProtocol);
316 bridge.addListener('getProfilingData', this.getProfilingData);
317 bridge.addListener('getProfilingStatus', this.getProfilingStatus);
318 bridge.addListener('getOwnersList', this.getOwnersList);
319 bridge.addListener('inspectElement', this.inspectElement);
320 bridge.addListener('inspectScreen', this.inspectScreen);
321 bridge.addListener('logElementToConsole', this.logElementToConsole);
322 bridge.addListener('overrideError', this.overrideError);
323 bridge.addListener('overrideSuspense', this.overrideSuspense);
324 bridge.addListener(
325 'overrideSuspenseMilestone',
326 this.overrideSuspenseMilestone,
327 );
328 bridge.addListener('overrideValueAtPath', this.overrideValueAtPath);
329 bridge.addListener('reloadAndProfile', this.reloadAndProfile);
330 bridge.addListener('renamePath', this.renamePath);
331 bridge.addListener('setTraceUpdatesEnabled', this.setTraceUpdatesEnabled);
332 bridge.addListener('startProfiling', this.startProfiling);
333 bridge.addListener('stopProfiling', this.stopProfiling);
334 bridge.addListener('storeAsGlobal', this.storeAsGlobal);
335 bridge.addListener(
336 'syncSelectionFromBuiltinElementsPanel',
337 this.syncSelectionFromBuiltinElementsPanel,
338 );
339 bridge.addListener('shutdown', this.shutdown);
340
341 bridge.addListener('updateHookSettings', this.updateHookSettings);
342 bridge.addListener('getHookSettings', this.getHookSettings);
343
344 bridge.addListener('updateComponentFilters', this.updateComponentFilters);
345 bridge.addListener('getEnvironmentNames', this.getEnvironmentNames);
346 bridge.addListener(
347 'getIfHasUnsupportedRendererVersion',
348 this.getIfHasUnsupportedRendererVersion,
349 );
350
351 // Temporarily support older standalone front-ends sending commands to newer embedded backends.
352 // We do this because React Native embeds the React DevTools backend,
353 // but cannot control which version of the frontend users use.
354 bridge.addListener('overrideContext', this.overrideContext);
355 bridge.addListener('overrideHookState', this.overrideHookState);
356 bridge.addListener('overrideProps', this.overrideProps);
357 bridge.addListener('overrideState', this.overrideState);
358
359 setupHighlighter(bridge, this);
360 setupTraceUpdates(this);
361
362 // By this time, Store should already be initialized and intercept events
363 bridge.send('backendInitialized');
364
365 if (this._isProfiling) {
366 bridge.send('profilingStatus', true);
367 }
368 }
369
370 get rendererInterfaces(): {[key: RendererID]: RendererInterface, ...} {
371 return this._rendererInterfaces;
372 }
373
374 clearErrorsAndWarnings: ({rendererID: RendererID}) => void = ({
375 rendererID,
376 }) => {
377 const renderer = this._rendererInterfaces[rendererID];
378 if (renderer == null) {
379 console.warn(`Invalid renderer id "${rendererID}"`);
380 } else {
381 renderer.clearErrorsAndWarnings();
382 }
383 };
384
385 clearErrorsForElementID: ElementAndRendererID => void = ({
386 id,
387 rendererID,
388 }) => {
389 const renderer = this._rendererInterfaces[rendererID];
390 if (renderer == null) {
391 console.warn(`Invalid renderer id "${rendererID}"`);
392 } else {
393 renderer.clearErrorsForElementID(id);
394 }
395 };
396
397 clearWarningsForElementID: ElementAndRendererID => void = ({
398 id,
399 rendererID,
400 }) => {
401 const renderer = this._rendererInterfaces[rendererID];
402 if (renderer == null) {
403 console.warn(`Invalid renderer id "${rendererID}"`);
404 } else {
405 renderer.clearWarningsForElementID(id);
406 }
407 };
408
409 copyElementPath: CopyElementParams => void = ({
410 id,
411 path,
412 rendererID,
413 }: CopyElementParams) => {
414 const renderer = this._rendererInterfaces[rendererID];
415 if (renderer == null) {
416 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
417 } else {
418 const value = renderer.getSerializedElementValueByPath(id, path);
419
420 if (value != null) {
421 this._bridge.send('saveToClipboard', value);
422 } else {
423 console.warn(`Unable to obtain serialized value for element "${id}"`);
424 }
425 }
426 };
427
428 deletePath: DeletePathParams => void = ({
429 hookID,
430 id,
431 path,
432 rendererID,
433 type,
434 }: DeletePathParams) => {
435 const renderer = this._rendererInterfaces[rendererID];
436 if (renderer == null) {
437 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
438 } else {
439 renderer.deletePath(type, id, hookID, path);
440 }
441 };
442
443 getInstanceAndStyle({
444 id,
445 rendererID,
446 }: ElementAndRendererID): InstanceAndStyle | null {
447 const renderer = this._rendererInterfaces[rendererID];
448 if (renderer == null) {
449 console.warn(`Invalid renderer id "${rendererID}"`);
450 return null;
451 }
452 return renderer.getInstanceAndStyle(id);
453 }
454
455 getIDForHostInstance(
456 target: HostInstance,
457 onlySuspenseNodes?: boolean,
458 ): null | {id: number, rendererID: number} {
459 if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') {
460 // In React Native or non-DOM we simply pick any renderer that has a match.
461 for (const rendererID in this._rendererInterfaces) {
462 const renderer = this._rendererInterfaces[
463 rendererID as any
464 ] as any as RendererInterface;
465 try {
466 const id = onlySuspenseNodes
467 ? renderer.getSuspenseNodeIDForHostInstance(target)
468 : renderer.getElementIDForHostInstance(target);
469 if (id !== null) {
470 return {
471 id: id,
472 rendererID: +rendererID,
473 };
474 }
475 } catch (error) {
476 // Some old React versions might throw if they can't find a match.
477 // If so we should ignore it...
478 }
479 }
480 return null;
481 } else {
482 // In the DOM we use a smarter mechanism to find the deepest a DOM node
483 // that is registered if there isn't an exact match.
484 let bestMatch: null | Element = null;
485 let bestRenderer: null | RendererInterface = null;
486 let bestRendererID: number = 0;
487 // Find the nearest ancestor which is mounted by a React.
488 for (const rendererID in this._rendererInterfaces) {
489 const renderer = this._rendererInterfaces[
490 rendererID as any
491 ] as any as RendererInterface;
492 const nearestNode: null | Element = renderer.getNearestMountedDOMNode(
493 target as any,
494 );
495 if (nearestNode !== null) {
496 if (nearestNode === target) {
497 // Exact match we can exit early.
498 bestMatch = nearestNode;
499 bestRenderer = renderer;
500 bestRendererID = +rendererID;
501 break;
502 }
503 if (bestMatch === null || bestMatch.contains(nearestNode)) {
504 // If this is the first match or the previous match contains the new match,
505 // so the new match is a deeper and therefore better match.
506 bestMatch = nearestNode;
507 bestRenderer = renderer;
508 bestRendererID = +rendererID;
509 }
510 }
511 }
512 if (bestRenderer != null && bestMatch != null) {
513 try {
514 const id = onlySuspenseNodes
515 ? bestRenderer.getSuspenseNodeIDForHostInstance(bestMatch)
516 : bestRenderer.getElementIDForHostInstance(bestMatch);
517 if (id !== null) {
518 return {
519 id,
520 rendererID: bestRendererID,
521 };
522 }
523 } catch (error) {
524 // Some old React versions might throw if they can't find a match.
525 // If so we should ignore it...
526 }
527 }
528 return null;
529 }
530 }
531
532 getComponentNameForHostInstance(target: HostInstance): string | null {
533 const match = this.getIDForHostInstance(target);
534 if (match !== null) {
535 const renderer = this._rendererInterfaces[
536 match.rendererID as any
537 ] as any as RendererInterface;
538 return renderer.getDisplayNameForElementID(match.id);
539 }
540 return null;
541 }
542
543 getBackendVersion: () => void = () => {
544 const version = process.env.DEVTOOLS_VERSION;
545 if (version) {
546 this._bridge.send('backendVersion', version);
547 }
548 };
549
550 getBridgeProtocol: () => void = () => {
551 this._bridge.send('bridgeProtocol', currentBridgeProtocol);
552 };
553
554 getProfilingData: ({rendererID: RendererID}) => void = ({rendererID}) => {
555 const renderer = this._rendererInterfaces[rendererID];
556 if (renderer == null) {
557 console.warn(`Invalid renderer id "${rendererID}"`);
558 }
559
560 this._bridge.send('profilingData', renderer.getProfilingData());
561 };
562
563 getProfilingStatus: () => void = () => {
564 this._bridge.send('profilingStatus', this._isProfiling);
565 };
566
567 getOwnersList: ElementAndRendererID => void = ({id, rendererID}) => {
568 const renderer = this._rendererInterfaces[rendererID];
569 if (renderer == null) {
570 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
571 } else {
572 const owners = renderer.getOwnersList(id);
573 this._bridge.send('ownersList', {id, owners} as OwnersList);
574 }
575 };
576
577 inspectElement: InspectElementParams => void = ({
578 forceFullData,
579 id,
580 path,
581 rendererID,
582 requestID,
583 }) => {
584 const renderer = this._rendererInterfaces[rendererID];
585 if (renderer == null) {
586 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
587 } else {
588 this._bridge.send(
589 'inspectedElement',
590 renderer.inspectElement(requestID, id, path, forceFullData),
591 );
592
593 // When user selects an element, stop trying to restore the selection,
594 // and instead remember the current selection for the next reload.
595 if (
596 this._persistedSelectionMatch === null ||
597 this._persistedSelectionMatch.id !== id
598 ) {
599 this._persistedSelection = null;
600 this._persistedSelectionMatch = null;
601 renderer.setTrackedPath(null);
602 // Throttle persisting the selection.
603 this._lastSelectedElementID = id;
604 this._lastSelectedRendererID = rendererID;
605 if (!this._persistSelectionTimerScheduled) {
606 this._persistSelectionTimerScheduled = true;
607 setTimeout(this._persistSelection, 1000);
608 }
609 }
610
611 // TODO: If there was a way to change the selected DOM element
612 // in built-in Elements tab without forcing a switch to it, we'd do it here.
613 // For now, it doesn't seem like there is a way to do that:
614 // https://github.com/bvaughn/react-devtools-experimental/issues/102
615 // (Setting $0 doesn't work, and calling inspect() switches the tab.)
616 }
617 };
618
619 inspectScreen: InspectScreenParams => void = ({
620 requestID,
621 id,
622 forceFullData,
623 path: screenPath,
624 }) => {
625 let inspectedScreen: InspectedElement | null = null;
626 let found = false;
627 // the suspendedBy index will be from the previously merged roots.
628 // We need to keep track of how many suspendedBy we've already seen to know
629 // to which renderer the index belongs.
630 let suspendedByOffset = 0;
631 let suspendedByPathIndex: number | null = null;
632 // The path to hydrate for a specific renderer
633 let rendererPath: InspectElementParams['path'] = null;
634 if (screenPath !== null && screenPath.length > 1) {
635 const secondaryCategory = screenPath[0];
636 if (secondaryCategory !== 'suspendedBy') {
637 throw new Error(
638 'Only hydrating suspendedBy paths is supported. This is a bug.',
639 );
640 }
641 if (typeof screenPath[1] !== 'number') {
642 throw new Error(
643 `Expected suspendedBy index to be a number. Received '${screenPath[1]}' instead. This is a bug.`,
644 );
645 }
646 suspendedByPathIndex = screenPath[1];
647 rendererPath = screenPath.slice(2);
648 }
649
650 for (const rendererID in this._rendererInterfaces) {
651 const renderer = this._rendererInterfaces[
652 rendererID as any
653 ] as any as RendererInterface;
654 let path: InspectElementParams['path'] = null;
655 if (suspendedByPathIndex !== null && rendererPath !== null) {
656 const suspendedByPathRendererIndex =
657 suspendedByPathIndex - suspendedByOffset;
658 const rendererHasRequestedSuspendedByPath =
659 renderer.getElementAttributeByPath(id, [
660 'suspendedBy',
661 suspendedByPathRendererIndex,
662 ]) !== undefined;
663 if (rendererHasRequestedSuspendedByPath) {
664 path = ['suspendedBy', suspendedByPathRendererIndex].concat(
665 rendererPath,
666 );
667 }
668 }
669
670 const inspectedRootsPayload = renderer.inspectElement(
671 requestID,
672 id,
673 path,
674 forceFullData,
675 );
676 switch (inspectedRootsPayload.type) {
677 case 'hydrated-path':
678 // The path will be relative to the Roots of this renderer. We adjust it
679 // to be relative to all Roots of this implementation.
680 inspectedRootsPayload.path[1] += suspendedByOffset;
681 // TODO: Hydration logic is flawed since the Frontend path is not based
682 // on the original backend data but rather its own representation of it (e.g. due to reorder).
683 // So we can receive null here instead when hydration fails
684 if (inspectedRootsPayload.value !== null) {
685 for (
686 let i = 0;
687 i < inspectedRootsPayload.value.cleaned.length;
688 i++
689 ) {
690 inspectedRootsPayload.value.cleaned[i][1] += suspendedByOffset;
691 }
692 }
693 this._bridge.send('inspectedScreen', inspectedRootsPayload);
694 // If we hydrated a path, it must've been in a specific renderer so we can stop here.
695 return;
696 case 'full-data':
697 const inspectedRoots = inspectedRootsPayload.value;
698 if (inspectedScreen === null) {
699 inspectedScreen = createEmptyInspectedScreen(
700 inspectedRoots.id,
701 inspectedRoots.type,
702 );
703 }
704 mergeRoots(inspectedScreen, inspectedRoots, suspendedByOffset);
705 const dehydratedSuspendedBy: DehydratedData =
706 inspectedRoots.suspendedBy;
707 const suspendedBy = dehydratedSuspendedBy.data as any as Array<mixed>;
708 suspendedByOffset += suspendedBy.length;
709 found = true;
710 break;
711 case 'no-change':
712 found = true;
713 const rootsSuspendedBy: Array<mixed> =
714 renderer.getElementAttributeByPath(id, ['suspendedBy']) as any;
715 suspendedByOffset += rootsSuspendedBy.length;
716 break;
717 case 'not-found':
718 break;
719 case 'error':
720 // bail out and show the error
721 // TODO: aggregate errors
722 this._bridge.send('inspectedScreen', inspectedRootsPayload);
723 return;
724 }
725 }
726
727 if (inspectedScreen === null) {
728 if (found) {
729 this._bridge.send('inspectedScreen', {
730 type: 'no-change',
731 responseID: requestID,
732 id,
733 });
734 } else {
735 this._bridge.send('inspectedScreen', {
736 type: 'not-found',
737 responseID: requestID,
738 id,
739 });
740 }
741 } else {
742 this._bridge.send('inspectedScreen', {
743 type: 'full-data',
744 responseID: requestID,
745 id,
746 value: inspectedScreen,
747 });
748 }
749 };
750
751 logElementToConsole: ElementAndRendererID => void = ({id, rendererID}) => {
752 const renderer = this._rendererInterfaces[rendererID];
753 if (renderer == null) {
754 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
755 } else {
756 renderer.logElementToConsole(id);
757 }
758 };
759
760 overrideError: OverrideErrorParams => void = ({
761 id,
762 rendererID,
763 forceError,
764 }) => {
765 const renderer = this._rendererInterfaces[rendererID];
766 if (renderer == null) {
767 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
768 } else {
769 renderer.overrideError(id, forceError);
770 }
771 };
772
773 overrideSuspense: OverrideSuspenseParams => void = ({
774 id,
775 rendererID,
776 forceFallback,
777 }) => {
778 const renderer = this._rendererInterfaces[rendererID];
779 if (renderer == null) {
780 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
781 } else {
782 renderer.overrideSuspense(id, forceFallback);
783 }
784 };
785
786 overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({
787 rendererID,
788 suspendedSet,
789 }) => {
790 const renderer = this._rendererInterfaces[
791 rendererID as any
792 ] as any as RendererInterface;
793 if (renderer.supportsTogglingSuspense) {
794 renderer.overrideSuspenseMilestone(suspendedSet);
795 }
796 };
797
798 overrideValueAtPath: OverrideValueAtPathParams => void = ({
799 hookID,
800 id,
801 path,
802 rendererID,
803 type,
804 value,
805 }) => {
806 const renderer = this._rendererInterfaces[rendererID];
807 if (renderer == null) {
808 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
809 } else {
810 renderer.overrideValueAtPath(type, id, hookID, path, value);
811 }
812 };
813
814 // Temporarily support older standalone front-ends by forwarding the older message types
815 // to the new "overrideValueAtPath" command the backend is now listening to.
816 overrideContext: SetInParams => void = ({
817 id,
818 path,
819 rendererID,
820 wasForwarded,
821 value,
822 }) => {
823 // Don't forward a message that's already been forwarded by the front-end Bridge.
824 // We only need to process the override command once!
825 if (!wasForwarded) {
826 this.overrideValueAtPath({
827 id,
828 path,
829 rendererID,
830 type: 'context',
831 value,
832 });
833 }
834 };
835
836 // Temporarily support older standalone front-ends by forwarding the older message types
837 // to the new "overrideValueAtPath" command the backend is now listening to.
838 overrideHookState: OverrideHookParams => void = ({
839 id,
840 hookID,
841 path,
842 rendererID,
843 wasForwarded,
844 value,
845 }) => {
846 // Don't forward a message that's already been forwarded by the front-end Bridge.
847 // We only need to process the override command once!
848 if (!wasForwarded) {
849 this.overrideValueAtPath({
850 id,
851 path,
852 rendererID,
853 type: 'hooks',
854 value,
855 });
856 }
857 };
858
859 // Temporarily support older standalone front-ends by forwarding the older message types
860 // to the new "overrideValueAtPath" command the backend is now listening to.
861 overrideProps: SetInParams => void = ({
862 id,
863 path,
864 rendererID,
865 wasForwarded,
866 value,
867 }) => {
868 // Don't forward a message that's already been forwarded by the front-end Bridge.
869 // We only need to process the override command once!
870 if (!wasForwarded) {
871 this.overrideValueAtPath({
872 id,
873 path,
874 rendererID,
875 type: 'props',
876 value,
877 });
878 }
879 };
880
881 // Temporarily support older standalone front-ends by forwarding the older message types
882 // to the new "overrideValueAtPath" command the backend is now listening to.
883 overrideState: SetInParams => void = ({
884 id,
885 path,
886 rendererID,
887 wasForwarded,
888 value,
889 }) => {
890 // Don't forward a message that's already been forwarded by the front-end Bridge.
891 // We only need to process the override command once!
892 if (!wasForwarded) {
893 this.overrideValueAtPath({
894 id,
895 path,
896 rendererID,
897 type: 'state',
898 value,
899 });
900 }
901 };
902
903 onReloadAndProfileSupportedByHost: () => void = () => {
904 this._bridge.send('isReloadAndProfileSupportedByBackend', true);
905 };
906
907 reloadAndProfile: ({recordChangeDescriptions: boolean}) => void = ({
908 recordChangeDescriptions,
909 }) => {
910 if (typeof this._onReloadAndProfile === 'function') {
911 this._onReloadAndProfile(recordChangeDescriptions);
912 }
913
914 // This code path should only be hit if the shell has explicitly told the Store that it supports profiling.
915 // In that case, the shell must also listen for this specific message to know when it needs to reload the app.
916 // The agent can't do this in a way that is renderer agnostic.
917 this._bridge.send('reloadAppForProfiling');
918 };
919
920 renamePath: RenamePathParams => void = ({
921 hookID,
922 id,
923 newPath,
924 oldPath,
925 rendererID,
926 type,
927 }) => {
928 const renderer = this._rendererInterfaces[rendererID];
929 if (renderer == null) {
930 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
931 } else {
932 renderer.renamePath(type, id, hookID, oldPath, newPath);
933 }
934 };
935
936 selectNode(target: HostInstance | null): void {
937 const match = target !== null ? this.getIDForHostInstance(target) : null;
938 this._bridge.send(
939 'selectElement',
940 match !== null
941 ? match.id
942 : // If you click outside a React root in the Elements panel, we want to give
943 // feedback that no selection is possible so we clear the selection.
944 // Otherwise clicking outside a React root is indistinguishable from clicking
945 // a different host node that leads to the same selected React element
946 // due to Component filters
947 null,
948 );
949 }
950
951 registerRendererInterface(
952 rendererID: RendererID,
953 rendererInterface: RendererInterface,
954 ) {
955 this._rendererInterfaces[rendererID] = rendererInterface;
956
957 rendererInterface.setTraceUpdatesEnabled(this._traceUpdatesEnabled);
958
959 // When the renderer is attached, we need to tell it whether
960 // we remember the previous selection that we'd like to restore.
961 // It'll start tracking mounts for matches to the last selection path.
962 const selection = this._persistedSelection;
963 if (selection !== null && selection.rendererID === rendererID) {
964 rendererInterface.setTrackedPath(selection.path);
965 }
966 }
967
968 setTraceUpdatesEnabled: (traceUpdatesEnabled: boolean) => void =
969 traceUpdatesEnabled => {
970 this._traceUpdatesEnabled = traceUpdatesEnabled;
971
972 setTraceUpdatesEnabled(traceUpdatesEnabled);
973
974 for (const rendererID in this._rendererInterfaces) {
975 const renderer = this._rendererInterfaces[
976 rendererID as any
977 ] as any as RendererInterface;
978 renderer.setTraceUpdatesEnabled(traceUpdatesEnabled);
979 }
980 };
981
982 syncSelectionFromBuiltinElementsPanel: () => void = () => {
983 const target = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0;
984 this.selectNode(target == null ? null : target);
985 };
986
987 shutdown: () => void = () => {
988 // Clean up the overlay if visible, and associated events.
989 this.emit('shutdown');
990
991 this._bridge.removeAllListeners();
992 this.removeAllListeners();
993 };
994
995 startProfiling: ({recordChangeDescriptions: boolean}) => void = ({
996 recordChangeDescriptions,
997 }) => {
998 this._isProfiling = true;
999 for (const rendererID in this._rendererInterfaces) {
1000 const renderer = this._rendererInterfaces[
1001 rendererID as any
1002 ] as any as RendererInterface;
1003 renderer.startProfiling(recordChangeDescriptions);
1004 }
1005 this._bridge.send('profilingStatus', this._isProfiling);
1006 };
1007
1008 stopProfiling: () => void = () => {
1009 this._isProfiling = false;
1010 for (const rendererID in this._rendererInterfaces) {
1011 const renderer = this._rendererInterfaces[
1012 rendererID as any
1013 ] as any as RendererInterface;
1014 renderer.stopProfiling();
1015 }
1016 this._bridge.send('profilingStatus', this._isProfiling);
1017 };
1018
1019 stopInspectingNative: (selected: boolean) => void = selected => {
1020 this._bridge.send('stopInspectingHost', selected);
1021 };
1022
1023 storeAsGlobal: StoreAsGlobalParams => void = ({
1024 count,
1025 id,
1026 path,
1027 rendererID,
1028 }) => {
1029 const renderer = this._rendererInterfaces[rendererID];
1030 if (renderer == null) {
1031 console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
1032 } else {
1033 renderer.storeAsGlobal(id, path, count);
1034 }
1035 };
1036
1037 updateHookSettings: (settings: $ReadOnly<DevToolsHookSettings>) => void =
1038 settings => {
1039 // Propagate the settings, so Backend can subscribe to it and modify hook
1040 this.emit('updateHookSettings', settings);
1041 };
1042
1043 getHookSettings: () => void = () => {
1044 this.emit('getHookSettings');
1045 };
1046
1047 onHookSettings: (settings: $ReadOnly<DevToolsHookSettings>) => void =
1048 settings => {
1049 this._bridge.send('hookSettings', settings);
1050 };
1051
1052 updateComponentFilters: (componentFilters: Array<ComponentFilter>) => void =
1053 componentFilters => {
1054 for (const rendererIDString in this._rendererInterfaces) {
1055 const rendererID = +rendererIDString;
1056 const renderer = this._rendererInterfaces[
1057 rendererID as any
1058 ] as any as RendererInterface;
1059 if (this._lastSelectedRendererID === rendererID) {
1060 // Changing component filters will unmount and remount the DevTools tree.
1061 // Track the last selection's path so we can restore the selection.
1062 const path = renderer.getPathForElement(this._lastSelectedElementID);
1063 if (path !== null) {
1064 renderer.setTrackedPath(path);
1065 this._persistedSelection = {
1066 rendererID,
1067 path,
1068 };
1069 }
1070 }
1071 renderer.updateComponentFilters(componentFilters);
1072 }
1073
1074 // Due to the component filters changing, we might be able
1075 // to select a closer match for the currently selected host element.
1076 // The store will already select a suitable parent if the the current
1077 // selection is now filtered out in which cases this will be a no-op.
1078 const target = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0;
1079 if (target != null) {
1080 this.selectNode(target);
1081 }
1082 };
1083
1084 getEnvironmentNames: () => void = () => {
1085 let accumulatedNames = null;
1086 for (const rendererID in this._rendererInterfaces) {
1087 const renderer = this._rendererInterfaces[+rendererID];
1088 const names = renderer.getEnvironmentNames();
1089 if (accumulatedNames === null) {
1090 accumulatedNames = names;
1091 } else {
1092 for (let i = 0; i < names.length; i++) {
1093 if (accumulatedNames.indexOf(names[i]) === -1) {
1094 accumulatedNames.push(names[i]);
1095 }
1096 }
1097 }
1098 }
1099 this._bridge.send('environmentNames', accumulatedNames || []);
1100 };
1101
1102 onTraceUpdates: (nodes: Set<HostInstance>) => void = nodes => {
1103 this.emit('traceUpdates', nodes);
1104 };
1105
1106 onFastRefreshScheduled: () => void = () => {
1107 // $FlowFixMe[constant-condition]
1108 if (__DEBUG__) {
1109 debug('onFastRefreshScheduled');
1110 }
1111
1112 this._bridge.send('fastRefreshScheduled');
1113 };
1114
1115 onHookOperations: (operations: Array<number>) => void = operations => {
1116 // $FlowFixMe[constant-condition]
1117 if (__DEBUG__) {
1118 debug(
1119 'onHookOperations',
1120 `(${operations.length}) [${operations.join(', ')}]`,
1121 );
1122 }
1123
1124 // TODO:
1125 // The chrome.runtime does not currently support transferables; it forces JSON serialization.
1126 // See bug https://bugs.chromium.org/p/chromium/issues/detail?id=927134
1127 //
1128 // Regarding transferables, the postMessage doc states:
1129 // If the ownership of an object is transferred, it becomes unusable (neutered)
1130 // in the context it was sent from and becomes available only to the worker it was sent to.
1131 //
1132 // Even though Chrome is eventually JSON serializing the array buffer,
1133 // using the transferable approach also sometimes causes it to throw:
1134 // DOMException: Failed to execute 'postMessage' on 'Window': ArrayBuffer at index 0 is already neutered.
1135 //
1136 // See bug https://github.com/bvaughn/react-devtools-experimental/issues/25
1137 //
1138 // The Store has a fallback in place that parses the message as JSON if the type isn't an array.
1139 // For now the simplest fix seems to be to not transfer the array.
1140 // This will negatively impact performance on Firefox so it's unfortunate,
1141 // but until we're able to fix the Chrome error mentioned above, it seems necessary.
1142 //
1143 // this._bridge.send('operations', operations, [operations.buffer]);
1144 this._bridge.send('operations', operations);
1145
1146 if (this._persistedSelection !== null) {
1147 const rendererID = operations[0];
1148 if (this._persistedSelection.rendererID === rendererID) {
1149 // Check if we can select a deeper match for the persisted selection.
1150 const renderer = this._rendererInterfaces[rendererID];
1151 if (renderer == null) {
1152 console.warn(`Invalid renderer id "${rendererID}"`);
1153 } else {
1154 const prevMatch = this._persistedSelectionMatch;
1155 const nextMatch = renderer.getBestMatchForTrackedPath();
1156 this._persistedSelectionMatch = nextMatch;
1157 const prevMatchID = prevMatch !== null ? prevMatch.id : null;
1158 const nextMatchID = nextMatch !== null ? nextMatch.id : null;
1159 if (prevMatchID !== nextMatchID) {
1160 if (nextMatchID !== null) {
1161 // We moved forward, unlocking a deeper node.
1162 this._bridge.send('selectElement', nextMatchID);
1163 }
1164 }
1165 if (nextMatch !== null && nextMatch.isFullMatch) {
1166 // We've just unlocked the innermost selected node.
1167 // There's no point tracking it further.
1168 this._persistedSelection = null;
1169 this._persistedSelectionMatch = null;
1170 renderer.setTrackedPath(null);
1171 }
1172 }
1173 }
1174 }
1175 };
1176
1177 getIfHasUnsupportedRendererVersion: () => void = () => {
1178 this.emit('getIfHasUnsupportedRendererVersion');
1179 };
1180
1181 onUnsupportedRenderer() {
1182 this._bridge.send('unsupportedRendererVersion');
1183 }
1184
1185 _persistSelectionTimerScheduled: boolean = false;
1186 _lastSelectedRendererID: number = -1;
1187 _lastSelectedElementID: number = -1;
1188 _persistSelection: any = () => {
1189 this._persistSelectionTimerScheduled = false;
1190 const rendererID = this._lastSelectedRendererID;
1191 const id = this._lastSelectedElementID;
1192 // This is throttled, so both renderer and selected ID
1193 // might not be available by the time we read them.
1194 // This is why we need the defensive checks here.
1195 const renderer = this._rendererInterfaces[rendererID];
1196 const path = renderer != null ? renderer.getPathForElement(id) : null;
1197 if (path !== null) {
1198 sessionStorageSetItem(
1199 SESSION_STORAGE_LAST_SELECTION_KEY,
1200 JSON.stringify({rendererID, path} as PersistedSelection),
1201 );
1202 } else {
1203 sessionStorageRemoveItem(SESSION_STORAGE_LAST_SELECTION_KEY);
1204 }
1205 };
1206 }