refactor[devtools]: copy to clipboard only on frontend side (#26604)

Fixes https://github.com/facebook/react/issues/26500

## Summary
- No more using `clipboard-js` from the backend side, now emitting
custom `saveToClipboard` event, also adding corresponding listener in
`store.js`
- Not migrating to `navigator.clipboard` api yet, there were some issues
with using it on Chrome, will add more details to
https://github.com/facebook/react/pull/26539

## How did you test this change?
- Tested on Chrome, Firefox, Edge
- Tested on standalone electron app: seems like context menu is not
expected to work there (cannot right-click on value, the menu is not
appearing), other logic (pressing on copy icon) was not changed
This commit is contained in:
Ruslan Lesiutin
2023-04-12 16:12:03 +01:00
committed by GitHub
parent 5426af3d50
commit 21021fb0f0
10 changed files with 73 additions and 70 deletions

View File

@@ -128,19 +128,3 @@ if (IS_FIREFOX) {
}
}
}
if (typeof exportFunction === 'function') {
// eslint-disable-next-line no-undef
exportFunction(
text => {
// Call clipboard.writeText from the extension content script
// (as it has the clipboardWrite permission) and return a Promise
// accessible to the webpage js code.
return new window.Promise((resolve, reject) =>
window.navigator.clipboard.writeText(text).then(resolve, reject),
);
},
window.wrappedJSObject.__REACT_DEVTOOLS_GLOBAL_HOOK__,
{defineAs: 'clipboardCopyText'},
);
}

View File

@@ -1801,7 +1801,7 @@ describe('InspectedElement', () => {
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify(nestedObject),
JSON.stringify(nestedObject, undefined, 2),
);
global.mockClipboardCopy.mockReset();
@@ -1811,7 +1811,7 @@ describe('InspectedElement', () => {
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify(nestedObject.a.b),
JSON.stringify(nestedObject.a.b, undefined, 2),
);
});
@@ -1894,7 +1894,7 @@ describe('InspectedElement', () => {
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify('123n'),
JSON.stringify('123n', undefined, 2),
);
global.mockClipboardCopy.mockReset();
@@ -1904,7 +1904,7 @@ describe('InspectedElement', () => {
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify({0: 100, 1: -100, 2: 0}),
JSON.stringify({0: 100, 1: -100, 2: 0}, undefined, 2),
);
});

View File

@@ -26,7 +26,7 @@ describe('InspectedElementContext', () => {
async function read(
id: number,
path?: Array<string | number> = null,
path: Array<string | number> = null,
): Promise<Object> {
const rendererID = ((store.getRendererIDForElement(id): any): number);
const promise = backendAPI
@@ -826,7 +826,7 @@ describe('InspectedElementContext', () => {
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify(nestedObject),
JSON.stringify(nestedObject, undefined, 2),
);
global.mockClipboardCopy.mockReset();
@@ -842,7 +842,7 @@ describe('InspectedElementContext', () => {
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify(nestedObject.a.b),
JSON.stringify(nestedObject.a.b, undefined, 2),
);
});
@@ -932,7 +932,7 @@ describe('InspectedElementContext', () => {
jest.runOnlyPendingTimers();
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
JSON.stringify({0: 100, 1: -100, 2: 0}),
JSON.stringify({0: 100, 1: -100, 2: 0}, undefined, 2),
);
});
});

View File

@@ -300,7 +300,13 @@ export default class Agent extends EventEmitter<{
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
} else {
renderer.copyElementPath(id, path);
const value = renderer.getSerializedElementValueByPath(id, path);
if (value != null) {
this._bridge.send('saveToClipboard', value);
} else {
console.warn(`Unable to obtain serialized value for element "${id}"`);
}
}
};

View File

