From 17301ff823f57b51d043eaea128309486e44c7d5 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Tue, 23 Feb 2021 15:44:21 -0330 Subject: [PATCH] Add Base Controller v2 (#358) This is the new base controller we'll be transitioning to as part of the controller redesign (#337). It has been added as a separate module so that we can transition each controller more easily, one at a time. Additional features will be added in future PRs (e.g. schema, messaging). --- package.json | 1 + src/BaseControllerV2.test.ts | 148 +++++++++++++++++++++++++++++++++++ src/BaseControllerV2.ts | 89 +++++++++++++++++++++ yarn.lock | 5 ++ 4 files changed, 243 insertions(+) create mode 100644 src/BaseControllerV2.test.ts create mode 100644 src/BaseControllerV2.ts diff --git a/package.json b/package.json index 60c7e87b6da..61853e42122 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "ethereumjs-wallet": "^1.0.1", "human-standard-collectible-abi": "^1.0.2", "human-standard-token-abi": "^2.0.0", + "immer": "^8.0.1", "isomorphic-fetch": "^3.0.0", "jsonschema": "^1.2.4", "nanoid": "^3.1.12", diff --git a/src/BaseControllerV2.test.ts b/src/BaseControllerV2.test.ts new file mode 100644 index 00000000000..e58f61be62c --- /dev/null +++ b/src/BaseControllerV2.test.ts @@ -0,0 +1,148 @@ +import type { Draft } from 'immer'; +import * as sinon from 'sinon'; + +import { BaseController } from './BaseControllerV2'; + +interface MockControllerState { + count: number; +} + +class MockController extends BaseController { + update(callback: (state: Draft) => void | MockControllerState) { + super.update(callback); + } + + destroy() { + super.destroy(); + } +} + +describe('BaseController', () => { + it('should set initial state', () => { + const controller = new MockController({ count: 0 }); + + expect(controller.state).toEqual({ count: 0 }); + }); + + it('should not allow mutating state directly', () => { + const controller = new MockController({ count: 0 }); + + expect(() => { + controller.state = { count: 1 }; + }).toThrow(); + }); + + it('should allow updating state by modifying draft', () => { + const controller = new MockController({ count: 0 }); + + controller.update((draft) => { + draft.count += 1; + }); + + expect(controller.state).toEqual({ count: 1 }); + }); + + it('should allow updating state by return a value', () => { + const controller = new MockController({ count: 0 }); + + controller.update(() => { + return { count: 1 }; + }); + + expect(controller.state).toEqual({ count: 1 }); + }); + + it('should throw an error if update callback modifies draft and returns value', () => { + const controller = new MockController({ count: 0 }); + + expect(() => { + controller.update((draft) => { + draft.count += 1; + return { count: 10 }; + }); + }).toThrow(); + }); + + it('should inform subscribers of state changes', () => { + const controller = new MockController({ count: 0 }); + const listener1 = sinon.stub(); + const listener2 = sinon.stub(); + + controller.subscribe(listener1); + controller.subscribe(listener2); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1.callCount).toEqual(1); + expect(listener1.firstCall.args).toEqual([{ count: 1 }]); + expect(listener2.callCount).toEqual(1); + expect(listener2.firstCall.args).toEqual([{ count: 1 }]); + }); + + it('should inform a subscriber of each state change once even after multiple subscriptions', () => { + const controller = new MockController({ count: 0 }); + const listener1 = sinon.stub(); + + controller.subscribe(listener1); + controller.subscribe(listener1); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1.callCount).toEqual(1); + expect(listener1.firstCall.args).toEqual([{ count: 1 }]); + }); + + it('should no longer inform a subscriber about state changes after unsubscribing', () => { + const controller = new MockController({ count: 0 }); + const listener1 = sinon.stub(); + + controller.subscribe(listener1); + controller.unsubscribe(listener1); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1.callCount).toEqual(0); + }); + + it('should no longer inform a subscriber about state changes after unsubscribing once, even if they subscribed many times', () => { + const controller = new MockController({ count: 0 }); + const listener1 = sinon.stub(); + + controller.subscribe(listener1); + controller.subscribe(listener1); + controller.unsubscribe(listener1); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1.callCount).toEqual(0); + }); + + it('should allow unsubscribing listeners who were never subscribed', () => { + const controller = new MockController({ count: 0 }); + const listener1 = sinon.stub(); + + expect(() => { + controller.unsubscribe(listener1); + }).not.toThrow(); + }); + + it('should no longer update subscribers after being destroyed', () => { + const controller = new MockController({ count: 0 }); + const listener1 = sinon.stub(); + const listener2 = sinon.stub(); + + controller.subscribe(listener1); + controller.subscribe(listener2); + controller.destroy(); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1.callCount).toEqual(0); + expect(listener2.callCount).toEqual(0); + }); +}); diff --git a/src/BaseControllerV2.ts b/src/BaseControllerV2.ts new file mode 100644 index 00000000000..60f15eae385 --- /dev/null +++ b/src/BaseControllerV2.ts @@ -0,0 +1,89 @@ +import { produce } from 'immer'; + +// Imported separately because only the type is used +// eslint-disable-next-line no-duplicate-imports +import type { Draft } from 'immer'; + +/** + * State change callbacks + */ +export type Listener = (state: T) => void; + +/** + * Controller class that provides state management and subscriptions + */ +export class BaseController> { + private internalState: S; + + private internalListeners: Set> = new Set(); + + /** + * Creates a BaseController instance. + * + * @param state - Initial controller state + */ + constructor(state: S) { + this.internalState = state; + } + + /** + * Retrieves current controller state + * + * @returns - Current state + */ + get state() { + return this.internalState; + } + + set state(_) { + throw new Error(`Controller state cannot be directly mutated; use 'update' method instead.`); + } + + /** + * Adds new listener to be notified of state changes + * + * @param listener - Callback triggered when state changes + */ + subscribe(listener: Listener) { + this.internalListeners.add(listener); + } + + /** + * Removes existing listener from receiving state changes + * + * @param listener - Callback to remove + */ + unsubscribe(listener: Listener) { + this.internalListeners.delete(listener); + } + + /** + * Updates controller state. Accepts a callback that is passed a draft copy + * of the controller state. If a value is returned, it is set as the new + * state. Otherwise, any changes made within that callback to the draft are + * applied to the controller state. + * + * @param callback - Callback for updating state, passed a draft state + * object. Return a new state object or mutate the draft to update state. + */ + protected update(callback: (state: Draft) => void | S) { + const nextState = produce(this.internalState, callback) as S; + this.internalState = nextState; + for (const listener of this.internalListeners) { + listener(nextState); + } + } + + /** + * Prepares the controller for garbage collection. This should be extended + * by any subclasses to clean up any additional connections or events. + * + * The only cleanup performed here is to remove listeners. While technically + * this is not required to ensure this instance is garbage collected, it at + * least ensures this instance won't be responsible for preventing the + * listeners from being garbage collected. + */ + protected destroy() { + this.internalListeners.clear(); + } +} diff --git a/yarn.lock b/yarn.lock index ce7edec268d..2866aa9ba08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3597,6 +3597,11 @@ immediate@^3.2.3: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.2.3.tgz#d140fa8f614659bd6541233097ddaac25cdd991c" integrity sha1-0UD6j2FGWb1lQSMwl92qwlzdmRw= +immer@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" + integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== + import-fresh@^3.0.0: version "3.2.1" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"