Make plugin loading async

Summary: This diff makes plugin loading async, which we'd need in a browser env (either because we'd use `import()` or we need to fetch the source and than eval it), and deals with all the fallout of that

Reviewed By: timur-valiev

Differential Revision: D32669995

fbshipit-source-id: 73babf38a6757c451b8200c3b320409f127b8b5b
This commit is contained in:
Michel Weststrate
2021-12-08 04:25:28 -08:00
committed by Facebook GitHub Bot
parent 64747dc417
commit de59bbedd2
20 changed files with 282 additions and 90 deletions

View File

@@ -23,6 +23,7 @@ import MockFlipper from '../../test-utils/MockFlipper';
import Client from '../../Client';
import React from 'react';
import BaseDevice from '../../devices/BaseDevice';
import {awaitPluginCommandQueueEmpty} from '../pluginManager';
const pluginDetails1 = TestUtils.createMockPluginDetails({
id: 'plugin1',
@@ -71,7 +72,7 @@ let mockDevice: BaseDevice;
beforeEach(async () => {
mockedRequirePlugin.mockImplementation(
(details) =>
async (details) =>
(details === pluginDetails1
? pluginDefinition1
: details === pluginDetails2
@@ -99,6 +100,8 @@ test('load plugin when no other version loaded', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1,
);
@@ -119,6 +122,8 @@ test('load plugin when other version loaded', async () => {
notifyIfFailed: false,
}),
);
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1V2,
);
@@ -132,6 +137,8 @@ test('load and enable Sandy plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}),
);
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1,
);
@@ -146,6 +153,8 @@ test('uninstall plugin', async () => {
loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}),
);
mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition1}));
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(
mockFlipper.getState().plugins.clientPlugins.has('plugin1'),
).toBeFalsy();
@@ -167,11 +176,13 @@ test('uninstall bundled plugin', async () => {
version: '0.43.0',
});
const pluginDefinition = new SandyPluginDefinition(pluginDetails, TestPlugin);
mockedRequirePlugin.mockReturnValue(pluginDefinition);
mockedRequirePlugin.mockReturnValue(Promise.resolve(pluginDefinition));
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails, enable: true, notifyIfFailed: false}),
);
mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition}));
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(
mockFlipper.getState().plugins.clientPlugins.has('bundled-plugin'),
).toBeFalsy();
@@ -196,6 +207,8 @@ test('star plugin', async () => {
selectedApp: mockClient.query.app,
}),
);
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(
mockFlipper.getState().connections.enabledPlugins[mockClient.query.app],
).toContain('plugin1');
@@ -218,6 +231,8 @@ test('disable plugin', async () => {
selectedApp: mockClient.query.app,
}),
);
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(
mockFlipper.getState().connections.enabledPlugins[mockClient.query.app],
).not.toContain('plugin1');
@@ -237,6 +252,8 @@ test('star device plugin', async () => {
plugin: devicePluginDefinition,
}),
);
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(
mockFlipper.getState().connections.enabledDevicePlugins.has('device'),
).toBeTruthy();
@@ -261,6 +278,8 @@ test('disable device plugin', async () => {
plugin: devicePluginDefinition,
}),
);
await awaitPluginCommandQueueEmpty(mockFlipper.store);
expect(
mockFlipper.getState().connections.enabledDevicePlugins.has('device'),
).toBeFalsy();

View File

