@samitouri / QOS-React-2 / commits / b14f8da155

refactor[devtools]: forbid editing class instances in props (#26522)

## Summary Fixes https://github.com/facebook/react/issues/24781 Restricting from editing props, which are class instances, because their internals should be opaque. Proposed changes: 1. Adding new data type `class_instance`: based on prototype chain of an object we will check if its plain or not. If not, then will be marked as `class_instance`. This should not affect `arrays`, ..., because we do this in the end of an `object` case in `getDataType` function. Important detail: this approach won't work for objects created with `Object.create`, because of the custom prototype. This can also be bypassed by manually deleting a prototype ¯\\\_(ツ)_/¯ I am not sure if there might be a better solution (which will cover all cases) to detect if object is a class instance. Initially I was trying to use `Object.getPrototypeOf(object) === Object.prototype`, but this won't work for cases when we are dealing with `iframe`. 2. Objects with a type `class_instance` will be marked as unserializable and read-only. ## Demo `person` is a class instance, `object` is a plain object https://user-images.githubusercontent.com/28902667/228914791-ebdc8ab0-eb5c-426d-8163-66d56b5e8790.mov

Ruslan Lesiutin committed Apr 3, 2023 at 11:32 UTC b14f8da15598cdc2253529a905421ac795d68ab1
5 files changed +94 -5
fixtures/devtools/standalone/index.html
+6
@@ -334,6 +334,11 @@
334 },
335 });
336
337 + class Foo {
338 + flag = false;
339 + object = {a: {b: {c: {d: 1}}}}
340 + }
341 +
342 function UnserializableProps() {
343 return (
344 <ChildComponent
@@ -343,6 +348,7 @@
348 setOfSets={setOfSets}
349 typedArray={typedArray}
350 immutable={immutable}
351 + classInstance={new Foo()}
352 />
353 );
354 }
packages/react-devtools-shared/src/__tests__/utils-test.js
+27
@@ -10,6 +10,7 @@
10 import {
11 getDisplayName,
12 getDisplayNameForReactElement,
13 + isPlainObject,
14 } from 'react-devtools-shared/src/utils';
15 import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils';
16 import {
@@ -270,4 +271,30 @@ describe('utils', () => {
271 expect(gte('10.0.0', '9.0.0')).toBe(true);
272 });
273 });
274 +
275 + describe('isPlainObject', () => {
276 + it('should return true for plain objects', () => {
277 + expect(isPlainObject({})).toBe(true);
278 + expect(isPlainObject({a: 1})).toBe(true);
279 + expect(isPlainObject({a: {b: {c: 123}}})).toBe(true);
280 + });
281 +
282 + it('should return false if object is a class instance', () => {
283 + expect(isPlainObject(new (class C {})())).toBe(false);
284 + });
285 +
286 + it('should retun false for objects, which have not only Object in its prototype chain', () => {
287 + expect(isPlainObject([])).toBe(false);
288 + expect(isPlainObject(Symbol())).toBe(false);
289 + });
290 +
291 + it('should retun false for primitives', () => {
292 + expect(isPlainObject(5)).toBe(false);
293 + expect(isPlainObject(true)).toBe(false);
294 + });
295 +
296 + it('should return true for objects with no prototype', () => {
297 + expect(isPlainObject(Object.create(null))).toBe(true);
298 + });
299 + });
300 });
packages/react-devtools-shared/src/hydration.js
+36 -5
@@ -52,7 +52,7 @@ export type Unserializable = {
52 size?: number,
53 type: string,
54 unserializable: boolean,
55 - ...
55 + [string | number]: any,
56 };
57
58 // This threshold determines the depth at which the bridge "dehydrates" nested data.
@@ -248,7 +248,6 @@ export function dehydrate(
248 // Other types (e.g. typed arrays, Sets) will not spread correctly.
249 Array.from(data).forEach(
250 (item, i) =>
251 - // $FlowFixMe[prop-missing] Unserializable doesn't have an index signature
251 (unserializableValue[i] = dehydrate(
252 item,
253 cleaned,
@@ -296,6 +295,7 @@ export function dehydrate(
295
296 case 'object':
297 isPathAllowedCheck = isPathAllowed(path);
298 +
299 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
300 return createDehydrated(type, true, data, cleaned, path);
301 } else {
@@ -316,15 +316,46 @@ export function dehydrate(
316 return object;
317 }
318
319 + case 'class_instance':
320 + isPathAllowedCheck = isPathAllowed(path);
321 +
322 + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
323 + return createDehydrated(type, true, data, cleaned, path);
324 + }
325 +
326 + const value: Unserializable = {
327 + unserializable: true,
328 + type,
329 + readonly: true,
330 + preview_short: formatDataForPreview(data, false),
331 + preview_long: formatDataForPreview(data, true),
332 + name: data.constructor.name,
333 + };
334 +
335 + getAllEnumerableKeys(data).forEach(key => {
336 + const keyAsString = key.toString();
337 +
338 + value[keyAsString] = dehydrate(
339 + data[key],
340 + cleaned,
341 + unserializable,
342 + path.concat([keyAsString]),
343 + isPathAllowed,
344 + isPathAllowedCheck ? 1 : level + 1,
345 + );
346 + });
347 +
348 + unserializable.push(path);
349 +
350 + return value;
351 +
352 case 'infinity':
353 case 'nan':
354 case 'undefined':
355 // Some values are lossy when sent through a WebSocket.
356 // We dehydrate+rehydrate them to preserve their type.
357 cleaned.push(path);
325 - return {
326 - type,
327 - };
358 + return {type};
359
360 default:
361 return data;
packages/react-devtools-shared/src/utils.js
+17
@@ -534,6 +534,7 @@ export type DataType =
534 | 'array_buffer'
535 | 'bigint'
536 | 'boolean'
537 + | 'class_instance'
538 | 'data_view'
539 | 'date'
540 | 'function'
@@ -620,6 +621,11 @@ export function getDataType(data: Object): DataType {
621 return 'html_all_collection';
622 }
623 }
624 +
625 + if (!isPlainObject(data)) {
626 + return 'class_instance';
627 + }
628 +
629 return 'object';
630 case 'string':
631 return 'string';
@@ -835,6 +841,8 @@ export function formatDataForPreview(
841 }
842 case 'date':
843 return data.toString();
844 + case 'class_instance':
845 + return data.constructor.name;
846 case 'object':
847 if (showFormattedValue) {
848 const keys = Array.from(getAllEnumerableKeys(data)).sort(alphaSortKeys);
@@ -873,3 +881,12 @@ export function formatDataForPreview(
881 }
882 }
883 }
884 +
885 +// Basically checking that the object only has Object in its prototype chain
886 +export const isPlainObject = (object: Object): boolean => {
887 + const objectPrototype = Object.getPrototypeOf(object);
888 + if (!objectPrototype) return true;
889 +
890 + const objectParentPrototype = Object.getPrototypeOf(objectPrototype);
891 + return !objectParentPrototype;
892 +};
packages/react-devtools-shell/src/app/InspectableElements/UnserializableProps.js
+8
@@ -33,6 +33,13 @@ const immutable = Immutable.fromJS({
33 });
34 const bigInt = BigInt(123); // eslint-disable-line no-undef
35
36 +class Foo {
37 + flag = false;
38 + object: Object = {
39 + a: {b: {c: {d: 1}}},
40 + };
41 +}
42 +
43 export default function UnserializableProps(): React.Node {
44 return (
45 <ChildComponent
@@ -45,6 +52,7 @@ export default function UnserializableProps(): React.Node {
52 typedArray={typedArray}
53 immutable={immutable}
54 bigInt={bigInt}
55 + classInstance={new Foo()}
56 />
57 );
58 }