Skip to content

Commit 933f6a0

Browse files
author
Brian Vaughn
authored
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
1 parent 7dc9745 commit 933f6a0

32 files changed

+1154
-155
lines changed

packages/react-devtools-extensions/src/main.js

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@ import {createElement} from 'react';
44
import {createRoot, flushSync} from 'react-dom';
55
import Bridge from 'react-devtools-shared/src/bridge';
66
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';
128
import {LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY} from 'react-devtools-shared/src/constants';
139
import {
1410
getSavedComponentFilters,
@@ -155,10 +151,54 @@ function createPanelIfReactLoaded() {
155151
},
156152
);
157153

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+
};
162202

163203
root = createRoot(document.createElement('div'));
164204

@@ -170,11 +210,13 @@ function createPanelIfReactLoaded() {
170210
bridge,
171211
browserTheme: getBrowserTheme(),
172212
componentsPortalContainer,
213+
enabledInspectedElementContextMenu: true,
173214
overrideTab,
174215
profilerPortalContainer,
175216
showTabBar: false,
176-
warnIfUnsupportedVersionDetected: true,
177217
store,
218+
warnIfUnsupportedVersionDetected: true,
219+
viewAttributeSourceFunction,
178220
viewElementSourceFunction,
179221
}),
180222
);

packages/react-devtools-extensions/src/utils.js

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,6 @@
22

33
const IS_CHROME = navigator.userAgent.indexOf('Firefox') < 0;
44

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-
375
export type BrowserName = 'Chrome' | 'Firefox';
386

397
export function getBrowserName(): BrowserName {

packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
*/
99

1010
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';
1216
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
1317
import type Store from 'react-devtools-shared/src/devtools/store';
1418

@@ -1203,4 +1207,139 @@ describe('InspectedElementContext', () => {
12031207

12041208
done();
12051209
});
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+
});
12061345
});

packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,4 +392,109 @@ describe('InspectedElementContext', () => {
392392

393393
done();
394394
});
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+
});
395500
});

0 commit comments

Comments
 (0)