diff --git a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap index 0e9098431..065f2308a 100644 --- a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap +++ b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap @@ -2,6 +2,22 @@ exports[`can create a Fake flipper 1`] = ` Object { + "activeClient": Object { + "id": "TestApp#Android#MockAndroidDevice#serial", + "query": Object { + "app": "TestApp", + "device": "MockAndroidDevice", + "device_id": "serial", + "os": "Android", + "sdk_version": 4, + }, + }, + "activeDevice": Object { + "deviceType": "physical", + "os": "Android", + "serial": "serial", + "title": "MockAndroidDevice", + }, "androidEmulators": Array [], "clients": Array [ Object { @@ -36,6 +52,7 @@ Object { "TestPlugin", ], }, + "metroDevice": null, "selectedApp": "TestApp#Android#MockAndroidDevice#serial", "selectedDevice": Object { "deviceType": "physical", diff --git a/desktop/app/src/dispatcher/index.tsx b/desktop/app/src/dispatcher/index.tsx index 02d704aed..920b74c82 100644 --- a/desktop/app/src/dispatcher/index.tsx +++ b/desktop/app/src/dispatcher/index.tsx @@ -23,6 +23,7 @@ import reactNative from './reactNative'; import pluginMarketplace from './fb-stubs/pluginMarketplace'; import pluginDownloads from './pluginDownloads'; import info from '../utils/info'; +import pluginLists from './pluginLists'; import {Logger} from '../fb-interfaces/Logger'; import {Store} from '../reducers/index'; @@ -51,6 +52,7 @@ export default function (store: Store, logger: Logger): () => Promise { pluginMarketplace, pluginDownloads, info, + pluginLists, ].filter(notNull); const globalCleanup = dispatchers .map((dispatcher) => dispatcher(store, logger)) diff --git a/desktop/app/src/dispatcher/pluginLists.tsx b/desktop/app/src/dispatcher/pluginLists.tsx new file mode 100644 index 000000000..5dace1ca0 --- /dev/null +++ b/desktop/app/src/dispatcher/pluginLists.tsx @@ -0,0 +1,120 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import Client from '../Client'; +import {Logger} from '../fb-interfaces/Logger'; +import {Store} from '../reducers'; +import {pluginListsChanged} from '../reducers/pluginLists'; +import {computePluginLists} from '../utils/pluginUtils'; +import {sideEffect} from '../utils/sideEffect'; + +export default (store: Store, _logger: Logger) => { + const recomputePluginList = () => { + store.dispatch( + pluginListsChanged( + computePluginLists( + store.getState().connections, + store.getState().plugins, + ), + ), + ); + }; + + let prevClient: null | Client = null; + + sideEffect( + store, + {name: 'computePluginLists', throttleMs: 100, fireImmediately: true}, + (state) => { + const { + activeClient, + activeDevice, + metroDevice, + enabledDevicePlugins, + enabledPlugins, + } = state.connections; + const { + bundledPlugins, + marketplacePlugins, + loadedPlugins, + devicePlugins, + disabledPlugins, + gatekeepedPlugins, + failedPlugins, + clientPlugins, + } = state.plugins; + return { + activeClient, + activeDevice, + metroDevice, + enabledDevicePlugins, + enabledPlugins, + bundledPlugins, + marketplacePlugins, + loadedPlugins, + devicePlugins, + disabledPlugins, + gatekeepedPlugins, + failedPlugins, + clientPlugins, + }; + }, + ( + { + activeClient, + activeDevice, + metroDevice, + enabledDevicePlugins, + enabledPlugins, + bundledPlugins, + marketplacePlugins, + loadedPlugins, + devicePlugins, + disabledPlugins, + gatekeepedPlugins, + failedPlugins, + clientPlugins, + }, + store, + ) => { + store.dispatch( + pluginListsChanged( + computePluginLists( + { + activeClient, + activeDevice, + metroDevice, + enabledDevicePlugins, + enabledPlugins, + }, + { + bundledPlugins, + marketplacePlugins, + loadedPlugins, + devicePlugins, + disabledPlugins, + gatekeepedPlugins, + failedPlugins, + clientPlugins, + }, + ), + ), + ); + if (activeClient !== prevClient) { + if (prevClient) { + prevClient.off('plugins-change', recomputePluginList); + } + prevClient = activeClient; + if (prevClient) { + prevClient.on('plugins-change', recomputePluginList); + } + } + }, + ); +}; diff --git a/desktop/app/src/reducers/connections.tsx b/desktop/app/src/reducers/connections.tsx index 0c8b609c8..57cf5f1e9 100644 --- a/desktop/app/src/reducers/connections.tsx +++ b/desktop/app/src/reducers/connections.tsx @@ -78,6 +78,9 @@ type StateV2 = { }>; deepLinkPayload: unknown; staticView: StaticView; + activeClient: Client | null; + activeDevice: BaseDevice | null; + metroDevice: MetroDevice | null; }; type StateV1 = Omit & { @@ -201,6 +204,9 @@ const INITAL_STATE: State = { uninitializedClients: [], deepLinkPayload: null, staticView: WelcomeScreenStaticView, + activeClient: null, + activeDevice: null, + metroDevice: null, }; export default (state: State = INITAL_STATE, action: Actions): State => { @@ -619,16 +625,33 @@ function updateSelection(state: Readonly): State { } // Select client based on device - const client = getBestAvailableClient( + updates.activeClient = getBestAvailableClient( device, state.clients, state.selectedApp || state.userPreferredApp, ); - updates.selectedApp = client ? client.id : null; + updates.selectedApp = updates.activeClient ? updates.activeClient.id : null; + + updates.metroDevice = + (state.devices?.find( + (device) => device.os === 'Metro' && !device.isArchived, + ) as MetroDevice) ?? null; + + updates.activeClient = + state.clients.find( + (c) => c.id === (updates.selectedApp || state.userPreferredApp), + ) ?? null; + + // if the selected device is Metro, we want to keep the owner of the selected App as active device if possible + updates.activeDevice = findBestDevice( + state, + updates.activeClient, + updates.metroDevice, + ); const availablePlugins: string[] = [ ...(device?.devicePlugins || []), - ...(client?.plugins || []), + ...(updates.activeClient?.plugins || []), ]; if ( @@ -649,6 +672,31 @@ function updateSelection(state: Readonly): State { return {...state, ...updates}; } +export function findBestDevice( + state: State, + client: Client | null, + metroDevice: BaseDevice | null, +): BaseDevice | null { + // if not Metro device, use the selected device as metro device + const selected = state.selectedDevice ?? null; + if (selected !== metroDevice) { + return selected; + } + // if there is an active app, use device owning the app + if (client) { + return client.deviceSync; + } + // if no active app, use the preferred device + if (state.userPreferredDevice) { + return ( + state.devices.find( + (device) => device.title === state.userPreferredDevice, + ) ?? selected + ); + } + return selected; +} + export function getSelectedPluginKey(state: State): string | undefined { return state.selectedPlugin ? getPluginKey( diff --git a/desktop/app/src/reducers/index.tsx b/desktop/app/src/reducers/index.tsx index 5d1a1ffae..0b12c8da4 100644 --- a/desktop/app/src/reducers/index.tsx +++ b/desktop/app/src/reducers/index.tsx @@ -64,6 +64,10 @@ import usageTracking, { Action as TrackingAction, State as TrackingState, } from './usageTracking'; +import pluginLists, { + State as PluginListsState, + Action as PluginListsAction, +} from './pluginLists'; import user, {State as UserState, Action as UserAction} from './user'; import JsonFileStorage from '../utils/jsonFileReduxPersistStorage'; import LauncherSettingsStorage from '../utils/launcherSettingsStorage'; @@ -93,6 +97,7 @@ export type Actions = | HealthcheckAction | TrackingAction | PluginDownloadsAction + | PluginListsAction | {type: 'INIT'}; export type State = { @@ -110,6 +115,7 @@ export type State = { healthchecks: HealthcheckState & PersistPartial; usageTracking: TrackingState; pluginDownloads: PluginDownloadsState; + pluginLists: PluginListsState; }; export type Store = ReduxStore; @@ -211,4 +217,5 @@ export default combineReducers({ ), usageTracking, pluginDownloads, + pluginLists, }); diff --git a/desktop/app/src/reducers/pluginLists.tsx b/desktop/app/src/reducers/pluginLists.tsx new file mode 100644 index 000000000..a9dc50b6a --- /dev/null +++ b/desktop/app/src/reducers/pluginLists.tsx @@ -0,0 +1,68 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import { + PluginDetails, + DownloadablePluginDetails, + BundledPluginDetails, +} from 'flipper-plugin-lib'; +import {Actions} from '.'; +import { + DevicePluginDefinition, + ClientPluginDefinition, + PluginDefinition, +} from '../plugin'; +import produce from 'immer'; + +export type State = { + devicePlugins: DevicePluginDefinition[]; + metroPlugins: DevicePluginDefinition[]; + enabledPlugins: ClientPluginDefinition[]; + disabledPlugins: PluginDefinition[]; + unavailablePlugins: [plugin: PluginDetails, reason: string][]; + downloadablePlugins: (DownloadablePluginDetails | BundledPluginDetails)[]; +}; + +const INITIAL_STATE: State = { + devicePlugins: [], + metroPlugins: [], + enabledPlugins: [], + disabledPlugins: [], + unavailablePlugins: [], + downloadablePlugins: [], +}; + +export type Action = { + type: 'PLUGIN_LISTS_CHANGED'; + payload: State; +}; + +export default function reducer( + state: State | undefined = INITIAL_STATE, + action: Actions, +): State { + if (action.type === 'PLUGIN_LISTS_CHANGED') { + const payload = action.payload; + return produce(state, (draft) => { + draft.devicePlugins = payload.devicePlugins; + draft.metroPlugins = payload.metroPlugins; + draft.enabledPlugins = payload.enabledPlugins; + draft.disabledPlugins = payload.disabledPlugins; + draft.unavailablePlugins = payload.unavailablePlugins; + draft.downloadablePlugins = payload.downloadablePlugins; + }); + } else { + return state; + } +} + +export const pluginListsChanged = (payload: State): Action => ({ + type: 'PLUGIN_LISTS_CHANGED', + payload, +}); diff --git a/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx index 434ecf965..ab59b78a0 100644 --- a/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx @@ -11,7 +11,7 @@ import React from 'react'; import {Typography} from 'antd'; import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar'; import {Layout, Link, styled} from '../../ui'; -import {theme, useValue, useMemoize} from 'flipper-plugin'; +import {theme, useValue} from 'flipper-plugin'; import {AppSelector} from './AppSelector'; import {useStore} from '../../utils/useStore'; import {PluginList} from './PluginList'; @@ -19,9 +19,7 @@ import ScreenCaptureButtons from '../../chrome/ScreenCaptureButtons'; import MetroButton from '../../chrome/MetroButton'; import {BookmarkSection} from './BookmarkSection'; import Client from '../../Client'; -import {State} from '../../reducers'; import BaseDevice from '../../devices/BaseDevice'; -import MetroDevice from '../../devices/MetroDevice'; import {ExclamationCircleOutlined, FieldTimeOutlined} from '@ant-design/icons'; const {Text} = Typography; @@ -40,20 +38,9 @@ const appTooltip = ( export function AppInspect() { const connections = useStore((state) => state.connections); - const metroDevice = useMemoize(findMetroDevice, [connections.devices]); - const client = useMemoize(findBestClient, [ - connections.clients, - connections.selectedApp, - connections.userPreferredApp, - ]); - // // if the selected device is Metro, we want to keep the owner of the selected App as active device if possible - const activeDevice = useMemoize(findBestDevice, [ - client, - connections.devices, - connections.selectedDevice, - metroDevice, - connections.userPreferredDevice, - ]); + const metroDevice = connections.metroDevice; + const client = connections.activeClient; + const activeDevice = connections.activeDevice; const isDeviceConnected = useValue(activeDevice?.connected, false); const isAppConnected = useValue(client?.connected, false); @@ -101,51 +88,10 @@ const Toolbar = styled(Layout.Horizontal)({ }, }); -export function findBestClient( - clients: Client[], - selectedApp: string | null, - userPreferredApp: string | null, -): Client | undefined { - return clients.find((c) => c.id === (selectedApp || userPreferredApp)); -} - -export function findMetroDevice( - devices: State['connections']['devices'], -): MetroDevice | undefined { - return devices?.find( - (device) => device.os === 'Metro' && !device.isArchived, - ) as MetroDevice; -} - -export function findBestDevice( - client: Client | undefined, - devices: State['connections']['devices'], - selectedDevice: BaseDevice | null, - metroDevice: BaseDevice | undefined, - userPreferredDevice: string | null, -): BaseDevice | undefined { - // if not Metro device, use the selected device as metro device - const selected = selectedDevice ?? undefined; - if (selected !== metroDevice) { - return selected; - } - // if there is an active app, use device owning the app - if (client) { - return client.deviceSync; - } - // if no active app, use the preferred device - if (userPreferredDevice) { - return ( - devices.find((device) => device.title === userPreferredDevice) ?? selected - ); - } - return selected; -} - function renderStatusMessage( isDeviceConnected: boolean, - activeDevice: BaseDevice | undefined, - client: Client | undefined, + activeDevice: BaseDevice | null, + client: Client | null, isAppConnected: boolean, ): React.ReactNode { if (!activeDevice) { diff --git a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx index 3cf46ba51..097a054fc 100644 --- a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx @@ -20,11 +20,7 @@ import { import {Glyph, Layout, styled} from '../../ui'; import {theme, NUX, Tracked, useValue, useMemoize} from 'flipper-plugin'; import {useDispatch, useStore} from '../../utils/useStore'; -import { - computePluginLists, - getPluginTitle, - getPluginTooltip, -} from '../../utils/pluginUtils'; +import {getPluginTitle, getPluginTooltip} from '../../utils/pluginUtils'; import {selectPlugin} from '../../reducers/connections'; import Client from '../../Client'; import BaseDevice from '../../devices/BaseDevice'; @@ -52,27 +48,17 @@ export const PluginList = memo(function PluginList({ activeDevice, metroDevice, }: { - client: Client | undefined; - activeDevice: BaseDevice | undefined; - metroDevice: MetroDevice | undefined; + client: Client | null; + activeDevice: BaseDevice | null; + metroDevice: MetroDevice | null; }) { const dispatch = useDispatch(); const connections = useStore((state) => state.connections); const plugins = useStore((state) => state.plugins); + const pluginLists = useStore((state) => state.pluginLists); const downloads = useStore((state) => state.pluginDownloads); - - // client is a mutable structure, so we need the event emitter to detect the addition of plugins.... - const [pluginsChanged, setPluginsChanged] = useState(0); - useEffect(() => { - if (!client) { - return; - } - const listener = () => setPluginsChanged((v) => v + 1); - client.on('plugins-change', listener); - return () => { - client.off('plugins-change', listener); - }; - }, [client]); + const isConnected = useValue(activeDevice?.connected, false); + const metroConnected = useValue(metroDevice?.connected, false); const { devicePlugins, @@ -81,17 +67,8 @@ export const PluginList = memo(function PluginList({ disabledPlugins, unavailablePlugins, downloadablePlugins, - } = useMemoize(computePluginLists, [ - activeDevice, - metroDevice, - client, - plugins, - connections.enabledPlugins, - connections.enabledDevicePlugins, - pluginsChanged, - ]); - const isConnected = useValue(activeDevice?.connected, false); - const metroConnected = useValue(metroDevice?.connected, false); + } = pluginLists; + const isArchived = activeDevice?.isArchived; const annotatedDownloadablePlugins = useMemoize< diff --git a/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx b/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx index 9d9704bf7..e02fa43a0 100644 --- a/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx @@ -11,7 +11,6 @@ import { createMockFlipperWithPlugin, MockFlipperResult, } from '../../../test-utils/createMockFlipperWithPlugin'; -import {findBestClient, findBestDevice, findMetroDevice} from '../AppInspect'; import {FlipperPlugin} from '../../../plugin'; import MetroDevice from '../../../devices/MetroDevice'; import BaseDevice from '../../../devices/BaseDevice'; @@ -47,39 +46,21 @@ describe('basic findBestDevice', () => { }); test('findBestDevice prefers selected device', () => { - const {client, device} = flipper; + const {device} = flipper; const {connections} = flipper.store.getState(); - expect( - findBestDevice( - client, - connections.devices, - device, - undefined, - device.title, - ), - ).toBe(device); + expect(connections.activeDevice).toBe(device); }); test('findBestDevice picks device of current client', () => { - const {client, device} = flipper; + const {device} = flipper; const {connections} = flipper.store.getState(); - expect( - findBestDevice(client, connections.devices, null, undefined, null), - ).toBe(device); + expect(connections.activeDevice).toBe(device); }); test('findBestDevice picks preferred device if no client and device', () => { const {device} = flipper; const {connections} = flipper.store.getState(); - expect( - findBestDevice( - undefined, - connections.devices, - null, - undefined, - device.title, - ), - ).toBe(device); + expect(connections.activeDevice).toBe(device); }); }); @@ -96,9 +77,7 @@ describe('basic findBestDevice with metro present', () => { testDevice = flipper.device; // flipper.store.dispatch(registerPlugins([LogsPlugin])) await registerMetroDevice(undefined, flipper.store, flipper.logger); - metro = findMetroDevice( - flipper.store.getState().connections.devices, - )! as MetroDevice; + metro = flipper.store.getState().connections.metroDevice!; metro.supportsPlugin = (p) => { return p.id !== 'unsupportedDevicePlugin'; }; @@ -118,13 +97,7 @@ describe('basic findBestDevice with metro present', () => { userPreferredPlugin: 'DeviceLogs', userPreferredApp: 'TestApp#Android#MockAndroidDevice#serial', }); - expect( - findBestClient( - connections.clients, - connections.selectedApp, - connections.userPreferredApp, - ), - ).toBe(flipper.client); + expect(connections.activeClient).toBe(flipper.client); }); test('selecting Metro Logs works but keeps normal device preferred', () => { @@ -147,37 +120,14 @@ describe('basic findBestDevice with metro present', () => { }); const {connections} = flipper.store.getState(); // find best device is still metro - expect( - findBestDevice( - undefined, - connections.devices, - connections.selectedDevice, - metro, - connections.userPreferredDevice, - ), - ).toBe(testDevice); + expect(connections.activeDevice).toBe(testDevice); // find best client still returns app - expect( - findBestClient( - connections.clients, - connections.selectedApp, - connections.userPreferredApp, - ), - ).toBe(flipper.client); + expect(connections.activeClient).toBe(flipper.client); }); test('computePluginLists', () => { const state = flipper.store.getState(); - expect( - computePluginLists( - testDevice, - metro, - flipper.client, - state.plugins, - state.connections.enabledPlugins, - state.connections.enabledDevicePlugins, - ), - ).toEqual({ + expect(computePluginLists(state.connections, state.plugins)).toEqual({ downloadablePlugins: [], devicePlugins: [logsPlugin], metroPlugins: [logsPlugin], @@ -281,14 +231,7 @@ describe('basic findBestDevice with metro present', () => { ]; let state = flipper.store.getState(); - const pluginLists = computePluginLists( - testDevice, - metro, - flipper.client, - state.plugins, - state.connections.enabledPlugins, - state.connections.enabledDevicePlugins, - ); + const pluginLists = computePluginLists(state.connections, state.plugins); expect(pluginLists).toEqual({ devicePlugins: [logsPlugin], metroPlugins: [logsPlugin], @@ -322,16 +265,7 @@ describe('basic findBestDevice with metro present', () => { }), ); state = flipper.store.getState(); - expect( - computePluginLists( - testDevice, - metro, - flipper.client, - state.plugins, - state.connections.enabledPlugins, - state.connections.enabledDevicePlugins, - ), - ).toMatchObject({ + expect(computePluginLists(state.connections, state.plugins)).toMatchObject({ enabledPlugins: [plugin2], disabledPlugins: [plugin1], }); diff --git a/desktop/app/src/utils/pluginUtils.tsx b/desktop/app/src/utils/pluginUtils.tsx index 056965e4b..b4eacbfa4 100644 --- a/desktop/app/src/utils/pluginUtils.tsx +++ b/desktop/app/src/utils/pluginUtils.tsx @@ -83,15 +83,7 @@ export function getExportablePlugins( device: BaseDevice | undefined | null, client?: Client, ): {id: string; label: string}[] { - const availablePlugins = computePluginLists( - device ?? undefined, - undefined, - client, - state.plugins, - state.connections.enabledPlugins, - state.connections.enabledDevicePlugins, - ); - + const availablePlugins = computePluginLists(state.connections, state.plugins); return [ ...availablePlugins.devicePlugins.filter((plugin) => { return isExportablePlugin(state, device, client, plugin); @@ -180,14 +172,38 @@ export function getPluginTooltip(details: PluginDetails): string { } export function computePluginLists( - device: BaseDevice | undefined, - metroDevice: BaseDevice | undefined, - client: Client | undefined, - plugins: State['plugins'], - enabledPluginsState: State['connections']['enabledPlugins'], - enabledDevicePluginsState: Set, - _pluginsChanged?: number, // this argument is purely used to invalidate the memoization cache -) { + connections: Pick< + State['connections'], + | 'activeDevice' + | 'activeClient' + | 'metroDevice' + | 'enabledDevicePlugins' + | 'enabledPlugins' + >, + plugins: Pick< + State['plugins'], + | 'bundledPlugins' + | 'marketplacePlugins' + | 'loadedPlugins' + | 'devicePlugins' + | 'disabledPlugins' + | 'gatekeepedPlugins' + | 'failedPlugins' + | 'clientPlugins' + >, +): { + devicePlugins: DevicePluginDefinition[]; + metroPlugins: DevicePluginDefinition[]; + enabledPlugins: ClientPluginDefinition[]; + disabledPlugins: PluginDefinition[]; + unavailablePlugins: [plugin: PluginDetails, reason: string][]; + downloadablePlugins: (DownloadablePluginDetails | BundledPluginDetails)[]; +} { + const device = connections.activeDevice; + const client = connections.activeClient; + const metroDevice = connections.metroDevice; + const enabledDevicePluginsState = connections.enabledDevicePlugins; + const enabledPluginsState = connections.enabledPlugins; const uninstalledMarketplacePlugins = getLatestCompatibleVersionOfEachPlugin([ ...plugins.bundledPlugins.values(), ...plugins.marketplacePlugins,