diff --git a/desktop/app/src/Client.tsx b/desktop/app/src/Client.tsx index b216a0354..b6491577c 100644 --- a/desktop/app/src/Client.tsx +++ b/desktop/app/src/Client.tsx @@ -291,6 +291,20 @@ export default class Client extends EventEmitter { }); } + initFromImport(initialStates: Record>): this { + this.plugins.forEach((pluginId) => { + const plugin = this.getPlugin(pluginId); + if (isSandyPlugin(plugin)) { + // TODO: needs to be wrapped in error tracking T68955280 + this.sandyPluginStates.set( + plugin.id, + new SandyPluginInstance(this, plugin, initialStates[pluginId]), + ); + } + }); + return this; + } + // get the supported plugins async loadPlugins(): Promise { const plugins = await this.rawCall<{plugins: Plugins}>( @@ -327,7 +341,6 @@ export default class Client extends EventEmitter { !this.sandyPluginStates.has(plugin.id) ) { // TODO: needs to be wrapped in error tracking T68955280 - // TODO: pick up any existing persisted state T68683449 this.sandyPluginStates.set( plugin.id, new SandyPluginInstance(this, plugin), @@ -345,7 +358,6 @@ export default class Client extends EventEmitter { const instance = this.sandyPluginStates.get(pluginId); if (instance) { instance.destroy(); - // TODO: make sure persisted state is writtenT68683449 this.sandyPluginStates.delete(pluginId); } } diff --git a/desktop/app/src/plugin.tsx b/desktop/app/src/plugin.tsx index a2e5e4b85..45d574260 100644 --- a/desktop/app/src/plugin.tsx +++ b/desktop/app/src/plugin.tsx @@ -38,7 +38,7 @@ export type ClientPluginMap = Map; export type DevicePluginMap = Map; export function isSandyPlugin( - plugin?: PluginDefinition, + plugin?: PluginDefinition | null, ): plugin is SandyPluginDefinition { return plugin instanceof SandyPluginDefinition; } diff --git a/desktop/app/src/store.tsx b/desktop/app/src/store.tsx index d73d8cefc..25a2804ed 100644 --- a/desktop/app/src/store.tsx +++ b/desktop/app/src/store.tsx @@ -70,7 +70,6 @@ export function rootReducer( client.deinitPlugin(selectedPlugin); } // stop sandy plugins - // TODO: forget any persisted state as well T68683449 client.stopPluginIfNeeded(plugin.id); delete draft.pluginMessageQueue[ getPluginKey(client.id, {serial: client.query.device_id}, plugin.id) diff --git a/desktop/app/src/utils/__tests__/exportData.node.tsx b/desktop/app/src/utils/__tests__/exportData.node.tsx index 4837f8661..86362ac6e 100644 --- a/desktop/app/src/utils/__tests__/exportData.node.tsx +++ b/desktop/app/src/utils/__tests__/exportData.node.tsx @@ -11,11 +11,24 @@ import {State} from '../../reducers/index'; import configureStore from 'redux-mock-store'; import {default as BaseDevice} from '../../devices/BaseDevice'; import {default as ArchivedDevice} from '../../devices/ArchivedDevice'; -import {processStore, determinePluginsToProcess} from '../exportData'; +import { + processStore, + determinePluginsToProcess, + exportStore, + importDataToStore, +} from '../exportData'; import {FlipperPlugin, FlipperDevicePlugin} from '../../plugin'; import {Notification} from '../../plugin'; import {default as Client, ClientExport} from '../../Client'; import {State as PluginsState} from '../../reducers/plugins'; +import {renderMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin'; +import { + TestUtils, + SandyPluginDefinition, + createState, + FlipperClient, +} from 'flipper-plugin'; +import {selectPlugin} from '../../reducers/connections'; class TestPlugin extends FlipperPlugin {} TestPlugin.title = 'TestPlugin'; @@ -170,18 +183,20 @@ test('test generateNotifications helper function', () => { }); }); -test('test processStore function for empty state', () => { - const json = processStore({ - activeNotifications: [], - device: null, - pluginStates: {}, - clients: [], - devicePlugins: new Map(), - clientPlugins: new Map(), - salt: 'salt', - selectedPlugins: [], - }); - expect(json).rejects.toMatchInlineSnapshot( +test('test processStore function for empty state', async () => { + expect( + processStore({ + activeNotifications: [], + device: null, + pluginStates: {}, + clients: [], + devicePlugins: new Map(), + clientPlugins: new Map(), + salt: 'salt', + selectedPlugins: [], + pluginStates2: {}, + }), + ).rejects.toMatchInlineSnapshot( `[Error: Selected device is null, please select a device]`, ); }); @@ -198,6 +213,7 @@ test('test processStore function for an iOS device connected', async () => { screenshotHandle: null, }), pluginStates: {}, + pluginStates2: {}, clients: [], devicePlugins: new Map(), clientPlugins: new Map(), @@ -232,6 +248,7 @@ test('test processStore function for an iOS device connected with client plugin logEntries: [], screenshotHandle: null, }); + const client = generateClientFromDevice(device, 'testapp'); const clientIdentifier = generateClientIdentifier(device, 'testapp'); const json = await processStore({ activeNotifications: [], @@ -239,7 +256,10 @@ test('test processStore function for an iOS device connected with client plugin pluginStates: { [`${clientIdentifier}#TestPlugin`]: {msg: 'Test plugin'}, }, - clients: [generateClientFromDevice(device, 'testapp')], + pluginStates2: { + [`${clientIdentifier}`]: {TestPlugin2: [{msg: 'Test plugin2'}]}, + }, + clients: [client], devicePlugins: new Map(), clientPlugins: new Map([['TestPlugin', TestPlugin]]), salt: 'salt', @@ -257,7 +277,17 @@ test('test processStore function for an iOS device connected with client plugin msg: 'Test plugin', }), }; + const expectedPluginState2 = { + [`${generateClientIdentifierWithSalt(clientIdentifier, 'salt')}`]: { + TestPlugin2: [ + { + msg: 'Test plugin2', + }, + ], + }, + }; expect(pluginStates).toEqual(expectedPluginState); + expect(json.pluginStates2).toEqual(expectedPluginState2); }); test('test processStore function to have only the client for the selected device', async () => { @@ -301,6 +331,7 @@ test('test processStore function to have only the client for the selected device msg: 'Test plugin selected device', }, }, + pluginStates2: {}, clients: [ selectedDeviceClient, generateClientFromDevice(unselectedDevice, 'testapp'), @@ -361,6 +392,7 @@ test('test processStore function to have multiple clients for the selected devic msg: 'Test plugin App2', }, }, + pluginStates2: {}, clients: [ generateClientFromDevice(selectedDevice, 'testapp1'), generateClientFromDevice(selectedDevice, 'testapp2'), @@ -411,6 +443,7 @@ test('test processStore function for device plugin state and no clients', async msg: 'Test Device plugin', }, }, + pluginStates2: {}, clients: [], devicePlugins: new Map([['TestDevicePlugin', TestDevicePlugin]]), clientPlugins: new Map(), @@ -448,6 +481,7 @@ test('test processStore function for unselected device plugin state and no clien msg: 'Test Device plugin', }, }, + pluginStates2: {}, clients: [], devicePlugins: new Map([['TestDevicePlugin', TestDevicePlugin]]), clientPlugins: new Map(), @@ -489,6 +523,7 @@ test('test processStore function for notifications for selected device', async ( activeNotifications: [activeNotification], device: selectedDevice, pluginStates: {}, + pluginStates2: {}, clients: [client], devicePlugins: new Map([['TestDevicePlugin', TestDevicePlugin]]), clientPlugins: new Map(), @@ -551,6 +586,7 @@ test('test processStore function for notifications for unselected device', async activeNotifications: [activeNotification], device: selectedDevice, pluginStates: {}, + pluginStates2: {}, clients: [client, unselectedclient], devicePlugins: new Map(), clientPlugins: new Map(), @@ -591,6 +627,7 @@ test('test processStore function for selected plugins', async () => { activeNotifications: [], device: selectedDevice, pluginStates: pluginstates, + pluginStates2: {}, clients: [client], devicePlugins: new Map([ ['TestDevicePlugin1', TestDevicePlugin], @@ -640,6 +677,7 @@ test('test processStore function for no selected plugins', async () => { activeNotifications: [], device: selectedDevice, pluginStates: pluginstates, + pluginStates2: {}, clients: [client], devicePlugins: new Map([ ['TestDevicePlugin1', TestDevicePlugin], @@ -935,3 +973,128 @@ test('test determinePluginsToProcess to ignore archived clients', async () => { }, ]); }); + +const sandyTestPlugin = new SandyPluginDefinition( + TestUtils.createMockPluginDetails(), + { + plugin( + client: FlipperClient<{ + inc: {}; + }>, + ) { + const counter = createState(0, {persist: 'counter'}); + const _somethingElse = createState(0); + const anotherState = createState({testCount: 0}, {persist: 'otherState'}); + + client.onMessage('inc', () => { + counter.set(counter.get() + 1); + anotherState.update((draft) => { + draft.testCount -= 1; + }); + }); + return {}; + }, + Component() { + return null; + }, + }, +); + +test('Sandy plugins are exported properly', async () => { + const {client, sendMessage, store} = await renderMockFlipperWithPlugin( + sandyTestPlugin, + ); + + // We do select another plugin, to verify that pending message queues are indeed processed before exporting + store.dispatch( + selectPlugin({ + selectedPlugin: 'DeviceLogs', + selectedApp: client.id, + deepLinkPayload: null, + }), + ); + + // Deliberately not using 'act' here, to verify that exportStore itself makes sure buffers are flushed first + sendMessage('inc', {}); + sendMessage('inc', {}); + sendMessage('inc', {}); + + const storeExport = await exportStore(store); + const serial = storeExport.exportStoreData.device!.serial; + expect(serial).not.toBeFalsy(); + expect(storeExport.exportStoreData.pluginStates2).toEqual({ + [`TestApp#Android#MockAndroidDevice#${serial}`]: { + TestPlugin: {counter: 3, otherState: {testCount: -3}}, + }, + }); +}); + +test('Sandy plugins are imported properly', async () => { + const data = { + clients: [ + { + id: + 'TestApp#Android#MockAndroidDevice#2e52cea6-94b0-4ea1-b9a8-c9135ede14ca-serial', + query: { + app: 'TestApp', + device: 'MockAndroidDevice', + device_id: '2e52cea6-94b0-4ea1-b9a8-c9135ede14ca-serial', + os: 'Android', + sdk_version: 4, + }, + }, + ], + device: { + deviceType: 'archivedPhysical', + logs: [], + os: 'Android', + serial: '2e52cea6-94b0-4ea1-b9a8-c9135ede14ca-serial', + title: 'MockAndroidDevice', + }, + deviceScreenshot: null, + fileVersion: '0.9.99', + flipperReleaseRevision: undefined, + pluginStates2: { + 'TestApp#Android#MockAndroidDevice#2e52cea6-94b0-4ea1-b9a8-c9135ede14ca-serial': { + TestPlugin: { + otherState: { + testCount: -3, + }, + counter: 3, + }, + }, + }, + store: { + activeNotifications: [], + pluginStates: {}, + }, + }; + + const {client, store} = await renderMockFlipperWithPlugin(sandyTestPlugin); + + await importDataToStore('unittest.json', JSON.stringify(data), store); + + const client2 = store.getState().connections.clients[1]; + expect(client2).not.toBeFalsy(); + expect(client2).not.toBe(client); + expect(client2.plugins).toEqual([TestPlugin.id]); + + expect(client.sandyPluginStates.get(TestPlugin.id)!.exportState()) + .toMatchInlineSnapshot(` + Object { + "counter": 0, + "otherState": Object { + "testCount": 0, + }, + } + `); + expect(client2.sandyPluginStates.get(TestPlugin.id)!.exportState()) + .toMatchInlineSnapshot(` + Object { + "counter": 3, + "otherState": Object { + "testCount": -3, + }, + } + `); +}); diff --git a/desktop/app/src/utils/exportData.tsx b/desktop/app/src/utils/exportData.tsx index 71374d069..98a0d97ed 100644 --- a/desktop/app/src/utils/exportData.tsx +++ b/desktop/app/src/utils/exportData.tsx @@ -22,7 +22,6 @@ import { FlipperDevicePlugin, callClient, supportsMethod, - FlipperBasePlugin, PluginDefinition, DevicePluginMap, ClientPluginMap, @@ -55,9 +54,16 @@ export const IMPORT_FLIPPER_TRACE_EVENT = 'import-flipper-trace'; export const EXPORT_FLIPPER_TRACE_EVENT = 'export-flipper-trace'; export const EXPORT_FLIPPER_TRACE_TIME_SERIALIZATION_EVENT = `${EXPORT_FLIPPER_TRACE_EVENT}:serialization`; +// maps clientId -> pluginId -> persistence key -> state +export type SandyPluginStates = Record< + string, + Record> +>; + export type PluginStatesExportState = { [pluginKey: string]: string; }; + export type ExportType = { fileVersion: string; flipperReleaseRevision: string | undefined; @@ -68,6 +74,7 @@ export type ExportType = { pluginStates: PluginStatesExportState; activeNotifications: Array; }; + pluginStates2: SandyPluginStates; supportRequestDetails?: SupportFormRequestDetailsState; }; @@ -106,6 +113,7 @@ type AddSaltToDeviceSerialOptions = { deviceScreenshot: string | null; clients: Array; pluginStates: PluginStatesExportState; + pluginStates2: SandyPluginStates; pluginNotification: Array; selectedPlugins: Array; statusUpdate?: (msg: string) => void; @@ -220,16 +228,10 @@ const serializePluginStates = async ( statusUpdate?: (msg: string) => void, idler?: Idler, ): Promise => { - const pluginsMap: Map = new Map([]); - clientPlugins.forEach((val, key) => { - // TODO: Support Sandy T68683449 and use ClientPluginsMap - if (!isSandyPlugin(val)) { - pluginsMap.set(key, val); - } - }); - devicePlugins.forEach((val, key) => { - pluginsMap.set(key, val); - }); + const pluginsMap = new Map([ + ...clientPlugins.entries(), + ...devicePlugins.entries(), + ]); const pluginExportState: PluginStatesExportState = {}; for (const key in pluginStates) { const pluginName = deconstructPluginKey(key).pluginName; @@ -237,7 +239,9 @@ const serializePluginStates = async ( const serializationMarker = `${EXPORT_FLIPPER_TRACE_EVENT}:serialization-per-plugin`; performance.mark(serializationMarker); const pluginClass = pluginName ? pluginsMap.get(pluginName) : null; - if (pluginClass) { + if (isSandyPlugin(pluginClass)) { + continue; // Those are already processed by `exportSandyPluginStates` + } else if (pluginClass) { pluginExportState[key] = await pluginClass.serializePersistedState( pluginStates[key], statusUpdate, @@ -252,19 +256,32 @@ const serializePluginStates = async ( return pluginExportState; }; +function exportSandyPluginStates( + pluginsToProcess: PluginsToProcess, +): SandyPluginStates { + const res: SandyPluginStates = {}; + pluginsToProcess.forEach(({pluginId, client, pluginClass}) => { + if (isSandyPlugin(pluginClass) && client.sandyPluginStates.has(pluginId)) { + if (!res[client.id]) { + res[client.id] = {}; + } + res[client.id][pluginId] = client.sandyPluginStates + .get(pluginId)! + .exportState(); + } + }); + return res; +} + const deserializePluginStates = ( pluginStatesExportState: PluginStatesExportState, clientPlugins: ClientPluginMap, devicePlugins: DevicePluginMap, ): PluginStatesState => { - const pluginsMap: Map = new Map([]); - clientPlugins.forEach((val, key) => { - // TODO: Support Sandy T68683449 - if (!isSandyPlugin(val)) pluginsMap.set(key, val); - }); - devicePlugins.forEach((val, key) => { - pluginsMap.set(key, val); - }); + const pluginsMap = new Map([ + ...clientPlugins.entries(), + ...devicePlugins.entries(), + ]); const pluginsState: PluginStatesState = {}; for (const key in pluginStatesExportState) { const pluginName = deconstructPluginKey(key).pluginName; @@ -272,7 +289,9 @@ const deserializePluginStates = ( continue; } const pluginClass = pluginsMap.get(pluginName); - if (pluginClass) { + if (isSandyPlugin(pluginClass)) { + pluginsState[key] = pluginStatesExportState[key]; + } else if (pluginClass) { pluginsState[key] = pluginClass.deserializePersistedState( pluginStatesExportState[key], ); @@ -281,19 +300,34 @@ const deserializePluginStates = ( return pluginsState; }; -const addSaltToDeviceSerial = async ( - options: AddSaltToDeviceSerialOptions, -): Promise => { - const { - salt, - device, - deviceScreenshot, - clients, - pluginStates, - pluginNotification, - statusUpdate, - selectedPlugins, - } = options; +function replaceSerialsInKeys>( + collection: T, + baseSerial: string, + newSerial: string, +): T { + const result: Record = {}; + for (const key in collection) { + if (!key.includes(baseSerial)) { + throw new Error( + `Error while exporting, plugin state (${key}) does not have ${baseSerial} in its key`, + ); + } + result[key.replace(baseSerial, newSerial)] = collection[key]; + } + return result as T; +} + +async function addSaltToDeviceSerial({ + salt, + device, + deviceScreenshot, + clients, + pluginStates, + pluginNotification, + statusUpdate, + selectedPlugins, + pluginStates2, +}: AddSaltToDeviceSerialOptions): Promise { const {serial} = device; const newSerial = salt + '-' + serial; const newDevice = new ArchivedDevice({ @@ -322,17 +356,16 @@ const addSaltToDeviceSerial = async ( statusUpdate( 'Adding salt to the selected device id in the plugin states...', ); - const updatedPluginStates: PluginStatesExportState = {}; - for (let key in pluginStates) { - if (!key.includes(serial)) { - throw new Error( - `Error while exporting, plugin state (${key}) does not have ${serial} in its key`, - ); - } - const pluginData = pluginStates[key]; - key = key.replace(serial, newSerial); - updatedPluginStates[key] = pluginData; - } + const updatedPluginStates = replaceSerialsInKeys( + pluginStates, + serial, + newSerial, + ); + const updatedPluginStates2 = replaceSerialsInKeys( + pluginStates2, + serial, + newSerial, + ); statusUpdate && statusUpdate( @@ -357,13 +390,15 @@ const addSaltToDeviceSerial = async ( pluginStates: updatedPluginStates, activeNotifications: updatedPluginNotifications, }, + pluginStates2: updatedPluginStates2, }; -}; +} type ProcessStoreOptions = { activeNotifications: Array; device: BaseDevice | null; pluginStates: PluginStatesState; + pluginStates2: SandyPluginStates; clients: Array; devicePlugins: DevicePluginMap; clientPlugins: ClientPluginMap; @@ -372,22 +407,21 @@ type ProcessStoreOptions = { statusUpdate?: (msg: string) => void; }; -export const processStore = async ( - options: ProcessStoreOptions, - idler?: Idler, -): Promise => { - const { +export async function processStore( + { activeNotifications, device, pluginStates, + pluginStates2, clients, devicePlugins, clientPlugins, salt, selectedPlugins, statusUpdate, - } = options; - + }: ProcessStoreOptions, + idler?: Idler, +): Promise { if (device) { const {serial} = device; statusUpdate && statusUpdate('Capturing screenshot...'); @@ -437,12 +471,13 @@ export const processStore = async ( pluginNotification: processedActiveNotifications, statusUpdate, selectedPlugins, + pluginStates2, }); return exportFlipperData; } throw new Error('Selected device is null, please select a device'); -}; +} export async function fetchMetadata( pluginsToProcess: PluginsToProcess, @@ -520,14 +555,18 @@ async function processQueues( pluginId, pluginKey, pluginClass, + client, } of pluginsToProcess) { - // TODO: Support Sandy T68683449 - if (!isSandyPlugin(pluginClass) && pluginClass.persistedStateReducer) { + if (isSandyPlugin(pluginClass) || pluginClass.persistedStateReducer) { + client.flushMessageBuffer(); const processQueueMarker = `${EXPORT_FLIPPER_TRACE_EVENT}:process-queue-per-plugin`; performance.mark(processQueueMarker); - + const plugin = isSandyPlugin(pluginClass) + ? client.sandyPluginStates.get(pluginId) + : pluginClass; + if (!plugin) continue; await processMessageQueue( - pluginClass, + plugin, pluginKey, store, ({current, total}) => { @@ -590,7 +629,7 @@ export function determinePluginsToProcess( return pluginsToProcess; } -export async function getStoreExport( +async function getStoreExport( store: MiddlewareAPI, statusUpdate?: (msg: string) => void, idler?: Idler, @@ -621,12 +660,18 @@ export async function getStoreExport( statusUpdate, idler, ); + const newPluginState = metadata.pluginStates; + + // TODO: support async export like fetchMetaData T68683476 + // TODO: support device plugins T68738317 + const pluginStates2 = pluginsToProcess + ? exportSandyPluginStates(pluginsToProcess) + : {}; getLogger().trackTimeSince(fetchMetaDataMarker, fetchMetaDataMarker, { plugins: state.plugins.selectedPlugins, }); const {errors} = metadata; - const newPluginState = metadata.pluginStates; const {activeNotifications} = state.notifications; const {devicePlugins, clientPlugins} = state.plugins; @@ -635,6 +680,7 @@ export async function getStoreExport( activeNotifications, device: selectedDevice, pluginStates: newPluginState, + pluginStates2, clients: client ? [client.toJSON()] : [], devicePlugins, clientPlugins, @@ -776,13 +822,18 @@ export function importDataToStore(source: string, data: string, store: Store) { }, }); }); + clients.forEach((client: {id: string; query: ClientQuery}) => { - const clientPlugins: Array = keys - .filter((key) => { - const plugin = deconstructPluginKey(key); - return plugin.type === 'client' && client.id === plugin.client; - }) - .map((pluginKey) => deconstructPluginKey(pluginKey).pluginName); + const sandyPluginStates = json.pluginStates2[client.id] || {}; + const clientPlugins: Array = [ + ...keys + .filter((key) => { + const plugin = deconstructPluginKey(key); + return plugin.type === 'client' && client.id === plugin.client; + }) + .map((pluginKey) => deconstructPluginKey(pluginKey).pluginName), + ...Object.keys(sandyPluginStates), + ]; store.dispatch({ type: 'NEW_CLIENT', payload: new Client( @@ -793,7 +844,7 @@ export function importDataToStore(source: string, data: string, store: Store) { store, clientPlugins, archivedDevice, - ), + ).initFromImport(sandyPluginStates), }); }); if (supportRequestDetails) { diff --git a/desktop/app/src/utils/exportMetrics.tsx b/desktop/app/src/utils/exportMetrics.tsx index 2bcd515ba..685d61245 100644 --- a/desktop/app/src/utils/exportMetrics.tsx +++ b/desktop/app/src/utils/exportMetrics.tsx @@ -47,7 +47,7 @@ async function exportMetrics( const metricsReducer: | ((persistedState: U) => Promise) | undefined = - pluginClass && !isSandyPlugin(pluginClass) + pluginClass && !isSandyPlugin(pluginClass) // This feature doesn't seem to be used at all, so let's add it when needed for Sandy ? pluginClass.metricsReducer : undefined; if (pluginsMap.has(pluginName) && metricsReducer) { @@ -135,6 +135,5 @@ export async function exportMetricsFromTrace( ), ); } - // TODO: Support Sandy T68683449 and use ClientPluginsMap, or kill feature return await exportMetrics(pluginStates, pluginsMap, selectedPlugins); } diff --git a/desktop/app/src/utils/pluginUtils.tsx b/desktop/app/src/utils/pluginUtils.tsx index 41ee2cc4c..6668736ca 100644 --- a/desktop/app/src/utils/pluginUtils.tsx +++ b/desktop/app/src/utils/pluginUtils.tsx @@ -203,11 +203,9 @@ export function getPersistentPlugins(plugins: PluginsState): Array { const pluginClass = pluginsMap.get(plugin); return ( plugin == 'DeviceLogs' || - (pluginClass && - // TODO: support Sandy plugin T68683449 - !isSandyPlugin(pluginClass) && - (pluginClass.defaultPersistedState != undefined || - pluginClass.exportPersistedState != undefined)) + isSandyPlugin(pluginClass) || + pluginClass?.defaultPersistedState || + pluginClass?.exportPersistedState ); }); } diff --git a/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx b/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx index 1b9956db1..6a8441860 100644 --- a/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx +++ b/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx @@ -35,7 +35,7 @@ export class SandyPluginDefinition { module: FlipperPluginModule; details: PluginDetails; - // TODO: Implement T68683449 + // TODO: Implement T68683476 exportPersistedState: | (( callClient: (method: string, params?: any) => Promise,