Skip to content

Commit

Permalink
DevTools context menu (#17608)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Brian Vaughn authored Dec 18, 2019
1 parent 7dc9745 commit 933f6a0
Show file tree
Hide file tree
Showing 32 changed files with 1,154 additions and 155 deletions.
62 changes: 52 additions & 10 deletions packages/react-devtools-extensions/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ import {createElement} from 'react';
import {createRoot, flushSync} from 'react-dom';
import Bridge from 'react-devtools-shared/src/bridge';
import Store from 'react-devtools-shared/src/devtools/store';
import {
createViewElementSource,
getBrowserName,
getBrowserTheme,
} from './utils';
import {getBrowserName, getBrowserTheme} from './utils';
import {LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY} from 'react-devtools-shared/src/constants';
import {
getSavedComponentFilters,
Expand Down Expand Up @@ -155,10 +151,54 @@ function createPanelIfReactLoaded() {
},
);

const viewElementSourceFunction = createViewElementSource(
bridge,
store,
);
const viewAttributeSourceFunction = (id, path) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID != null) {
// Ask the renderer interface to find the specified attribute,
// and store it as a global variable on the window.
bridge.send('viewAttributeSource', {id, path, rendererID});

setTimeout(() => {
// Ask Chrome to display the location of the attribute,
// assuming the renderer found a match.
chrome.devtools.inspectedWindow.eval(`
if (window.$attribute != null) {
inspect(window.$attribute);
}
`);
}, 100);
}
};

const viewElementSourceFunction = id => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID != null) {
// Ask the renderer interface to determine the component function,
// and store it as a global variable on the window
bridge.send('viewElementSource', {id, rendererID});

setTimeout(() => {
// Ask Chrome to display the location of the component function,
// or a render method if it is a Class (ideally Class instance, not type)
// assuming the renderer found one.
chrome.devtools.inspectedWindow.eval(`
if (window.$type != null) {
if (
window.$type &&
window.$type.prototype &&
window.$type.prototype.isReactComponent
) {
// inspect Component.render, not constructor
inspect(window.$type.prototype.render);
} else {
// inspect Functional Component
inspect(window.$type);
}
}
`);
}, 100);
}
};

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

Expand All @@ -170,11 +210,13 @@ function createPanelIfReactLoaded() {
bridge,
browserTheme: getBrowserTheme(),
componentsPortalContainer,
enabledInspectedElementContextMenu: true,
overrideTab,
profilerPortalContainer,
showTabBar: false,
warnIfUnsupportedVersionDetected: true,
store,
warnIfUnsupportedVersionDetected: true,
viewAttributeSourceFunction,
viewElementSourceFunction,
}),
);
Expand Down
32 changes: 0 additions & 32 deletions packages/react-devtools-extensions/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,6 @@

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

export function createViewElementSource(bridge: Bridge, store: Store) {
return function viewElementSource(id) {
const rendererID = store.getRendererIDForElement(id);
if (rendererID != null) {
// Ask the renderer interface to determine the component function,
// and store it as a global variable on the window
bridge.send('viewElementSource', {id, rendererID});

setTimeout(() => {
// Ask Chrome to display the location of the component function,
// or a render method if it is a Class (ideally Class instance, not type)
// assuming the renderer found one.
chrome.devtools.inspectedWindow.eval(`
if (window.$type != null) {
if (
window.$type &&
window.$type.prototype &&
window.$type.prototype.isReactComponent
) {
// inspect Component.render, not constructor
inspect(window.$type.prototype.render);
} else {
// inspect Functional Component
inspect(window.$type);
}
}
`);
}, 100);
}
};
}

export type BrowserName = 'Chrome' | 'Firefox';

export function getBrowserName(): BrowserName {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
*/

import typeof ReactTestRenderer from 'react-test-renderer';
import type {GetInspectedElementPath} from 'react-devtools-shared/src/devtools/views/Components/InspectedElementContext';
import type {
CopyInspectedElementPath,
GetInspectedElementPath,
StoreAsGlobal,
} from 'react-devtools-shared/src/devtools/views/Components/InspectedElementContext';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type Store from 'react-devtools-shared/src/devtools/store';

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

done();
});

