@samitouri / QOS-React-2 / commits / 21021fb0f0

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.