@@ -17,10 +17,10 @@ import {
import {getUID, utfEncodeString, printOperationsArray} from '../../utils';
import {
cleanForBridge,
copyToClipboard,
copyWithDelete,
copyWithRename,
copyWithSet,
serializeToString,
} from '../utils';
import {
deletePathInObject,
@@ -701,10 +701,15 @@ export function attach(
}
}
function copyElementPath(id: number, path: Array<string | number>): void {
function getSerializedElementValueByPath(
id: number,
path: Array<string | number>,
): ?string {
const inspectedElement = inspectElementRaw(id);
if (inspectedElement !== null) {
copyToClipboard(getInObject(inspectedElement, path));
const valueToCopy = getInObject(inspectedElement, path);
return serializeToString(valueToCopy);
}
}
@@ -1105,7 +1110,7 @@ export function attach(
clearErrorsForFiberID,
clearWarningsForFiberID,
cleanup,
copyElementPath,
getSerializedElementValueByPath,
deletePath,
flushInitialOperations,
getBestMatchForTrackedPath,

View File

@@ -38,10 +38,13 @@ import {
utfEncodeString,
} from 'react-devtools-shared/src/utils';
import {sessionStorageGetItem} from 'react-devtools-shared/src/storage';
import {gt, gte} from 'react-devtools-shared/src/backend/utils';
import {
gt,
gte,
serializeToString,
} from 'react-devtools-shared/src/backend/utils';
import {
cleanForBridge,
copyToClipboard,
copyWithDelete,
copyWithRename,
copyWithSet,
@@ -809,7 +812,7 @@ export function attach(
name: string,
fiber: Fiber,
parentFiber: ?Fiber,
extraString?: string = '',
extraString: string = '',
): void => {
if (__DEBUG__) {
const displayName =
@@ -3544,14 +3547,17 @@ export function attach(
}
}
function copyElementPath(id: number, path: Array<string | number>): void {
function getSerializedElementValueByPath(
id: number,
path: Array<string | number>,
): ?string {
if (isMostRecentlyInspectedElement(id)) {
copyToClipboard(
getInObject(
((mostRecentlyInspectedElement: any): InspectedElement),
path,
),
const valueToCopy = getInObject(
((mostRecentlyInspectedElement: any): InspectedElement),
path,
);
return serializeToString(valueToCopy);
}
}
@@ -4494,7 +4500,7 @@ export function attach(
clearErrorsAndWarnings,
clearErrorsForFiberID,
clearWarningsForFiberID,
copyElementPath,
getSerializedElementValueByPath,
deletePath,
findNativeNodesForFiberID,
flushInitialOperations,

View File

@@ -350,7 +350,6 @@ export type RendererInterface = {
clearErrorsAndWarnings: () => void,
clearErrorsForFiberID: (id: number) => void,
clearWarningsForFiberID: (id: number) => void,
copyElementPath: (id: number, path: Array<string | number>) => void,
deletePath: (
type: Type,
id: number,
@@ -367,6 +366,10 @@ export type RendererInterface = {
getProfilingData(): ProfilingDataBackend,
getOwnersList: (id: number) => Array<SerializedElement> | null,
getPathForElement: (id: number) => Array<PathFrame> | null,
getSerializedElementValueByPath: (
id: number,
path: Array<string | number>,
) => ?string,
handleCommitFiberRoot: (fiber: Object, commitPriority?: number) => void,
handleCommitFiberUnmount: (fiber: Object) => void,
handlePostCommitFiberRoot: (fiber: Object) => void,

View File

@@ -8,7 +8,6 @@
* @flow
*/
import {copy} from 'clipboard-js';
import {compareVersions} from 'compare-versions';
import {dehydrate} from '../hydration';
import isArray from 'shared/isArray';
@@ -18,7 +17,7 @@ import type {DehydratedData} from 'react-devtools-shared/src/devtools/views/Comp
export function cleanForBridge(
data: Object | null,
isPathAllowed: (path: Array<string | number>) => boolean,
path?: Array<string | number> = [],
path: Array<string | number> = [],
): DehydratedData | null {
if (data !== null) {
const cleanedPaths: Array<Array<string | number>> = [];
@@ -41,23 +40,6 @@ export function cleanForBridge(
}
}
export function copyToClipboard(value: any): void {
const safeToCopy = serializeToString(value);
const text = safeToCopy === undefined ? 'undefined' : safeToCopy;
const {clipboardCopyText} = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
// On Firefox navigator.clipboard.writeText has to be called from
// the content script js code (because it requires the clipboardWrite
// permission to be allowed out of a "user handling" callback),
// clipboardCopyText is an helper injected into the page from.
// injectGlobalHook.
if (typeof clipboardCopyText === 'function') {
clipboardCopyText(text).catch(err => {});
} else {
copy(text);
}
}
export function copyWithDelete(
obj: Object | Array<any>,
path: Array<string | number>,
@@ -144,20 +126,28 @@ export function getEffectDurations(root: Object): {
}
export function serializeToString(data: any): string {
if (data === undefined) {
return 'undefined';
}
const cache = new Set<mixed>();
// Use a custom replacer function to protect against circular references.
return JSON.stringify(data, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (cache.has(value)) {
return;
return JSON.stringify(
data,
(key, value) => {
if (typeof value === 'object' && value !== null) {
if (cache.has(value)) {
return;
}
cache.add(value);
}
cache.add(value);
}
if (typeof value === 'bigint') {
return value.toString() + 'n';
}
return value;
});
if (typeof value === 'bigint') {
return value.toString() + 'n';
}
return value;
},
2,
);
}
// Formats an array of args with a style for console methods, using

View File

@@ -194,6 +194,7 @@ export type BackendEvents = {
profilingData: [ProfilingDataBackend],
profilingStatus: [boolean],
reloadAppForProfiling: [],
saveToClipboard: [string],
selectFiber: [number],
shutdown: [],
stopInspectingNative: [boolean],

View File

@@ -7,6 +7,7 @@
* @flow
*/
import {copy} from 'clipboard-js';
import EventEmitter from '../events';
import {inspect} from 'util';
import {
@@ -272,6 +273,8 @@ export default class Store extends EventEmitter<{
bridge.addListener('backendVersion', this.onBridgeBackendVersion);
bridge.send('getBackendVersion');
bridge.addListener('saveToClipboard', this.onSaveToClipboard);
}
// This is only used in tests to avoid memory leaks.
@@ -1362,6 +1365,7 @@ export default class Store extends EventEmitter<{
);
bridge.removeListener('backendVersion', this.onBridgeBackendVersion);
bridge.removeListener('bridgeProtocol', this.onBridgeProtocol);
bridge.removeListener('saveToClipboard', this.onSaveToClipboard);
if (this._onBridgeProtocolTimeoutID !== null) {
clearTimeout(this._onBridgeProtocolTimeoutID);
@@ -1422,6 +1426,10 @@ export default class Store extends EventEmitter<{
this.emit('unsupportedBridgeProtocolDetected');
};
onSaveToClipboard: (text: string) => void = text => {
copy(text);
};
// The Store should never throw an Error without also emitting an event.
// Otherwise Store errors will be invisible to users,
// but the downstream errors they cause will be reported as bugs.