diff --git a/desktop/flipper-frontend-core/package.json b/desktop/flipper-frontend-core/package.json index ee1237d7d..b8bbbe520 100644 --- a/desktop/flipper-frontend-core/package.json +++ b/desktop/flipper-frontend-core/package.json @@ -12,7 +12,7 @@ "dependencies": { "eventemitter3": "^4.0.7", "flipper-common": "0.0.0", - "flipper-plugin": "0.0.0", + "flipper-plugin-core": "0.0.0", "immer": "^9.0.12", "js-base64": "^3.7.2", "p-map": "^4.0.0", diff --git a/desktop/flipper-frontend-core/src/AbstractClient.tsx b/desktop/flipper-frontend-core/src/AbstractClient.tsx index 87b59145a..e228ddc5e 100644 --- a/desktop/flipper-frontend-core/src/AbstractClient.tsx +++ b/desktop/flipper-frontend-core/src/AbstractClient.tsx @@ -30,7 +30,7 @@ import { _SandyPluginInstance, getFlipperLib, _SandyPluginDefinition, -} from 'flipper-plugin'; +} from 'flipper-plugin-core'; import {createServerAddOnControls} from './utils/createServerAddOnControls'; import isProduction from './utils/isProduction'; @@ -135,17 +135,23 @@ export default abstract class AbstractClient extends EventEmitter { initialState?: Record, ) { try { - this.sandyPluginStates.set( - plugin.id, - new _SandyPluginInstance( - this.serverAddOnControls, - getFlipperLib(), - plugin, - this, - getPluginKey(this.id, {serial: this.query.device_id}, plugin.id), - initialState, - ), + const pluginInstance = new _SandyPluginInstance( + this.serverAddOnControls, + getFlipperLib(), + plugin, + this, + getPluginKey(this.id, {serial: this.query.device_id}, plugin.id), + initialState, ); + pluginInstance.events.on('error', (message) => { + const error: ClientErrorType = { + message, + name: 'Plugin Error', + stacktrace: '', + }; + this.emit('error', error); + }); + this.sandyPluginStates.set(plugin.id, pluginInstance); } catch (e) { console.error(`Failed to start plugin '${plugin.id}': `, e); } diff --git a/desktop/flipper-frontend-core/src/RenderHost.tsx b/desktop/flipper-frontend-core/src/RenderHost.tsx index 4bf1bee34..b37aa59a0 100644 --- a/desktop/flipper-frontend-core/src/RenderHost.tsx +++ b/desktop/flipper-frontend-core/src/RenderHost.tsx @@ -7,7 +7,7 @@ * @format */ -import {FlipperLib, Notification} from 'flipper-plugin'; +import {FlipperLib, Notification} from 'flipper-plugin-core'; import {FlipperServer, FlipperServerConfig} from 'flipper-common'; type NotificationEvents = 'show' | 'click' | 'close' | 'reply' | 'action'; diff --git a/desktop/flipper-frontend-core/src/__tests__/plugins.node.tsx b/desktop/flipper-frontend-core/src/__tests__/plugins.node.tsx index 731795888..93b77d478 100644 --- a/desktop/flipper-frontend-core/src/__tests__/plugins.node.tsx +++ b/desktop/flipper-frontend-core/src/__tests__/plugins.node.tsx @@ -15,7 +15,7 @@ import { getLatestCompatibleVersionOfEachPlugin, } from '../plugins'; import {BundledPluginDetails, InstalledPluginDetails} from 'flipper-common'; -import {_SandyPluginDefinition} from 'flipper-plugin'; +import {_SandyPluginDefinition} from 'flipper-plugin-core'; import {getRenderHostInstance} from '../RenderHost'; let loadDynamicPluginsMock: jest.Mock; diff --git a/desktop/flipper-frontend-core/src/devices/ArchivedDevice.tsx b/desktop/flipper-frontend-core/src/devices/ArchivedDevice.tsx index 63dc423bc..b1be6d441 100644 --- a/desktop/flipper-frontend-core/src/devices/ArchivedDevice.tsx +++ b/desktop/flipper-frontend-core/src/devices/ArchivedDevice.tsx @@ -8,7 +8,7 @@ */ import BaseDevice from './BaseDevice'; -import type {DeviceOS, DeviceType} from 'flipper-plugin'; +import type {DeviceOS, DeviceType} from 'flipper-plugin-core'; export default class ArchivedDevice extends BaseDevice { isArchived = true; diff --git a/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx b/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx index 9f88fe905..abaa60585 100644 --- a/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx +++ b/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx @@ -16,7 +16,7 @@ import { createState, getFlipperLib, CrashLogListener, -} from 'flipper-plugin'; +} from 'flipper-plugin-core'; import { DeviceLogEntry, DeviceOS, @@ -49,7 +49,11 @@ export default class BaseDevice implements Device { hasDevicePlugins = false; // true if there are device plugins for this device (not necessarily enabled) private readonly serverAddOnControls: ServerAddOnControls; - constructor(flipperServer: FlipperServer, description: DeviceDescription) { + constructor( + flipperServer: FlipperServer, + description: DeviceDescription, + private pluginErrorHandler?: (msg: string) => void, + ) { this.flipperServer = flipperServer; this.description = description; this.serverAddOnControls = createServerAddOnControls(this.flipperServer); @@ -341,18 +345,19 @@ export default class BaseDevice implements Device { this.hasDevicePlugins = true; if (plugin instanceof _SandyPluginDefinition) { try { - this.sandyPluginStates.set( - plugin.id, - new _SandyDevicePluginInstance( - this.serverAddOnControls, - getFlipperLib(), - plugin, - this, - // break circular dep, one of those days again... - getPluginKey(undefined, {serial: this.serial}, plugin.id), - initialState, - ), + const pluginInstance = new _SandyDevicePluginInstance( + this.serverAddOnControls, + getFlipperLib(), + plugin, + this, + // break circular dep, one of those days again... + getPluginKey(undefined, {serial: this.serial}, plugin.id), + initialState, ); + if (this.pluginErrorHandler) { + pluginInstance.events.on('error', this.pluginErrorHandler); + } + this.sandyPluginStates.set(plugin.id, pluginInstance); } catch (e) { console.error(`Failed to start device plugin '${plugin.id}': `, e); } diff --git a/desktop/flipper-frontend-core/src/devices/TestDevice.tsx b/desktop/flipper-frontend-core/src/devices/TestDevice.tsx index dbe4af157..b14d5e6c5 100644 --- a/desktop/flipper-frontend-core/src/devices/TestDevice.tsx +++ b/desktop/flipper-frontend-core/src/devices/TestDevice.tsx @@ -7,7 +7,7 @@ * @format */ -import type {DeviceOS, DeviceType} from 'flipper-plugin'; +import type {DeviceOS, DeviceType} from 'flipper-plugin-core'; import {DeviceSpec} from 'flipper-common'; import BaseDevice from './BaseDevice'; import {getRenderHostInstance} from '../RenderHost'; diff --git a/desktop/flipper-frontend-core/src/devices/__tests__/BaseDevice.node.tsx b/desktop/flipper-frontend-core/src/devices/__tests__/BaseDevice.node.tsx index c58ba84f5..fe1f1a9fd 100644 --- a/desktop/flipper-frontend-core/src/devices/__tests__/BaseDevice.node.tsx +++ b/desktop/flipper-frontend-core/src/devices/__tests__/BaseDevice.node.tsx @@ -15,7 +15,7 @@ import { TestUtils, _SandyPluginDefinition, _setFlipperLibImplementation, -} from 'flipper-plugin'; +} from 'flipper-plugin-core'; import {default as ArchivedDevice} from '../ArchivedDevice'; import {TestDevice} from '../TestDevice'; diff --git a/desktop/flipper-frontend-core/src/flipperLibImplementation/downloadFile.tsx b/desktop/flipper-frontend-core/src/flipperLibImplementation/downloadFile.tsx index 2739726c0..9572ecc67 100644 --- a/desktop/flipper-frontend-core/src/flipperLibImplementation/downloadFile.tsx +++ b/desktop/flipper-frontend-core/src/flipperLibImplementation/downloadFile.tsx @@ -8,7 +8,7 @@ */ import {assertNever, DownloadFileUpdate} from 'flipper-common'; -import {FlipperLib, DownloadFileResponse} from 'flipper-plugin'; +import {FlipperLib, DownloadFileResponse} from 'flipper-plugin-core'; import {RenderHost} from '../RenderHost'; export const downloadFileFactory = diff --git a/desktop/flipper-frontend-core/src/flipperLibImplementation/index.tsx b/desktop/flipper-frontend-core/src/flipperLibImplementation/index.tsx index aa6623f63..53d48ca5f 100644 --- a/desktop/flipper-frontend-core/src/flipperLibImplementation/index.tsx +++ b/desktop/flipper-frontend-core/src/flipperLibImplementation/index.tsx @@ -7,7 +7,7 @@ * @format */ -import {RemoteServerContext, FlipperLib} from 'flipper-plugin'; +import {RemoteServerContext, FlipperLib} from 'flipper-plugin-core'; import { BufferEncoding, ExecOptions, diff --git a/desktop/flipper-frontend-core/src/plugins.tsx b/desktop/flipper-frontend-core/src/plugins.tsx index 84f63b12d..dd9ba8fef 100644 --- a/desktop/flipper-frontend-core/src/plugins.tsx +++ b/desktop/flipper-frontend-core/src/plugins.tsx @@ -18,7 +18,7 @@ import { ConcretePluginDetails, } from 'flipper-common'; import {reportUsage} from 'flipper-common'; -import {_SandyPluginDefinition} from 'flipper-plugin'; +import {_SandyPluginDefinition} from 'flipper-plugin-core'; import isPluginCompatible from './utils/isPluginCompatible'; import isPluginVersionMoreRecent from './utils/isPluginVersionMoreRecent'; import {getRenderHostInstance} from './RenderHost'; diff --git a/desktop/flipper-frontend-core/tsconfig.json b/desktop/flipper-frontend-core/tsconfig.json index b81ea72b3..7d648e3d0 100644 --- a/desktop/flipper-frontend-core/tsconfig.json +++ b/desktop/flipper-frontend-core/tsconfig.json @@ -11,7 +11,7 @@ "path": "../flipper-common" }, { - "path": "../flipper-plugin" + "path": "../flipper-plugin-core" }, { "path": "../test-utils" diff --git a/desktop/flipper-plugin-core/README.md b/desktop/flipper-plugin-core/README.md new file mode 100644 index 000000000..c90ccd1d5 --- /dev/null +++ b/desktop/flipper-plugin-core/README.md @@ -0,0 +1 @@ +# flipper-plugin-core diff --git a/desktop/flipper-plugin-core/package.json b/desktop/flipper-plugin-core/package.json new file mode 100644 index 000000000..d182dcdd9 --- /dev/null +++ b/desktop/flipper-plugin-core/package.json @@ -0,0 +1,40 @@ +{ + "name": "flipper-plugin-core", + "version": "0.0.0", + "description": "Flipper Desktop plugin SDK and components", + "repository": "facebook/flipper", + "main": "lib/index.js", + "flipperBundlerEntry": "src", + "types": "lib/index.d.ts", + "license": "MIT", + "bugs": "https://github.com/facebook/flipper/issues", + "dependencies": { + "eventemitter3": "^4.0.7", + "flipper-common": "0.0.0", + "immer": "^9.0.12", + "js-base64": "^3.7.2", + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.0" + }, + "devDependencies": { + "@types/react": "17.0.39", + "@types/string-natural-compare": "^3.0.2", + "jest-mock-console": "^1.2.3" + }, + "peerDependencies": { + "@testing-library/dom": "^7.26.3" + }, + "scripts": { + "reset": "rimraf lib *.tsbuildinfo", + "build": "tsc -b", + "prepack": "yarn reset && yarn build" + }, + "files": [ + "lib/**/*" + ], + "homepage": "https://github.com/facebook/flipper", + "keywords": [ + "Flipper" + ], + "author": "Facebook, Inc" +} diff --git a/desktop/flipper-plugin/src/data-source/DataSource.tsx b/desktop/flipper-plugin-core/src/data-source/DataSource.tsx similarity index 100% rename from desktop/flipper-plugin/src/data-source/DataSource.tsx rename to desktop/flipper-plugin-core/src/data-source/DataSource.tsx diff --git a/desktop/flipper-plugin/src/data-source/__tests__/datasource-basics.node.tsx b/desktop/flipper-plugin-core/src/data-source/__tests__/datasource-basics.node.tsx similarity index 100% rename from desktop/flipper-plugin/src/data-source/__tests__/datasource-basics.node.tsx rename to desktop/flipper-plugin-core/src/data-source/__tests__/datasource-basics.node.tsx diff --git a/desktop/flipper-plugin/src/data-source/__tests__/datasource-perf.node.tsx b/desktop/flipper-plugin-core/src/data-source/__tests__/datasource-perf.node.tsx similarity index 100% rename from desktop/flipper-plugin/src/data-source/__tests__/datasource-perf.node.tsx rename to desktop/flipper-plugin-core/src/data-source/__tests__/datasource-perf.node.tsx diff --git a/desktop/flipper-plugin-core/src/index.tsx b/desktop/flipper-plugin-core/src/index.tsx new file mode 100644 index 000000000..400804791 --- /dev/null +++ b/desktop/flipper-plugin-core/src/index.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +// Dummy exports to support running plugin code in a headless context. +// We do not want to bundle real code that is going to be used in a browser context to decrease the bundle size. +// Yet some parts of the browser-only code is being evaluated at plugin import, not when it is being rendered. +// Expand the list of stubs as needed when we onboard more and more headless plugins +export const theme = {}; +export const styled = () => () => ({}); + +export {produce, Draft} from 'immer'; + +import * as TestUtilites from './test-utils/test-utils'; +export const TestUtils = TestUtilites; +export {StartPluginOptions as _StartPluginOptions} from './test-utils/test-utils'; + +import './plugin/PluginBase'; + +export {BasePluginInstance as _BasePluginInstance} from './plugin/PluginBase'; +export { + SandyPluginInstance as _SandyPluginInstance, + PluginClient, + PluginFactory as _PluginFactory, + RealFlipperClient as _RealFlipperClient, +} from './plugin/Plugin'; +export { + Device, + DeviceLogListener, + DevicePluginClient, + CrashLogListener, + SandyDevicePluginInstance as _SandyDevicePluginInstance, + DevicePluginFactory as _DevicePluginFactory, +} from './plugin/DevicePlugin'; +export { + SandyPluginDefinition as _SandyPluginDefinition, + FlipperPluginInstance, + FlipperPluginModule as _FlipperPluginModule, + FlipperDevicePluginModule as _FlipperDevicePluginModule, +} from './plugin/SandyPluginDefinition'; + +export { + DataSource, + DataSourceView as _DataSourceView, + DataSourceOptionKey as _DataSourceOptionKey, + DataSourceOptions as _DataSourceOptions, +} from './data-source/DataSource'; +export {createDataSource} from './state/createDataSource'; + +export { + createState, + Atom, + isAtom, + ReadOnlyAtom as _ReadOnlyAtom, + AtomValue as _AtomValue, +} from './state/atom'; +export { + setBatchedUpdateImplementation as _setBatchedUpdateImplementation, + batch, +} from './state/batch'; +export { + FlipperLib, + getFlipperLib, + setFlipperLibImplementation as _setFlipperLibImplementation, + tryGetFlipperLibImplementation as _tryGetFlipperLibImplementation, + FileDescriptor, + FileEncoding, + RemoteServerContext, + DownloadFileResponse, +} from './plugin/FlipperLib'; +export { + MenuEntry, + NormalizedMenuEntry, + buildInMenuEntries as _buildInMenuEntries, + DefaultKeyboardAction, +} from './plugin/MenuEntry'; +export {Notification} from './plugin/Notification'; +export {CreatePasteArgs, CreatePasteResult} from './plugin/Paste'; + +export {Idler} from './utils/Idler'; + +export { + makeShallowSerializable as _makeShallowSerializable, + deserializeShallowObject as _deserializeShallowObject, +} from './utils/shallowSerialization'; + +import * as path from './utils/path'; +export {path}; +export {safeStringify} from './utils/safeStringify'; +export {stubLogger as _stubLogger} from './utils/Logger'; + +export { + sleep, + timeout, + createControlledPromise, + uuid, + DeviceOS, + DeviceType, + DeviceLogEntry, + DeviceLogLevel, + Logger, + CrashLog, + ServerAddOn, + ServerAddOnPluginConnection, + FlipperServerForServerAddOn, +} from 'flipper-common'; diff --git a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx b/desktop/flipper-plugin-core/src/plugin/DevicePlugin.tsx similarity index 100% rename from desktop/flipper-plugin/src/plugin/DevicePlugin.tsx rename to desktop/flipper-plugin-core/src/plugin/DevicePlugin.tsx diff --git a/desktop/flipper-plugin/src/plugin/FlipperLib.tsx b/desktop/flipper-plugin-core/src/plugin/FlipperLib.tsx similarity index 96% rename from desktop/flipper-plugin/src/plugin/FlipperLib.tsx rename to desktop/flipper-plugin-core/src/plugin/FlipperLib.tsx index 7bcea5cd8..52071e6a7 100644 --- a/desktop/flipper-plugin/src/plugin/FlipperLib.tsx +++ b/desktop/flipper-plugin-core/src/plugin/FlipperLib.tsx @@ -7,12 +7,12 @@ * @format */ +import type {ReactElement} from 'react'; import {Logger} from '../utils/Logger'; import {Device} from './DevicePlugin'; import {NormalizedMenuEntry} from './MenuEntry'; import {RealFlipperClient} from './Plugin'; import {Notification} from './Notification'; -import {DetailSidebarProps} from '../ui/DetailSidebar'; import { ExecOptions, ExecOut, @@ -109,9 +109,11 @@ export interface FlipperLib { writeTextToClipboard(text: string): void; openLink(url: string): void; showNotification(pluginKey: string, notification: Notification): void; - DetailsSidebarImplementation?( - props: DetailSidebarProps, - ): React.ReactElement | null; + DetailsSidebarImplementation?(props: { + children: any; + width?: number; + minWidth?: number; + }): ReactElement | null; /** * @returns * Imported file data. diff --git a/desktop/flipper-plugin/src/plugin/MenuEntry.tsx b/desktop/flipper-plugin-core/src/plugin/MenuEntry.tsx similarity index 100% rename from desktop/flipper-plugin/src/plugin/MenuEntry.tsx rename to desktop/flipper-plugin-core/src/plugin/MenuEntry.tsx diff --git a/desktop/flipper-plugin/src/plugin/Notification.tsx b/desktop/flipper-plugin-core/src/plugin/Notification.tsx similarity index 86% rename from desktop/flipper-plugin/src/plugin/Notification.tsx rename to desktop/flipper-plugin-core/src/plugin/Notification.tsx index 733b4d066..040069d12 100644 --- a/desktop/flipper-plugin/src/plugin/Notification.tsx +++ b/desktop/flipper-plugin-core/src/plugin/Notification.tsx @@ -7,10 +7,11 @@ * @format */ +import type {ReactNode} from 'react'; export type Notification = { id: string; title: string; - message: string | React.ReactNode; + message: string | ReactNode; severity: 'warning' | 'error'; timestamp?: number; category?: string; diff --git a/desktop/flipper-plugin/src/plugin/Paste.tsx b/desktop/flipper-plugin-core/src/plugin/Paste.tsx similarity index 100% rename from desktop/flipper-plugin/src/plugin/Paste.tsx rename to desktop/flipper-plugin-core/src/plugin/Paste.tsx diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin-core/src/plugin/Plugin.tsx similarity index 99% rename from desktop/flipper-plugin/src/plugin/Plugin.tsx rename to desktop/flipper-plugin-core/src/plugin/Plugin.tsx index c7fd847f5..407f5b782 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin-core/src/plugin/Plugin.tsx @@ -18,6 +18,7 @@ import { EventsContract, MethodsContract, } from 'flipper-common'; +import type {FC} from 'react'; type PreventIntersectionWith> = { [Key in keyof Contract]?: never; @@ -140,7 +141,7 @@ export type PluginFactory< client: PluginClient, ) => object; -export type FlipperPluginComponent = React.FC<{}>; +export type FlipperPluginComponent = FC<{}>; export class SandyPluginInstance extends BasePluginInstance { static is(thing: any): thing is SandyPluginInstance { diff --git a/desktop/flipper-plugin/src/plugin/PluginBase.tsx b/desktop/flipper-plugin-core/src/plugin/PluginBase.tsx similarity index 99% rename from desktop/flipper-plugin/src/plugin/PluginBase.tsx rename to desktop/flipper-plugin-core/src/plugin/PluginBase.tsx index 13eae745b..430f0b865 100644 --- a/desktop/flipper-plugin/src/plugin/PluginBase.tsx +++ b/desktop/flipper-plugin-core/src/plugin/PluginBase.tsx @@ -7,7 +7,6 @@ * @format */ -import {message} from 'antd'; import EventEmitter from 'eventemitter3'; import {SandyPluginDefinition} from './SandyPluginDefinition'; import {MenuEntry, NormalizedMenuEntry, normalizeMenuEntry} from './MenuEntry'; @@ -312,7 +311,7 @@ export abstract class BasePluginInstance { // msg is already specific // eslint-disable-next-line console.error(msg, e); - message.error(msg); + this.events.emit('error', msg); } } this.initialStates = undefined; @@ -325,7 +324,7 @@ export abstract class BasePluginInstance { // msg is already specific // eslint-disable-next-line console.error(msg, e); - message.error(msg); + this.events.emit('error', msg); } } diff --git a/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx b/desktop/flipper-plugin-core/src/plugin/SandyPluginDefinition.tsx similarity index 100% rename from desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx rename to desktop/flipper-plugin-core/src/plugin/SandyPluginDefinition.tsx diff --git a/desktop/flipper-plugin/src/state/__tests__/atom.node.tsx b/desktop/flipper-plugin-core/src/state/__tests__/atom.node.tsx similarity index 100% rename from desktop/flipper-plugin/src/state/__tests__/atom.node.tsx rename to desktop/flipper-plugin-core/src/state/__tests__/atom.node.tsx diff --git a/desktop/flipper-plugin-core/src/state/atom.tsx b/desktop/flipper-plugin-core/src/state/atom.tsx new file mode 100644 index 000000000..b4ba65730 --- /dev/null +++ b/desktop/flipper-plugin-core/src/state/atom.tsx @@ -0,0 +1,141 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 {produce, Draft, enableMapSet} from 'immer'; +import { + getCurrentPluginInstance, + Persistable, + registerStorageAtom, +} from '../plugin/PluginBase'; +import { + deserializeShallowObject, + makeShallowSerializable, +} from '../utils/shallowSerialization'; + +enableMapSet(); + +export interface ReadOnlyAtom { + get(): T; + subscribe(listener: (value: T, prevValue: T) => void): () => void; + unsubscribe(listener: (value: T, prevValue: T) => void): void; +} + +export interface Atom extends ReadOnlyAtom { + set(newValue: T): void; + update(recipe: (draft: Draft) => void): void; + update(recipe: (draft: X) => void): void; +} + +export class AtomValue implements Atom, Persistable { + value: T; + listeners: ((value: T, prevValue: T) => void)[] = []; + + constructor(initialValue: T) { + this.value = initialValue; + } + + get() { + return this.value; + } + + set(nextValue: T) { + if (nextValue !== this.value) { + const prevValue = this.value; + this.value = nextValue; + this.notifyChanged(prevValue); + } + } + + deserialize(value: T) { + this.set(deserializeShallowObject(value)); + } + + serialize() { + return makeShallowSerializable(this.get()); + } + + update(recipe: (draft: Draft) => void) { + this.set(produce(this.value, recipe)); + } + + notifyChanged(prevValue: T) { + // TODO: add scheduling + this.listeners.slice().forEach((l) => l(this.value, prevValue)); + } + + subscribe(listener: (value: T, prevValue: T) => void) { + this.listeners.push(listener); + return () => this.unsubscribe(listener); + } + + unsubscribe(listener: (value: T, prevValue: T) => void) { + const idx = this.listeners.indexOf(listener); + if (idx !== -1) { + this.listeners.splice(idx, 1); + } + } +} + +type StateOptions = { + /** + * Should this state persist when exporting a plugin? + * If set, the atom will be saved / loaded under the key provided + */ + persist?: string; + /** + * Store this state in local storage, instead of as part of the plugin import / export. + * State stored in local storage is shared between the same plugin + * across multiple clients/ devices, but not actively synced. + */ + persistToLocalStorage?: boolean; +}; + +export function createState( + initialValue: T, + options?: StateOptions, +): Atom; +export function createState(): Atom; +export function createState( + initialValue: any = undefined, + options: StateOptions = {}, +): Atom { + const atom = new AtomValue(initialValue); + if (options?.persistToLocalStorage) { + syncAtomWithLocalStorage(options, atom); + } else { + registerStorageAtom(options.persist, atom); + } + return atom; +} + +function syncAtomWithLocalStorage(options: StateOptions, atom: AtomValue) { + if (!options?.persist) { + throw new Error( + "The 'persist' option should be set when 'persistToLocalStorage' is set", + ); + } + const pluginInstance = getCurrentPluginInstance(); + if (!pluginInstance) { + throw new Error( + "The 'persistToLocalStorage' option cannot be used outside a plugin definition", + ); + } + const storageKey = `flipper:${pluginInstance.definition.id}:atom:${options.persist}`; + const storedValue = window.localStorage.getItem(storageKey); + if (storedValue != null) { + atom.deserialize(JSON.parse(storedValue)); + } + atom.subscribe(() => { + window.localStorage.setItem(storageKey, JSON.stringify(atom.serialize())); + }); +} + +export function isAtom(value: any): value is Atom { + return value instanceof AtomValue; +} diff --git a/desktop/flipper-plugin-core/src/state/batch.tsx b/desktop/flipper-plugin-core/src/state/batch.tsx new file mode 100644 index 000000000..cb2158c8c --- /dev/null +++ b/desktop/flipper-plugin-core/src/state/batch.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +export let batch: (callback: (...args: any[]) => void) => void = (callback) => + callback(); + +export const setBatchedUpdateImplementation = ( + impl: (callback: (...args: any[]) => void) => void, +) => { + batch = impl; +}; + +export function batched(fn: T): T; +export function batched(fn: any) { + return function (this: any) { + let res: any; + batch(() => { + // eslint-disable-next-line + res = fn.apply(this, arguments); + }); + return res; + }; +} diff --git a/desktop/flipper-plugin/src/state/createDataSource.tsx b/desktop/flipper-plugin-core/src/state/createDataSource.tsx similarity index 97% rename from desktop/flipper-plugin/src/state/createDataSource.tsx rename to desktop/flipper-plugin-core/src/state/createDataSource.tsx index 55427d62b..10f7b2dca 100644 --- a/desktop/flipper-plugin/src/state/createDataSource.tsx +++ b/desktop/flipper-plugin-core/src/state/createDataSource.tsx @@ -12,7 +12,7 @@ import { createDataSource as baseCreateDataSource, DataSourceOptions as BaseDataSourceOptions, DataSourceOptionKey as BaseDataSourceOptionKey, -} from '../data-source/index'; +} from '../data-source/DataSource'; import {registerStorageAtom} from '../plugin/PluginBase'; type DataSourceOptions = BaseDataSourceOptions & { diff --git a/desktop/flipper-plugin-core/src/test-utils/test-utils.tsx b/desktop/flipper-plugin-core/src/test-utils/test-utils.tsx new file mode 100644 index 000000000..35621d253 --- /dev/null +++ b/desktop/flipper-plugin-core/src/test-utils/test-utils.tsx @@ -0,0 +1,209 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 { + BundledPluginDetails, + fsConstants, + InstalledPluginDetails, +} from 'flipper-common'; + +import {FlipperServer, FlipperServerCommands} from 'flipper-common'; +import {Device} from '../plugin/DevicePlugin'; +import {FlipperLib} from '../plugin/FlipperLib'; +import {PluginFactory} from '../plugin/Plugin'; +import { + FlipperDevicePluginModule, + FlipperPluginModule, + SandyPluginDefinition, +} from '../plugin/SandyPluginDefinition'; +import {stubLogger} from '../utils/Logger'; + +declare const process: any; + +export interface StartPluginOptions { + initialState?: Record; + isArchived?: boolean; + isBackgroundPlugin?: boolean; + startUnactivated?: boolean; + /** Provide a set of unsupported methods to simulate older clients that don't support certain methods yet */ + unsupportedMethods?: string[]; + /** + * Provide a set of GKs that are enabled in this test. + */ + GKs?: string[]; + testDevice?: Device; +} + +export function createStubFunction(): jest.Mock { + // we shouldn't be usign jest.fn() outside a unit test, as it would not resolve / cause jest to be bundled up! + if (typeof jest !== 'undefined') { + return jest.fn(); + } + return (() => { + console.warn('Using a stub function outside a test environment!'); + }) as any; +} + +export function createMockFlipperLib(options?: StartPluginOptions): FlipperLib { + return { + isFB: false, + logger: stubLogger, + enableMenuEntries: createStubFunction(), + createPaste: createStubFunction(), + GK(gk: string) { + return options?.GKs?.includes(gk) || false; + }, + selectPlugin: createStubFunction(), + writeTextToClipboard: createStubFunction(), + openLink: createStubFunction(), + showNotification: createStubFunction(), + exportFile: createStubFunction(), + importFile: createStubFunction(), + paths: { + appPath: process.cwd(), + homePath: `/dev/null`, + staticPath: process.cwd(), + tempPath: `/dev/null`, + }, + environmentInfo: { + os: { + arch: 'Test', + unixname: 'test', + platform: 'linux', + }, + }, + intern: { + graphGet: createStubFunction(), + graphPost: createStubFunction(), + }, + remoteServerContext: { + childProcess: { + exec: createStubFunction(), + }, + fs: { + access: createStubFunction(), + pathExists: createStubFunction(), + unlink: createStubFunction(), + mkdir: createStubFunction(), + rm: createStubFunction(), + copyFile: createStubFunction(), + constants: fsConstants, + stat: createStubFunction(), + readlink: createStubFunction(), + readFile: createStubFunction(), + readFileBinary: createStubFunction(), + writeFile: createStubFunction(), + writeFileBinary: createStubFunction(), + }, + downloadFile: createStubFunction(), + }, + }; +} + +export function createMockPluginDetails( + details?: Partial, +): InstalledPluginDetails { + return { + id: 'TestPlugin', + dir: '', + name: 'TestPlugin', + specVersion: 0, + entry: '', + isBundled: false, + isActivatable: true, + main: '', + source: '', + title: 'Testing Plugin', + version: '', + ...details, + }; +} + +export function createTestPlugin>( + implementation: Pick, 'plugin'> & + Partial>, + details?: Partial, +) { + return new SandyPluginDefinition( + createMockPluginDetails({ + pluginType: 'client', + ...details, + }), + { + Component() { + return null; + }, + ...implementation, + }, + ); +} + +export function createTestDevicePlugin( + implementation: Pick & + Partial, + details?: Partial, +) { + return new SandyPluginDefinition( + createMockPluginDetails({ + pluginType: 'device', + ...details, + }), + { + supportsDevice() { + return true; + }, + Component() { + return null; + }, + ...implementation, + }, + ); +} + +export function createMockBundledPluginDetails( + details?: Partial, +): BundledPluginDetails { + return { + id: 'TestBundledPlugin', + name: 'TestBundledPlugin', + specVersion: 0, + pluginType: 'client', + isBundled: true, + isActivatable: true, + main: '', + source: '', + title: 'Testing Bundled Plugin', + version: '', + ...details, + }; +} + +export function createFlipperServerMock( + overrides?: Partial, +): FlipperServer { + return { + async connect() {}, + on: createStubFunction(), + off: createStubFunction(), + exec: jest + .fn() + .mockImplementation( + async (cmd: keyof FlipperServerCommands, ...args: any[]) => { + if (overrides?.[cmd]) { + return (overrides[cmd] as any)(...args); + } + console.warn( + `Empty server response stubbed for command '${cmd}', set 'getRenderHostInstance().flipperServer.exec' in your test to override the behavior.`, + ); + return undefined; + }, + ), + close: createStubFunction(), + }; +} diff --git a/desktop/flipper-plugin/src/utils/Idler.tsx b/desktop/flipper-plugin-core/src/utils/Idler.tsx similarity index 100% rename from desktop/flipper-plugin/src/utils/Idler.tsx rename to desktop/flipper-plugin-core/src/utils/Idler.tsx diff --git a/desktop/flipper-plugin/src/utils/Logger.tsx b/desktop/flipper-plugin-core/src/utils/Logger.tsx similarity index 100% rename from desktop/flipper-plugin/src/utils/Logger.tsx rename to desktop/flipper-plugin-core/src/utils/Logger.tsx diff --git a/desktop/flipper-plugin/src/utils/__tests__/shallowSerialization.node.tsx b/desktop/flipper-plugin-core/src/utils/__tests__/shallowSerialization.node.tsx similarity index 100% rename from desktop/flipper-plugin/src/utils/__tests__/shallowSerialization.node.tsx rename to desktop/flipper-plugin-core/src/utils/__tests__/shallowSerialization.node.tsx diff --git a/desktop/flipper-plugin/src/utils/path.tsx b/desktop/flipper-plugin-core/src/utils/path.tsx similarity index 100% rename from desktop/flipper-plugin/src/utils/path.tsx rename to desktop/flipper-plugin-core/src/utils/path.tsx diff --git a/desktop/flipper-plugin/src/utils/safeStringify.tsx b/desktop/flipper-plugin-core/src/utils/safeStringify.tsx similarity index 100% rename from desktop/flipper-plugin/src/utils/safeStringify.tsx rename to desktop/flipper-plugin-core/src/utils/safeStringify.tsx diff --git a/desktop/flipper-plugin/src/utils/shallowSerialization.tsx b/desktop/flipper-plugin-core/src/utils/shallowSerialization.tsx similarity index 100% rename from desktop/flipper-plugin/src/utils/shallowSerialization.tsx rename to desktop/flipper-plugin-core/src/utils/shallowSerialization.tsx diff --git a/desktop/flipper-plugin-core/tsconfig.json b/desktop/flipper-plugin-core/tsconfig.json new file mode 100644 index 000000000..f9a26ecc1 --- /dev/null +++ b/desktop/flipper-plugin-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "lib": ["dom", "ES2019"], + "types": ["jest", "../types/jest-extensions", "react/next", "react-dom/next"] + }, + "references": [ + { + "path": "../flipper-common" + } + ] +} diff --git a/desktop/flipper-plugin/package.json b/desktop/flipper-plugin/package.json index b6b734297..0eb6b1b5b 100644 --- a/desktop/flipper-plugin/package.json +++ b/desktop/flipper-plugin/package.json @@ -18,6 +18,7 @@ "@types/react-dom": "^17.0.13", "eventemitter3": "^4.0.7", "flipper-common": "0.0.0", + "flipper-plugin-core": "0.0.0", "immer": "^9.0.12", "js-base64": "^3.7.2", "lodash": "^4.17.21", diff --git a/desktop/flipper-plugin/src/__tests__/DeviceTestPlugin.tsx b/desktop/flipper-plugin/src/__tests__/DeviceTestPlugin.tsx index 9a35c013d..99db4a23a 100644 --- a/desktop/flipper-plugin/src/__tests__/DeviceTestPlugin.tsx +++ b/desktop/flipper-plugin/src/__tests__/DeviceTestPlugin.tsx @@ -8,9 +8,10 @@ */ import * as React from 'react'; -import {DevicePluginClient, Device} from '../plugin/DevicePlugin'; +import {DevicePluginClient, Device} from 'flipper-plugin-core'; import {usePlugin} from '../plugin/PluginContext'; -import {createState, useValue} from '../state/atom'; +import {createState} from 'flipper-plugin-core'; +import {useValue} from '../state/atom'; export function supportsDevice(_device: Device) { return true; diff --git a/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx b/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx index 9f3bacaa3..17705ab4e 100644 --- a/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx +++ b/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx @@ -8,9 +8,10 @@ */ import * as React from 'react'; -import {PluginClient} from '../plugin/Plugin'; +import {PluginClient} from 'flipper-plugin-core'; import {usePlugin} from '../plugin/PluginContext'; -import {createState, useValue} from '../state/atom'; +import {useValue} from '../state/atom'; +import {createState} from 'flipper-plugin-core'; type Events = { inc: { diff --git a/desktop/flipper-plugin/src/__tests__/test-utils-device.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils-device.node.tsx index 037873e0b..73e650ef8 100644 --- a/desktop/flipper-plugin/src/__tests__/test-utils-device.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/test-utils-device.node.tsx @@ -9,7 +9,7 @@ import * as TestUtils from '../test-utils/test-utils'; import * as testPlugin from './DeviceTestPlugin'; -import {createState} from '../state/atom'; +import {createState} from 'flipper-plugin-core'; const testLogMessage = { date: new Date(), diff --git a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx index 6e5f6f667..7c8825268 100644 --- a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx @@ -9,12 +9,12 @@ import * as TestUtils from '../test-utils/test-utils'; import * as testPlugin from './TestPlugin'; -import {createState} from '../state/atom'; -import {PluginClient} from '../plugin/Plugin'; -import {DevicePluginClient} from '../plugin/DevicePlugin'; +import {createState} from 'flipper-plugin-core'; +import {PluginClient} from 'flipper-plugin-core'; +import {DevicePluginClient} from 'flipper-plugin-core'; import mockConsole from 'jest-mock-console'; import {sleep} from 'flipper-common'; -import {createDataSource} from '../state/createDataSource'; +import {createDataSource} from 'flipper-plugin-core'; test('it can start a plugin and lifecycle events', () => { const {instance, ...p} = TestUtils.startPlugin(testPlugin); @@ -357,9 +357,6 @@ test('plugins can handle import errors', async () => { "An error occurred when importing data for plugin 'TestPlugin': 'Error: Oops", [Error: Oops], ], - Array [ - "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot", - ], ] `); } finally { diff --git a/desktop/flipper-plugin/src/data-source/DataSourceRendererStatic.tsx b/desktop/flipper-plugin/src/data-source/DataSourceRendererStatic.tsx index 59765d24d..fca5a9fdd 100644 --- a/desktop/flipper-plugin/src/data-source/DataSourceRendererStatic.tsx +++ b/desktop/flipper-plugin/src/data-source/DataSourceRendererStatic.tsx @@ -7,7 +7,8 @@ * @format */ -import {DataSourceView} from './DataSource'; +// eslint-disable-next-line node/no-extraneous-import +import type {_DataSourceView} from 'flipper-plugin-core'; import React, {memo, useCallback, useEffect, useState} from 'react'; import {RedrawContext} from './DataSourceRendererVirtual'; @@ -16,7 +17,7 @@ type DataSourceProps = { /** * The data view to render */ - dataView: DataSourceView; + dataView: _DataSourceView; /** * additional context that will be passed verbatim to the itemRenderer, so that it can be easily memoized */ @@ -35,7 +36,7 @@ type DataSourceProps = { onUpdateAutoScroll?(autoScroll: boolean): void; emptyRenderer?: | null - | ((dataView: DataSourceView) => React.ReactElement); + | ((dataView: _DataSourceView) => React.ReactElement); }; /** diff --git a/desktop/flipper-plugin/src/data-source/DataSourceRendererVirtual.tsx b/desktop/flipper-plugin/src/data-source/DataSourceRendererVirtual.tsx index 32eae8610..12f6c6e32 100644 --- a/desktop/flipper-plugin/src/data-source/DataSourceRendererVirtual.tsx +++ b/desktop/flipper-plugin/src/data-source/DataSourceRendererVirtual.tsx @@ -18,7 +18,8 @@ import React, { useContext, createContext, } from 'react'; -import {DataSourceView} from './DataSource'; +// eslint-disable-next-line node/no-extraneous-import +import type {_DataSourceView} from 'flipper-plugin-core'; import {useVirtual} from 'react-virtual'; import observeRect from '@reach/observe-rect'; @@ -39,7 +40,7 @@ type DataSourceProps = { /** * The data source to render */ - dataView: DataSourceView; + dataView: _DataSourceView; /** * Automatically scroll if the user is near the end? */ @@ -68,7 +69,7 @@ type DataSourceProps = { onUpdateAutoScroll?(autoScroll: boolean): void; emptyRenderer?: | null - | ((dataView: DataSourceView) => React.ReactElement); + | ((dataView: _DataSourceView) => React.ReactElement); }; /** diff --git a/desktop/flipper-plugin/src/data-source/README.md b/desktop/flipper-plugin/src/data-source/README.md index 41923adcb..cf39149ac 100644 --- a/desktop/flipper-plugin/src/data-source/README.md +++ b/desktop/flipper-plugin/src/data-source/README.md @@ -52,12 +52,12 @@ The significant difference to many other solutions is that DataSource doesn't pr Instead, it keeps internally a mutable dataset (the records stored themselves are still immutable but can be replaced) to which new entries are added. However, instead of propagating the dataset to the rendering layer, events are emitted instead. -### DataSourceView +### _DataSourceView -Conceptually, `DataSourceView` is a materialized view of a `DataSource`. +Conceptually, `_DataSourceView` is a materialized view of a `DataSource`. For visualizations, typically the following transformations need to be applied: filter/search, sorting and windowing. -Where many libraries applies these transformations as part of the _rendering_, DataSourceView applies these operations directly when updates to the dataset are received. +Where many libraries applies these transformations as part of the _rendering_, _DataSourceView applies these operations directly when updates to the dataset are received. As a result the transformations need to be applied only to the newly arriving data. For example, if a new record arrives for a sorted dataset, we will apply a binary inseration sort for the new entry, avoiding the need for a full re-sort of the dataset during Rendering. @@ -66,11 +66,11 @@ The events will describe how the current view should be updated to reflect the d ### DataSourceRendererVirtual -`DataSourceRendererVirtual` is one of the possible visualizations of a DataSourceView. -It takes care of subscribing to the events emitted by the `DataSourceView`, and applies them when they are relevant (e.g. within the visible window). -Beyond that, it manages virtualizations (using the `react-virtual` library), so that for example scroll interactions are used to move the window of the`DataSourceView`. +`DataSourceRendererVirtual` is one of the possible visualizations of a _DataSourceView. +It takes care of subscribing to the events emitted by the `_DataSourceView`, and applies them when they are relevant (e.g. within the visible window). +Beyond that, it manages virtualizations (using the `react-virtual` library), so that for example scroll interactions are used to move the window of the`_DataSourceView`. -Typically this component is used as underlying abstraction for a Table representation. +Typically this component is used as underlying abstraction for a Table representation. ### DataSourceRendererStatic @@ -163,7 +163,7 @@ Project setup: Features: -* [ ] **Support multiple DataSourceView's per DataSource**: Currently there is a one view per source limitation because we didn't need more yet. +* [ ] **Support multiple _DataSourceView's per DataSource**: Currently there is a one view per source limitation because we didn't need more yet. * [ ] **Break up operations that process the full data set in smaller tasks**: There are several operations that process the full data set, for example changing the sort / filter criteria. Currently this is done synchronously (and we debounce changing the filter), in the future we will split up the filtering in smaller taks to make it efficient. But we don't have a way to efficiently break down sorting into smaller tasks as using insertion sorting is 20x slower than the native sorting mechanism if the full data set needs to be processed. * [ ] **Add built-in support for downsampling data** * [ ] **Leverage React concurrent mode**: Currently there is custom scheduler logic to handle high- and low- (outside window) priority updates. In principle this could probably be achieved through React concurrent mode as well, but ANT.design (which is used in Flipper) doesn't support it yet. diff --git a/desktop/flipper-plugin/src/data-source/index.tsx b/desktop/flipper-plugin/src/data-source/index.tsx index 785c343ed..8c483a8aa 100644 --- a/desktop/flipper-plugin/src/data-source/index.tsx +++ b/desktop/flipper-plugin/src/data-source/index.tsx @@ -9,11 +9,11 @@ export { DataSource, - DataSourceView, + _DataSourceView, createDataSource, - DataSourceOptions, - DataSourceOptionKey, -} from './DataSource'; + _DataSourceOptions, + _DataSourceOptionKey, // eslint-disable-next-line node/no-extraneous-import +} from 'flipper-plugin-core'; export { DataSourceRendererVirtual, DataSourceVirtualizer, diff --git a/desktop/flipper-plugin/src/index.tsx b/desktop/flipper-plugin/src/index.tsx index af7f7cad3..bcaf0bfa5 100644 --- a/desktop/flipper-plugin/src/index.tsx +++ b/desktop/flipper-plugin/src/index.tsx @@ -7,52 +7,22 @@ * @format */ -export {produce, Draft} from 'immer'; +export * from 'flipper-plugin-core'; + import styledImport from '@emotion/styled'; export const styled = styledImport; -import './plugin/PluginBase'; +import './state/batch'; + +export {useValue} from './state/atom'; + import * as TestUtilites from './test-utils/test-utils'; -export { - SandyPluginInstance as _SandyPluginInstance, - PluginClient, -} from './plugin/Plugin'; -export { - Device, - DeviceLogListener, - DevicePluginClient, - CrashLogListener, - SandyDevicePluginInstance as _SandyDevicePluginInstance, -} from './plugin/DevicePlugin'; -export { - SandyPluginDefinition as _SandyPluginDefinition, - FlipperPluginInstance, -} from './plugin/SandyPluginDefinition'; export {SandyPluginRenderer as _SandyPluginRenderer} from './plugin/PluginRenderer'; export { SandyPluginContext as _SandyPluginContext, usePlugin, } from './plugin/PluginContext'; -export {createState, useValue, Atom, isAtom} from './state/atom'; -export {batch} from './state/batch'; -export { - FlipperLib, - getFlipperLib, - setFlipperLibImplementation as _setFlipperLibImplementation, - FileDescriptor, - FileEncoding, - RemoteServerContext, - DownloadFileResponse, -} from './plugin/FlipperLib'; -export { - MenuEntry, - NormalizedMenuEntry, - buildInMenuEntries as _buildInMenuEntries, - DefaultKeyboardAction, -} from './plugin/MenuEntry'; -export {Notification} from './plugin/Notification'; -export {CreatePasteArgs, CreatePasteResult} from './plugin/Paste'; export {theme} from './ui/theme'; export {Layout} from './ui/Layout'; @@ -83,12 +53,6 @@ export {DataFormatter} from './ui/DataFormatter'; export {useLogger, _LoggerContext} from './utils/useLogger'; -export {Idler} from './utils/Idler'; - -// Import from the index file directly, to make sure package.json's main field is skipped. -export {DataSource} from './data-source/index'; -export {createDataSource} from './state/createDataSource'; - export {DataTable, DataTableColumn} from './ui/data-table/DataTable'; export {DataTableManager} from './ui/data-table/DataTableManager'; export {DataList} from './ui/DataList'; @@ -127,36 +91,13 @@ 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'; export {textContent} from './utils/textContent'; -import * as path from './utils/path'; -export {path}; -export {safeStringify} from './utils/safeStringify'; // It's not ideal that this exists in flipper-plugin sources directly, // but is the least pain for plugin authors. // Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin) // T69106962 export const TestUtils = TestUtilites; - -export { - sleep, - timeout, - createControlledPromise, - uuid, - DeviceOS, - DeviceType, - DeviceLogEntry, - DeviceLogLevel, - Logger, - CrashLog, - ServerAddOn, - ServerAddOnPluginConnection, - FlipperServerForServerAddOn, -} from 'flipper-common'; diff --git a/desktop/flipper-plugin/src/plugin/PluginContext.tsx b/desktop/flipper-plugin/src/plugin/PluginContext.tsx index 7850cf9f7..9f39136dd 100644 --- a/desktop/flipper-plugin/src/plugin/PluginContext.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginContext.tsx @@ -8,16 +8,20 @@ */ import {createContext, useContext} from 'react'; -import {SandyPluginInstance, PluginFactory} from './Plugin'; -import {SandyDevicePluginInstance, DevicePluginFactory} from './DevicePlugin'; +import { + _SandyDevicePluginInstance, + _DevicePluginFactory, + _SandyPluginInstance, + _PluginFactory, +} from 'flipper-plugin-core'; export const SandyPluginContext = createContext< - SandyPluginInstance | SandyDevicePluginInstance | undefined + _SandyPluginInstance | _SandyDevicePluginInstance | undefined >(undefined); export function usePluginInstance(): - | SandyPluginInstance - | SandyDevicePluginInstance { + | _SandyPluginInstance + | _SandyDevicePluginInstance { const pluginInstance = useContext(SandyPluginContext); if (!pluginInstance) { throw new Error('Sandy Plugin context not available'); @@ -26,14 +30,14 @@ export function usePluginInstance(): } export function usePluginInstanceMaybe(): - | SandyPluginInstance - | SandyDevicePluginInstance + | _SandyPluginInstance + | _SandyDevicePluginInstance | undefined { return useContext(SandyPluginContext); } export function usePlugin< - Factory extends PluginFactory | DevicePluginFactory, + Factory extends _PluginFactory | _DevicePluginFactory, >(plugin: Factory): ReturnType { const pluginInstance = usePluginInstance(); // In principle we don't *need* the plugin, but having it passed it makes sure the diff --git a/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx b/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx index a97a4430a..9008dfc3f 100644 --- a/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx @@ -9,20 +9,22 @@ import React, {memo, useEffect, createElement} from 'react'; import {SandyPluginContext} from './PluginContext'; -import {SandyPluginInstance} from './Plugin'; -import {SandyDevicePluginInstance} from './DevicePlugin'; -import {BasePluginInstance} from './PluginBase'; +import { + _SandyPluginInstance, + _SandyDevicePluginInstance, + _BasePluginInstance, +} from 'flipper-plugin-core'; import {TrackingScope} from '../ui/Tracked'; type Props = { - plugin: SandyPluginInstance | SandyDevicePluginInstance; + plugin: _SandyPluginInstance | _SandyDevicePluginInstance; }; /** * Component to render a Sandy plugin container */ export const SandyPluginRenderer = memo(({plugin}: Props) => { - if (!plugin || !(plugin instanceof BasePluginInstance)) { + if (!plugin || !(plugin instanceof _BasePluginInstance)) { throw new Error('Expected plugin, got ' + plugin); } useEffect(() => { diff --git a/desktop/flipper-plugin/src/server.tsx b/desktop/flipper-plugin/src/server.tsx deleted file mode 100644 index f99872fb3..000000000 --- a/desktop/flipper-plugin/src/server.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - */ - -// Exports for server add-ons - -import * as path from './utils/path'; -export {path}; -export {safeStringify} from './utils/safeStringify'; - -export { - sleep, - timeout, - createControlledPromise, - uuid, - ServerAddOn, - ServerAddOnPluginConnection, - FlipperServerForServerAddOn, -} from 'flipper-common'; diff --git a/desktop/flipper-plugin/src/state/__tests__/atom-local-storage.node.tsx b/desktop/flipper-plugin/src/state/__tests__/atom-local-storage.node.tsx index c2fd9220e..3a5645262 100644 --- a/desktop/flipper-plugin/src/state/__tests__/atom-local-storage.node.tsx +++ b/desktop/flipper-plugin/src/state/__tests__/atom-local-storage.node.tsx @@ -7,7 +7,7 @@ * @format */ -import {createState} from '../atom'; +import {createState} from 'flipper-plugin-core'; import * as TestUtils from '../../test-utils/test-utils'; beforeEach(() => { diff --git a/desktop/flipper-plugin/src/state/atom.tsx b/desktop/flipper-plugin/src/state/atom.tsx index 102703c49..48d3d3b20 100644 --- a/desktop/flipper-plugin/src/state/atom.tsx +++ b/desktop/flipper-plugin/src/state/atom.tsx @@ -7,143 +7,16 @@ * @format */ -import {produce, Draft, enableMapSet} from 'immer'; +import {_AtomValue, _ReadOnlyAtom} from 'flipper-plugin-core'; import {useState, useEffect} from 'react'; -import { - getCurrentPluginInstance, - Persistable, - registerStorageAtom, -} from '../plugin/PluginBase'; -import { - deserializeShallowObject, - makeShallowSerializable, -} from '../utils/shallowSerialization'; -enableMapSet(); - -export interface ReadOnlyAtom { - get(): T; - subscribe(listener: (value: T, prevValue: T) => void): () => void; - unsubscribe(listener: (value: T, prevValue: T) => void): void; -} - -export interface Atom extends ReadOnlyAtom { - set(newValue: T): void; - update(recipe: (draft: Draft) => void): void; - update(recipe: (draft: X) => void): void; -} - -class AtomValue implements Atom, Persistable { - value: T; - listeners: ((value: T, prevValue: T) => void)[] = []; - - constructor(initialValue: T) { - this.value = initialValue; - } - - get() { - return this.value; - } - - set(nextValue: T) { - if (nextValue !== this.value) { - const prevValue = this.value; - this.value = nextValue; - this.notifyChanged(prevValue); - } - } - - deserialize(value: T) { - this.set(deserializeShallowObject(value)); - } - - serialize() { - return makeShallowSerializable(this.get()); - } - - update(recipe: (draft: Draft) => void) { - this.set(produce(this.value, recipe)); - } - - notifyChanged(prevValue: T) { - // TODO: add scheduling - this.listeners.slice().forEach((l) => l(this.value, prevValue)); - } - - subscribe(listener: (value: T, prevValue: T) => void) { - this.listeners.push(listener); - return () => this.unsubscribe(listener); - } - - unsubscribe(listener: (value: T, prevValue: T) => void) { - const idx = this.listeners.indexOf(listener); - if (idx !== -1) { - this.listeners.splice(idx, 1); - } - } -} - -type StateOptions = { - /** - * Should this state persist when exporting a plugin? - * If set, the atom will be saved / loaded under the key provided - */ - persist?: string; - /** - * Store this state in local storage, instead of as part of the plugin import / export. - * State stored in local storage is shared between the same plugin - * across multiple clients/ devices, but not actively synced. - */ - persistToLocalStorage?: boolean; -}; - -export function createState( - initialValue: T, - options?: StateOptions, -): Atom; -export function createState(): Atom; -export function createState( - initialValue: any = undefined, - options: StateOptions = {}, -): Atom { - const atom = new AtomValue(initialValue); - if (options?.persistToLocalStorage) { - syncAtomWithLocalStorage(options, atom); - } else { - registerStorageAtom(options.persist, atom); - } - return atom; -} - -function syncAtomWithLocalStorage(options: StateOptions, atom: AtomValue) { - if (!options?.persist) { - throw new Error( - "The 'persist' option should be set when 'persistToLocalStorage' is set", - ); - } - const pluginInstance = getCurrentPluginInstance(); - if (!pluginInstance) { - throw new Error( - "The 'persistToLocalStorage' option cannot be used outside a plugin definition", - ); - } - const storageKey = `flipper:${pluginInstance.definition.id}:atom:${options.persist}`; - const storedValue = window.localStorage.getItem(storageKey); - if (storedValue != null) { - atom.deserialize(JSON.parse(storedValue)); - } - atom.subscribe(() => { - window.localStorage.setItem(storageKey, JSON.stringify(atom.serialize())); - }); -} - -export function useValue(atom: ReadOnlyAtom): T; +export function useValue(atom: _ReadOnlyAtom): T; export function useValue( - atom: ReadOnlyAtom | undefined, + atom: _ReadOnlyAtom | undefined, defaultValue: T, ): T; export function useValue( - atom: ReadOnlyAtom | undefined, + atom: _ReadOnlyAtom | undefined, defaultValue?: T, ): T { const [localValue, setLocalValue] = useState( @@ -156,14 +29,10 @@ export function useValue( // atom might have changed between mounting and effect setup // in that case, this will cause a re-render, otherwise not setLocalValue(atom.get()); - (atom as AtomValue).subscribe(setLocalValue); + (atom as _AtomValue).subscribe(setLocalValue); return () => { - (atom as AtomValue).unsubscribe(setLocalValue); + (atom as _AtomValue).unsubscribe(setLocalValue); }; }, [atom]); return localValue; } - -export function isAtom(value: any): value is Atom { - return value instanceof AtomValue; -} diff --git a/desktop/flipper-plugin/src/state/batch.tsx b/desktop/flipper-plugin/src/state/batch.tsx index 26493604e..c7a540710 100644 --- a/desktop/flipper-plugin/src/state/batch.tsx +++ b/desktop/flipper-plugin/src/state/batch.tsx @@ -8,17 +8,6 @@ */ import {unstable_batchedUpdates} from 'react-dom'; +import {_setBatchedUpdateImplementation} from 'flipper-plugin-core'; -export const batch = unstable_batchedUpdates; - -export function batched(fn: T): T; -export function batched(fn: any) { - return function (this: any) { - let res: any; - batch(() => { - // eslint-disable-next-line - res = fn.apply(this, arguments); - }); - return res; - }; -} +_setBatchedUpdateImplementation(unstable_batchedUpdates); diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 2429ed91c..fbbf920a7 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -10,71 +10,45 @@ import * as React from 'react'; import type {RenderResult} from '@testing-library/react'; import {queries} from '@testing-library/dom'; -import { - BundledPluginDetails, - fsConstants, - InstalledPluginDetails, - ServerAddOnControls, -} from 'flipper-common'; +import {ServerAddOnControls} from 'flipper-common'; import { - RealFlipperClient, - SandyPluginInstance, + _RealFlipperClient, + _SandyPluginInstance, PluginClient, - PluginFactory, -} from '../plugin/Plugin'; -import { - SandyPluginDefinition, - FlipperPluginModule, - FlipperDevicePluginModule, -} from '../plugin/SandyPluginDefinition'; -import {SandyPluginRenderer} from '../plugin/PluginRenderer'; -import { - SandyDevicePluginInstance, + _PluginFactory, + _SandyPluginDefinition, + _FlipperPluginModule, + _FlipperDevicePluginModule, + _SandyDevicePluginInstance, Device, DeviceLogListener, CrashLogListener, -} from '../plugin/DevicePlugin'; -import {BasePluginInstance} from '../plugin/PluginBase'; -import {FlipperLib} from '../plugin/FlipperLib'; -import {stubLogger} from '../utils/Logger'; -import {Idler} from '../utils/Idler'; -import {createState} from '../state/atom'; -import { - DeviceLogEntry, - FlipperServer, - FlipperServerCommands, -} from 'flipper-common'; + _BasePluginInstance, + FlipperLib, + _stubLogger, + Idler, + createState, + TestUtils, + _StartPluginOptions, +} from 'flipper-plugin-core'; +import {SandyPluginRenderer} from '../plugin/PluginRenderer'; +import {DeviceLogEntry} from 'flipper-common'; -declare const process: any; declare const electronRequire: any; type Renderer = RenderResult; -interface StartPluginOptions { - initialState?: Record; - isArchived?: boolean; - isBackgroundPlugin?: boolean; - startUnactivated?: boolean; - /** Provide a set of unsupported methods to simulate older clients that don't support certain methods yet */ - unsupportedMethods?: string[]; - /** - * Provide a set of GKs that are enabled in this test. - */ - GKs?: string[]; - testDevice?: Device; -} - -type ExtractClientType> = Parameters< +type ExtractClientType> = Parameters< Module['plugin'] >[0]; -type ExtractMethodsType> = +type ExtractMethodsType> = ExtractClientType extends PluginClient ? Methods : never; -type ExtractEventsType> = +type ExtractEventsType> = ExtractClientType extends PluginClient ? Events : never; @@ -125,7 +99,7 @@ interface BasePluginResult { serverAddOnControls: ServerAddOnControls; } -interface StartPluginResult> +interface StartPluginResult> extends BasePluginResult { /** * the instantiated plugin for this test @@ -173,7 +147,7 @@ interface StartPluginResult> ): void; } -interface StartDevicePluginResult +interface StartDevicePluginResult extends BasePluginResult { /** * the instantiated plugin for this test @@ -189,24 +163,14 @@ interface StartDevicePluginResult sendLogEntry(logEntry: DeviceLogEntry): void; } -export function createStubFunction(): jest.Mock { - // we shouldn't be usign jest.fn() outside a unit test, as it would not resolve / cause jest to be bundled up! - if (typeof jest !== 'undefined') { - return jest.fn(); - } - return (() => { - console.warn('Using a stub function outside a test environment!'); - }) as any; -} - -export function startPlugin>( +export function startPlugin>( module: Module, - options?: StartPluginOptions, + options?: _StartPluginOptions, ): StartPluginResult { const {act} = electronRequire('@testing-library/react'); - const definition = new SandyPluginDefinition( - createMockPluginDetails(), + const definition = new _SandyPluginDefinition( + TestUtils.createMockPluginDetails(), module, ); if (definition.isDevicePlugin) { @@ -215,12 +179,12 @@ export function startPlugin>( ); } - const sendStub = createStubFunction(); - const flipperUtils = createMockFlipperLib(options); + const sendStub = TestUtils.createStubFunction(); + const flipperUtils = TestUtils.createMockFlipperLib(options); const testDevice = createMockDevice(options); const appName = 'TestApplication'; const deviceName = 'TestDevice'; - const fakeFlipperClient: RealFlipperClient = { + const fakeFlipperClient: _RealFlipperClient = { id: `${appName}#${testDevice.os}#${deviceName}#${testDevice.serial}`, plugins: new Set([definition.id]), query: { @@ -263,7 +227,7 @@ export function startPlugin>( const serverAddOnControls = createServerAddOnControlsMock(); - const pluginInstance = new SandyPluginInstance( + const pluginInstance = new _SandyPluginInstance( serverAddOnControls, flipperUtils, definition, @@ -305,9 +269,9 @@ export function startPlugin>( return res; } -export function renderPlugin>( +export function renderPlugin>( module: Module, - options?: StartPluginOptions, + options?: _StartPluginOptions, ): StartPluginResult & { renderer: Renderer; act: (cb: () => void) => void; @@ -315,7 +279,7 @@ export function renderPlugin>( // prevent bundling in UI bundle const {render, act} = electronRequire('@testing-library/react'); const res = startPlugin(module, options); - const pluginInstance: SandyPluginInstance = (res as any)._backingInstance; + const pluginInstance: _SandyPluginInstance = (res as any)._backingInstance; const renderer = render(); @@ -330,14 +294,14 @@ export function renderPlugin>( }; } -export function startDevicePlugin( +export function startDevicePlugin( module: Module, - options?: StartPluginOptions, + options?: _StartPluginOptions, ): StartDevicePluginResult { const {act} = electronRequire('@testing-library/react'); - const definition = new SandyPluginDefinition( - createMockPluginDetails({pluginType: 'device'}), + const definition = new _SandyPluginDefinition( + TestUtils.createMockPluginDetails({pluginType: 'device'}), module, ); if (!definition.isDevicePlugin) { @@ -346,10 +310,10 @@ export function startDevicePlugin( ); } - const flipperLib = createMockFlipperLib(options); + const flipperLib = TestUtils.createMockFlipperLib(options); const testDevice = createMockDevice(options); const serverAddOnControls = createServerAddOnControlsMock(); - const pluginInstance = new SandyDevicePluginInstance( + const pluginInstance = new _SandyDevicePluginInstance( serverAddOnControls, flipperLib, definition, @@ -376,9 +340,9 @@ export function startDevicePlugin( return res; } -export function renderDevicePlugin( +export function renderDevicePlugin( module: Module, - options?: StartPluginOptions, + options?: _StartPluginOptions, ): StartDevicePluginResult & { renderer: Renderer; act: (cb: () => void) => void; @@ -387,7 +351,7 @@ export function renderDevicePlugin( const res = startDevicePlugin(module, options); // @ts-ignore hidden api - const pluginInstance: SandyDevicePluginInstance = (res as any) + const pluginInstance: _SandyDevicePluginInstance = (res as any) ._backingInstance; const renderer = render(); @@ -403,64 +367,8 @@ export function renderDevicePlugin( }; } -export function createMockFlipperLib(options?: StartPluginOptions): FlipperLib { - return { - isFB: false, - logger: stubLogger, - enableMenuEntries: createStubFunction(), - createPaste: createStubFunction(), - GK(gk: string) { - return options?.GKs?.includes(gk) || false; - }, - selectPlugin: createStubFunction(), - writeTextToClipboard: createStubFunction(), - openLink: createStubFunction(), - showNotification: createStubFunction(), - exportFile: createStubFunction(), - importFile: createStubFunction(), - paths: { - appPath: process.cwd(), - homePath: `/dev/null`, - staticPath: process.cwd(), - tempPath: `/dev/null`, - }, - environmentInfo: { - os: { - arch: 'Test', - unixname: 'test', - platform: 'linux', - }, - }, - intern: { - graphGet: createStubFunction(), - graphPost: createStubFunction(), - }, - remoteServerContext: { - childProcess: { - exec: createStubFunction(), - }, - fs: { - access: createStubFunction(), - pathExists: createStubFunction(), - unlink: createStubFunction(), - mkdir: createStubFunction(), - rm: createStubFunction(), - copyFile: createStubFunction(), - constants: fsConstants, - stat: createStubFunction(), - readlink: createStubFunction(), - readFile: createStubFunction(), - readFileBinary: createStubFunction(), - writeFile: createStubFunction(), - writeFileBinary: createStubFunction(), - }, - downloadFile: createStubFunction(), - }, - }; -} - function createBasePluginResult( - pluginInstance: BasePluginInstance, + pluginInstance: _BasePluginInstance, serverAddOnControls: ServerAddOnControls, ): BasePluginResult { return { @@ -491,85 +399,7 @@ function createBasePluginResult( }; } -export function createMockPluginDetails( - details?: Partial, -): InstalledPluginDetails { - return { - id: 'TestPlugin', - dir: '', - name: 'TestPlugin', - specVersion: 0, - entry: '', - isBundled: false, - isActivatable: true, - main: '', - source: '', - title: 'Testing Plugin', - version: '', - ...details, - }; -} - -export function createTestPlugin>( - implementation: Pick, 'plugin'> & - Partial>, - details?: Partial, -) { - return new SandyPluginDefinition( - createMockPluginDetails({ - pluginType: 'client', - ...details, - }), - { - Component() { - return null; - }, - ...implementation, - }, - ); -} - -export function createTestDevicePlugin( - implementation: Pick & - Partial, - details?: Partial, -) { - return new SandyPluginDefinition( - createMockPluginDetails({ - pluginType: 'device', - ...details, - }), - { - supportsDevice() { - return true; - }, - Component() { - return null; - }, - ...implementation, - }, - ); -} - -export function createMockBundledPluginDetails( - details?: Partial, -): BundledPluginDetails { - return { - id: 'TestBundledPlugin', - name: 'TestBundledPlugin', - specVersion: 0, - pluginType: 'client', - isBundled: true, - isActivatable: true, - main: '', - source: '', - title: 'Testing Bundled Plugin', - version: '', - ...details, - }; -} - -function createMockDevice(options?: StartPluginOptions): Device & { +function createMockDevice(options?: _StartPluginOptions): Device & { addLogEntry(entry: DeviceLogEntry): void; } { const logListeners: (undefined | DeviceLogListener)[] = []; @@ -608,18 +438,18 @@ function createMockDevice(options?: StartPluginOptions): Device & { addLogEntry(entry: DeviceLogEntry) { logListeners.forEach((f) => f?.(entry)); }, - executeShell: createStubFunction(), - clearLogs: createStubFunction(), - forwardPort: createStubFunction(), + executeShell: TestUtils.createStubFunction(), + clearLogs: TestUtils.createStubFunction(), + forwardPort: TestUtils.createStubFunction(), get isConnected() { return this.connected.get(); }, installApp(_: string) { return Promise.resolve(); }, - navigateToLocation: createStubFunction(), - screenshot: createStubFunction(), - sendMetroCommand: createStubFunction(), + navigateToLocation: TestUtils.createStubFunction(), + screenshot: TestUtils.createStubFunction(), + sendMetroCommand: TestUtils.createStubFunction(), }; } @@ -638,38 +468,22 @@ function createStubIdler(): Idler { }; } -export function createFlipperServerMock( - overrides?: Partial, -): FlipperServer { +function createServerAddOnControlsMock(): ServerAddOnControls { return { - async connect() {}, - on: createStubFunction(), - off: createStubFunction(), - exec: jest - .fn() - .mockImplementation( - async (cmd: keyof FlipperServerCommands, ...args: any[]) => { - if (overrides?.[cmd]) { - return (overrides[cmd] as any)(...args); - } - console.warn( - `Empty server response stubbed for command '${cmd}', set 'getRenderHostInstance().flipperServer.exec' in your test to override the behavior.`, - ); - return undefined; - }, - ), - close: createStubFunction(), + start: TestUtils.createStubFunction(), + stop: TestUtils.createStubFunction(), + sendMessage: TestUtils.createStubFunction(), + receiveMessage: TestUtils.createStubFunction(), + receiveAnyMessage: TestUtils.createStubFunction(), + unsubscribePlugin: TestUtils.createStubFunction(), + unsubscribe: TestUtils.createStubFunction(), }; } -function createServerAddOnControlsMock(): ServerAddOnControls { - return { - start: createStubFunction(), - stop: createStubFunction(), - sendMessage: createStubFunction(), - receiveMessage: createStubFunction(), - receiveAnyMessage: createStubFunction(), - unsubscribePlugin: createStubFunction(), - unsubscribe: createStubFunction(), - }; -} +export const createMockFlipperLib = TestUtils.createMockFlipperLib; +export const createMockPluginDetails = TestUtils.createMockPluginDetails; +export const createTestPlugin = TestUtils.createTestPlugin; +export const createTestDevicePlugin = TestUtils.createTestDevicePlugin; +export const createMockBundledPluginDetails = + TestUtils.createMockBundledPluginDetails; +export const createFlipperServerMock = TestUtils.createFlipperServerMock; diff --git a/desktop/flipper-plugin/src/ui/DataFormatter.tsx b/desktop/flipper-plugin/src/ui/DataFormatter.tsx index a1dc5fdc4..5a087a5e8 100644 --- a/desktop/flipper-plugin/src/ui/DataFormatter.tsx +++ b/desktop/flipper-plugin/src/ui/DataFormatter.tsx @@ -15,8 +15,8 @@ import { import {Button, Typography} from 'antd'; import {isPlainObject, pad} from 'lodash'; import React, {createElement, Fragment, isValidElement, useState} from 'react'; -import {tryGetFlipperLibImplementation} from '../plugin/FlipperLib'; -import {safeStringify} from '../utils/safeStringify'; +import {_tryGetFlipperLibImplementation} from 'flipper-plugin-core'; +import {safeStringify} from 'flipper-plugin-core'; import {urlRegex} from '../utils/urlRegex'; import {useTableRedraw} from '../data-source/index'; import {theme} from './theme'; @@ -196,7 +196,7 @@ export function TruncateHelper({