@samitouri / QOS-React-2 / commits / 933f6a07ca

DevTools context menu (#17608)

* Added rudimentary context menu hook and menu UI * Added backend support for copying a value at a specific path for the inspected element * Added backend support for storing a value (at a specified path) as a global variable * Added special casing to enable copying undefined/unserializable values to the clipboard * Added copy and store-as-global context menu options to selected element props panel * Store global variables separately, with auto-incremented name (like browsers do) * Added tests for new copy and store-as-global backend functions * Fixed some ownerDocument/contentWindow edge cases * Refactored context menu to support dynamic options Used this mechanism to add a conditional menu option for inspecting the current value (if it's a function) * Renamed "safeSerialize" to "serializeToString" and added inline comment

Brian Vaughn committed Dec 18, 2019 at 12:12 UTC 933f6a07cac6beb5a5540b1942e84064f1c8dd36
32 files changed +1154 -155
packages/react-devtools-extensions/src/main.js
+52 -10
@@ -4,11 +4,7 @@ import {createElement} from 'react';
4 import {createRoot, flushSync} from 'react-dom';
5 import Bridge from 'react-devtools-shared/src/bridge';
6 import Store from 'react-devtools-shared/src/devtools/store';
7 -import {
8 - createViewElementSource,
9 - getBrowserName,
10 - getBrowserTheme,
11 -} from './utils';
7 +import {getBrowserName, getBrowserTheme} from './utils';
8 import {LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY} from 'react-devtools-shared/src/constants';
9 import {
10 getSavedComponentFilters,
@@ -155,10 +151,54 @@ function createPanelIfReactLoaded() {
151 },
152 );
153
158 - const viewElementSourceFunction = createViewElementSource(
159 - bridge,
160 - store,
161 - );
154 + const viewAttributeSourceFunction = (id, path) => {
155 + const rendererID = store.getRendererIDForElement(id);
156 + if (rendererID != null) {
157 + // Ask the renderer interface to find the specified attribute,
158 + // and store it as a global variable on the window.
159 + bridge.send('viewAttributeSource', {id, path, rendererID});
160 +
161 + setTimeout(() => {
162 + // Ask Chrome to display the location of the attribute,
163 + // assuming the renderer found a match.
164 + chrome.devtools.inspectedWindow.eval(`
165 + if (window.$attribute != null) {
166 + inspect(window.$attribute);
167 + }
168 + `);
169 + }, 100);
170 + }
171 + };
172 +
173 + const viewElementSourceFunction = id => {
174 + const rendererID = store.getRendererIDForElement(id);
175 + if (rendererID != null) {
176 + // Ask the renderer interface to determine the component function,
177 + // and store it as a global variable on the window
178 + bridge.send('viewElementSource', {id, rendererID});
179 +
180 + setTimeout(() => {
181 + // Ask Chrome to display the location of the component function,
182 + // or a render method if it is a Class (ideally Class instance, not type)
183 + // assuming the renderer found one.
184 + chrome.devtools.inspectedWindow.eval(`
185 + if (window.$type != null) {
186 + if (
187 + window.$type &&
188 + window.$type.prototype &&
189 + window.$type.prototype.isReactComponent
190 + ) {
191 + // inspect Component.render, not constructor
192 + inspect(window.$type.prototype.render);
193 + } else {
194 + // inspect Functional Component
195 + inspect(window.$type);
196 + }
197 + }
198 + `);
199 + }, 100);
200 + }
201 + };
202
203 root = createRoot(document.createElement('div'));
204
@@ -170,11 +210,13 @@ function createPanelIfReactLoaded() {
210 bridge,
211 browserTheme: getBrowserTheme(),
212 componentsPortalContainer,
213 + enabledInspectedElementContextMenu: true,
214 overrideTab,
215 profilerPortalContainer,
216 showTabBar: false,
176 - warnIfUnsupportedVersionDetected: true,
217 store,
218 + warnIfUnsupportedVersionDetected: true,
219 + viewAttributeSourceFunction,
220 viewElementSourceFunction,
221 }),
222 );
packages/react-devtools-extensions/src/utils.js
-32
@@ -2,38 +2,6 @@
2
3 const IS_CHROME = navigator.userAgent.indexOf('Firefox') < 0;
4
5 -export function createViewElementSource(bridge: Bridge, store: Store) {
6 - return function viewElementSource(id) {
7 - const rendererID = store.getRendererIDForElement(id);
8 - if (rendererID != null) {
9 - // Ask the renderer interface to determine the component function,
10 - // and store it as a global variable on the window
11 - bridge.send('viewElementSource', {id, rendererID});
12 -
13 - setTimeout(() => {
14 - // Ask Chrome to display the location of the component function,
15 - // or a render method if it is a Class (ideally Class instance, not type)
16 - // assuming the renderer found one.
17 - chrome.devtools.inspectedWindow.eval(`
18 - if (window.$type != null) {
19 - if (
20 - window.$type &&
21 - window.$type.prototype &&
22 - window.$type.prototype.isReactComponent
23 - ) {
24 - // inspect Component.render, not constructor
25 - inspect(window.$type.prototype.render);
26 - } else {
27 - // inspect Functional Component
28 - inspect(window.$type);
29 - }
30 - }
31 - `);
32 - }, 100);
33 - }
34 - };
35 -}
36 -
5 export type BrowserName = 'Chrome' | 'Firefox';
6
7 export function getBrowserName(): BrowserName {
packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js
+140 -1
@@ -8,7 +8,11 @@
8 */
9
10 import typeof ReactTestRenderer from 'react-test-renderer';
11 -import type {GetInspectedElementPath} from 'react-devtools-shared/src/devtools/views/Components/InspectedElementContext';
11 +import type {
12 + CopyInspectedElementPath,
13 + GetInspectedElementPath,
14 + StoreAsGlobal,
15 +} from 'react-devtools-shared/src/devtools/views/Components/InspectedElementContext';
16 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
17 import type Store from 'react-devtools-shared/src/devtools/store';
18
@@ -1203,4 +1207,139 @@ describe('InspectedElementContext', () => {
1207
1208 done();
1209 });
1210 +
1211 + it('should enable inspected values to be stored as global variables', async done => {
1212 + const Example = () => null;
1213 +
1214 + const nestedObject = {
1215 + a: {
1216 + value: 1,
1217 + b: {
1218 + value: 1,
1219 + c: {
1220 + value: 1,
1221 + },
1222 + },
1223 + },
1224 + };
1225 +
1226 + await utils.actAsync(() =>
1227 + ReactDOM.render(
1228 + <Example nestedObject={nestedObject} />,
1229 + document.createElement('div'),
1230 + ),
1231 + );
1232 +
1233 + const id = ((store.getElementIDAtIndex(0): any): number);
1234 +
1235 + let storeAsGlobal: StoreAsGlobal = ((null: any): StoreAsGlobal);
1236 +
1237 + function Suspender({target}) {
1238 + const context = React.useContext(InspectedElementContext);
1239 + storeAsGlobal = context.storeAsGlobal;
1240 + return null;
1241 + }
1242 +
1243 + await utils.actAsync(
1244 + () =>
1245 + TestRenderer.create(
1246 + <Contexts
1247 + defaultSelectedElementID={id}
1248 + defaultSelectedElementIndex={0}>
1249 + <React.Suspense fallback={null}>
1250 + <Suspender target={id} />
1251 + </React.Suspense>
1252 + </Contexts>,
1253 + ),
1254 + false,
1255 + );
1256 + expect(storeAsGlobal).not.toBeNull();
1257 +
1258 + const logSpy = jest.fn();
1259 + spyOn(console, 'log').and.callFake(logSpy);
1260 +
1261 + // Should store the whole value (not just the hydrated parts)
1262 + storeAsGlobal(id, ['props', 'nestedObject']);
1263 + jest.runOnlyPendingTimers();
1264 + expect(logSpy).toHaveBeenCalledWith('$reactTemp1');
1265 + expect(global.$reactTemp1).toBe(nestedObject);
1266 +
1267 + logSpy.mockReset();
1268 +
1269 + // Should store the nested property specified (not just the outer value)
1270 + storeAsGlobal(id, ['props', 'nestedObject', 'a', 'b']);
1271 + jest.runOnlyPendingTimers();
1272 + expect(logSpy).toHaveBeenCalledWith('$reactTemp2');
1273 + expect(global.$reactTemp2).toBe(nestedObject.a.b);
1274 +
1275 + done();
1276 + });
1277 +
1278 + it('should enable inspected values to be copied to the clipboard', async done => {
1279 + const Example = () => null;
1280 +
1281 + const nestedObject = {
1282 + a: {
1283 + value: 1,
1284 + b: {
1285 + value: 1,
1286 + c: {
1287 + value: 1,
1288 + },
1289 + },
1290 + },
1291 + };
1292 +
1293 + await utils.actAsync(() =>
1294 + ReactDOM.render(
1295 + <Example nestedObject={nestedObject} />,
1296 + document.createElement('div'),
1297 + ),
1298 + );
1299 +
1300 + const id = ((store.getElementIDAtIndex(0): any): number);
1301 +
1302 + let copyPath: CopyInspectedElementPath = ((null: any): CopyInspectedElementPath);
1303 +
1304 + function Suspender({target}) {
1305 + const context = React.useContext(InspectedElementContext);
1306 + copyPath = context.copyInspectedElementPath;
1307 + return null;
1308 + }
1309 +
1310 + await utils.actAsync(
1311 + () =>
1312 + TestRenderer.create(
1313 + <Contexts
1314 + defaultSelectedElementID={id}
1315 + defaultSelectedElementIndex={0}>
1316 + <React.Suspense fallback={null}>
1317 + <Suspender target={id} />
1318 + </React.Suspense>
1319 + </Contexts>,
1320 + ),
1321 + false,
1322 + );
1323 + expect(copyPath).not.toBeNull();
1324 +
1325 + // Should copy the whole value (not just the hydrated parts)
1326 + copyPath(id, ['props', 'nestedObject']);
1327 + jest.runOnlyPendingTimers();
1328 + expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
1329 + expect(global.mockClipboardCopy).toHaveBeenCalledWith(
1330 + JSON.stringify(nestedObject),
1331 + );
1332 +
1333 + global.mockClipboardCopy.mockReset();
1334 +
1335 + // Should copy the nested property specified (not just the outer value)
1336 + copyPath(id, ['props', 'nestedObject', 'a', 'b']);
1337 + jest.runOnlyPendingTimers();
1338 + expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
1339 + expect(global.mockClipboardCopy).toHaveBeenCalledWith(
1340 + JSON.stringify(nestedObject.a.b),
1341 + );
1342 +
1343 + done();
1344 + });
1345 });
packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js
+105
@@ -392,4 +392,109 @@ describe('InspectedElementContext', () => {
392
393 done();
394 });
395 +
396 + it('should enable inspected values to be stored as global variables', () => {
397 + const Example = () => null;
398 +
399 + const nestedObject = {
400 + a: {
401 + value: 1,
402 + b: {
403 + value: 1,
404 + c: {
405 + value: 1,
406 + },
407 + },
408 + },
409 + };
410 +
411 + act(() =>
412 + ReactDOM.render(
413 + <Example nestedObject={nestedObject} />,
414 + document.createElement('div'),
415 + ),
416 + );
417 +
418 + const id = ((store.getElementIDAtIndex(0): any): number);
419 + const rendererID = ((store.getRendererIDForElement(id): any): number);
420 +
421 + const logSpy = jest.fn();
422 + spyOn(console, 'log').and.callFake(logSpy);
423 +
424 + // Should store the whole value (not just the hydrated parts)
425 + bridge.send('storeAsGlobal', {
426 + count: 1,
427 + id,
428 + path: ['props', 'nestedObject'],
429 + rendererID,
430 + });
431 + jest.runOnlyPendingTimers();
432 + expect(logSpy).toHaveBeenCalledWith('$reactTemp1');
433 + expect(global.$reactTemp1).toBe(nestedObject);
434 +
435 + logSpy.mockReset();
436 +
437 + // Should store the nested property specified (not just the outer value)
438 + bridge.send('storeAsGlobal', {
439 + count: 2,
440 + id,
441 + path: ['props', 'nestedObject', 'a', 'b'],
442 + rendererID,
443 + });
444 + jest.runOnlyPendingTimers();
445 + expect(logSpy).toHaveBeenCalledWith('$reactTemp2');
446 + expect(global.$reactTemp2).toBe(nestedObject.a.b);
447 + });
448 +
449 + it('should enable inspected values to be copied to the clipboard', () => {
450 + const Example = () => null;
451 +
452 + const nestedObject = {
453 + a: {
454 + value: 1,
455 + b: {
456 + value: 1,
457 + c: {
458 + value: 1,
459 + },
460 + },
461 + },
462 + };
463 +
464 + act(() =>
465 + ReactDOM.render(
466 + <Example nestedObject={nestedObject} />,
467 + document.createElement('div'),
468 + ),
469 + );
470 +
471 + const id = ((store.getElementIDAtIndex(0): any): number);
472 + const rendererID = ((store.getRendererIDForElement(id): any): number);
473 +
474 + // Should copy the whole value (not just the hydrated parts)
475 + bridge.send('copyElementPath', {
476 + id,
477 + path: ['props', 'nestedObject'],
478 + rendererID,
479 + });
480 + jest.runOnlyPendingTimers();
481 + expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
482 + expect(global.mockClipboardCopy).toHaveBeenCalledWith(
483 + JSON.stringify(nestedObject),
484 + );
485 +
486 + global.mockClipboardCopy.mockReset();
487 +
488 + // Should copy the nested property specified (not just the outer value)
489 + bridge.send('copyElementPath', {
490 + id,
491 + path: ['props', 'nestedObject', 'a', 'b'],
492 + rendererID,
493 + });
494 + jest.runOnlyPendingTimers();
495 + expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
496 + expect(global.mockClipboardCopy).toHaveBeenCalledWith(
497 + JSON.stringify(nestedObject.a.b),
498 + );
499 + });
500 });
packages/react-devtools-shared/src/__tests__/setupTests.js
+7
@@ -14,6 +14,13 @@ import type {
14
15 const env = jasmine.getEnv();
16 env.beforeEach(() => {
17 + global.mockClipboardCopy = jest.fn();
18 +
19 + // Test environment doesn't support document methods like execCommand()
20 + // Also once the backend components below have been required,
21 + // it's too late for a test to mock the clipboard-js modules.
22 + jest.mock('clipboard-js', () => ({copy: global.mockClipboardCopy}));
23 +
24 // These files should be required (and re-reuired) before each test,
25 // rather than imported at the head of the module.
26 // That's because we reset modules between tests,
packages/react-devtools-shared/src/backend/agent.js
+43
@@ -55,6 +55,19 @@ type ElementAndRendererID = {|
55 rendererID: number,
56 |};
57
58 +type StoreAsGlobalParams = {|
59 + count: number,
60 + id: number,
61 + path: Array<string | number>,
62 + rendererID: number,
63 +|};
64 +
65 +type CopyElementParams = {|
66 + id: number,
67 + path: Array<string | number>,
68 + rendererID: number,
69 +|};
70 +
71 type InspectElementParams = {|
72 id: number,
73 path?: Array<string | number>,
@@ -126,6 +139,7 @@ export default class Agent extends EventEmitter<{|
139
140 this._bridge = bridge;
141
142 + bridge.addListener('copyElementPath', this.copyElementPath);
143 bridge.addListener('getProfilingData', this.getProfilingData);
144 bridge.addListener('getProfilingStatus', this.getProfilingStatus);
145 bridge.addListener('getOwnersList', this.getOwnersList);
@@ -140,6 +154,7 @@ export default class Agent extends EventEmitter<{|
154 bridge.addListener('setTraceUpdatesEnabled', this.setTraceUpdatesEnabled);
155 bridge.addListener('startProfiling', this.startProfiling);
156 bridge.addListener('stopProfiling', this.stopProfiling);
157 + bridge.addListener('storeAsGlobal', this.storeAsGlobal);
158 bridge.addListener(
159 'syncSelectionFromNativeElementsPanel',
160 this.syncSelectionFromNativeElementsPanel,
@@ -150,6 +165,7 @@ export default class Agent extends EventEmitter<{|
165 this.updateAppendComponentStack,
166 );
167 bridge.addListener('updateComponentFilters', this.updateComponentFilters);
168 + bridge.addListener('viewAttributeSource', this.viewAttributeSource);
169 bridge.addListener('viewElementSource', this.viewElementSource);
170
171 if (this._isProfiling) {
@@ -173,6 +189,15 @@ export default class Agent extends EventEmitter<{|
189 return this._rendererInterfaces;
190 }
191
192 + copyElementPath = ({id, path, rendererID}: CopyElementParams) => {
193 + const renderer = this._rendererInterfaces[rendererID];
194 + if (renderer == null) {
195 + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
196 + } else {
197 + renderer.copyElementPath(id, path);
198 + }
199 + };
200 +
201 getInstanceAndStyle({
202 id,
203 rendererID,
@@ -409,6 +434,15 @@ export default class Agent extends EventEmitter<{|
434 this._bridge.send('profilingStatus', this._isProfiling);
435 };
436
437 + storeAsGlobal = ({count, id, path, rendererID}: StoreAsGlobalParams) => {
438 + const renderer = this._rendererInterfaces[rendererID];
439 + if (renderer == null) {
440 + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
441 + } else {
442 + renderer.storeAsGlobal(id, path, count);
443 + }
444 + };
445 +
446 updateAppendComponentStack = (appendComponentStack: boolean) => {
447 // If the frontend preference has change,
448 // or in the case of React Native- if the backend is just finding out the preference-
@@ -430,6 +464,15 @@ export default class Agent extends EventEmitter<{|
464 }
465 };
466
467 + viewAttributeSource = ({id, path, rendererID}: CopyElementParams) => {
468 + const renderer = this._rendererInterfaces[rendererID];
469 + if (renderer == null) {
470 + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
471 + } else {
472 + renderer.prepareViewAttributeSource(id, path);
473 + }
474 + };
475 +
476 viewElementSource = ({id, rendererID}: ElementAndRendererID) => {
477 const renderer = this._rendererInterfaces[rendererID];
478 if (renderer == null) {
packages/react-devtools-shared/src/backend/legacy/renderer.js
+39 -2
@@ -15,8 +15,8 @@ import {
15 ElementTypeOtherOrUnknown,
16 } from 'react-devtools-shared/src/types';
17 import {getUID, utfEncodeString, printOperationsArray} from '../../utils';
18 -import {cleanForBridge, copyWithSet} from '../utils';
19 -import {getDisplayName} from 'react-devtools-shared/src/utils';
18 +import {cleanForBridge, copyToClipboard, copyWithSet} from '../utils';
19 +import {getDisplayName, getInObject} from 'react-devtools-shared/src/utils';
20 import {
21 __DEBUG__,
22 TREE_OPERATION_ADD,
@@ -649,6 +649,30 @@ export function attach(
649 }
650 }
651
652 + function storeAsGlobal(
653 + id: number,
654 + path: Array<string | number>,
655 + count: number,
656 + ): void {
657 + const inspectedElement = inspectElementRaw(id);
658 + if (inspectedElement !== null) {
659 + const value = getInObject(inspectedElement, path);
660 + const key = `$reactTemp${count}`;
661 +
662 + window[key] = value;
663 +
664 + console.log(key);
665 + console.log(value);
666 + }
667 + }
668 +
669 + function copyElementPath(id: number, path: Array<string | number>): void {
670 + const inspectedElement = inspectElementRaw(id);
671 + if (inspectedElement !== null) {
672 + copyToClipboard(getInObject(inspectedElement, path));
673 + }
674 + }
675 +
676 function inspectElement(
677 id: number,
678 path?: Array<string | number>,
@@ -812,6 +836,16 @@ export function attach(
836 }
837 }
838
839 + function prepareViewAttributeSource(
840 + id: number,
841 + path: Array<string | number>,
842 + ): void {
843 + const inspectedElement = inspectElementRaw(id);
844 + if (inspectedElement !== null) {
845 + window.$attribute = getInObject(inspectedElement, path);
846 + }
847 + }
848 +
849 function prepareViewElementSource(id: number): void {
850 const internalInstance = idToInternalInstanceMap.get(id);
851 if (internalInstance == null) {
@@ -927,6 +961,7 @@ export function attach(
961
962 return {
963 cleanup,
964 + copyElementPath,
965 flushInitialOperations,
966 getBestMatchForTrackedPath,
967 getFiberIDForNative: getInternalIDForNative,
@@ -943,6 +978,7 @@ export function attach(
978 inspectElement,
979 logElementToConsole,
980 overrideSuspense,
981 + prepareViewAttributeSource,
982 prepareViewElementSource,
983 renderer,
984 setInContext,
@@ -953,6 +989,7 @@ export function attach(
989 setTrackedPath,
990 startProfiling,
991 stopProfiling,
992 + storeAsGlobal,
993 updateComponentFilters,
994 };
995 }
packages/react-devtools-shared/src/backend/renderer.js
+51 -1
@@ -34,7 +34,7 @@ import {
34 utfEncodeString,
35 } from 'react-devtools-shared/src/utils';
36 import {sessionStorageGetItem} from 'react-devtools-shared/src/storage';
37 -import {cleanForBridge, copyWithSet} from './utils';
37 +import {cleanForBridge, copyToClipboard, copyWithSet} from './utils';
38 import {
39 __DEBUG__,
40 SESSION_STORAGE_RELOAD_AND_PROFILE_KEY,
@@ -2113,6 +2113,19 @@ export function attach(
2113 }
2114 // END copied code
2115
2116 + function prepareViewAttributeSource(
2117 + id: number,
2118 + path: Array<string | number>,
2119 + ): void {
2120 + const isCurrent = isMostRecentlyInspectedElementCurrent(id);
2121 + if (isCurrent) {
2122 + window.$attribute = getInObject(
2123 + ((mostRecentlyInspectedElement: any): InspectedElement),
2124 + path,
2125 + );
2126 + }
2127 + }
2128 +
2129 function prepareViewElementSource(id: number): void {
2130 let fiber = idToFiberMap.get(id);
2131 if (fiber == null) {
@@ -2488,6 +2501,40 @@ export function attach(
2501 }
2502 }
2503
2504 + function storeAsGlobal(
2505 + id: number,
2506 + path: Array<string | number>,
2507 + count: number,
2508 + ): void {
2509 + const isCurrent = isMostRecentlyInspectedElementCurrent(id);
2510 +
2511 + if (isCurrent) {
2512 + const value = getInObject(
2513 + ((mostRecentlyInspectedElement: any): InspectedElement),
2514 + path,
2515 + );
2516 + const key = `$reactTemp${count}`;
2517 +
2518 + window[key] = value;
2519 +
2520 + console.log(key);
2521 + console.log(value);
2522 + }
2523 + }
2524 +
2525 + function copyElementPath(id: number, path: Array<string | number>): void {
2526 + const isCurrent = isMostRecentlyInspectedElementCurrent(id);
2527 +
2528 + if (isCurrent) {
2529 + copyToClipboard(
2530 + getInObject(
2531 + ((mostRecentlyInspectedElement: any): InspectedElement),
2532 + path,
2533 + ),
2534 + );
2535 + }
2536 + }
2537 +
2538 function inspectElement(
2539 id: number,
2540 path?: Array<string | number>,
@@ -3129,6 +3176,7 @@ export function attach(
3176
3177 return {
3178 cleanup,
3179 + copyElementPath,
3180 findNativeNodesForFiberID,
3181 flushInitialOperations,
3182 getBestMatchForTrackedPath,
@@ -3141,6 +3189,7 @@ export function attach(
3189 handleCommitFiberUnmount,
3190 inspectElement,
3191 logElementToConsole,
3192 + prepareViewAttributeSource,
3193 prepareViewElementSource,
3194 overrideSuspense,
3195 renderer,
@@ -3152,6 +3201,7 @@ export function attach(
3201 setTrackedPath,
3202 startProfiling,
3203 stopProfiling,
3204 + storeAsGlobal,
3205 updateComponentFilters,
3206 };
3207 }
packages/react-devtools-shared/src/backend/types.js
+10
@@ -225,6 +225,7 @@ export type InstanceAndStyle = {|
225
226 export type RendererInterface = {
227 cleanup: () => void,
228 + copyElementPath: (id: number, path: Array<string | number>) => void,
229 findNativeNodesForFiberID: FindNativeNodesForFiberID,
230 flushInitialOperations: () => void,
231 getBestMatchForTrackedPath: () => PathMatch | null,
@@ -241,6 +242,10 @@ export type RendererInterface = {
242 ) => InspectedElementPayload,
243 logElementToConsole: (id: number) => void,
244 overrideSuspense: (id: number, forceFallback: boolean) => void,
245 + prepareViewAttributeSource: (
246 + id: number,
247 + path: Array<string | number>,
248 + ) => void,
249 prepareViewElementSource: (id: number) => void,
250 renderer: ReactRenderer | null,
251 setInContext: (id: number, path: Array<string | number>, value: any) => void,
@@ -256,6 +261,11 @@ export type RendererInterface = {
261 setTrackedPath: (path: Array<PathFrame> | null) => void,
262 startProfiling: (recordChangeDescriptions: boolean) => void,
263 stopProfiling: () => void,
264 + storeAsGlobal: (
265 + id: number,
266 + path: Array<string | number>,
267 + count: number,
268 + ) => void,
269 updateComponentFilters: (componentFilters: Array<ComponentFilter>) => void,
270 };
271
packages/react-devtools-shared/src/backend/utils.js
+20
@@ -7,6 +7,7 @@
7 * @flow
8 */
9
10 +import {copy} from 'clipboard-js';
11 import {dehydrate} from '../hydration';
12
13 import type {DehydratedData} from 'react-devtools-shared/src/devtools/views/Components/types';
@@ -37,6 +38,11 @@ export function cleanForBridge(
38 }
39 }
40
41 +export function copyToClipboard(value: any): void {
42 + const safeToCopy = serializeToString(value);
43 + copy(safeToCopy === undefined ? 'undefined' : safeToCopy);
44 +}
45 +
46 export function copyWithSet(
47 obj: Object | Array<any>,
48 path: Array<string | number>,
@@ -52,3 +58,17 @@ export function copyWithSet(
58 updated[key] = copyWithSet(obj[key], path, value, index + 1);
59 return updated;
60 }
61 +
62 +export function serializeToString(data: any): string {
63 + const cache = new Set();
64 + // Use a custom replacer function to protect against circular references.
65 + return JSON.stringify(data, (key, value) => {
66 + if (typeof value === 'object' && value !== null) {
67 + if (cache.has(value)) {
68 + return;
69 + }
70 + cache.add(value);
71 + }
72 + return value;
73 + });
74 +}
packages/react-devtools-shared/src/bridge.js
+19
@@ -51,11 +51,27 @@ type OverrideSuspense = {|
51 forceFallback: boolean,
52 |};
53
54 +type CopyElementPathParams = {|
55 + ...ElementAndRendererID,
56 + path: Array<string | number>,
57 +|};
58 +
59 +type ViewAttributeSourceParams = {|
60 + ...ElementAndRendererID,
61 + path: Array<string | number>,
62 +|};
63 +
64 type InspectElementParams = {|
65 ...ElementAndRendererID,
66 path?: Array<string | number>,
67 |};
68
69 +type StoreAsGlobalParams = {|
70 + ...ElementAndRendererID,
71 + count: number,
72 + path: Array<string | number>,
73 +|};
74 +
75 type NativeStyleEditor_RenameAttributeParams = {|
76 ...ElementAndRendererID,
77 oldName: string,
@@ -95,6 +111,7 @@ type BackendEvents = {|
111
112 type FrontendEvents = {|
113 clearNativeElementHighlight: [],
114 + copyElementPath: [CopyElementPathParams],
115 getOwnersList: [ElementAndRendererID],
116 getProfilingData: [{|rendererID: RendererID|}],
117 getProfilingStatus: [],
@@ -115,8 +132,10 @@ type FrontendEvents = {|
132 startProfiling: [boolean],
133 stopInspectingNative: [boolean],
134 stopProfiling: [],
135 + storeAsGlobal: [StoreAsGlobalParams],
136 updateAppendComponentStack: [boolean],
137 updateComponentFilters: [Array<ComponentFilter>],
138 + viewAttributeSource: [ViewAttributeSourceParams],
139 viewElementSource: [ElementAndRendererID],
140
141 // React Native style editor plug-in.
packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css new
+7
@@ -0,0 +1,7 @@
1 +.ContextMenu {
2 + position: absolute;
3 + background-color: var(--color-context-background);
4 + border-radius: 0.25rem;
5 + overflow: hidden;
6 + z-index: 10000002;
7 +}
\ No newline at end of file
packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js new
+128
@@ -0,0 +1,128 @@
1 +import React, {
2 + useContext,
3 + useEffect,
4 + useLayoutEffect,
5 + useRef,
6 + useState,
7 +} from 'react';
8 +import {createPortal} from 'react-dom';
9 +import {RegistryContext} from './Contexts';
10 +
11 +import styles from './ContextMenu.css';
12 +
13 +function respositionToFit(element, pageX, pageY) {
14 + const ownerWindow = element.ownerDocument.defaultView;
15 + if (element !== null) {
16 + if (pageY + element.offsetHeight >= ownerWindow.innerHeight) {
17 + if (pageY - element.offsetHeight > 0) {
18 + element.style.top = `${pageY - element.offsetHeight}px`;
19 + } else {
20 + element.style.top = '0px';
21 + }
22 + } else {
23 + element.style.top = `${pageY}px`;
24 + }
25 +
26 + if (pageX + element.offsetWidth >= ownerWindow.innerWidth) {
27 + if (pageX - element.offsetWidth > 0) {
28 + element.style.left = `${pageX - element.offsetWidth}px`;
29 + } else {
30 + element.style.left = '0px';
31 + }
32 + } else {
33 + element.style.left = `${pageX}px`;
34 + }
35 + }
36 +}
37 +
38 +const HIDDEN_STATE = {
39 + data: null,
40 + isVisible: false,
41 + pageX: 0,
42 + pageY: 0,
43 +};
44 +
45 +type Props = {|
46 + children: React$Node,
47 + id: string,
48 +|};
49 +
50 +export default function ContextMenu({children, id}: Props) {
51 + const {registerMenu} = useContext(RegistryContext);
52 +
53 + const [state, setState] = useState(HIDDEN_STATE);
54 +
55 + const bodyAccessorRef = useRef(null);
56 + const containerRef = useRef(null);
57 + const menuRef = useRef(null);
58 +
59 + useEffect(() => {
60 + const ownerDocument = bodyAccessorRef.current.ownerDocument;
61 + containerRef.current = ownerDocument.createElement('div');
62 + ownerDocument.body.appendChild(containerRef.current);
63 + return () => {
64 + ownerDocument.body.removeChild(containerRef.current);
65 + };
66 + }, []);
67 +
68 + useEffect(
69 + () => {
70 + const showMenu = ({data, pageX, pageY}) => {
71 + setState({data, isVisible: true, pageX, pageY});
72 + };
73 + const hideMenu = () => setState(HIDDEN_STATE);
74 + return registerMenu(id, showMenu, hideMenu);
75 + },
76 + [id],
77 + );
78 +
79 + useLayoutEffect(
80 + () => {
81 + if (!state.isVisible) {
82 + return;
83 + }
84 +
85 + const menu = menuRef.current;
86 +
87 + const hideUnlessContains = event => {
88 + if (!menu.contains(event.target)) {
89 + setState(HIDDEN_STATE);
90 + }
91 + };
92 +
93 + const hide = event => {
94 + setState(HIDDEN_STATE);
95 + };
96 +
97 + const ownerDocument = containerRef.current.ownerDocument;
98 + ownerDocument.addEventListener('mousedown', hideUnlessContains);
99 + ownerDocument.addEventListener('touchstart', hideUnlessContains);
100 + ownerDocument.addEventListener('keydown', hideUnlessContains);
101 +
102 + const ownerWindow = ownerDocument.defaultView;
103 + ownerWindow.addEventListener('resize', hide);
104 +
105 + respositionToFit(menu, state.pageX, state.pageY);
106 +
107 + return () => {
108 + ownerDocument.removeEventListener('mousedown', hideUnlessContains);
109 + ownerDocument.removeEventListener('touchstart', hideUnlessContains);
110 + ownerDocument.removeEventListener('keydown', hideUnlessContains);
111 +
112 + ownerWindow.removeEventListener('resize', hide);
113 + };
114 + },
115 + [state],
116 + );
117 +
118 + if (!state.isVisible) {
119 + return <div ref={bodyAccessorRef} />;
120 + } else {
121 + return createPortal(
122 + <div ref={menuRef} className={styles.ContextMenu}>
123 + {children(state.data)}
124 + </div>,
125 + containerRef.current,
126 + );
127 + }
128 +}
packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css new
+22
@@ -0,0 +1,22 @@
1 +.ContextMenuItem {
2 + display: flex;
3 + align-items: center;
4 + color: var(--color-context-text);
5 + padding: 0.5rem 0.75rem;
6 + cursor: default;
7 + border-top: 1px solid var(--color-context-border);
8 + font-family: var(--font-family-sans);
9 + font-size: var(--font-size-sans-normal);
10 +}
11 +.ContextMenuItem:first-of-type {
12 + border-top: none;
13 +}
14 +.ContextMenuItem:hover,
15 +.ContextMenuItem:focus {
16 + outline: 0;
17 + background-color: var(--color-context-background-hover);
18 +}
19 +.ContextMenuItem:active {
20 + background-color: var(--color-context-background-selected);
21 + color: var(--color-context-text-selected);
22 +}
\ No newline at end of file
packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js new
+28
@@ -0,0 +1,28 @@
1 +import React, {useContext} from 'react';
2 +import {RegistryContext} from './Contexts';
3 +
4 +import styles from './ContextMenuItem.css';
5 +
6 +type Props = {|
7 + children: React$Node,
8 + onClick: Object => void,
9 + title: string,
10 +|};
11 +
12 +export default function ContextMenuItem({children, onClick, title}: Props) {
13 + const {hideMenu} = useContext(RegistryContext);
14 +
15 + const handleClick = event => {
16 + onClick();
17 + hideMenu();
18 + };
19 +
20 + return (
21 + <div
22 + className={styles.ContextMenuItem}
23 + onClick={handleClick}
24 + onTouchEnd={handleClick}>
25 + {children}
26 + </div>
27 + );
28 +}
packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js new
+53
@@ -0,0 +1,53 @@
1 +import {createContext} from 'react';
2 +
3 +export type ShowFn = ({data: Object, pageX: number, pageY: number}) => void;
4 +export type HideFn = () => void;
5 +
6 +const idToShowFnMap = new Map();
7 +const idToHideFnMap = new Map();
8 +
9 +let currentHideFn = null;
10 +
11 +function hideMenu() {
12 + if (typeof currentHideFn === 'function') {
13 + currentHideFn();
14 + }
15 +}
16 +
17 +function showMenu({
18 + data,
19 + id,
20 + pageX,
21 + pageY,
22 +}: {|
23 + data: Object,
24 + id: string,
25 + pageX: number,
26 + pageY: number,
27 +|}) {
28 + const showFn = idToShowFnMap.get(id);
29 + if (typeof showFn === 'function') {
30 + currentHideFn = idToHideFnMap.get(id);
31 + showFn({data, pageX, pageY});
32 + }
33 +}
34 +
35 +function registerMenu(id: string, showFn: ShowFn, hideFn: HideFn) {
36 + if (idToShowFnMap.has(id)) {
37 + throw Error(`Context menu with id "${id}" already registered.`);
38 + }
39 +
40 + idToShowFnMap.set(id, showFn);
41 + idToHideFnMap.set(id, hideFn);
42 +
43 + return function unregisterMenu() {
44 + idToShowFnMap.delete(id, showFn);
45 + idToHideFnMap.delete(id, hideFn);
46 + };
47 +}
48 +
49 +export const RegistryContext = createContext({
50 + hideMenu,
51 + showMenu,
52 + registerMenu,
53 +});
packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js new
+32
@@ -0,0 +1,32 @@
1 +import {useContext, useEffect} from 'react';
2 +import {RegistryContext} from './Contexts';
3 +
4 +export default function useContextMenu({data, id, ref}) {
5 + const {showMenu} = useContext(RegistryContext);
6 +
7 + useEffect(
8 + () => {
9 + if (ref.current !== null) {
10 + const handleContextMenu = event => {
11 + event.preventDefault();
12 + event.stopPropagation();
13 +
14 + const pageX =
15 + event.pageX || (event.touches && event.touches[0].pageX);
16 + const pageY =
17 + event.pageY || (event.touches && event.touches[0].pageY);
18 +
19 + showMenu({data, id, pageX, pageY});
20 + };
21 +
22 + const trigger = ref.current;
23 + trigger.addEventListener('contextmenu', handleContextMenu);
24 +
25 + return () => {
26 + trigger.removeEventListener('contextmenu', handleContextMenu);
27 + };
28 + }
29 + },
30 + [data, id, showMenu],
31 + );
32 +}
packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js
+24 -4
@@ -8,7 +8,7 @@
8 */
9
10 import {copy} from 'clipboard-js';
11 -import React, {useCallback, useContext, useState} from 'react';
11 +import React, {useCallback, useContext, useRef, useState} from 'react';
12 import {BridgeContext, StoreContext} from '../context';
13 import Button from '../Button';
14 import ButtonIcon from '../ButtonIcon';
@@ -18,6 +18,7 @@ import {InspectedElementContext} from './InspectedElementContext';
18 import KeyValue from './KeyValue';
19 import {serializeHooksForCopy} from '../utils';
20 import styles from './HooksTree.css';
21 +import useContextMenu from '../../ContextMenu/useContextMenu';
22 import {meta} from '../../../hydration';
23
24 import type {InspectPath} from './SelectedElement';
@@ -113,6 +114,22 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
114 [],
115 );
116
117 + const contextMenuTriggerRef = useRef(null);
118 +
119 + useContextMenu({
120 + data: {
121 + path: ['hooks', ...path],
122 + type:
123 + hook !== null &&
124 + typeof hook === 'object' &&
125 + hook.hasOwnProperty(meta.type)
126 + ? hook[meta.type]
127 + : typeof value,
128 + },
129 + id: 'SelectedElement',
130 + ref: contextMenuTriggerRef,
131 + });
132 +
133 if (hook.hasOwnProperty(meta.inspected)) {
134 // This Hook is too deep and hasn't been hydrated.
135 if (__DEV__) {
@@ -169,6 +186,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
186 inspectPath={inspectPath}
187 name="subHooks"
188 path={path.concat(['subHooks'])}
189 + pathRoot="hooks"
190 value={subHooks}
191 />
192 );
@@ -176,7 +194,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
194 if (isComplexDisplayValue) {
195 return (
196 <div className={styles.Hook}>
179 - <div className={styles.NameValueRow}>
197 + <div ref={contextMenuTriggerRef} className={styles.NameValueRow}>
198 <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
199 <span
200 onClick={toggleIsOpen}
@@ -191,6 +209,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
209 inspectPath={inspectPath}
210 name="DebugValue"
211 path={path.concat(['value'])}
212 + pathRoot="hooks"
213 value={value}
214 />
215 {subHooksView}
@@ -200,7 +219,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
219 } else {
220 return (
221 <div className={styles.Hook}>
203 - <div className={styles.NameValueRow}>
222 + <div ref={contextMenuTriggerRef} className={styles.NameValueRow}>
223 <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
224 <span
225 onClick={toggleIsOpen}
@@ -253,6 +272,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
272 name={name}
273 overrideValueFn={overrideValueFn}
274 path={path.concat(['value'])}
275 + pathRoot="hooks"
276 value={value}
277 />
278 </div>
@@ -260,7 +280,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
280 } else {
281 return (
282 <div className={styles.Hook}>
263 - <div className={styles.NameValueRow}>
283 + <div ref={contextMenuTriggerRef} className={styles.NameValueRow}>
284 <span className={styles.ExpandCollapseToggleSpacer} />
285 <span
286 className={
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js
+53 -2
@@ -13,6 +13,7 @@ import React, {
13 useContext,
14 useEffect,
15 useMemo,
16 + useRef,
17 useState,
18 } from 'react';
19 import {unstable_batchedUpdates as batchedUpdates} from 'react-dom';
@@ -33,17 +34,27 @@ import type {
34 } from 'react-devtools-shared/src/devtools/views/Components/types';
35 import type {Resource, Thenable} from '../../cache';
36
37 +export type StoreAsGlobal = (id: number, path: Array<string | number>) => void;
38 +
39 +export type CopyInspectedElementPath = (
40 + id: number,
41 + path: Array<string | number>,
42 +) => void;
43 +
44 export type GetInspectedElementPath = (
45 id: number,
46 path: Array<string | number>,
47 ) => void;
48 +
49 export type GetInspectedElement = (
50 id: number,
51 ) => InspectedElementFrontend | null;
52
53 type Context = {|
54 + copyInspectedElementPath: CopyInspectedElementPath,
55 getInspectedElementPath: GetInspectedElementPath,
56 getInspectedElement: GetInspectedElement,
57 + storeAsGlobal: StoreAsGlobal,
58 |};
59
60 const InspectedElementContext = createContext<Context>(((null: any): Context));
@@ -88,6 +99,35 @@ function InspectedElementContextController({children}: Props) {
99 const bridge = useContext(BridgeContext);
100 const store = useContext(StoreContext);
101
102 + const storeAsGlobalCount = useRef(1);
103 +
104 + // Ask the backend to store the value at the specified path as a global variable.
105 + const storeAsGlobal = useCallback<GetInspectedElementPath>(
106 + (id: number, path: Array<string | number>) => {
107 + const rendererID = store.getRendererIDForElement(id);
108 + if (rendererID !== null) {
109 + bridge.send('storeAsGlobal', {
110 + count: storeAsGlobalCount.current++,
111 + id,
112 + path,
113 + rendererID,
114 + });
115 + }
116 + },
117 + [bridge, store],
118 + );
119 +
120 + // Ask the backend to copy the specified path to the clipboard.
121 + const copyInspectedElementPath = useCallback<GetInspectedElementPath>(
122 + (id: number, path: Array<string | number>) => {
123 + const rendererID = store.getRendererIDForElement(id);
124 + if (rendererID !== null) {
125 + bridge.send('copyElementPath', {id, path, rendererID});
126 + }
127 + },
128 + [bridge, store],
129 + );
130 +
131 // Ask the backend to fill in a "dehydrated" path; this will result in a "inspectedElement".
132 const getInspectedElementPath = useCallback<GetInspectedElementPath>(
133 (id: number, path: Array<string | number>) => {
@@ -287,9 +327,20 @@ function InspectedElementContextController({children}: Props) {
327 );
328
329 const value = useMemo(
290 - () => ({getInspectedElement, getInspectedElementPath}),
330 + () => ({
331 + copyInspectedElementPath,
332 + getInspectedElement,
333 + getInspectedElementPath,
334 + storeAsGlobal,
335 + }),
336 // InspectedElement is used to invalidate the cache and schedule an update with React.
292 - [currentlyInspectedElement, getInspectedElement, getInspectedElementPath],
337 + [
338 + copyInspectedElementPath,
339 + currentlyInspectedElement,
340 + getInspectedElement,
341 + getInspectedElementPath,
342 + storeAsGlobal,
343 + ],
344 );
345
346 return (
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js
+3
@@ -26,6 +26,7 @@ type Props = {|
26 inspectPath?: InspectPath,
27 label: string,
28 overrideValueFn?: ?OverrideValueFn,
29 + pathRoot: string,
30 showWhenEmpty?: boolean,
31 canAddEntries?: boolean,
32 |};
@@ -35,6 +36,7 @@ export default function InspectedElementTree({
36 inspectPath,
37 label,
38 overrideValueFn,
39 + pathRoot,
40 canAddEntries = false,
41 showWhenEmpty = false,
42 }: Props) {
@@ -88,6 +90,7 @@ export default function InspectedElementTree({
90 <KeyValue
91 key={name}
92 alphaSort={true}
93 + pathRoot={pathRoot}
94 depth={1}
95 inspectPath={inspectPath}
96 name={name}
packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js
+36 -3
@@ -8,13 +8,14 @@
8 */
9
10 import React, {useEffect, useRef, useState} from 'react';
11 -import type {Element} from 'react';
11 import EditableValue from './EditableValue';
12 import ExpandCollapseToggle from './ExpandCollapseToggle';
13 import {alphaSortEntries, getMetaValueLabel} from '../utils';
14 import {meta} from '../../../hydration';
15 +import useContextMenu from '../../ContextMenu/useContextMenu';
16 import styles from './KeyValue.css';
17
18 +import type {Element} from 'react';
19 import type {InspectPath} from './SelectedElement';
20
21 type OverrideValueFn = (path: Array<string | number>, value: any) => void;
@@ -28,6 +29,7 @@ type KeyValueProps = {|
29 name: string,
30 overrideValueFn?: ?OverrideValueFn,
31 path: Array<any>,
32 + pathRoot: string,
33 value: any,
34 |};
35
@@ -40,10 +42,12 @@ export default function KeyValue({
42 name,
43 overrideValueFn,
44 path,
45 + pathRoot,
46 value,
47 }: KeyValueProps) {
48 const [isOpen, setIsOpen] = useState<boolean>(false);
49 const prevIsOpenRef = useRef(isOpen);
50 + const contextMenuTriggerRef = useRef(null);
51
52 const isInspectable =
53 value !== null &&
@@ -68,6 +72,20 @@ export default function KeyValue({
72
73 const toggleIsOpen = () => setIsOpen(prevIsOpen => !prevIsOpen);
74
75 + useContextMenu({
76 + data: {
77 + path: [pathRoot, ...path],
78 + type:
79 + value !== null &&
80 + typeof value === 'object' &&
81 + value.hasOwnProperty(meta.type)
82 + ? value[meta.type]
83 + : typeof value,
84 + },
85 + id: 'SelectedElement',
86 + ref: contextMenuTriggerRef,
87 + });
88 +
89 const dataType = typeof value;
90 const isSimpleType =
91 dataType === 'number' ||
@@ -95,7 +113,13 @@ export default function KeyValue({
113 const isEditable = typeof overrideValueFn === 'function' && !isReadOnly;
114
115 children = (
98 - <div key="root" className={styles.Item} hidden={hidden} style={style}>
116 + <div
117 + key="root"
118 + path={path}
119 + className={styles.Item}
120 + hidden={hidden}
121 + ref={contextMenuTriggerRef}
122 + style={style}>
123 <div className={styles.ExpandCollapseToggleSpacer} />
124 <span className={isEditable ? styles.EditableName : styles.Name}>
125 {name}
@@ -116,7 +140,12 @@ export default function KeyValue({
140 !value.hasOwnProperty(meta.unserializable)
141 ) {
142 children = (
119 - <div key="root" className={styles.Item} hidden={hidden} style={style}>
143 + <div
144 + ref={contextMenuTriggerRef}
145 + key="root"
146 + className={styles.Item}
147 + hidden={hidden}
148 + style={style}>
149 {isInspectable ? (
150 <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
151 ) : (
@@ -150,11 +179,13 @@ export default function KeyValue({
179 name={index}
180 overrideValueFn={overrideValueFn}
181 path={path.concat(index)}
182 + pathRoot={pathRoot}
183 value={value[index]}
184 />
185 ));
186 children.unshift(
187 <div
188 + ref={contextMenuTriggerRef}
189 key={`${depth}-root`}
190 className={styles.Item}
191 hidden={hidden}
@@ -200,11 +231,13 @@ export default function KeyValue({
231 name={key}
232 overrideValueFn={overrideValueFn}
233 path={path.concat(key)}
234 + pathRoot={pathRoot}
235 value={keyValue}
236 />
237 ));
238 children.unshift(
239 <div
240 + ref={contextMenuTriggerRef}
241 key={`${depth}-root`}
242 className={styles.Item}
243 hidden={hidden}
packages/react-devtools-shared/src/devtools/views/Components/SelectedElement.css
+4
@@ -112,3 +112,7 @@
112 margin-left: 0.5rem;
113 padding: 0;
114 }
115 +
116 +.ContextMenuIcon {
117 + margin-right: 0.5rem;
118 +}
\ No newline at end of file
packages/react-devtools-shared/src/devtools/views/Components/SelectedElement.js
+120 -57
@@ -8,11 +8,14 @@
8 */
9
10 import {copy} from 'clipboard-js';
11 -import React, {useCallback, useContext} from 'react';
11 +import React, {Fragment, useCallback, useContext} from 'react';
12 import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
13 -import {BridgeContext, StoreContext} from '../context';
13 +import {BridgeContext, ContextMenuContext, StoreContext} from '../context';
14 +import ContextMenu from '../../ContextMenu/ContextMenu';
15 +import ContextMenuItem from '../../ContextMenu/ContextMenuItem';
16 import Button from '../Button';
17 import ButtonIcon from '../ButtonIcon';
18 +import Icon from '../Icon';
19 import HooksTree from './HooksTree';
20 import {ModalDialogContext} from '../ModalDialog';
21 import HocBadges from './HocBadges';
@@ -33,7 +36,11 @@ import {
36
37 import styles from './SelectedElement.css';
38
36 -import type {GetInspectedElementPath} from './InspectedElementContext';
39 +import type {
40 + CopyInspectedElementPath,
41 + GetInspectedElementPath,
42 + StoreAsGlobal,
43 +} from './InspectedElementContext';
44 import type {Element, InspectedElement} from './types';
45 import type {ElementType} from 'react-devtools-shared/src/types';
46
@@ -49,9 +56,13 @@ export default function SelectedElement(_: Props) {
56 const store = useContext(StoreContext);
57 const {dispatch: modalDialogDispatch} = useContext(ModalDialogContext);
58
52 - const {getInspectedElementPath, getInspectedElement} = useContext(
53 - InspectedElementContext,
54 - );
59 + const {
60 + copyInspectedElementPath,
61 + getInspectedElementPath,
62 + getInspectedElement,
63 + storeAsGlobal,
64 + viewInspectedElementPath,
65 + } = useContext(InspectedElementContext);
66
67 const element =
68 inspectedElementID !== null
@@ -239,29 +250,38 @@ export default function SelectedElement(_: Props) {
250 key={
251 inspectedElementID /* Force reset when seleted Element changes */
252 }
253 + copyInspectedElementPath={copyInspectedElementPath}
254 element={element}
255 getInspectedElementPath={getInspectedElementPath}
256 inspectedElement={inspectedElement}
257 + storeAsGlobal={storeAsGlobal}
258 + viewInspectedElementPath={viewInspectedElementPath}
259 />
260 )}
261 </div>
262 );
263 }
264
265 +export type CopyPath = (path: Array<string | number>) => void;
266 export type InspectPath = (path: Array<string | number>) => void;
267
268 type InspectedElementViewProps = {|
269 + copyInspectedElementPath: CopyInspectedElementPath,
270 element: Element,
271 getInspectedElementPath: GetInspectedElementPath,
272 inspectedElement: InspectedElement,
273 + storeAsGlobal: StoreAsGlobal,
274 |};
275
276 const IS_SUSPENDED = 'Suspended';
277
278 function InspectedElementView({
279 + copyInspectedElementPath,
280 element,
281 getInspectedElementPath,
282 inspectedElement,
283 + storeAsGlobal,
284 + viewInspectedElementPath,
285 }: InspectedElementViewProps) {
286 const {id, type} = element;
287 const {
@@ -281,6 +301,11 @@ function InspectedElementView({
301 const bridge = useContext(BridgeContext);
302 const store = useContext(StoreContext);
303
304 + const {
305 + isEnabledForInspectedElement,
306 + viewAttributeSourceFunction,
307 + } = useContext(ContextMenuContext);
308 +
309 const inspectContextPath = useCallback(
310 (path: Array<string | number>) => {
311 getInspectedElementPath(id, ['context', ...path]);
@@ -352,64 +377,102 @@ function InspectedElementView({
377 }
378
379 return (
355 - <div className={styles.InspectedElement}>
356 - <HocBadges element={element} />
357 - <InspectedElementTree
358 - label="props"
359 - data={props}
360 - inspectPath={inspectPropsPath}
361 - overrideValueFn={overridePropsFn}
362 - showWhenEmpty={true}
363 - canAddEntries={typeof overridePropsFn === 'function'}
364 - />
365 - {type === ElementTypeSuspense ? (
380 + <Fragment>
381 + <div className={styles.InspectedElement}>
382 + <HocBadges element={element} />
383 <InspectedElementTree
367 - label="suspense"
368 - data={{
369 - [IS_SUSPENDED]: state !== null,
370 - }}
371 - overrideValueFn={overrideSuspenseFn}
384 + label="props"
385 + data={props}
386 + inspectPath={inspectPropsPath}
387 + overrideValueFn={overridePropsFn}
388 + pathRoot="props"
389 + showWhenEmpty={true}
390 + canAddEntries={typeof overridePropsFn === 'function'}
391 />
373 - ) : (
392 + {type === ElementTypeSuspense ? (
393 + <InspectedElementTree
394 + label="suspense"
395 + data={{
396 + [IS_SUSPENDED]: state !== null,
397 + }}
398 + overrideValueFn={overrideSuspenseFn}
399 + />
400 + ) : (
401 + <InspectedElementTree
402 + label="state"
403 + data={state}
404 + inspectPath={inspectStatePath}
405 + overrideValueFn={overrideStateFn}
406 + pathRoot="state"
407 + />
408 + )}
409 + <HooksTree canEditHooks={canEditHooks} hooks={hooks} id={id} />
410 <InspectedElementTree
375 - label="state"
376 - data={state}
377 - inspectPath={inspectStatePath}
378 - overrideValueFn={overrideStateFn}
411 + label={hasLegacyContext ? 'legacy context' : 'context'}
412 + data={context}
413 + inspectPath={inspectContextPath}
414 + overrideValueFn={overrideContextFn}
415 + pathRoot="context"
416 />
380 - )}
381 - <HooksTree canEditHooks={canEditHooks} hooks={hooks} id={id} />
382 - <InspectedElementTree
383 - label={hasLegacyContext ? 'legacy context' : 'context'}
384 - data={context}
385 - inspectPath={inspectContextPath}
386 - overrideValueFn={overrideContextFn}
387 - />
388 -
389 - <NativeStyleEditor />
390 -
391 - {ownerID === null &&
392 - owners !== null &&
393 - owners.length > 0 && (
394 - <div className={styles.Owners}>
395 - <div className={styles.OwnersHeader}>rendered by</div>
396 - {owners.map(owner => (
397 - <OwnerView
398 - key={owner.id}
399 - displayName={owner.displayName || 'Anonymous'}
400 - hocDisplayNames={owner.hocDisplayNames}
401 - id={owner.id}
402 - isInStore={store.containsElement(owner.id)}
403 - type={owner.type}
404 - />
405 - ))}
406 - </div>
417 +
418 + <NativeStyleEditor />
419 +
420 + {ownerID === null &&
421 + owners !== null &&
422 + owners.length > 0 && (
423 + <div className={styles.Owners}>
424 + <div className={styles.OwnersHeader}>rendered by</div>
425 + {owners.map(owner => (
426 + <OwnerView
427 + key={owner.id}
428 + displayName={owner.displayName || 'Anonymous'}
429 + hocDisplayNames={owner.hocDisplayNames}
430 + id={owner.id}
431 + isInStore={store.containsElement(owner.id)}
432 + type={owner.type}
433 + />
434 + ))}
435 + </div>
436 + )}
437 +
438 + {source !== null && (
439 + <Source fileName={source.fileName} lineNumber={source.lineNumber} />
440 )}
441 + </div>
442
409 - {source !== null && (
410 - <Source fileName={source.fileName} lineNumber={source.lineNumber} />
443 + {isEnabledForInspectedElement && (
444 + <ContextMenu id="SelectedElement">
445 + {data => (
446 + <Fragment>
447 + <ContextMenuItem
448 + onClick={() => copyInspectedElementPath(id, data.path)}
449 + title="Copy value to clipboard">
450 + <Icon className={styles.ContextMenuIcon} type="copy" /> Copy
451 + value to clipboard
452 + </ContextMenuItem>
453 + <ContextMenuItem
454 + onClick={() => storeAsGlobal(id, data.path)}
455 + title="Store as global variable">
456 + <Icon
457 + className={styles.ContextMenuIcon}
458 + type="store-as-global-variable"
459 + />{' '}
460 + Store as global variable
461 + </ContextMenuItem>
462 + {viewAttributeSourceFunction !== null &&
463 + data.type === 'function' && (
464 + <ContextMenuItem
465 + onClick={() => viewAttributeSourceFunction(id, data.path)}
466 + title="Go to definition">
467 + <Icon className={styles.ContextMenuIcon} type="code" /> Go
468 + to definition
469 + </ContextMenuItem>
470 + )}
471 + </Fragment>
472 + )}
473 + </ContextMenu>
474 )}
412 - </div>
475 + </Fragment>
476 );
477 }
478
packages/react-devtools-shared/src/devtools/views/DevTools.js
+62 -42
@@ -14,7 +14,7 @@ import '@reach/tooltip/styles.css';
14
15 import React, {useEffect, useMemo, useState} from 'react';
16 import Store from '../store';
17 -import {BridgeContext, StoreContext} from './context';
17 +import {BridgeContext, ContextMenuContext, StoreContext} from './context';
18 import Components from './Components/Components';
19 import Profiler from './Profiler/Profiler';
20 import TabBar from './TabBar';
@@ -40,6 +40,10 @@ export type ViewElementSource = (
40 id: number,
41 inspectedElement: InspectedElement,
42 ) => void;
43 +export type ViewAttributeSource = (
44 + id: number,
45 + path: Array<string | number>,
46 +) => void;
47 export type CanViewElementSource = (
48 inspectedElement: InspectedElement,
49 ) => boolean;
@@ -49,10 +53,12 @@ export type Props = {|
53 browserTheme?: BrowserTheme,
54 canViewElementSourceFunction?: ?CanViewElementSource,
55 defaultTab?: TabID,
56 + enabledInspectedElementContextMenu?: boolean,
57 showTabBar?: boolean,
58 store: Store,
59 warnIfLegacyBackendDetected?: boolean,
60 warnIfUnsupportedVersionDetected?: boolean,
61 + viewAttributeSourceFunction?: ?ViewAttributeSource,
62 viewElementSourceFunction?: ?ViewElementSource,
63
64 // This property is used only by the web extension target.
@@ -87,14 +93,16 @@ export default function DevTools({
93 bridge,
94 browserTheme = 'light',
95 canViewElementSourceFunction,
90 - defaultTab = 'components',
96 componentsPortalContainer,
97 + defaultTab = 'components',
98 + enabledInspectedElementContextMenu = false,
99 overrideTab,
100 profilerPortalContainer,
101 showTabBar = false,
102 store,
103 warnIfLegacyBackendDetected = false,
104 warnIfUnsupportedVersionDetected = false,
105 + viewAttributeSourceFunction,
106 viewElementSourceFunction,
107 }: Props) {
108 const [tab, setTab] = useState(defaultTab);
@@ -110,6 +118,14 @@ export default function DevTools({
118 [canViewElementSourceFunction, viewElementSourceFunction],
119 );
120
121 + const contextMenu = useMemo(
122 + () => ({
123 + isEnabledForInspectedElement: enabledInspectedElementContextMenu,
124 + viewAttributeSourceFunction: viewAttributeSourceFunction || null,
125 + }),
126 + [enabledInspectedElementContextMenu, viewAttributeSourceFunction],
127 + );
128 +
129 useEffect(
130 () => {
131 return () => {
@@ -126,49 +142,53 @@ export default function DevTools({
142 return (
143 <BridgeContext.Provider value={bridge}>
144 <StoreContext.Provider value={store}>
129 - <ModalDialogContextController>
130 - <SettingsContextController
131 - browserTheme={browserTheme}
132 - componentsPortalContainer={componentsPortalContainer}
133 - profilerPortalContainer={profilerPortalContainer}>
134 - <ViewElementSourceContext.Provider value={viewElementSource}>
135 - <TreeContextController>
136 - <ProfilerContextController>
137 - <div className={styles.DevTools}>
138 - {showTabBar && (
139 - <div className={styles.TabBar}>
140 - <ReactLogo />
141 - <span className={styles.DevToolsVersion}>
142 - {process.env.DEVTOOLS_VERSION}
143 - </span>
144 - <div className={styles.Spacer} />
145 - <TabBar
146 - currentTab={tab}
147 - id="DevTools"
148 - selectTab={setTab}
149 - tabs={tabs}
150 - type="navigation"
145 + <ContextMenuContext.Provider value={contextMenu}>
146 + <ModalDialogContextController>
147 + <SettingsContextController
148 + browserTheme={browserTheme}
149 + componentsPortalContainer={componentsPortalContainer}
150 + profilerPortalContainer={profilerPortalContainer}>
151 + <ViewElementSourceContext.Provider value={viewElementSource}>
152 + <TreeContextController>
153 + <ProfilerContextController>
154 + <div className={styles.DevTools}>
155 + {showTabBar && (
156 + <div className={styles.TabBar}>
157 + <ReactLogo />
158 + <span className={styles.DevToolsVersion}>
159 + {process.env.DEVTOOLS_VERSION}
160 + </span>
161 + <div className={styles.Spacer} />
162 + <TabBar
163 + currentTab={tab}
164 + id="DevTools"
165 + selectTab={setTab}
166 + tabs={tabs}
167 + type="navigation"
168 + />
169 + </div>
170 + )}
171 + <div
172 + className={styles.TabContent}
173 + hidden={tab !== 'components'}>
174 + <Components
175 + portalContainer={componentsPortalContainer}
176 />
177 </div>
153 - )}
154 - <div
155 - className={styles.TabContent}
156 - hidden={tab !== 'components'}>
157 - <Components portalContainer={componentsPortalContainer} />
158 - </div>
159 - <div
160 - className={styles.TabContent}
161 - hidden={tab !== 'profiler'}>
162 - <Profiler portalContainer={profilerPortalContainer} />
178 + <div
179 + className={styles.TabContent}
180 + hidden={tab !== 'profiler'}>
181 + <Profiler portalContainer={profilerPortalContainer} />
182 + </div>
183 </div>
164 - </div>
165 - </ProfilerContextController>
166 - </TreeContextController>
167 - </ViewElementSourceContext.Provider>
168 - </SettingsContextController>
169 - {warnIfLegacyBackendDetected && <WarnIfLegacyBackendDetected />}
170 - {warnIfUnsupportedVersionDetected && <UnsupportedVersionDialog />}
171 - </ModalDialogContextController>
184 + </ProfilerContextController>
185 + </TreeContextController>
186 + </ViewElementSourceContext.Provider>
187 + </SettingsContextController>
188 + {warnIfLegacyBackendDetected && <WarnIfLegacyBackendDetected />}
189 + {warnIfUnsupportedVersionDetected && <UnsupportedVersionDialog />}
190 + </ModalDialogContextController>
191 + </ContextMenuContext.Provider>
192 </StoreContext.Provider>
193 </BridgeContext.Provider>
194 );
packages/react-devtools-shared/src/devtools/views/Icon.js
+29 -1
@@ -12,13 +12,16 @@ import styles from './Icon.css';
12
13 export type IconType =
14 | 'arrow'
15 + | 'code'
16 | 'components'
17 + | 'copy'
18 | 'flame-chart'
19 | 'interactions'
20 | 'profiler'
21 | 'ranked-chart'
22 | 'search'
21 - | 'settings';
23 + | 'settings'
24 + | 'store-as-global-variable';
25
26 type Props = {|
27 className?: string,
@@ -31,9 +34,15 @@ export default function Icon({className = '', type}: Props) {
34 case 'arrow':
35 pathData = PATH_ARROW;
36 break;
37 + case 'code':
38 + pathData = PATH_CODE;
39 + break;
40 case 'components':
41 pathData = PATH_COMPONENTS;
42 break;
43 + case 'copy':
44 + pathData = PATH_COPY;
45 + break;
46 case 'flame-chart':
47 pathData = PATH_FLAME_CHART;
48 break;
@@ -52,6 +61,9 @@ export default function Icon({className = '', type}: Props) {
61 case 'settings':
62 pathData = PATH_SETTINGS;
63 break;
64 + case 'store-as-global-variable':
65 + pathData = PATH_STORE_AS_GLOBAL_VARIABLE;
66 + break;
67 default:
68 console.warn(`Unsupported type "${type}" specified for Icon`);
69 break;
@@ -72,9 +84,18 @@ export default function Icon({className = '', type}: Props) {
84
85 const PATH_ARROW = 'M8 5v14l11-7z';
86
87 +const PATH_CODE = `
88 + M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z
89 + `;
90 +
91 const PATH_COMPONENTS =
92 'M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z';
93
94 +const PATH_COPY = `
95 + M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3a2 2 0 0 0 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2
96 + 2v10a2 2 0 0 0 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z
97 +`;
98 +
99 const PATH_FLAME_CHART = `
100 M10.0650893,21.5040462 C7.14020814,20.6850349 5,18.0558698 5,14.9390244 C5,14.017627
101 5,9.81707317 7.83333333,7.37804878 C7.83333333,7.37804878 7.58333333,11.199187 10,
@@ -115,3 +136,10 @@ const PATH_SETTINGS = `
136 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5
137 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z
138 `;
139 +
140 +const PATH_STORE_AS_GLOBAL_VARIABLE = `
141 + M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41
142 + 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04
143 + 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6
144 + 8h-4v-2h4v2zm0-4h-4v-2h4v2z
145 +`;
packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js
+10
@@ -313,6 +313,16 @@ function updateThemeVariables(
313 'color-component-badge-count-inverted',
314 documentElements,
315 );
316 + updateStyleHelper(theme, 'color-context-background', documentElements);
317 + updateStyleHelper(theme, 'color-context-background-hover', documentElements);
318 + updateStyleHelper(
319 + theme,
320 + 'color-context-background-selected',
321 + documentElements,
322 + );
323 + updateStyleHelper(theme, 'color-context-border', documentElements);
324 + updateStyleHelper(theme, 'color-context-text', documentElements);
325 + updateStyleHelper(theme, 'color-context-text-selected', documentElements);
326 updateStyleHelper(theme, 'color-dim', documentElements);
327 updateStyleHelper(theme, 'color-dimmer', documentElements);
328 updateStyleHelper(theme, 'color-dimmest', documentElements);
packages/react-devtools-shared/src/devtools/views/context.js
+12
@@ -10,6 +10,7 @@
10 import {createContext} from 'react';
11 import Store from '../store';
12
13 +import type {ViewAttributeSource} from 'react-devtools-shared/src/devtools/views/DevTools';
14 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
15
16 export const BridgeContext = createContext<FrontendBridge>(
@@ -19,3 +20,14 @@ BridgeContext.displayName = 'BridgeContext';
20
21 export const StoreContext = createContext<Store>(((null: any): Store));
22 StoreContext.displayName = 'StoreContext';
23 +
24 +export type ContextMenuContextType = {|
25 + isEnabledForInspectedElement: boolean,
26 + viewAttributeSourceFunction?: ?ViewAttributeSource,
27 +|};
28 +
29 +export const ContextMenuContext = createContext<ContextMenuContextType>({
30 + isEnabledForInspectedElement: false,
31 + viewAttributeSourceFunction: null,
32 +});
33 +ContextMenuContext.displayName = 'ContextMenuContext';
packages/react-devtools-shared/src/devtools/views/root.css
+12
@@ -43,6 +43,12 @@
43 --light-color-component-badge-background-inverted: rgba(255, 255, 255, 0.25);
44 --light-color-component-badge-count: #777d88;
45 --light-color-component-badge-count-inverted: rgba(255, 255, 255, 0.7);
46 + --light-color-context-background: rgba(0,0,0,.9);
47 + --light-color-context-background-hover: rgba(255, 255, 255, 0.1);
48 + --light-color-context-background-selected: #178fb9;
49 + --light-color-context-border: #3d424a;
50 + --light-color-context-text: #ffffff;
51 + --light-color-context-text-selected: #ffffff;
52 --light-color-dim: #777d88;
53 --light-color-dimmer: #cfd1d5;
54 --light-color-dimmest: #eff0f1;
@@ -109,6 +115,12 @@
115 --dark-color-component-badge-background-inverted: rgba(0, 0, 0, 0.25);
116 --dark-color-component-badge-count: #8f949d;
117 --dark-color-component-badge-count-inverted: rgba(255, 255, 255, 0.7);
118 + --dark-color-context-background: rgba(255,255,255,.9);
119 + --dark-color-context-background-hover: rgba(0, 136, 250, 0.1);
120 + --dark-color-context-background-selected: #0088fa;
121 + --dark-color-context-border: #eeeeee;
122 + --dark-color-context-text: #000000;
123 + --dark-color-context-text-selected: #ffffff;
124 --dark-color-dim: #8f949d;
125 --dark-color-dimmer: #777d88;
126 --dark-color-dimmest: #4f5766;
packages/react-devtools-shell/src/app/InspectableElements/CircularReferences.js new
+27
@@ -0,0 +1,27 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 React from 'react';
11 +
12 +const arrayOne = [];
13 +const arrayTwo = [];
14 +arrayTwo.push(arrayOne);
15 +arrayOne.push(arrayTwo);
16 +
17 +const objectOne = {};
18 +const objectTwo = {objectOne};
19 +objectOne.objectTwo = objectTwo;
20 +
21 +export default function CircularReferences() {
22 + return <ChildComponent arrayOne={arrayOne} objectOne={objectOne} />;
23 +}
24 +
25 +function ChildComponent(props: any) {
26 + return null;
27 +}
packages/react-devtools-shell/src/app/InspectableElements/InspectableElements.js
+2
@@ -9,6 +9,7 @@
9
10 import React, {Fragment} from 'react';
11 import UnserializableProps from './UnserializableProps';
12 +import CircularReferences from './CircularReferences';
13 import Contexts from './Contexts';
14 import CustomHooks from './CustomHooks';
15 import CustomObject from './CustomObject';
@@ -27,6 +28,7 @@ export default function InspectableElements() {
28 <Contexts />
29 <CustomHooks />
30 <CustomObject />
31 + <CircularReferences />
32 </Fragment>
33 );
34 }
packages/react-devtools-shell/src/app/InspectableElements/SimpleValues.js
+3
@@ -9,6 +9,8 @@
9
10 import React from 'react';
11
12 +function noop() {}
13 +
14 export default function SimpleValues() {
15 return (
16 <ChildComponent
@@ -21,6 +23,7 @@ export default function SimpleValues() {
23 infinity={Infinity}
24 true={true}
25 false={false}
26 + function={noop}
27 />
28 );
29 }
packages/react-devtools-shell/src/devtools.js
+1
@@ -55,6 +55,7 @@ inject('dist/app.js', () => {
55 root.render(
56 createElement(DevTools, {
57 browserTheme: 'light',
58 + enabledInspectedElementContextMenu: true,
59 showTabBar: true,
60 warnIfLegacyBackendDetected: true,
61 warnIfUnsupportedVersionDetected: true,