diff --git a/desktop/app/package.json b/desktop/app/package.json index df896acc7..ed9425b21 100644 --- a/desktop/app/package.json +++ b/desktop/app/package.json @@ -65,6 +65,7 @@ "recursive-readdir": "^2.2.2", "redux": "^4.1.0", "redux-persist": "^6.0.0", + "reselect": "^4.0.0", "rsocket-core": "^0.0.19", "rsocket-flowable": "^0.0.25", "rsocket-tcp-server": "^0.0.25", diff --git a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap index 1efb2c290..1ffdedcf6 100644 --- a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap +++ b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap @@ -2,22 +2,6 @@ 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 { @@ -52,8 +36,8 @@ Object { "TestPlugin", ], }, - "metroDevice": null, "selectedApp": "TestApp#Android#MockAndroidDevice#serial", + "selectedAppPluginListRevision": 0, "selectedDevice": Object { "deviceType": "physical", "os": "Android", @@ -90,22 +74,6 @@ Object { exports[`can create a Fake flipper with legacy wrapper 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 { @@ -140,8 +108,8 @@ Object { "TestPlugin", ], }, - "metroDevice": null, "selectedApp": "TestApp#Android#MockAndroidDevice#serial", + "selectedAppPluginListRevision": 0, "selectedDevice": Object { "deviceType": "physical", "os": "Android", diff --git a/desktop/app/src/chrome/ExportDataPluginSheet.tsx b/desktop/app/src/chrome/ExportDataPluginSheet.tsx index b316cd35f..3a308d905 100644 --- a/desktop/app/src/chrome/ExportDataPluginSheet.tsx +++ b/desktop/app/src/chrome/ExportDataPluginSheet.tsx @@ -13,7 +13,6 @@ import {ShareType} from '../reducers/application'; import {State as Store} from '../reducers'; import {ActiveSheet} from '../reducers/application'; import {selectedPlugins as actionForSelectedPlugins} from '../reducers/plugins'; -import {getExportablePlugins} from '../utils/pluginUtils'; import { ACTIVE_SHEET_SHARE_DATA, setActiveSheet as getActiveSheetAction, @@ -23,6 +22,7 @@ import ListView from './ListView'; import {Dispatch, Action} from 'redux'; import {unsetShare} from '../reducers/application'; import {FlexColumn, styled} from '../ui'; +import {getExportablePlugins} from '../selectors/connections'; type OwnProps = { onHide: () => void; @@ -104,14 +104,7 @@ class ExportDataPluginSheet extends Component { export default connect( (state) => { - const selectedClient = state.connections.clients.find((o) => { - return o.id === state.connections.selectedApp; - }); - const availablePluginsToExport = getExportablePlugins( - state, - state.connections.selectedDevice ?? undefined, - selectedClient, - ); + const availablePluginsToExport = getExportablePlugins(state); return { share: state.application.share, selectedPlugins: state.plugins.selectedPlugins, diff --git a/desktop/app/src/chrome/__tests__/ExportDataPluginSheet.node.tsx b/desktop/app/src/chrome/__tests__/ExportDataPluginSheet.node.tsx index 093272752..92a6e1f53 100644 --- a/desktop/app/src/chrome/__tests__/ExportDataPluginSheet.node.tsx +++ b/desktop/app/src/chrome/__tests__/ExportDataPluginSheet.node.tsx @@ -12,9 +12,10 @@ import {create, act, ReactTestRenderer} from 'react-test-renderer'; import {Provider} from 'react-redux'; import ExportDataPluginSheet from '../ExportDataPluginSheet'; import {FlipperPlugin, FlipperDevicePlugin} from '../../plugin'; -import {getExportablePlugins, getPluginKey} from '../../utils/pluginUtils'; +import {getPluginKey} from '../../utils/pluginUtils'; import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin'; import {setPluginState} from '../../reducers/pluginStates'; +import {getExportablePlugins} from '../../selectors/connections'; class TestPlugin extends FlipperPlugin { static details = { @@ -56,7 +57,7 @@ test('SettingsSheet snapshot with nothing enabled', async () => { }), ); - expect(getExportablePlugins(store.getState(), device, client)).toEqual([]); + expect(getExportablePlugins(store.getState())).toEqual([]); // makes device plugin visible store.dispatch( @@ -66,7 +67,7 @@ test('SettingsSheet snapshot with nothing enabled', async () => { }), ); - expect(getExportablePlugins(store.getState(), device, client)).toEqual([ + expect(getExportablePlugins(store.getState())).toEqual([ { id: 'TestDevicePlugin', label: 'TestDevicePlugin', @@ -86,7 +87,7 @@ test('SettingsSheet snapshot with nothing enabled', async () => { test('SettingsSheet snapshot with one plugin enabled', async () => { let root: ReactTestRenderer; - const {store, device, client, pluginKey} = await createMockFlipperWithPlugin( + const {store, device, pluginKey} = await createMockFlipperWithPlugin( TestPlugin, { additionalPlugins: [TestDevicePlugin], @@ -96,7 +97,7 @@ test('SettingsSheet snapshot with one plugin enabled', async () => { // enabled // in Sandy wrapper, a plugin is either persistable or not, but it doesn't depend on the current state. // So this plugin will show up, even though its state is still the default - expect(getExportablePlugins(store.getState(), device, client)).toEqual([ + expect(getExportablePlugins(store.getState())).toEqual([ { id: 'TestPlugin', label: 'TestPlugin', @@ -115,7 +116,7 @@ test('SettingsSheet snapshot with one plugin enabled', async () => { state: {test: '1'}, }), ); - expect(getExportablePlugins(store.getState(), device, client)).toEqual([ + expect(getExportablePlugins(store.getState())).toEqual([ { id: 'TestDevicePlugin', label: 'TestDevicePlugin', diff --git a/desktop/app/src/chrome/fb-stubs/PluginInfo.tsx b/desktop/app/src/chrome/fb-stubs/PluginInfo.tsx index 066a745a4..4687a1765 100644 --- a/desktop/app/src/chrome/fb-stubs/PluginInfo.tsx +++ b/desktop/app/src/chrome/fb-stubs/PluginInfo.tsx @@ -14,6 +14,8 @@ import {switchPlugin} from '../../reducers/pluginManager'; import {isPluginEnabled} from '../../reducers/connections'; import {theme} from 'flipper-plugin'; import {PluginDefinition} from '../../plugin'; +import {useSelector} from 'react-redux'; +import {getActiveClient} from '../../selectors/connections'; const Waiting = styled(Layout.Container)({ width: '100%', @@ -30,7 +32,7 @@ export default function PluginInfo() { const enabledDevicePlugins = useStore( (state) => state.connections.enabledDevicePlugins, ); - const activeClient = useStore((state) => state.connections.activeClient); + const activeClient = useSelector(getActiveClient); const clientPlugins = useStore((state) => state.plugins.clientPlugins); const devicePlugins = useStore((state) => state.plugins.devicePlugins); const selectedClientId = activeClient?.id ?? null; diff --git a/desktop/app/src/dispatcher/index.tsx b/desktop/app/src/dispatcher/index.tsx index 920b74c82..e2c96b8f8 100644 --- a/desktop/app/src/dispatcher/index.tsx +++ b/desktop/app/src/dispatcher/index.tsx @@ -23,7 +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 pluginChangeListener from './pluginsChangeListener'; import {Logger} from '../fb-interfaces/Logger'; import {Store} from '../reducers/index'; @@ -52,7 +52,7 @@ export default function (store: Store, logger: Logger): () => Promise { pluginMarketplace, pluginDownloads, info, - pluginLists, + pluginChangeListener, ].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 deleted file mode 100644 index 5dace1ca0..000000000 --- a/desktop/app/src/dispatcher/pluginLists.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/** - * 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/dispatcher/pluginsChangeListener.tsx b/desktop/app/src/dispatcher/pluginsChangeListener.tsx new file mode 100644 index 000000000..f15a6c09f --- /dev/null +++ b/desktop/app/src/dispatcher/pluginsChangeListener.tsx @@ -0,0 +1,40 @@ +/** + * 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 {appPluginListChanged} from '../reducers/connections'; +import {getActiveClient} from '../selectors/connections'; +import {sideEffect} from '../utils/sideEffect'; + +export default (store: Store, _logger: Logger) => { + let prevClient: null | Client = null; + + const onActiveAppPluginListChanged = () => { + store.dispatch(appPluginListChanged()); + }; + + sideEffect( + store, + {name: 'pluginsChangeListener', throttleMs: 100, fireImmediately: true}, + getActiveClient, + (activeClient, _store) => { + if (activeClient !== prevClient) { + if (prevClient) { + prevClient.off('plugins-change', onActiveAppPluginListChanged); + } + prevClient = activeClient; + if (prevClient) { + prevClient.on('plugins-change', onActiveAppPluginListChanged); + } + } + }, + ); +}; diff --git a/desktop/app/src/reducers/connections.tsx b/desktop/app/src/reducers/connections.tsx index d3bc49602..72a20ccba 100644 --- a/desktop/app/src/reducers/connections.tsx +++ b/desktop/app/src/reducers/connections.tsx @@ -78,9 +78,7 @@ type StateV2 = { }>; deepLinkPayload: unknown; staticView: StaticView; - activeClient: Client | null; - activeDevice: BaseDevice | null; - metroDevice: MetroDevice | null; + selectedAppPluginListRevision: number; }; type StateV1 = Omit & { @@ -179,6 +177,9 @@ export type Action = type: 'SELECT_CLIENT'; payload: string | null; } + | { + type: 'APP_PLUGIN_LIST_CHANGED'; + } | RegisterPluginAction; const DEFAULT_PLUGIN = 'DeviceLogs'; @@ -204,9 +205,7 @@ const INITAL_STATE: State = { uninitializedClients: [], deepLinkPayload: null, staticView: WelcomeScreenStaticView, - activeClient: null, - activeDevice: null, - metroDevice: null, + selectedAppPluginListRevision: 0, }; export default (state: State = INITAL_STATE, action: Actions): State => { @@ -469,6 +468,11 @@ export default (state: State = INITAL_STATE, action: Actions): State => { draft.enabledDevicePlugins.delete(pluginId); }); } + case 'APP_PLUGIN_LIST_CHANGED': { + return produce(state, (draft) => { + draft.selectedAppPluginListRevision++; + }); + } default: return state; } @@ -544,6 +548,10 @@ export const setPluginDisabled = (pluginId: string, appId: string): Action => ({ }, }); +export const appPluginListChanged = (): Action => ({ + type: 'APP_PLUGIN_LIST_CHANGED', +}); + export function getAvailableClients( device: null | undefined | BaseDevice, clients: Client[], @@ -568,10 +576,10 @@ function getBestAvailableClient( device: BaseDevice | null | undefined, clients: Client[], preferredClient: string | null, -): Client | undefined { +): Client | null { const availableClients = getAvailableClients(device, clients); if (availableClients.length === 0) { - return undefined; + return null; } return ( getClientById(availableClients, preferredClient) || @@ -625,29 +633,12 @@ function updateSelection(state: Readonly): State { } // Select client based on device - updates.activeClient = getBestAvailableClient( + const client = getBestAvailableClient( device, state.clients, state.selectedApp || state.userPreferredApp, ); - 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, - ); + updates.selectedApp = client ? client.id : null; if ( // Try the preferred plugin first @@ -667,31 +658,6 @@ 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 2f9f60468..4415f844c 100644 --- a/desktop/app/src/reducers/index.tsx +++ b/desktop/app/src/reducers/index.tsx @@ -64,10 +64,6 @@ 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'; @@ -97,7 +93,6 @@ export type Actions = | HealthcheckAction | TrackingAction | PluginDownloadsAction - | PluginListsAction | {type: 'INIT'}; export type State = { @@ -115,7 +110,6 @@ export type State = { healthchecks: HealthcheckState & PersistPartial; usageTracking: TrackingState; pluginDownloads: PluginDownloadsState; - pluginLists: PluginListsState; }; export type Store = ReduxStore; @@ -218,6 +212,5 @@ export function createRootReducer() { ), usageTracking, pluginDownloads, - pluginLists, }); } diff --git a/desktop/app/src/reducers/pluginLists.tsx b/desktop/app/src/reducers/pluginLists.tsx deleted file mode 100644 index a9dc50b6a..000000000 --- a/desktop/app/src/reducers/pluginLists.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** - * 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/reducers/supportForm.tsx b/desktop/app/src/reducers/supportForm.tsx index 80ede5bdb..bfc66d90d 100644 --- a/desktop/app/src/reducers/supportForm.tsx +++ b/desktop/app/src/reducers/supportForm.tsx @@ -17,10 +17,9 @@ import {addStatusMessage, removeStatusMessage} from './application'; import constants from '../fb-stubs/constants'; import {getInstance} from '../fb-stubs/Logger'; import {logPlatformSuccessRate} from '../utils/metrics'; -import {getExportablePlugins} from '../utils/pluginUtils'; export const SUPPORT_FORM_PREFIX = 'support-form-v2'; -import Client from '../Client'; -import BaseDevice, {OS} from '../devices/BaseDevice'; +import {OS} from '../devices/BaseDevice'; +import {getExportablePlugins} from '../selectors/connections'; const {DEFAULT_SUPPORT_GROUP} = constants; @@ -193,11 +192,7 @@ export class Group { selectedGroup: this, }), ); - const pluginsList = getExportablePlugins( - store.getState(), - store.getState().connections.selectedDevice ?? undefined, - selectedClient, - ); + const pluginsList = getExportablePlugins(store.getState()); store.dispatch( setSelectedPlugins( @@ -220,10 +215,8 @@ export class Group { getWarningMessage( state: Parameters[0], - device: BaseDevice | undefined, - client: Client, ): string | null { - const activePersistentPlugins = getExportablePlugins(state, device, client); + const activePersistentPlugins = getExportablePlugins(state); const emptyPlugins: Array = []; for (const plugin of this.requiredPlugins) { if ( diff --git a/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx index ab59b78a0..bed93e91b 100644 --- a/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx @@ -13,7 +13,6 @@ import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar'; import {Layout, Link, styled} from '../../ui'; import {theme, useValue} from 'flipper-plugin'; import {AppSelector} from './AppSelector'; -import {useStore} from '../../utils/useStore'; import {PluginList} from './PluginList'; import ScreenCaptureButtons from '../../chrome/ScreenCaptureButtons'; import MetroButton from '../../chrome/MetroButton'; @@ -21,6 +20,12 @@ import {BookmarkSection} from './BookmarkSection'; import Client from '../../Client'; import BaseDevice from '../../devices/BaseDevice'; import {ExclamationCircleOutlined, FieldTimeOutlined} from '@ant-design/icons'; +import {useSelector} from 'react-redux'; +import { + getActiveClient, + getActiveDevice, + getMetroDevice, +} from '../../selectors/connections'; const {Text} = Typography; @@ -36,11 +41,9 @@ const appTooltip = ( ); export function AppInspect() { - const connections = useStore((state) => state.connections); - - const metroDevice = connections.metroDevice; - const client = connections.activeClient; - const activeDevice = connections.activeDevice; + const metroDevice = useSelector(getMetroDevice); + const client = useSelector(getActiveClient); + const activeDevice = useSelector(getActiveDevice); const isDeviceConnected = useValue(activeDevice?.connected, false); const isAppConnected = useValue(client?.connected, false); diff --git a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx index e867ed079..ecda5ec09 100644 --- a/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/PluginList.tsx @@ -39,6 +39,8 @@ import { import {BundledPluginDetails} from 'flipper-plugin-lib'; import {reportUsage} from '../../utils/metrics'; import ConnectivityStatus from './fb-stubs/ConnectivityStatus'; +import {useSelector} from 'react-redux'; +import {getPluginLists} from '../../selectors/connections'; const {SubMenu} = Menu; const {Text} = Typography; @@ -55,7 +57,7 @@ export const PluginList = memo(function PluginList({ const dispatch = useDispatch(); const connections = useStore((state) => state.connections); const plugins = useStore((state) => state.plugins); - const pluginLists = useStore((state) => state.pluginLists); + const pluginLists = useSelector(getPluginLists); const downloads = useStore((state) => state.pluginDownloads); const isConnected = useValue(activeDevice?.connected, false); const metroConnected = useValue(metroDevice?.connected, false); 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 7c990b94c..da05528c5 100644 --- a/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/__tests__/PluginList.spec.tsx @@ -28,7 +28,12 @@ import {switchPlugin} from '../../../reducers/pluginManager'; // eslint-disable-next-line import * as LogsPluginModule from '../../../../../plugins/public/logs/index'; import {createMockDownloadablePluginDetails} from '../../../utils/testUtils'; -import {computePluginLists} from '../../../utils/pluginUtils'; +import { + getActiveClient, + getActiveDevice, + getMetroDevice, + getPluginLists, +} from '../../../selectors/connections'; const createMockPluginDetails = TestUtils.createMockPluginDetails; @@ -39,32 +44,29 @@ const logsPlugin = new _SandyPluginDefinition( class TestPlugin extends FlipperPlugin {} -describe('basic findBestDevice', () => { +describe('basic getActiveDevice', () => { let flipper: MockFlipperResult; beforeEach(async () => { flipper = await createMockFlipperWithPlugin(TestPlugin); }); - test('findBestDevice prefers selected device', () => { - const {device} = flipper; - const {connections} = flipper.store.getState(); - expect(connections.activeDevice).toBe(device); + test('getActiveDevice prefers selected device', () => { + const {device, store} = flipper; + expect(getActiveDevice(store.getState())).toBe(device); }); - test('findBestDevice picks device of current client', () => { - const {device} = flipper; - const {connections} = flipper.store.getState(); - expect(connections.activeDevice).toBe(device); + test('getActiveDevice picks device of current client', () => { + const {device, store} = flipper; + expect(getActiveDevice(store.getState())).toBe(device); }); - test('findBestDevice picks preferred device if no client and device', () => { - const {device} = flipper; - const {connections} = flipper.store.getState(); - expect(connections.activeDevice).toBe(device); + test('getActiveDevice picks preferred device if no client and device', () => { + const {device, store} = flipper; + expect(getActiveDevice(store.getState())).toBe(device); }); }); -describe('basic findBestDevice with metro present', () => { +describe('basic getActiveDevice with metro present', () => { let flipper: MockFlipperResult; let metro: MetroDevice; let testDevice: BaseDevice; @@ -77,7 +79,7 @@ describe('basic findBestDevice with metro present', () => { testDevice = flipper.device; // flipper.store.dispatch(registerPlugins([LogsPlugin])) await registerMetroDevice(undefined, flipper.store, flipper.logger); - metro = flipper.store.getState().connections.metroDevice!; + metro = getMetroDevice(flipper.store.getState())!; metro.supportsPlugin = (p) => { return p.id !== 'unsupportedDevicePlugin'; }; @@ -88,7 +90,8 @@ describe('basic findBestDevice with metro present', () => { }); test('correct base selection state', () => { - const {connections} = flipper.store.getState(); + const state = flipper.store.getState(); + const {connections} = state; expect(connections).toMatchObject({ devices: [testDevice, metro], selectedDevice: testDevice, @@ -97,10 +100,11 @@ describe('basic findBestDevice with metro present', () => { userPreferredPlugin: 'DeviceLogs', userPreferredApp: 'TestApp#Android#MockAndroidDevice#serial', }); - expect(connections.activeClient).toBe(flipper.client); + expect(getActiveClient(state)).toBe(flipper.client); }); test('selecting Metro Logs works but keeps normal device preferred', () => { + expect(getActiveClient(flipper.store.getState())).toBe(flipper.client); flipper.store.dispatch( selectPlugin({ selectedPlugin: logsPlugin.id, @@ -118,16 +122,16 @@ describe('basic findBestDevice with metro present', () => { userPreferredPlugin: 'DeviceLogs', userPreferredApp: 'TestApp#Android#MockAndroidDevice#serial', }); - const {connections} = flipper.store.getState(); + const state = flipper.store.getState(); // find best device is still metro - expect(connections.activeDevice).toBe(testDevice); + expect(getActiveDevice(state)).toBe(testDevice); // find best client still returns app - expect(connections.activeClient).toBe(flipper.client); + expect(getActiveClient(state)).toBe(flipper.client); }); test('computePluginLists', () => { const state = flipper.store.getState(); - expect(computePluginLists(state.connections, state.plugins)).toEqual({ + expect(getPluginLists(state)).toEqual({ downloadablePlugins: [], devicePlugins: [logsPlugin], metroPlugins: [logsPlugin], @@ -231,7 +235,7 @@ describe('basic findBestDevice with metro present', () => { ]); let state = flipper.store.getState(); - const pluginLists = computePluginLists(state.connections, state.plugins); + const pluginLists = getPluginLists(state); expect(pluginLists).toEqual({ devicePlugins: [logsPlugin], metroPlugins: [logsPlugin], @@ -265,7 +269,7 @@ describe('basic findBestDevice with metro present', () => { }), ); state = flipper.store.getState(); - expect(computePluginLists(state.connections, state.plugins)).toMatchObject({ + expect(getPluginLists(state)).toMatchObject({ enabledPlugins: [plugin2], disabledPlugins: [plugin1], }); diff --git a/desktop/app/src/selectors/connections.tsx b/desktop/app/src/selectors/connections.tsx new file mode 100644 index 000000000..754af7bcf --- /dev/null +++ b/desktop/app/src/selectors/connections.tsx @@ -0,0 +1,137 @@ +/** + * 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 MetroDevice from '../devices/MetroDevice'; +import { + DevicePluginDefinition, + ClientPluginDefinition, + PluginDefinition, +} from '../plugin'; +import {State} from '../reducers'; +import { + computePluginLists, + computeExportablePlugins, +} from '../utils/pluginUtils'; +import createSelector from './createSelector'; + +export type PluginLists = { + devicePlugins: DevicePluginDefinition[]; + metroPlugins: DevicePluginDefinition[]; + enabledPlugins: ClientPluginDefinition[]; + disabledPlugins: PluginDefinition[]; + unavailablePlugins: [plugin: PluginDetails, reason: string][]; + downloadablePlugins: (DownloadablePluginDetails | BundledPluginDetails)[]; +}; + +const getSelectedApp = (state: State) => + state.connections.selectedApp || state.connections.userPreferredApp; +const getSelectedDevice = (state: State) => state.connections.selectedDevice; +const getUserPreferredDevice = (state: State) => + state.connections.userPreferredDevice; +const getClients = (state: State) => state.connections.clients; +const getDevices = (state: State) => state.connections.devices; + +export const getActiveClient = createSelector( + getSelectedApp, + getClients, + (selectedApp, clients) => { + return clients.find((c) => c.id === selectedApp) || null; + }, +); + +export const getMetroDevice = createSelector(getDevices, (devices) => { + return ( + (devices.find( + (device) => device.os === 'Metro' && !device.isArchived, + ) as MetroDevice) ?? null + ); +}); + +export const getActiveDevice = createSelector( + getSelectedDevice, + getUserPreferredDevice, + getDevices, + getActiveClient, + getMetroDevice, + (selectedDevice, userPreferredDevice, devices, client, metroDevice) => { + // if not Metro device, use the selected device as metro device + if (selectedDevice !== metroDevice) { + return selectedDevice; + } + // 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) ?? + selectedDevice + ); + } + return selectedDevice; + }, +); + +export const getPluginLists = createSelector( + ({ + connections: { + enabledDevicePlugins, + enabledPlugins, + selectedAppPluginListRevision, // used only to invalidate cache + }, + }: State) => ({ + enabledDevicePlugins, + enabledPlugins, + selectedAppPluginListRevision, + }), + ({ + plugins: { + clientPlugins, + devicePlugins, + bundledPlugins, + marketplacePlugins, + loadedPlugins, + disabledPlugins, + gatekeepedPlugins, + failedPlugins, + }, + }: State) => ({ + clientPlugins, + devicePlugins, + bundledPlugins, + marketplacePlugins, + loadedPlugins, + disabledPlugins, + gatekeepedPlugins, + failedPlugins, + }), + getActiveDevice, + getMetroDevice, + getActiveClient, + computePluginLists, +); + +export const getExportablePlugins = createSelector( + ({plugins, connections, pluginStates, pluginMessageQueue}: State) => ({ + plugins, + connections, + pluginStates, + pluginMessageQueue, + }), + getActiveDevice, + getActiveClient, + getPluginLists, + computeExportablePlugins, +); diff --git a/desktop/app/src/selectors/createSelector.tsx b/desktop/app/src/selectors/createSelector.tsx new file mode 100644 index 000000000..c2ef9af4e --- /dev/null +++ b/desktop/app/src/selectors/createSelector.tsx @@ -0,0 +1,18 @@ +/** + * 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 {shallowEqual} from 'react-redux'; +import {createSelectorCreator, defaultMemoize} from 'reselect'; + +export const createSelector = createSelectorCreator( + defaultMemoize, + shallowEqual, +); + +export default createSelector; diff --git a/desktop/app/src/utils/__tests__/pluginUtils.node.tsx b/desktop/app/src/utils/__tests__/pluginUtils.node.tsx index cd2e8a4f0..780bd7286 100644 --- a/desktop/app/src/utils/__tests__/pluginUtils.node.tsx +++ b/desktop/app/src/utils/__tests__/pluginUtils.node.tsx @@ -7,9 +7,10 @@ * @format */ -import {getExportablePlugins, getPluginKey} from '../pluginUtils'; +import {getPluginKey} from '../pluginUtils'; import {FlipperPlugin, FlipperDevicePlugin} from '../../plugin'; import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin'; +import {getExportablePlugins} from '../../selectors/connections'; function createMockFlipperPluginWithDefaultPersistedState(id: string) { return class MockFlipperPluginWithDefaultPersistedState extends FlipperPlugin< @@ -89,7 +90,7 @@ test('getActivePersistentPlugins, where the non persistent plugins getting exclu [getPluginKey(client.id, device, 'ClientPlugin4')]: {msg: 'ClientPlugin2'}, }; - const list = getExportablePlugins(state, device, client); + const list = getExportablePlugins(state); expect(list).toEqual([ { id: 'ClientPlugin1', @@ -130,7 +131,7 @@ test('getActivePersistentPlugins, where the plugins not in pluginState or queue ], }; - const list = getExportablePlugins(store.getState(), device, client); + const list = getExportablePlugins(store.getState()); expect(list).toEqual([ { id: 'ClientPlugin2', // has state diff --git a/desktop/app/src/utils/pluginUtils.tsx b/desktop/app/src/utils/pluginUtils.tsx index 48c41736f..cb58fcd25 100644 --- a/desktop/app/src/utils/pluginUtils.tsx +++ b/desktop/app/src/utils/pluginUtils.tsx @@ -26,6 +26,7 @@ import type { PluginDetails, } from 'flipper-plugin-lib'; import {getLatestCompatibleVersionOfEachPlugin} from '../dispatcher/plugins'; +import {PluginLists} from '../selectors/connections'; export const defaultEnabledBackgroundPlugins = ['Navigation']; // The navigation plugin is enabled always, to make sure the navigation features works @@ -75,15 +76,15 @@ export function getPersistedState( return persistedState; } -export function getExportablePlugins( +export function computeExportablePlugins( state: Pick< State, 'plugins' | 'connections' | 'pluginStates' | 'pluginMessageQueue' >, - device: BaseDevice | undefined | null, - client?: Client, + device: BaseDevice | null, + client: Client | null, + availablePlugins: PluginLists, ): {id: string; label: string}[] { - const availablePlugins = computePluginLists(state.connections, state.plugins); return [ ...availablePlugins.devicePlugins.filter((plugin) => { return isExportablePlugin(state, device, client, plugin); @@ -102,8 +103,8 @@ function isExportablePlugin( pluginStates, pluginMessageQueue, }: Pick, - device: BaseDevice | undefined | null, - client: Client | undefined, + device: BaseDevice | null, + client: Client | null, plugin: PluginDefinition, ): boolean { // can generate an export when requested @@ -174,11 +175,7 @@ export function getPluginTooltip(details: PluginDetails): string { export function computePluginLists( connections: Pick< State['connections'], - | 'activeDevice' - | 'activeClient' - | 'metroDevice' - | 'enabledDevicePlugins' - | 'enabledPlugins' + 'enabledDevicePlugins' | 'enabledPlugins' >, plugins: Pick< State['plugins'], @@ -191,6 +188,9 @@ export function computePluginLists( | 'failedPlugins' | 'clientPlugins' >, + device: BaseDevice | null, + metroDevice: BaseDevice | null, + client: Client | null, ): { devicePlugins: DevicePluginDefinition[]; metroPlugins: DevicePluginDefinition[]; @@ -199,9 +199,6 @@ export function computePluginLists( 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([ diff --git a/desktop/yarn.lock b/desktop/yarn.lock index 61d493578..c9e35ac7c 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -12086,6 +12086,11 @@ require-main-filename@^2.0.0: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== +reselect@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" + integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== + resize-observer-polyfill@^1.5.0, resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"