From ab8fa9558cff68f6298560062568f6fd0a9d2b39 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Thu, 30 Mar 2023 18:15:12 +0100 Subject: [PATCH] refactor[devtools]: forbid editing class instances in props --- .../src/__tests__/utils-test.js | 27 ++++++++++++++ .../react-devtools-shared/src/hydration.js | 37 ++++++++++++++++--- packages/react-devtools-shared/src/utils.js | 15 ++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/utils-test.js b/packages/react-devtools-shared/src/__tests__/utils-test.js index 9c2c58f3298b0..706fa99e090f7 100644 --- a/packages/react-devtools-shared/src/__tests__/utils-test.js +++ b/packages/react-devtools-shared/src/__tests__/utils-test.js @@ -10,6 +10,7 @@ import { getDisplayName, getDisplayNameForReactElement, + isPlainObject, } from 'react-devtools-shared/src/utils'; import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils'; import { @@ -270,4 +271,30 @@ describe('utils', () => { expect(gte('10.0.0', '9.0.0')).toBe(true); }); }); + + describe('isPlainObject', () => { + it('should return true for plain objects', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({a: 1})).toBe(true); + expect(isPlainObject({a: {b: {c: 123}}})).toBe(true); + }); + + it('should return false if object is a class instance', () => { + expect(isPlainObject(new (class C {})())).toBe(false); + }); + + it('should retun false for objects, which have not only Object in its prototype chain', () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(Symbol())).toBe(false); + }); + + it('should retun false for primitives', () => { + expect(isPlainObject(5)).toBe(false); + expect(isPlainObject(true)).toBe(false); + }); + + it('should return true for objects with no prototype', () => { + expect(isPlainObject(Object.create(null))).toBe(true); + }); + }); }); diff --git a/packages/react-devtools-shared/src/hydration.js b/packages/react-devtools-shared/src/hydration.js index 610719c9db4e4..cd6726a22f2e2 100644 --- a/packages/react-devtools-shared/src/hydration.js +++ b/packages/react-devtools-shared/src/hydration.js @@ -52,7 +52,7 @@ export type Unserializable = { size?: number, type: string, unserializable: boolean, - ... + [string | number]: any, }; // This threshold determines the depth at which the bridge "dehydrates" nested data. @@ -248,7 +248,6 @@ export function dehydrate( // Other types (e.g. typed arrays, Sets) will not spread correctly. Array.from(data).forEach( (item, i) => - // $FlowFixMe[prop-missing] Unserializable doesn't have an index signature (unserializableValue[i] = dehydrate( item, cleaned, @@ -296,6 +295,7 @@ export function dehydrate( case 'object': isPathAllowedCheck = isPathAllowed(path); + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { return createDehydrated(type, true, data, cleaned, path); } else { @@ -316,15 +316,42 @@ export function dehydrate( return object; } + case 'class_instance': + isPathAllowedCheck = isPathAllowed(path); + + const value: Unserializable = { + unserializable: true, + type, + readonly: true, + preview_short: formatDataForPreview(data, false), + preview_long: formatDataForPreview(data, true), + name: data.constructor.name, + }; + + getAllEnumerableKeys(data).forEach(key => { + const keyAsString = key.toString(); + + value[keyAsString] = dehydrate( + data[key], + cleaned, + unserializable, + path.concat([keyAsString]), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + }); + + unserializable.push(path); + + return value; + case 'infinity': case 'nan': case 'undefined': // Some values are lossy when sent through a WebSocket. // We dehydrate+rehydrate them to preserve their type. cleaned.push(path); - return { - type, - }; + return {type}; default: return data; diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 0feea7399f5f0..8a62f355e5dc1 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -534,6 +534,7 @@ export type DataType = | 'array_buffer' | 'bigint' | 'boolean' + | 'class_instance' | 'data_view' | 'date' | 'function' @@ -620,6 +621,9 @@ export function getDataType(data: Object): DataType { return 'html_all_collection'; } } + + if (!isPlainObject(data)) return 'class_instance'; + return 'object'; case 'string': return 'string'; @@ -835,6 +839,8 @@ export function formatDataForPreview( } case 'date': return data.toString(); + case 'class_instance': + return data.constructor.name; case 'object': if (showFormattedValue) { const keys = Array.from(getAllEnumerableKeys(data)).sort(alphaSortKeys); @@ -873,3 +879,12 @@ export function formatDataForPreview( } } } + +// Basically checking that the object only has Object in its prototype chain +export const isPlainObject = (object: Object): boolean => { + const objectPrototype = Object.getPrototypeOf(object); + if (!objectPrototype) return true; + + const objectParentPrototype = Object.getPrototypeOf(objectPrototype); + return objectParentPrototype == null; +};