diff --git a/desktop/app/src/plugin.tsx b/desktop/app/src/plugin.tsx index db21c101d..f590db46c 100644 --- a/desktop/app/src/plugin.tsx +++ b/desktop/app/src/plugin.tsx @@ -12,13 +12,18 @@ import {Logger} from './fb-interfaces/Logger'; import Client from './Client'; import {Component} from 'react'; import BaseDevice from './devices/BaseDevice'; -import {serialize, deserialize} from './utils/serialization'; import {StaticView} from './reducers/connections'; import {State as ReduxState} from './reducers'; import {DEFAULT_MAX_QUEUE_SIZE} from './reducers/pluginMessageQueue'; import {ActivatablePluginDetails} from 'flipper-plugin-lib'; import {Settings} from './reducers/settings'; -import {Notification, Idler, _SandyPluginDefinition} from 'flipper-plugin'; +import { + Notification, + Idler, + _SandyPluginDefinition, + _makeShallowSerializable, + _deserializeShallowObject, +} from 'flipper-plugin'; type Parameters = {[key: string]: any}; @@ -145,24 +150,44 @@ export abstract class FlipperBasePlugin< statusUpdate?: (msg: string) => void, idler?: Idler, pluginName?: string, - ) => Promise = ( + ) => Promise = async ( persistedState: StaticPersistedState, - statusUpdate?: (msg: string) => void, - idler?: Idler, - pluginName?: string, + _statusUpdate?: (msg: string) => void, + _idler?: Idler, + _pluginName?: string, ) => { - return serialize( - persistedState, - idler, - statusUpdate, - pluginName != null ? `Serializing ${pluginName}` : undefined, - ); + if ( + persistedState && + typeof persistedState === 'object' && + !Array.isArray(persistedState) + ) { + return JSON.stringify( + Object.fromEntries( + Object.entries(persistedState).map(([key, value]) => [ + key, + _makeShallowSerializable(value), // make first level of persisted state serializable + ]), + ), + ); + } else { + return JSON.stringify(persistedState); + } }; static deserializePersistedState: ( serializedString: string, ) => StaticPersistedState = (serializedString: string) => { - return deserialize(serializedString); + const raw = JSON.parse(serializedString); + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return Object.fromEntries( + Object.entries(raw).map(([key, value]) => [ + key, + _deserializeShallowObject(value), + ]), + ); + } else { + return raw; + } }; teardown(): void {} diff --git a/desktop/app/src/utils/exportData.tsx b/desktop/app/src/utils/exportData.tsx index abc5a3c43..8f5156f52 100644 --- a/desktop/app/src/utils/exportData.tsx +++ b/desktop/app/src/utils/exportData.tsx @@ -49,7 +49,6 @@ import {getPluginTitle, isSandyPlugin} from './pluginUtils'; import {capture} from './screenshot'; import {uploadFlipperMedia} from '../fb-stubs/user'; import {Idler} from 'flipper-plugin'; -import {deserializeObject, makeObjectSerializable} from './serialization'; export const IMPORT_FLIPPER_TRACE_EVENT = 'import-flipper-trace'; export const EXPORT_FLIPPER_TRACE_EVENT = 'export-flipper-trace'; @@ -264,15 +263,14 @@ async function exportSandyPluginStates( if (!res[client.id]) { res[client.id] = {}; } - // makeObjectSerializable is slow but very convenient by default. If people want to speed things up - res[client.id][pluginId] = await makeObjectSerializable( - await client.sandyPluginStates + try { + res[client.id][pluginId] = await client.sandyPluginStates .get(pluginId)! - .exportState(idler, statusUpdate), - idler, - statusUpdate, - 'Serializing plugin: ' + pluginId, - ); + .exportState(idler, statusUpdate); + } catch (error) { + console.error('Error while serializing plugin ' + pluginId, error); + throw new Error(`Failed to serialize plugin ${pluginId}: ${error}`); + } } } return res; @@ -461,11 +459,10 @@ export async function processStore( idler, ); - const devicePluginStates = await makeObjectSerializable( - await device.exportState(idler, statusUpdate, selectedPlugins), + const devicePluginStates = await device.exportState( idler, statusUpdate, - 'Serializing device plugins', + selectedPlugins, ); statusUpdate('Uploading screenshot...'); @@ -800,7 +797,7 @@ export function importDataToStore(source: string, data: string, store: Store) { archivedDevice.loadDevicePlugins( store.getState().plugins.devicePlugins, store.getState().connections.enabledDevicePlugins, - deserializeObject(device.pluginStates), + device.pluginStates, ); store.dispatch({ type: 'REGISTER_DEVICE', @@ -829,9 +826,7 @@ export function importDataToStore(source: string, data: string, store: Store) { }); clients.forEach((client: {id: string; query: ClientQuery}) => { - const sandyPluginStates = deserializeObject( - json.pluginStates2[client.id] || {}, - ); + const sandyPluginStates = json.pluginStates2[client.id] || {}; const clientPlugins: Array = [ ...keys .filter((key) => { diff --git a/desktop/flipper-plugin/package.json b/desktop/flipper-plugin/package.json index 87769d52e..2342cf432 100644 --- a/desktop/flipper-plugin/package.json +++ b/desktop/flipper-plugin/package.json @@ -22,7 +22,6 @@ "uuid": "^8.3.2" }, "devDependencies": { - "@types/jest": "^26.0.23", "@types/string-natural-compare": "^3.0.0", "jest-mock-console": "^1.1.0", "typescript": "^4.3.4" diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 31e956e68..ea21f2adc 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -125,6 +125,10 @@ export { ElementID, } from './ui/elements-inspector/ElementsInspector'; export {useMemoize} from './utils/useMemoize'; +export { + makeShallowSerializable as _makeShallowSerializable, + deserializeShallowObject as _deserializeShallowObject, +} from './utils/shallowSerialization'; export {createTablePlugin} from './utils/createTablePlugin'; diff --git a/desktop/flipper-plugin/src/plugin/PluginBase.tsx b/desktop/flipper-plugin/src/plugin/PluginBase.tsx index 0a8171fc8..31569c906 100644 --- a/desktop/flipper-plugin/src/plugin/PluginBase.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginBase.tsx @@ -390,10 +390,13 @@ export abstract class BasePluginInstance { private serializeRootStates() { return Object.fromEntries( - Object.entries(this.rootStates).map(([key, atom]) => [ - key, - atom.serialize(), - ]), + Object.entries(this.rootStates).map(([key, atom]) => { + try { + return [key, atom.serialize()]; + } catch (e) { + throw new Error(`Failed to serialize state '${key}': ${e}`); + } + }), ); } diff --git a/desktop/flipper-plugin/src/state/atom.tsx b/desktop/flipper-plugin/src/state/atom.tsx index 3c07dd0c7..afc66e0e1 100644 --- a/desktop/flipper-plugin/src/state/atom.tsx +++ b/desktop/flipper-plugin/src/state/atom.tsx @@ -10,6 +10,10 @@ import {produce, Draft, enableMapSet} from 'immer'; import {useState, useEffect} from 'react'; import {Persistable, registerStorageAtom} from '../plugin/PluginBase'; +import { + deserializeShallowObject, + makeShallowSerializable, +} from '../utils/shallowSerialization'; enableMapSet(); @@ -46,11 +50,11 @@ class AtomValue implements Atom, Persistable { } deserialize(value: T) { - this.set(value); + this.set(deserializeShallowObject(value)); } serialize() { - return this.get(); + return makeShallowSerializable(this.get()); } update(recipe: (draft: Draft) => void) { diff --git a/desktop/flipper-plugin/src/utils/__tests__/shallowSerialization.node.tsx b/desktop/flipper-plugin/src/utils/__tests__/shallowSerialization.node.tsx new file mode 100644 index 000000000..93f2370b5 --- /dev/null +++ b/desktop/flipper-plugin/src/utils/__tests__/shallowSerialization.node.tsx @@ -0,0 +1,249 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import { + makeShallowSerializable, + deserializeShallowObject, +} from '../shallowSerialization'; +import mockConsole from 'jest-mock-console'; + +class TestObject extends Object { + constructor(title: Object, map?: Map, set?: Set) { + super(); + this.title = title; + this.map = map; + this.set = set; + } + title: Object; + map?: Map; + set?: Set; +} + +test('test cyclic data structure', () => { + const a: any = {x: 0, b: {c: []}}; + a.b.c.push(a); + expect(() => { + makeShallowSerializable(a); + }).toThrowErrorMatchingInlineSnapshot( + `"Cycle detected: object at path '.b.c.0' is referring to itself: '[object Object]'"`, + ); +}); + +test('test shared data structure', () => { + const restoreConsole = mockConsole(); + try { + const a = {hello: 'world'}; + const b = {x: a, y: a}; + + const res = JSON.parse(JSON.stringify(makeShallowSerializable(b))); + expect(res).toEqual({ + x: {hello: 'world'}, + y: {hello: 'world'}, + }); + expect(b.x).toBe(b.y); + expect(res.x).not.toBe(res.y); + // @ts-ignore + expect(console.warn.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "Duplicate value, object lives at path '.y', but also at path '.x': '[object Object]'. This might not behave correct after import and lead to unnecessary big exports.", + ], + ] + `); + } finally { + restoreConsole(); + } +}); + +test('test makeObjectSerializable function for unnested object with no Set and Map', () => { + const obj = {key1: 'value1', key2: 'value2'}; + const output = makeShallowSerializable(obj); + expect(output).toEqual(obj); + + // Testing numbers + const obj2 = {key1: 1, key2: 2}; + const output2 = makeShallowSerializable(obj2); + expect(output2).toEqual(obj2); +}); + +test('makeObjectSerializable function for unnested object with values which returns false when put in an if condition', () => { + const obj2 = {key1: 0, key2: ''}; + const output2 = makeShallowSerializable(obj2); + return expect(output2).toEqual(obj2); +}); + +test('test deserializeShallowObject function for unnested object with no Set and Map', () => { + const obj = {key1: 'value1', key2: 'value2'}; + const output = deserializeShallowObject(obj); + expect(output).toEqual(obj); + + // Testing numbers + const obj2 = {key1: 1, key2: 2}; + const output2 = deserializeShallowObject(obj2); + expect(output2).toEqual(obj2); +}); + +test('test makeObjectSerializable and deserializeShallowObject function for nested object with no Set and Map', () => { + const subObj = {key1: 'value1', key2: 'value2'}; + const subObj2 = {key21: 'value21', key22: 'value22'}; + const obj = {key1: subObj, key2: subObj2}; + const output = makeShallowSerializable(obj); + expect(output).toEqual(obj); + expect(deserializeShallowObject(output)).toEqual(obj); + + const subObjNum = {key1: 1, key2: 2}; + const subObjNum2 = {key21: 21, key22: 22}; + const obj2 = {key1: subObjNum, key2: subObjNum2}; + const output2 = makeShallowSerializable(obj2); + expect(output2).toEqual(obj2); + expect(deserializeShallowObject(output2)).toEqual(obj2); +}); + +test('test makeObjectSerializable and deserializeShallowObject function for Map and Set with no nesting', () => { + const map = new Map([ + ['k1', 'v1'], + ['k2', 'v2'], + ]); + const output = makeShallowSerializable(map); + const expected = { + __flipper_object_type__: 'Map', + data: [ + ['k1', 'v1'], + ['k2', 'v2'], + ], + }; + expect(output).toEqual(expected); + expect(deserializeShallowObject(output)).toEqual(map); + + const set = new Set([1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1]); + const outputSet = makeShallowSerializable(set); + const expectedSet = { + __flipper_object_type__: 'Set', + data: [1, 2, 3, 4, 5, 6], + }; + expect(outputSet).toEqual(expectedSet); + expect(deserializeShallowObject(outputSet)).toEqual(set); +}); + +test('test makeObjectSerializable and deserializeShallowObject function for Map and Set with nesting', () => { + const map = new Map([ + [{title: 'k1'}, {title: 'v1'}], + [{title: 'k2'}, {title: 'v2'}], + ]); + const output = makeShallowSerializable(map); + const expected = { + __flipper_object_type__: 'Map', + data: [ + [{title: 'k1'}, {title: 'v1'}], + [{title: 'k2'}, {title: 'v2'}], + ], + }; + expect(output).toEqual(expected); + expect(deserializeShallowObject(output)).toEqual(map); + + const set = new Set([ + {title: '1'}, + {title: '2'}, + {title: '3'}, + {title: '4'}, + {title: '5'}, + {title: '6'}, + ]); + const outputSet = makeShallowSerializable(set); + const expectedSet = { + __flipper_object_type__: 'Set', + data: [ + {title: '1'}, + {title: '2'}, + {title: '3'}, + {title: '4'}, + {title: '5'}, + {title: '6'}, + ], + }; + expect(outputSet).toEqual(expectedSet); + expect(deserializeShallowObject(outputSet)).toEqual(set); +}); + +test('test makeObjectSerializable and deserializeShallowObject function for custom Object', () => { + const obj = new TestObject('title'); + expect(() => { + makeShallowSerializable(obj); + }).toThrowErrorMatchingInlineSnapshot( + `"Unserializable object type (TestObject) at path '.': [object Object]."`, + ); +}); + +test('test makeObjectSerializable and deserializeShallowObject object with map', () => { + const nestedObjWithMap = { + map: new Map([ + ['k1', 'v1'], + ['k2', 'v2'], + ]), + }; + expect(() => { + makeShallowSerializable(nestedObjWithMap); + }).toThrowErrorMatchingInlineSnapshot( + `"Unserializable object type (Map) at path '.map': [object Map]."`, + ); +}); + +test('test makeObjectSerializable and deserializeShallowObject function for Array as input', () => { + const arr = [1, 2, 4, 5]; + const output = makeShallowSerializable(arr); + expect(output).toEqual(arr); + expect(deserializeShallowObject(output)).toEqual(arr); + + const arrMap = [ + new Map([ + ['a1', 'v1'], + ['a2', 'v2'], + ]), + ]; + + expect(() => { + makeShallowSerializable(arrMap); + }).toThrowErrorMatchingInlineSnapshot( + `"Unserializable object type (Map) at path '.0': [object Map]."`, + ); +}); + +test('test serialize and deserializeShallowObject function for non Object input', () => { + expect(makeShallowSerializable('octopus')).toEqual('octopus'); + expect(deserializeShallowObject(makeShallowSerializable('octopus'))).toEqual( + 'octopus', + ); + expect(makeShallowSerializable(24567)).toEqual(24567); + expect(deserializeShallowObject(makeShallowSerializable(24567))).toEqual( + 24567, + ); +}); + +test('test makeObjectSerializable and deserializeShallowObject function for Date input', () => { + const date = new Date(2021, 1, 29, 10, 31, 7, 205); + expect(makeShallowSerializable(date)).toMatchInlineSnapshot(` + Object { + "__flipper_object_type__": "Date", + "data": 1614555067205, + } + `); + expect(deserializeShallowObject(makeShallowSerializable(date))).toEqual(date); +}); + +test('test makeObjectSerializable and deserializeShallowObject function for Map of Sets', () => { + const map = new Map([ + ['k1', new Set([1, 2, 3, 4, 5, 6])], + [new Set([1, 2]), new Map([['k3', 'v3']])], + ] as any); + expect(() => { + makeShallowSerializable(map); + }).toThrowErrorMatchingInlineSnapshot( + `"Unserializable object type (Set) at path '.01': [object Set]."`, + ); +}); diff --git a/desktop/flipper-plugin/src/utils/shallowSerialization.tsx b/desktop/flipper-plugin/src/utils/shallowSerialization.tsx new file mode 100644 index 000000000..ab4af9165 --- /dev/null +++ b/desktop/flipper-plugin/src/utils/shallowSerialization.tsx @@ -0,0 +1,187 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +/** + * makeShallowSerializable will prepare common data structures, like Map and Set, for JSON serialization. + * However, this will happen only for the root object and not recursively to keep things efficiently. + * + * The function does not take care of actual stringification; use JSON.serialize. + */ +export function makeShallowSerializable(obj: any): any { + if (!obj || typeof obj !== 'object') { + assertSerializable(obj); + return obj; + } + if (obj instanceof Map) { + const data = Array.from(obj.entries()); + assertSerializable(data); + return { + __flipper_object_type__: 'Map', + data, + }; + } else if (obj instanceof Set) { + const data = Array.from(obj.values()); + assertSerializable(data); + return { + __flipper_object_type__: 'Set', + data, + }; + } else if (obj instanceof Date) { + return { + __flipper_object_type__: 'Date', + data: obj.getTime(), + }; + } else { + assertSerializable(obj); + return obj; + } +} + +/** + * Inverse of makeShallowSerializable + */ +export function deserializeShallowObject(obj: any): any { + if (!obj || typeof obj !== 'object') { + return obj; + } + if (obj['__flipper_object_type__']) { + const type = obj['__flipper_object_type__']; + switch (type) { + case 'Map': { + return new Map(obj.data); + } + case 'Set': { + return new Set(obj.data); + } + case 'Date': + return new Date(obj.data); + } + } + return obj; +} + +/** + * Asserts a value is JSON serializable. + * Will print a warning if a value is JSON serializable, but isn't a pure tree + */ +export function assertSerializable(obj: any) { + if ( + process.env.NODE_ENV !== 'test' && + process.env.NODE_ENV !== 'development' + ) { + return; + } + // path to current object + const path: string[] = []; + // current object stack + const stack = new Set(); + // past objects, object -> path to reach it + const seen = new Set(); + + // to safe a lot of memory allocations, if we find a duplicate, we just start over again to search for the first, + // rather than storing all paths at first encounter + let duplicateFound = false; + let duplicatePath: string[] | undefined; + let duplicateObject: any = undefined; + let done = false; + + function check(value: any) { + if (value === null || done) { + return; + } + switch (typeof value) { + case 'undefined': + // undefined is not strictly speaking serializable, but behaves fine. + // JSON.stringify({x : undefined}) ==> '{}' + break; + case 'boolean': + case 'number': + case 'string': + break; + case 'object': + // A cycle is truly not serializable, as it would create an unending serialization loop... + if (stack.has(value)) { + throw new Error( + `Cycle detected: object at path '.${path.join( + '.', + )}' is referring to itself: '${value}'`, + ); + } + // Encountering an object multiple times is bad, as reference equality will be lost upon + // deserialization, so the data isn't properly normalised. + // But it *might* work fine, and can serialize, so we just warn + + // Warning is only printed during the second check loop, so that we know *both* paths + // - Second walk (which finds first object) + if (duplicateFound && duplicateObject && value === duplicateObject) { + console.warn( + `Duplicate value, object lives at path '.${duplicatePath!.join( + '.', + )}', but also at path '.${path!.join( + '.', + )}': '${value}'. This might not behave correct after import and lead to unnecessary big exports.`, + ); + done = true; // no need to finish the second walk + break; + } + // - First walk (which detects the duplicate and stores location of duplicate) + if (!duplicateFound) { + if (seen.has(value)) { + duplicateFound = true; + duplicateObject = value; + duplicatePath = path.slice(); + } + seen.add(value); + } + stack.add(value); + const proto = Object.getPrototypeOf(value); + if (Array.isArray(value)) { + value.forEach((child, index) => { + path.push('' + index); + check(child); + path.pop(); + }); + } else if (proto === null || proto === Object.prototype) { + for (const key in value) { + path.push(key); + check(value[key]); + path.pop(); + } + } else { + throw new Error( + `Unserializable object type (${ + proto?.constructor?.name ?? 'Unknown' + }) at path '.${path.join('')}': ${value}.`, + ); + } + stack.delete(value); + break; + case 'bigint': + case 'function': + case 'symbol': + default: + throw new Error( + `Unserializable value (${typeof value}) at path '.${path.join( + '.', + )}': '${value}'`, + ); + } + } + + check(obj); + // if there is a duplicate found, re-walk the tree so that we can print both of the paths and report it + // this setup is slightly more confusion in code than walking once and storing past paths, + // but a lot more efficient :) + if (duplicateFound) { + path.splice(0); + seen.clear(); + stack.clear(); + check(obj); + } +} diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index a0a6674d4..dc4f59ea7 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -473,7 +473,7 @@ Optionally, `options` can be provided when creating state. Supported options: * `persist: string`. If the `persist` value is set, this state container will be serialized when n Flipper snapshot export is being made. When a snapshot is imported into Flipper, and plugins are initialized, this state container will load its initial value from the snapshot, rather than using the `initialValue` parameter. The `persist` key should be unique within the plugin and only be set if the state stored in this container is serializable and won't become unreasonably large. See also `exportState` and `initialState` in the [`TestUtils`](#testutils) section. -Serializable is defined as: non-cyclic data, consisting purely of primitive values, plain objects, arrays or Date, Set or Map objects. +Serializable is defined as: non-cyclic data, consisting purely of primitive values, plain objects and arrays. Precisely as the root, `Date`, `Set` or `Map` objects are allowed as well, but they shouldn't appear deeper in the tree. #### The state atom object