@@ -135,9 +135,9 @@ test('checkGK for failing plugin', () => {
expect(gatekeepedPlugins[0].name).toEqual(name);
});
test('requirePlugin returns null for invalid requires', () => {
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
test('requirePlugin returns null for invalid requires', async () => {
const requireFn = createRequirePluginFunction([]);
const plugin = await requireFn({
...sampleInstalledPluginDetails,
name: 'pluginID',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample',
@@ -148,10 +148,10 @@ test('requirePlugin returns null for invalid requires', () => {
expect(plugin).toBeNull();
});
test('requirePlugin loads plugin', () => {
test('requirePlugin loads plugin', async () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
const requireFn = createRequirePluginFunction([]);
const plugin = await requireFn({
...sampleInstalledPluginDetails,
name,
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample',
@@ -224,10 +224,10 @@ test('newest version of each plugin is used', () => {
});
});
test('requirePlugin loads valid Sandy plugin', () => {
test('requirePlugin loads valid Sandy plugin', async () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
const requireFn = createRequirePluginFunction([]);
const plugin = (await requireFn({
...sampleInstalledPluginDetails,
name,
dir: path.join(
@@ -240,7 +240,7 @@ test('requirePlugin loads valid Sandy plugin', () => {
),
version: '1.0.0',
flipperSDKVersion: '0.0.0',
}) as _SandyPluginDefinition;
})) as _SandyPluginDefinition;
expect(plugin).not.toBeNull();
expect(plugin).toBeInstanceOf(_SandyPluginDefinition);
expect(plugin.id).toBe('Sample');
@@ -261,11 +261,11 @@ test('requirePlugin loads valid Sandy plugin', () => {
expect(typeof plugin.asPluginModule().plugin).toBe('function');
});
test('requirePlugin errors on invalid Sandy plugin', () => {
test('requirePlugin errors on invalid Sandy plugin', async () => {
const name = 'pluginID';
const failedPlugins: any[] = [];
const requireFn = createRequirePluginFunction(failedPlugins, require);
requireFn({
const requireFn = createRequirePluginFunction(failedPlugins);
await requireFn({
...sampleInstalledPluginDetails,
name,
// Intentionally the wrong file:
@@ -279,10 +279,10 @@ test('requirePlugin errors on invalid Sandy plugin', () => {
);
});
test('requirePlugin loads valid Sandy Device plugin', () => {
test('requirePlugin loads valid Sandy Device plugin', async () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
const requireFn = createRequirePluginFunction([]);
const plugin = (await requireFn({
...sampleInstalledPluginDetails,
pluginType: 'device',
name,
@@ -296,7 +296,7 @@ test('requirePlugin loads valid Sandy Device plugin', () => {
),
version: '1.0.0',
flipperSDKVersion: '0.0.0',
}) as _SandyPluginDefinition;
})) as _SandyPluginDefinition;
expect(plugin).not.toBeNull();
expect(plugin).toBeInstanceOf(_SandyPluginDefinition);
expect(plugin.id).toBe('Sample');

View File

@@ -75,6 +75,7 @@ export default (
});
}
let running = false;
const unsubscribeHandlePluginCommands = sideEffect(
store,
{
@@ -85,14 +86,49 @@ export default (
noTimeBudgetWarns: true, // These side effects are critical, so we're doing them with zero throttling and want to avoid unnecessary warns
},
(state) => state.pluginManager.pluginCommandsQueue,
processPluginCommandsQueue,
async (_queue: PluginCommand[], store: Store) => {
// To make sure all commands are running in order, and not kicking off parallel command
// processing when new commands arrive (sideEffect doesn't await)
// we keep the 'running' flag, and keep running in a loop until the commandQueue is empty,
// to make sure any commands that have arrived during execution are executed
if (running) {
return; // will be picked up in while(true) loop
}
running = true;
try {
while (true) {
const remaining = store.getState().pluginManager.pluginCommandsQueue;
if (!remaining.length) {
return; // done
}
await processPluginCommandsQueue(remaining, store);
store.dispatch(pluginCommandsProcessed(remaining.length));
}
} finally {
running = false;
}
},
);
return async () => {
unsubscribeHandlePluginCommands();
};
};
export function processPluginCommandsQueue(
export async function awaitPluginCommandQueueEmpty(store: Store) {
if (store.getState().pluginManager.pluginCommandsQueue.length === 0) {
return;
}
return new Promise<void>((resolve) => {
const unsubscribe = store.subscribe(() => {
if (store.getState().pluginManager.pluginCommandsQueue.length === 0) {
unsubscribe();
resolve();
}
});
});
}
async function processPluginCommandsQueue(
queue: PluginCommand[],
store: Store,
) {
@@ -100,7 +136,7 @@ export function processPluginCommandsQueue(
try {
switch (command.type) {
case 'LOAD_PLUGIN':
loadPlugin(store, command.payload);
await loadPlugin(store, command.payload);
break;
case 'UNINSTALL_PLUGIN':
uninstallPlugin(store, command.payload);
@@ -121,12 +157,11 @@ export function processPluginCommandsQueue(
console.error('Failed to process command', command);
}
}
store.dispatch(pluginCommandsProcessed(queue.length));
}
function loadPlugin(store: Store, payload: LoadPluginActionPayload) {
async function loadPlugin(store: Store, payload: LoadPluginActionPayload) {
try {
const plugin = requirePlugin(payload.plugin);
const plugin = await requirePlugin(payload.plugin);
const enablePlugin = payload.enable;
updatePlugin(store, {plugin, enablePlugin});
} catch (err) {

View File

@@ -8,7 +8,11 @@
*/
import type {Store} from '../reducers/index';
import type {InstalledPluginDetails, Logger} from 'flipper-common';
import {
InstalledPluginDetails,
Logger,
tryCatchReportPluginFailuresAsync,
} from 'flipper-common';
import {PluginDefinition} from '../plugin';
import React from 'react';
import ReactDOM from 'react-dom';
@@ -31,7 +35,7 @@ import {
BundledPluginDetails,
ConcretePluginDetails,
} from 'flipper-common';
import {tryCatchReportPluginFailures, reportUsage} from 'flipper-common';
import {reportUsage} from 'flipper-common';
import * as FlipperPluginSDK from 'flipper-plugin';
import {_SandyPluginDefinition} from 'flipper-plugin';
import * as Immer from 'immer';
@@ -46,6 +50,8 @@ import isPluginCompatible from '../utils/isPluginCompatible';
import isPluginVersionMoreRecent from '../utils/isPluginVersionMoreRecent';
import {createSandyPluginWrapper} from '../utils/createSandyPluginWrapper';
import {getRenderHostInstance} from '../RenderHost';
import pMap from 'p-map';
let defaultPluginsIndex: any = null;
export default async (store: Store, _logger: Logger) => {
@@ -88,12 +94,15 @@ export default async (store: Store, _logger: Logger) => {
const loadedPlugins =
getLatestCompatibleVersionOfEachPlugin(allLocalVersions);
const initialPlugins: PluginDefinition[] = loadedPlugins
const pluginsToLoad = loadedPlugins
.map(reportVersion)
.filter(checkDisabled(disabledPlugins))
.filter(checkGK(gatekeepedPlugins))
.map(createRequirePluginFunction(failedPlugins))
.filter(notNull);
.filter(checkGK(gatekeepedPlugins));
const loader = createRequirePluginFunction(failedPlugins);
const initialPlugins: PluginDefinition[] = (
await pMap(pluginsToLoad, loader)
).filter(notNull);
const classicPlugins = initialPlugins.filter(
(p) => !isSandyPlugin(p.details),
@@ -235,11 +244,12 @@ export const checkDisabled = (
export const createRequirePluginFunction = (
failedPlugins: Array<[ActivatablePluginDetails, string]>,
reqFn: Function = global.electronRequire,
) => {
return (pluginDetails: ActivatablePluginDetails): PluginDefinition | null => {
return async (
pluginDetails: ActivatablePluginDetails,
): Promise<PluginDefinition | null> => {
try {
const pluginDefinition = requirePlugin(pluginDetails, reqFn);
const pluginDefinition = await requirePlugin(pluginDetails);
if (
pluginDefinition &&
isDevicePluginDefinition(pluginDefinition) &&
@@ -260,8 +270,7 @@ export const createRequirePluginFunction = (
export const requirePlugin = (
pluginDetails: ActivatablePluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
): Promise<PluginDefinition> => {
reportUsage(
'plugin:load',
{
@@ -269,8 +278,8 @@ export const requirePlugin = (
},
pluginDetails.id,
);
return tryCatchReportPluginFailures(
() => requirePluginInternal(pluginDetails, reqFn),
return tryCatchReportPluginFailuresAsync(
() => requirePluginInternal(pluginDetails),
'plugin:load',
pluginDetails.id,
);
@@ -280,13 +289,12 @@ const isSandyPlugin = (pluginDetails: ActivatablePluginDetails) => {
return !!pluginDetails.flipperSDKVersion;
};
const requirePluginInternal = (
const requirePluginInternal = async (
pluginDetails: ActivatablePluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
): Promise<PluginDefinition> => {
let plugin = pluginDetails.isBundled
? defaultPluginsIndex[pluginDetails.name]
: reqFn(pluginDetails.entry);
: await getRenderHostInstance().requirePlugin(pluginDetails.entry);
if (isSandyPlugin(pluginDetails)) {
// Sandy plugin
return new _SandyPluginDefinition(pluginDetails, plugin);