it('should enable inspected values to be stored as global variables', async done => {
const Example = () => null;

const nestedObject = {
a: {
value: 1,
b: {
value: 1,
c: {
value: 1,
},
},
},
};

await utils.actAsync(() =>
ReactDOM.render(
<Example nestedObject={nestedObject} />,
document.createElement('div'),
),
);

const id = ((store.getElementIDAtIndex(0): any): number);

let storeAsGlobal: StoreAsGlobal = ((null: any): StoreAsGlobal);

function Suspender({target}) {
const context = React.useContext(InspectedElementContext);
storeAsGlobal = context.storeAsGlobal;
return null;
}

await utils.actAsync(
() =>
TestRenderer.create(
<Contexts
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}>
<React.Suspense fallback={null}>
<Suspender target={id} />
</React.Suspense>
</Contexts>,
),
false,
);
expect(storeAsGlobal).not.toBeNull();

const logSpy = jest.fn();
spyOn(console, 'log').and.callFake(logSpy);

// Should store the whole value (not just the hydrated parts)
storeAsGlobal(id, ['props', 'nestedObject']);
jest.runOnlyPendingTimers();
expect(logSpy).toHaveBeenCalledWith('$reactTemp1');
expect(global.$reactTemp1).toBe(nestedObject);

logSpy.mockReset();

// Should store the nested property specified (not just the outer value)
storeAsGlobal(id, ['props', 'nestedObject', 'a', 'b']);
jest.runOnlyPendingTimers();
expect(logSpy).toHaveBeenCalledWith('$reactTemp2');
expect(global.$reactTemp2).toBe(nestedObject.a.b);

done();
});

it('should enable inspected values to be copied to the clipboard', async done => {
const Example = () => null;

const nestedObject = {
a: {
value: 1,
b: {
value: 1,
c: {
value: 1,
},
},
},
};

await utils.actAsync(() =>
ReactDOM.render(
<Example nestedObject={nestedObject} />,
document.createElement('div'),
),
);

const id = ((store.getElementIDAtIndex(0): any): number);

let copyPath: CopyInspectedElementPath = ((null: any): CopyInspectedElementPath);

function Suspender({target}) {
const context = React.useContext(InspectedElementContext);
copyPath = context.copyInspectedElementPath;
return null;
}

await utils.actAsync(
() =>
TestRenderer.create(
<Contexts
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}>
<React.Suspense fallback={null}>
<Suspender target={id} />
</React.Suspense>
</Contexts>,
),
false,
);
expect(copyPath).not.toBeNull();

// Should copy the whole value (not just the hydrated parts)
copyPath(id, ['props', 'nestedObject']);
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify(nestedObject),
);

global.mockClipboardCopy.mockReset();

// Should copy the nested property specified (not just the outer value)
copyPath(id, ['props', 'nestedObject', 'a', 'b']);
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify(nestedObject.a.b),
);

done();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -392,4 +392,109 @@ describe('InspectedElementContext', () => {

done();
});

it('should enable inspected values to be stored as global variables', () => {
const Example = () => null;

const nestedObject = {
a: {
value: 1,
b: {
value: 1,
c: {
value: 1,
},
},
},
};

act(() =>
ReactDOM.render(
<Example nestedObject={nestedObject} />,
document.createElement('div'),
),
);

const id = ((store.getElementIDAtIndex(0): any): number);
const rendererID = ((store.getRendererIDForElement(id): any): number);

const logSpy = jest.fn();
spyOn(console, 'log').and.callFake(logSpy);

// Should store the whole value (not just the hydrated parts)
bridge.send('storeAsGlobal', {
count: 1,
id,
path: ['props', 'nestedObject'],
rendererID,
});
jest.runOnlyPendingTimers();
expect(logSpy).toHaveBeenCalledWith('$reactTemp1');
expect(global.$reactTemp1).toBe(nestedObject);

logSpy.mockReset();

// Should store the nested property specified (not just the outer value)
bridge.send('storeAsGlobal', {
count: 2,
id,
path: ['props', 'nestedObject', 'a', 'b'],
rendererID,
});
jest.runOnlyPendingTimers();
expect(logSpy).toHaveBeenCalledWith('$reactTemp2');
expect(global.$reactTemp2).toBe(nestedObject.a.b);
});

it('should enable inspected values to be copied to the clipboard', () => {
const Example = () => null;

const nestedObject = {
a: {
value: 1,
b: {
value: 1,
c: {
value: 1,
},
},
},
};

act(() =>
ReactDOM.render(
<Example nestedObject={nestedObject} />,
document.createElement('div'),
),
);

const id = ((store.getElementIDAtIndex(0): any): number);
const rendererID = ((store.getRendererIDForElement(id): any): number);

// Should copy the whole value (not just the hydrated parts)
bridge.send('copyElementPath', {
id,
path: ['props', 'nestedObject'],
rendererID,
});
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify(nestedObject),
);

global.mockClipboardCopy.mockReset();

// Should copy the nested property specified (not just the outer value)
bridge.send('copyElementPath', {
id,
path: ['props', 'nestedObject', 'a', 'b'],
rendererID,
});
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify(nestedObject.a.b),
);
});
});
Loading

0 comments on commit 933f6a0

Please sign in to comment.