Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Base Controller v2 #358

Merged
merged 2 commits into from
Feb 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
89 changes: 89 additions & 0 deletions src/BaseControllerV2.ts
Original file line number Diff line number Diff line change
@@ -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<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();
}
}
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