Skip to content

Commit

Permalink
Add Base Controller v2 (#358)
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 authored and MajorLift committed Oct 11, 2023
1 parent d76049c commit 17301ff
Show file tree
Hide file tree
Showing 4 changed files with 243 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);
});
});
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

0 comments on commit 17301ff

Please sign in to comment.