Introduce onConnect / onDisconnect hooks

Summary:
Introduced hooks that are called whenever the plugin is connected / disconnected to it's counter part on the device.

There is some logic duplication between `PluginContainer` for old plugins, and `PluginRenderer` for new plugins, mostly caused by the fact that those lifecycles are triggered from the UI rather than from the reducers, but I figured refactoring that to be too risky.

Reviewed By: jknoxville

Differential Revision: D22232337

fbshipit-source-id: a384c45731a4c8d9b8b532a83e2becf49ce807c2
This commit is contained in:
Michel Weststrate
2020-07-01 08:58:40 -07:00
committed by Facebook GitHub Bot
parent dd0d957d8b
commit bde112bf85
10 changed files with 222 additions and 63 deletions

View File

@@ -673,14 +673,16 @@ export default class Client extends EventEmitter {
initPlugin(pluginId: string) {
this.activePlugins.add(pluginId);
this.rawSend('init', {plugin: pluginId});
// TODO: call sandyOnConnect
this.sandyPluginStates.get(pluginId)?.connect();
}
deinitPlugin(pluginId: string) {
// TODO: call sandyOnDisconnect
this.activePlugins.delete(pluginId);
this.sandyPluginStates.get(pluginId)?.disconnect();
if (this.connected) {
this.rawSend('deinit', {plugin: pluginId});
}
}
rawSend(method: string, params?: Object): void {
const data = {

View File

@@ -142,6 +142,7 @@ class PluginContainer extends PureComponent<Props, State> {
| null
| undefined,
) => {
// N.B. for Sandy plugins this lifecycle is managed by PluginRenderer
if (this.plugin) {
this.plugin._teardown();
this.plugin = null;

View File

@@ -14,7 +14,12 @@ import {
renderMockFlipperWithPlugin,
createMockPluginDetails,
} from '../test-utils/createMockFlipperWithPlugin';
import {SandyPluginContext, SandyPluginDefinition} from 'flipper-plugin';
import {
SandyPluginContext,
SandyPluginDefinition,
FlipperClient,
} from 'flipper-plugin';
import {selectPlugin} from '../reducers/connections';
interface PersistedState {
count: 1;
@@ -94,7 +99,13 @@ test('PluginContainer can render Sandy plugins', async () => {
return <div>Hello from Sandy</div>;
}
const plugin = () => ({});
const plugin = (client: FlipperClient) => {
const connectedStub = jest.fn();
const disconnectedStub = jest.fn();
client.onConnect(connectedStub);
client.onDisconnect(disconnectedStub);
return {connectedStub, disconnectedStub};
};
const definition = new SandyPluginDefinition(createMockPluginDetails(), {
plugin,
@@ -103,9 +114,13 @@ test('PluginContainer can render Sandy plugins', async () => {
// any cast because this plugin is not enriched with the meta data that the plugin loader
// normally adds. Our further sandy plugin test infra won't need this, but
// for this test we do need to act a s a loaded plugin, to make sure PluginContainer itself can handle it
const {renderer, act, sendMessage} = await renderMockFlipperWithPlugin(
definition,
);
const {
renderer,
act,
sendMessage,
client,
store,
} = await renderMockFlipperWithPlugin(definition);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
@@ -129,9 +144,44 @@ test('PluginContainer can render Sandy plugins', async () => {
act(() => {
sendMessage('inc', {delta: 2});
});
expect(renders).toBe(1);
// make sure the plugin gets connected
const pluginInstance: ReturnType<typeof plugin> = client.sandyPluginStates.get(
definition.id,
)!.instanceApi;
expect(pluginInstance.connectedStub).toBeCalledTimes(1);
expect(pluginInstance.disconnectedStub).toBeCalledTimes(0);
// TODO: check that onConnect is called T68683507
// TODO: check that messages have arrived T68683442
expect(renders).toBe(1);
// select non existing plugin
act(() => {
store.dispatch(
selectPlugin({
selectedPlugin: 'Logs',
deepLinkPayload: null,
}),
);
});
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div />
</body>
`);
expect(pluginInstance.connectedStub).toBeCalledTimes(1);
expect(pluginInstance.disconnectedStub).toBeCalledTimes(1);
// go back
act(() => {
store.dispatch(
selectPlugin({
selectedPlugin: definition.id,
deepLinkPayload: null,
}),
);
});
expect(pluginInstance.connectedStub).toBeCalledTimes(2);
expect(pluginInstance.disconnectedStub).toBeCalledTimes(1);
});

View File

@@ -11,6 +11,7 @@ Object {
"device": "MockAndroidDevice",
"device_id": "serial",
"os": "Android",
"sdk_version": 4,
},
},
],

View File

@@ -297,10 +297,7 @@ export class FlipperPlugin<
}
// run plugin teardown
this.teardown();
if (
this.realClient.connected &&
!this.realClient.isBackgroundPlugin(pluginId)
) {
if (!this.realClient.isBackgroundPlugin(pluginId)) {
this.realClient.deinitPlugin(pluginId);
}
}

View File

@@ -26,24 +26,36 @@ interface PersistedState {
const pluginDetails = createMockPluginDetails();
let TestPlugin: SandyPluginDefinition;
let initialized = false;
beforeEach(() => {
function plugin(client: FlipperClient) {
initialized = false;
});
function plugin(client: FlipperClient) {
const connectStub = jest.fn();
const disconnectStub = jest.fn();
const destroyStub = jest.fn();
client.onConnect(connectStub);
client.onDisconnect(disconnectStub);
client.onDestroy(destroyStub);
initialized = true;
return {
connectStub,
disconnectStub,
destroyStub,
};
}
TestPlugin = new SandyPluginDefinition(pluginDetails, {
}
const TestPlugin = new SandyPluginDefinition(pluginDetails, {
plugin: jest.fn().mockImplementation(plugin) as typeof plugin,
Component: jest.fn().mockImplementation(() => null),
});
});
type PluginApi = ReturnType<typeof plugin>;
function starTestPlugin(store: Store, client: Client) {
store.dispatch(
starPlugin({
@@ -68,27 +80,40 @@ test('it should initialize starred sandy plugins', async () => {
const {client, store} = await createMockFlipperWithPlugin(TestPlugin);
// already started, so initialized immediately
expect(TestPlugin.module.plugin).toBeCalledTimes(1);
expect(initialized).toBe(true);
expect(client.sandyPluginStates.get(TestPlugin.id)).toBeInstanceOf(
SandyPluginInstance,
);
const instanceApi: PluginApi = client.sandyPluginStates.get(TestPlugin.id)!
.instanceApi;
expect(instanceApi.connectStub).toBeCalledTimes(0);
selectTestPlugin(store, client);
// TODO: make sure lifecycle 'activated' or something is triggered T68683507
// without rendering, non-bg plugins won't connect automatically,
// so this isn't the best test, but PluginContainer tests do test that part of the lifecycle
client.initPlugin(TestPlugin.id);
expect(instanceApi.connectStub).toBeCalledTimes(1);
client.deinitPlugin(TestPlugin.id);
expect(instanceApi.disconnectStub).toBeCalledTimes(1);
expect(instanceApi.destroyStub).toBeCalledTimes(0);
});
test('it should cleanup a plugin if disabled', async () => {
const {client, store} = await createMockFlipperWithPlugin(TestPlugin);
expect(TestPlugin.module.plugin).toBeCalledTimes(1);
const pluginInstance = client.sandyPluginStates.get(TestPlugin.id)!;
expect(pluginInstance).toBeInstanceOf(SandyPluginInstance);
expect(pluginInstance.instanceApi.destroyStub).toHaveBeenCalledTimes(0);
const pluginInstance: PluginApi = client.sandyPluginStates.get(TestPlugin.id)!
.instanceApi;
expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(0);
client.initPlugin(TestPlugin.id);
expect(pluginInstance.connectStub).toHaveBeenCalledTimes(1);
// unstar
starTestPlugin(store, client);
expect(client.sandyPluginStates.has(TestPlugin.id)).toBeFalsy();
expect(pluginInstance.instanceApi.destroyStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.disconnectStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(1);
});
test('it should cleanup if client is removed', async () => {
@@ -99,7 +124,9 @@ test('it should cleanup if client is removed', async () => {
// close client
client.close();
expect(client.sandyPluginStates.has(TestPlugin.id)).toBeFalsy();
expect(pluginInstance.instanceApi.destroyStub).toHaveBeenCalledTimes(1);
expect(
(pluginInstance.instanceApi as PluginApi).destroyStub,
).toHaveBeenCalledTimes(1);
});
test('it should not initialize a sandy plugin if not enabled', async () => {
@@ -111,11 +138,7 @@ test('it should not initialize a sandy plugin if not enabled', async () => {
id: 'Plugin2',
}),
{
plugin: jest.fn().mockImplementation((client) => {
const destroyStub = jest.fn();
client.onDestroy(destroyStub);
return {destroyStub};
}),
plugin: jest.fn().mockImplementation(plugin),
Component() {
return null;
},
@@ -140,13 +163,13 @@ test('it should not initialize a sandy plugin if not enabled', async () => {
expect(client.sandyPluginStates.get(Plugin2.id)).toBeInstanceOf(
SandyPluginInstance,
);
const destroyStub = client.sandyPluginStates.get(Plugin2.id)!.instanceApi
.destroyStub;
const instance = client.sandyPluginStates.get(Plugin2.id)!
.instanceApi as PluginApi;
expect(client.sandyPluginStates.get(TestPlugin.id)).toBe(pluginState1); // not reinitialized
expect(TestPlugin.module.plugin).toBeCalledTimes(1);
expect(Plugin2.module.plugin).toBeCalledTimes(1);
expect(destroyStub).toHaveBeenCalledTimes(0);
expect(instance.destroyStub).toHaveBeenCalledTimes(0);
// disable plugin again
store.dispatch(
@@ -157,7 +180,33 @@ test('it should not initialize a sandy plugin if not enabled', async () => {
);
expect(client.sandyPluginStates.get(Plugin2.id)).toBeUndefined();
expect(destroyStub).toHaveBeenCalledTimes(1);
expect(instance.connectStub).toHaveBeenCalledTimes(0);
// disconnect wasn't called because connect was never called
expect(instance.disconnectStub).toHaveBeenCalledTimes(0);
expect(instance.destroyStub).toHaveBeenCalledTimes(1);
});
test('it trigger hooks for background plugins', async () => {
const {client} = await createMockFlipperWithPlugin(TestPlugin, {
onSend(method) {
if (method === 'getBackgroundPlugins') {
return {plugins: [TestPlugin.id]};
}
},
});
const pluginInstance: PluginApi = client.sandyPluginStates.get(TestPlugin.id)!
.instanceApi;
expect(client.isBackgroundPlugin(TestPlugin.id)).toBeTruthy();
expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(0);
expect(pluginInstance.connectStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.disconnectStub).toHaveBeenCalledTimes(0);
// close client
client.close();
expect(client.sandyPluginStates.has(TestPlugin.id)).toBeFalsy();
expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.connectStub).toHaveBeenCalledTimes(1);
expect(pluginInstance.disconnectStub).toHaveBeenCalledTimes(1);
});
// TODO: T68683449 state is persisted if a plugin connects and reconnects

View File

@@ -47,8 +47,17 @@ type MockFlipperResult = {
logger: Logger;
};
type MockOptions = Partial<{
/**
* can be used to intercept outgoing calls. If it returns undefined
* the base implementation will be used
*/
onSend(method: string, params?: object): object | undefined;
}>;
export async function createMockFlipperWithPlugin(
pluginClazz: PluginDefinition,
options?: MockOptions,
): Promise<MockFlipperResult> {
const store = createStore(reducers);
const logger = getInstance();
@@ -77,6 +86,7 @@ export async function createMockFlipperWithPlugin(
os: 'Android',
device: device.title,
device_id: device.serial,
sdk_version: 4,
};
const id = buildClientId({
app: query.app,
@@ -101,8 +111,11 @@ export async function createMockFlipperWithPlugin(
return device;
},
} as any;
client.rawCall = async (method, _fromPlugin, _params): Promise<any> => {
// TODO: could use an interceptor here
client.rawCall = async (method, _fromPlugin, params): Promise<any> => {
const intercepted = options?.onSend?.(method, params);
if (intercepted !== undefined) {
return intercepted;
}
switch (method) {
case 'getPlugins':
// assuming this plugin supports all plugins for now
@@ -112,8 +125,10 @@ export async function createMockFlipperWithPlugin(
...store.getState().plugins.devicePlugins.keys(),
],
};
case 'getBackgroundPlugins':
return {plugins: []};
default:
throw new Error(`Test client doesn't supoprt rawCall to ${method}`);
throw new Error(`Test client doesn't support rawCall to ${method}`);
}
};

View File

@@ -24,13 +24,32 @@ export interface FlipperClient<
* the onDestroy event is fired whenever a client is unloaded from Flipper, or a plugin is disabled.
*/
onDestroy(cb: () => void): void;
/**
* the onConnect event is fired whenever the plugin is connected to it's counter part on the device.
* For most plugins this event is fired if the user selects the plugin,
* for background plugins when the initial connection is made.
*/
onConnect(cb: () => void): void;
/**
* The counterpart of the `onConnect` handler.
* Will also be fired before the plugin is cleaned up if the connection is currently active:
* - when the client disconnects
* - when the plugin is disabled
*/
onDisconnect(cb: () => void): void;
}
/**
* Internal API exposed by Flipper, and wrapped by FlipperPluginInstance to be passed to the
* Plugin Factory. For internal purposes only
*/
interface RealFlipperClient {}
interface RealFlipperClient {
isBackgroundPlugin(pluginId: string): boolean;
initPlugin(pluginId: string): void;
deinitPlugin(pluginId: string): void;
}
export type FlipperPluginFactory<
Events extends EventsContract,
@@ -49,6 +68,7 @@ export class SandyPluginInstance {
/** the plugin instance api as used inside components and such */
instanceApi: any;
connected = false;
events = new EventEmitter();
constructor(
@@ -61,19 +81,48 @@ export class SandyPluginInstance {
onDestroy: (cb) => {
this.events.on('destroy', cb);
},
onConnect: (cb) => {
this.events.on('connect', cb);
},
onDisconnect: (cb) => {
this.events.on('disconnect', cb);
},
};
this.instanceApi = definition.module.plugin(this.client);
}
// the plugin is selected in the UI
activate() {
// TODO: T68683507
const pluginId = this.definition.id;
if (!this.realClient.isBackgroundPlugin(pluginId)) {
this.realClient.initPlugin(pluginId); // will call connect() if needed
}
}
// the plugin is deselected in the UI
deactivate() {
// TODO: T68683507
const pluginId = this.definition.id;
if (!this.realClient.isBackgroundPlugin(pluginId)) {
this.realClient.deinitPlugin(pluginId);
}
}
connect() {
if (!this.connected) {
this.connected = true;
this.events.emit('connect');
}
}
disconnect() {
if (this.connected) {
this.connected = false;
this.events.emit('disconnect');
}
}
destroy() {
this.disconnect();
this.events.emit('destroy');
}

View File

@@ -8,16 +8,8 @@
*/
import {createContext} from 'react';
import {SandyPluginInstance} from './Plugin';
export type SandyPluginContext = {
deactivate(): void;
};
// TODO: to be filled in later with testing and such
const stubPluginContext: SandyPluginContext = {
deactivate() {},
};
export const SandyPluginContext = createContext<SandyPluginContext>(
stubPluginContext,
);
export const SandyPluginContext = createContext<
SandyPluginInstance | undefined
>(undefined);

View File

@@ -21,7 +21,10 @@ type Props = {
export const SandyPluginRenderer = memo(
({plugin}: Props) => {
useEffect(() => {
plugin.activate();
return () => {
plugin.deactivate();
};
}, [plugin]);
return (