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
Ruslan Lesiutin committed
Apr 12, 2023 at 16:12 UTC
21021fb0f06f5b8ccdad0774d53ff5f865faeb6d
10 files changed
+73
-70
packages/react-devtools-extensions/src/contentScripts/prepareInjection.js
-16
@@ -128,19 +128,3 @@ if (IS_FIREFOX) {
128
}
129
}
130
}
131
-
132
-if (typeof exportFunction === 'function') {
133
- // eslint-disable-next-line no-undef
134
- exportFunction(
135
- text => {
136
- // Call clipboard.writeText from the extension content script
137
- // (as it has the clipboardWrite permission) and return a Promise
138
- // accessible to the webpage js code.
139
- return new window.Promise((resolve, reject) =>
140
- window.navigator.clipboard.writeText(text).then(resolve, reject),
141
- );
142
- },
143
- window.wrappedJSObject.__REACT_DEVTOOLS_GLOBAL_HOOK__,
144
- {defineAs: 'clipboardCopyText'},
145
- );
146
-}
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+4
-4
@@ -1801,7 +1801,7 @@ describe('InspectedElement', () => {
1801
jest.runOnlyPendingTimers();
1802
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
1803
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
1804
- JSON.stringify(nestedObject),
1804
+ JSON.stringify(nestedObject, undefined, 2),
1805
);
1806
1807
global.mockClipboardCopy.mockReset();
@@ -1811,7 +1811,7 @@ describe('InspectedElement', () => {
1811
jest.runOnlyPendingTimers();
1812
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
1813
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
1814
- JSON.stringify(nestedObject.a.b),
1814
+ JSON.stringify(nestedObject.a.b, undefined, 2),
1815
);
1816
});
1817
@@ -1894,7 +1894,7 @@ describe('InspectedElement', () => {
1894
jest.runOnlyPendingTimers();
1895
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
1896
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
1897
- JSON.stringify('123n'),
1897
+ JSON.stringify('123n', undefined, 2),
1898
);
1899
1900
global.mockClipboardCopy.mockReset();
@@ -1904,7 +1904,7 @@ describe('InspectedElement', () => {
1904
jest.runOnlyPendingTimers();
1905
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
1906
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
1907
- JSON.stringify({0: 100, 1: -100, 2: 0}),
1907
+ JSON.stringify({0: 100, 1: -100, 2: 0}, undefined, 2),
1908
);
1909
});
1910
packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js
+4
-4
@@ -26,7 +26,7 @@ describe('InspectedElementContext', () => {
26
27
async function read(
28
id: number,
29
- path?: Array<string | number> = null,
29
+ path: Array<string | number> = null,
30
): Promise<Object> {
31
const rendererID = ((store.getRendererIDForElement(id): any): number);
32
const promise = backendAPI
@@ -826,7 +826,7 @@ describe('InspectedElementContext', () => {
826
jest.runOnlyPendingTimers();
827
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
828
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
829
- JSON.stringify(nestedObject),
829
+ JSON.stringify(nestedObject, undefined, 2),
830
);
831
832
global.mockClipboardCopy.mockReset();
@@ -842,7 +842,7 @@ describe('InspectedElementContext', () => {
842
jest.runOnlyPendingTimers();
843
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
844
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
845
- JSON.stringify(nestedObject.a.b),
845
+ JSON.stringify(nestedObject.a.b, undefined, 2),
846
);
847
});
848
@@ -932,7 +932,7 @@ describe('InspectedElementContext', () => {
932
jest.runOnlyPendingTimers();
933
expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1);
934
expect(global.mockClipboardCopy).toHaveBeenCalledWith(
935
- JSON.stringify({0: 100, 1: -100, 2: 0}),
935
+ JSON.stringify({0: 100, 1: -100, 2: 0}, undefined, 2),
936
);
937
});
938
});
packages/react-devtools-shared/src/backend/agent.js
+7
-1
@@ -300,7 +300,13 @@ export default class Agent extends EventEmitter<{
300
if (renderer == null) {
301
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
302
} else {
303
- renderer.copyElementPath(id, path);
303
+ const value = renderer.getSerializedElementValueByPath(id, path);
304
+
305
+ if (value != null) {
306
+ this._bridge.send('saveToClipboard', value);
307
+ } else {
308
+ console.warn(`Unable to obtain serialized value for element "${id}"`);
309
+ }
310
}
311
};
312
packages/react-devtools-shared/src/backend/legacy/renderer.js
+9
-4
@@ -17,10 +17,10 @@ import {
17
import {getUID, utfEncodeString, printOperationsArray} from '../../utils';
18
import {
19
cleanForBridge,
20
- copyToClipboard,
20
copyWithDelete,
21
copyWithRename,
22
copyWithSet,
23
+ serializeToString,
24
} from '../utils';
25
import {
26
deletePathInObject,
@@ -701,10 +701,15 @@ export function attach(
701
}
702
}
703
704
- function copyElementPath(id: number, path: Array<string | number>): void {
704
+ function getSerializedElementValueByPath(
705
+ id: number,
706
+ path: Array<string | number>,
707
+ ): ?string {
708
const inspectedElement = inspectElementRaw(id);
709
if (inspectedElement !== null) {
707
- copyToClipboard(getInObject(inspectedElement, path));
710
+ const valueToCopy = getInObject(inspectedElement, path);
711
+
712
+ return serializeToString(valueToCopy);
713
}
714
}
715
@@ -1105,7 +1110,7 @@ export function attach(
1110
clearErrorsForFiberID,
1111
clearWarningsForFiberID,
1112
cleanup,
1108
- copyElementPath,
1113
+ getSerializedElementValueByPath,
1114
deletePath,
1115
flushInitialOperations,
1116
getBestMatchForTrackedPath,
packages/react-devtools-shared/src/backend/renderer.js
+16
-10
@@ -38,10 +38,13 @@ import {
38
utfEncodeString,
39
} from 'react-devtools-shared/src/utils';
40
import {sessionStorageGetItem} from 'react-devtools-shared/src/storage';
41
-import {gt, gte} from 'react-devtools-shared/src/backend/utils';
41
+import {
42
+ gt,
43
+ gte,
44
+ serializeToString,
45
+} from 'react-devtools-shared/src/backend/utils';
46
import {
47
cleanForBridge,
44
- copyToClipboard,
48
copyWithDelete,
49
copyWithRename,
50
copyWithSet,
@@ -809,7 +812,7 @@ export function attach(
812
name: string,
813
fiber: Fiber,
814
parentFiber: ?Fiber,
812
- extraString?: string = '',
815
+ extraString: string = '',
816
): void => {
817
if (__DEBUG__) {
818
const displayName =
@@ -3544,14 +3547,17 @@ export function attach(
3547
}
3548
}
3549
3547
- function copyElementPath(id: number, path: Array<string | number>): void {
3550
+ function getSerializedElementValueByPath(
3551
+ id: number,
3552
+ path: Array<string | number>,
3553
+ ): ?string {
3554
if (isMostRecentlyInspectedElement(id)) {
3549
- copyToClipboard(
3550
- getInObject(
3551
- ((mostRecentlyInspectedElement: any): InspectedElement),
3552
- path,
3553
- ),
3555
+ const valueToCopy = getInObject(
3556
+ ((mostRecentlyInspectedElement: any): InspectedElement),
3557
+ path,
3558
);
3559
+
3560
+ return serializeToString(valueToCopy);
3561
}
3562
}
3563
@@ -4494,7 +4500,7 @@ export function attach(
4500
clearErrorsAndWarnings,
4501
clearErrorsForFiberID,
4502
clearWarningsForFiberID,
4497
- copyElementPath,
4503
+ getSerializedElementValueByPath,
4504
deletePath,
4505
findNativeNodesForFiberID,
4506
flushInitialOperations,
packages/react-devtools-shared/src/backend/types.js
+4
-1
@@ -350,7 +350,6 @@ export type RendererInterface = {
350
clearErrorsAndWarnings: () => void,
351
clearErrorsForFiberID: (id: number) => void,
352
clearWarningsForFiberID: (id: number) => void,
353
- copyElementPath: (id: number, path: Array<string | number>) => void,
353
deletePath: (
354
type: Type,
355
id: number,
@@ -367,6 +366,10 @@ export type RendererInterface = {
366
getProfilingData(): ProfilingDataBackend,
367
getOwnersList: (id: number) => Array<SerializedElement> | null,
368
getPathForElement: (id: number) => Array<PathFrame> | null,
369
+ getSerializedElementValueByPath: (
370
+ id: number,
371
+ path: Array<string | number>,
372
+ ) => ?string,
373
handleCommitFiberRoot: (fiber: Object, commitPriority?: number) => void,
374
handleCommitFiberUnmount: (fiber: Object) => void,
375
handlePostCommitFiberRoot: (fiber: Object) => void,
packages/react-devtools-shared/src/backend/utils.js
+20
-30
@@ -8,7 +8,6 @@
8
* @flow
9
*/
10
11
-import {copy} from 'clipboard-js';
11
import {compareVersions} from 'compare-versions';
12
import {dehydrate} from '../hydration';
13
import isArray from 'shared/isArray';
@@ -18,7 +17,7 @@ import type {DehydratedData} from 'react-devtools-shared/src/devtools/views/Comp
17
export function cleanForBridge(
18
data: Object | null,
19
isPathAllowed: (path: Array<string | number>) => boolean,
21
- path?: Array<string | number> = [],
20
+ path: Array<string | number> = [],
21
): DehydratedData | null {
22
if (data !== null) {
23
const cleanedPaths: Array<Array<string | number>> = [];
@@ -41,23 +40,6 @@ export function cleanForBridge(
40
}
41
}
42
44
-export function copyToClipboard(value: any): void {
45
- const safeToCopy = serializeToString(value);
46
- const text = safeToCopy === undefined ? 'undefined' : safeToCopy;
47
- const {clipboardCopyText} = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
48
-
49
- // On Firefox navigator.clipboard.writeText has to be called from
50
- // the content script js code (because it requires the clipboardWrite
51
- // permission to be allowed out of a "user handling" callback),
52
- // clipboardCopyText is an helper injected into the page from.
53
- // injectGlobalHook.
54
- if (typeof clipboardCopyText === 'function') {
55
- clipboardCopyText(text).catch(err => {});
56
- } else {
57
- copy(text);
58
- }
59
-}
60
-
43
export function copyWithDelete(
44
obj: Object | Array<any>,
45
path: Array<string | number>,
@@ -144,20 +126,28 @@ export function getEffectDurations(root: Object): {
126
}
127
128
export function serializeToString(data: any): string {
129
+ if (data === undefined) {
130
+ return 'undefined';
131
+ }
132
+
133
const cache = new Set<mixed>();
134
// Use a custom replacer function to protect against circular references.
149
- return JSON.stringify(data, (key, value) => {
150
- if (typeof value === 'object' && value !== null) {
151
- if (cache.has(value)) {
152
- return;
135
+ return JSON.stringify(
136
+ data,
137
+ (key, value) => {
138
+ if (typeof value === 'object' && value !== null) {
139
+ if (cache.has(value)) {
140
+ return;
141
+ }
142
+ cache.add(value);
143
}
154
- cache.add(value);
155
- }
156
- if (typeof value === 'bigint') {
157
- return value.toString() + 'n';
158
- }
159
- return value;
160
- });
144
+ if (typeof value === 'bigint') {
145
+ return value.toString() + 'n';
146
+ }
147
+ return value;
148
+ },
149
+ 2,
150
+ );
151
}
152
153
// Formats an array of args with a style for console methods, using
packages/react-devtools-shared/src/bridge.js
+1
@@ -194,6 +194,7 @@ export type BackendEvents = {
194
profilingData: [ProfilingDataBackend],
195
profilingStatus: [boolean],
196
reloadAppForProfiling: [],
197
+ saveToClipboard: [string],
198
selectFiber: [number],
199
shutdown: [],
200
stopInspectingNative: [boolean],
packages/react-devtools-shared/src/devtools/store.js
+8
@@ -7,6 +7,7 @@
7
* @flow
8
*/
9
10
+import {copy} from 'clipboard-js';
11
import EventEmitter from '../events';
12
import {inspect} from 'util';
13
import {
@@ -272,6 +273,8 @@ export default class Store extends EventEmitter<{
273
274
bridge.addListener('backendVersion', this.onBridgeBackendVersion);
275
bridge.send('getBackendVersion');
276
+
277
+ bridge.addListener('saveToClipboard', this.onSaveToClipboard);
278
}
279
280
// This is only used in tests to avoid memory leaks.
@@ -1362,6 +1365,7 @@ export default class Store extends EventEmitter<{
1365
);
1366
bridge.removeListener('backendVersion', this.onBridgeBackendVersion);
1367
bridge.removeListener('bridgeProtocol', this.onBridgeProtocol);
1368
+ bridge.removeListener('saveToClipboard', this.onSaveToClipboard);
1369
1370
if (this._onBridgeProtocolTimeoutID !== null) {
1371
clearTimeout(this._onBridgeProtocolTimeoutID);
@@ -1422,6 +1426,10 @@ export default class Store extends EventEmitter<{
1426
this.emit('unsupportedBridgeProtocolDetected');
1427
};
1428
1429
+ onSaveToClipboard: (text: string) => void = text => {
1430
+ copy(text);
1431
+ };
1432
+
1433
// The Store should never throw an Error without also emitting an event.
1434
// Otherwise Store errors will be invisible to users,
1435
// but the downstream errors they cause will be reported as bugs.