Skip to content

Commit

Permalink
Add Base Controller v2
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
Gudahtt committed Feb 19, 2021
1 parent 720e1d5 commit e8fba64
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
148 changes: 148 additions & 0 deletions src/BaseControllerV2.test.ts
Original file line number Diff line number Diff line change
@@ -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<MockControllerState> {
update(callback: (state: Draft<MockControllerState>) => 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);
});
});
91 changes: 91 additions & 0 deletions src/BaseControllerV2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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<T> = (state: T) => void;

/**
* Controller class that provides state management and subscriptions
*/
export class BaseController<S extends Record<string, any>> {
private internalState: S;

private internalListeners: Set<Listener<S>> = 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<S>) {
this.internalListeners.add(listener);
}

/**
* Removes existing listener from receiving state changes
*
* @param listener - Callback to remove
*/
unsubscribe(listener: Listener<S>) {
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<S>) => 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();
}
}

export default BaseController;
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit e8fba64

Please sign in to comment.