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:
Michel Weststrate
2020-07-01 08:58:40 -07:00
committed by Facebook GitHub Bot
parent 159c0deaf1
commit d16c6061c1
7 changed files with 117 additions and 10 deletions

View File

@@ -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",

View File

@@ -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>;
} }

View File

@@ -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);
}); });

View File

@@ -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.

View 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;
}

View File

@@ -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

View File

@@ -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"