Introduce createState which can be used in components to propagate updates
Summary:
Introduced a minimal state abstraction so that state can be maintained in the plugin instance, but subscribe to by the UI.
At a later point we could pick an off the shelve solution like Recoil or MobX, or introduce cursors to read something deep etc etc, but for now that doesn't seem to be needed at all, and I think this will be pretty comprehensible for plugin authors (see also the 25th diff in this stack).
The api
```
createState(initialValue): Atom
Atom {
get() // returns current value
set(newValue) // sets a new value
update(draft => { }) // updates a complex value using Immer behind the scenes
}
// hook, subscribes to the updates of the Atom and always returns the current value
useValue(atom)
```
Reviewed By: nikoant
Differential Revision: D22306673
fbshipit-source-id: c49f5af85ae9929187e4d8a051311a07c1b88eb5
This commit is contained in:
committed by
Facebook GitHub Bot
parent
159c0deaf1
commit
d16c6061c1
@@ -9,7 +9,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bugs": "https://github.com/facebook/flipper/issues",
|
"bugs": "https://github.com/facebook/flipper/issues",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@testing-library/react": "^10.4.3"
|
"@testing-library/react": "^10.4.3",
|
||||||
|
"immer": "^7.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^26.0.3",
|
"@types/jest": "^26.0.3",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {FlipperClient} from '../plugin/Plugin';
|
import {FlipperClient} from '../plugin/Plugin';
|
||||||
import {usePlugin} from '../plugin/PluginContext';
|
import {usePlugin} from '../plugin/PluginContext';
|
||||||
|
import {createState, useValue} from '../state/atom';
|
||||||
|
|
||||||
type Events = {
|
type Events = {
|
||||||
inc: {
|
inc: {
|
||||||
@@ -25,9 +26,9 @@ export function plugin(client: FlipperClient<Events, Methods>) {
|
|||||||
const connectStub = jest.fn();
|
const connectStub = jest.fn();
|
||||||
const disconnectStub = jest.fn();
|
const disconnectStub = jest.fn();
|
||||||
const destroyStub = jest.fn();
|
const destroyStub = jest.fn();
|
||||||
const state = {
|
const state = createState({
|
||||||
count: 0,
|
count: 0,
|
||||||
};
|
});
|
||||||
|
|
||||||
// TODO: add tests for sending and receiving data T68683442
|
// TODO: add tests for sending and receiving data T68683442
|
||||||
// including typescript assertions
|
// including typescript assertions
|
||||||
@@ -36,7 +37,9 @@ export function plugin(client: FlipperClient<Events, Methods>) {
|
|||||||
client.onDisconnect(disconnectStub);
|
client.onDisconnect(disconnectStub);
|
||||||
client.onDestroy(destroyStub);
|
client.onDestroy(destroyStub);
|
||||||
client.onMessage('inc', ({delta}) => {
|
client.onMessage('inc', ({delta}) => {
|
||||||
state.count += delta;
|
state.update((draft) => {
|
||||||
|
draft.count += delta;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function _unused_JustTypeChecks() {
|
function _unused_JustTypeChecks() {
|
||||||
@@ -69,10 +72,11 @@ export function plugin(client: FlipperClient<Events, Methods>) {
|
|||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const api = usePlugin(plugin);
|
const api = usePlugin(plugin);
|
||||||
|
const count = useValue(api.state).count;
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
api.bla;
|
api.bla;
|
||||||
|
|
||||||
// TODO N.b.: state updates won't be visible
|
// TODO N.b.: state updates won't be visible
|
||||||
return <h1>Hi from test plugin {api.state.count}</h1>;
|
return <h1>Hi from test plugin {count}</h1>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test('it can start a plugin and lifecycle events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it can render a plugin', () => {
|
test('it can render a plugin', () => {
|
||||||
const {renderer} = TestUtils.renderPlugin(testPlugin);
|
const {renderer, sendEvent, instance} = TestUtils.renderPlugin(testPlugin);
|
||||||
|
|
||||||
expect(renderer.baseElement).toMatchInlineSnapshot(`
|
expect(renderer.baseElement).toMatchInlineSnapshot(`
|
||||||
<body>
|
<body>
|
||||||
@@ -59,7 +59,25 @@ test('it can render a plugin', () => {
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
`);
|
`);
|
||||||
// TODO: test sending updates T68683442
|
|
||||||
|
sendEvent('inc', {delta: 3});
|
||||||
|
|
||||||
|
expect(renderer.baseElement).toMatchInlineSnapshot(`
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
Hi from test plugin
|
||||||
|
3
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
expect(instance.state.listeners.length).toBe(1);
|
||||||
|
renderer.unmount();
|
||||||
|
// @ts-ignore
|
||||||
|
expect(instance.state.listeners.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a plugin can send messages', async () => {
|
test('a plugin can send messages', async () => {
|
||||||
@@ -97,8 +115,8 @@ test('a plugin cannot send messages after being disconnected', async () => {
|
|||||||
|
|
||||||
test('a plugin can receive messages', async () => {
|
test('a plugin can receive messages', async () => {
|
||||||
const {instance, sendEvent} = TestUtils.startPlugin(testPlugin);
|
const {instance, sendEvent} = TestUtils.startPlugin(testPlugin);
|
||||||
expect(instance.state.count).toBe(0);
|
expect(instance.state.get().count).toBe(0);
|
||||||
|
|
||||||
sendEvent('inc', {delta: 2});
|
sendEvent('inc', {delta: 2});
|
||||||
expect(instance.state.count).toBe(2);
|
expect(instance.state.get().count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export {SandyPluginInstance, FlipperClient} from './plugin/Plugin';
|
|||||||
export {SandyPluginDefinition} from './plugin/SandyPluginDefinition';
|
export {SandyPluginDefinition} from './plugin/SandyPluginDefinition';
|
||||||
export {SandyPluginRenderer} from './plugin/PluginRenderer';
|
export {SandyPluginRenderer} from './plugin/PluginRenderer';
|
||||||
export {SandyPluginContext, usePlugin} from './plugin/PluginContext';
|
export {SandyPluginContext, usePlugin} from './plugin/PluginContext';
|
||||||
|
export {createState as createValue, useValue, Atom} from './state/atom';
|
||||||
|
|
||||||
// It's not ideal that this exists in flipper-plugin sources directly,
|
// It's not ideal that this exists in flipper-plugin sources directly,
|
||||||
// but is the least pain for plugin authors.
|
// but is the least pain for plugin authors.
|
||||||
|
|||||||
75
desktop/flipper-plugin/src/state/atom.tsx
Normal file
75
desktop/flipper-plugin/src/state/atom.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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 {produce} from 'immer';
|
||||||
|
import {useState, useEffect} from 'react';
|
||||||
|
|
||||||
|
export type Atom<T> = {
|
||||||
|
get(): T;
|
||||||
|
set(newValue: T): void;
|
||||||
|
update(recipe: (draft: T) => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AtomValue<T> implements Atom<T> {
|
||||||
|
value: T;
|
||||||
|
listeners: ((value: T) => void)[] = [];
|
||||||
|
|
||||||
|
constructor(initialValue: T) {
|
||||||
|
this.value = initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(nextValue: T) {
|
||||||
|
if (nextValue !== this.value) {
|
||||||
|
this.value = nextValue;
|
||||||
|
this.notifyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(recipe: (draft: T) => void) {
|
||||||
|
this.set(produce(this.value, recipe));
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyChanged() {
|
||||||
|
// TODO: add scheduling
|
||||||
|
this.listeners.slice().forEach((l) => l(this.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (value: T) => void) {
|
||||||
|
this.listeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(listener: (value: T) => void) {
|
||||||
|
const idx = this.listeners.indexOf(listener);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.listeners.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createState<T>(initialValue: T): Atom<T> {
|
||||||
|
return new AtomValue<T>(initialValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useValue<T>(atom: Atom<T>): T {
|
||||||
|
const [localValue, setLocalValue] = useState<T>(atom.get());
|
||||||
|
useEffect(() => {
|
||||||
|
// atom might have changed between mounting and effect setup
|
||||||
|
// in that case, this will cause a re-render, otherwise not
|
||||||
|
setLocalValue(atom.get());
|
||||||
|
(atom as AtomValue<T>).subscribe(setLocalValue);
|
||||||
|
return () => {
|
||||||
|
(atom as AtomValue<T>).unsubscribe(setLocalValue);
|
||||||
|
};
|
||||||
|
}, [atom]);
|
||||||
|
return localValue;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
FlipperPluginModule,
|
FlipperPluginModule,
|
||||||
} from '../plugin/SandyPluginDefinition';
|
} from '../plugin/SandyPluginDefinition';
|
||||||
import {SandyPluginRenderer} from '../plugin/PluginRenderer';
|
import {SandyPluginRenderer} from '../plugin/PluginRenderer';
|
||||||
|
import {act} from '@testing-library/react';
|
||||||
|
|
||||||
type Renderer = RenderResult<typeof import('testing-library__dom/queries')>;
|
type Renderer = RenderResult<typeof import('testing-library__dom/queries')>;
|
||||||
|
|
||||||
@@ -148,7 +149,9 @@ export function startPlugin<Module extends FlipperPluginModule<any>>(
|
|||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
sendEvents: (messages) => {
|
sendEvents: (messages) => {
|
||||||
|
act(() => {
|
||||||
pluginInstance.receiveMessages(messages as any);
|
pluginInstance.receiveMessages(messages as any);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -6414,6 +6414,11 @@ immer@^6.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/immer/-/immer-6.0.3.tgz#94d5051cd724668160a900d66d85ec02816f29bd"
|
resolved "https://registry.yarnpkg.com/immer/-/immer-6.0.3.tgz#94d5051cd724668160a900d66d85ec02816f29bd"
|
||||||
integrity sha512-12VvNrfSrXZdm/BJgi/KDW2soq5freVSf3I1+4CLunUM8mAGx2/0Njy0xBVzi5zewQZiwM7z1/1T+8VaI7NkmQ==
|
integrity sha512-12VvNrfSrXZdm/BJgi/KDW2soq5freVSf3I1+4CLunUM8mAGx2/0Njy0xBVzi5zewQZiwM7z1/1T+8VaI7NkmQ==
|
||||||
|
|
||||||
|
immer@^7.0.5:
|
||||||
|
version "7.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.5.tgz#8af347db5b60b40af8ae7baf1784ea4d35b5208e"
|
||||||
|
integrity sha512-TtRAKZyuqld2eYjvWgXISLJ0ZlOl1OOTzRmrmiY8SlB0dnAhZ1OiykIDL5KDFNaPHDXiLfGQFNJGtet8z8AEmg==
|
||||||
|
|
||||||
immutable@^4.0.0-rc.12:
|
immutable@^4.0.0-rc.12:
|
||||||
version "4.0.0-rc.12"
|
version "4.0.0-rc.12"
|
||||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0-rc.12.tgz#ca59a7e4c19ae8d9bf74a97bdf0f6e2f2a5d0217"
|
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0-rc.12.tgz#ca59a7e4c19ae8d9bf74a97bdf0f6e2f2a5d0217"
|
||||||
|
|||||||
Reference in New Issue
Block a user