Skip to content

Commit 98201d1

Browse files
committed
Add Base Controller v2
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).
1 parent 4cfac4a commit 98201d1

File tree

4 files changed

+243
-0
lines changed

4 files changed

+243
-0
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"ethereumjs-wallet": "^1.0.1",
5151
"human-standard-collectible-abi": "^1.0.2",
5252
"human-standard-token-abi": "^2.0.0",
53+
"immer": "^8.0.1",
5354
"isomorphic-fetch": "^3.0.0",
5455
"jsonschema": "^1.2.4",
5556
"nanoid": "^3.1.12",

src/BaseControllerV2.test.ts

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import type { Draft } from 'immer';
2+
import * as sinon from 'sinon';
3+
4+
import { BaseController } from './BaseControllerV2';
5+
6+
interface MockControllerState {
7+
count: number;
8+
}
9+
10+
class MockController extends BaseController<MockControllerState> {
11+
update(callback: (state: Draft<MockControllerState>) => void | MockControllerState) {
12+
super.update(callback);
13+
}
14+
15+
destroy() {
16+
super.destroy();
17+
}
18+
}
19+
20+
describe('BaseController', () => {
21+
it('should set initial state', () => {
22+
const controller = new MockController({ count: 0 });
23+
24+
expect(controller.state).toEqual({ count: 0 });
25+
});
26+
27+
it('should not allow mutating state directly', () => {
28+
const controller = new MockController({ count: 0 });
29+
30+
expect(() => {
31+
controller.state = { count: 1 };
32+
}).toThrow();
33+
});
34+
35+
it('should allow updating state by modifying draft', () => {
36+
const controller = new MockController({ count: 0 });
37+
38+
controller.update((draft) => {
39+
draft.count += 1;
40+
});
41+
42+
expect(controller.state).toEqual({ count: 1 });
43+
});
44+
45+
it('should allow updating state by return a value', () => {
46+
const controller = new MockController({ count: 0 });
47+
48+
controller.update(() => {
49+
return { count: 1 };
50+
});
51+
52+
expect(controller.state).toEqual({ count: 1 });
53+
});
54+
55+
it('should throw an error if update callback modifies draft and returns value', () => {
56+
const controller = new MockController({ count: 0 });
57+
58+
expect(() => {
59+
controller.update((draft) => {
60+
draft.count += 1;
61+
return { count: 10 };
62+
});
63+
}).toThrow();
64+
});
65+
66+
it('should inform subscribers of state changes', () => {
67+
const controller = new MockController({ count: 0 });
68+
const listener1 = sinon.stub();
69+
const listener2 = sinon.stub();
70+
71+
controller.subscribe(listener1);
72+
controller.subscribe(listener2);
73+
controller.update(() => {
74+
return { count: 1 };
75+
});
76+
77+
expect(listener1.callCount).toEqual(1);
78+
expect(listener1.firstCall.args).toEqual([{ count: 1 }]);
79+
expect(listener2.callCount).toEqual(1);
80+
expect(listener2.firstCall.args).toEqual([{ count: 1 }]);
81+
});
82+
83+
it('should inform a subscriber of each state change once even after multiple subscriptions', () => {
84+
const controller = new MockController({ count: 0 });
85+
const listener1 = sinon.stub();
86+
87+
controller.subscribe(listener1);
88+
controller.subscribe(listener1);
89+
controller.update(() => {
90+
return { count: 1 };
91+
});
92+
93+
expect(listener1.callCount).toEqual(1);
94+
expect(listener1.firstCall.args).toEqual([{ count: 1 }]);
95+
});
96+
97+
it('should no longer inform a subscriber about state changes after unsubscribing', () => {
98+
const controller = new MockController({ count: 0 });
99+
const listener1 = sinon.stub();
100+
101+
controller.subscribe(listener1);
102+
controller.unsubscribe(listener1);
103+
controller.update(() => {
104+
return { count: 1 };
105+
});
106+
107+
expect(listener1.callCount).toEqual(0);
108+
});
109+
110+
it('should no longer inform a subscriber about state changes after unsubscribing once, even if they subscribed many times', () => {
111+
const controller = new MockController({ count: 0 });
112+
const listener1 = sinon.stub();
113+
114+
controller.subscribe(listener1);
115+
controller.subscribe(listener1);
116+
controller.unsubscribe(listener1);
117+
controller.update(() => {
118+
return { count: 1 };
119+
});
120+
121+
expect(listener1.callCount).toEqual(0);
122+
});
123+
124+
it('should allow unsubscribing listeners who were never subscribed', () => {
125+
const controller = new MockController({ count: 0 });
126+
const listener1 = sinon.stub();
127+
128+
expect(() => {
129+
controller.unsubscribe(listener1);
130+
}).not.toThrow();
131+
});
132+
133+
it('should no longer update subscribers after being destroyed', () => {
134+
const controller = new MockController({ count: 0 });
135+
const listener1 = sinon.stub();
136+
const listener2 = sinon.stub();
137+
138+
controller.subscribe(listener1);
139+
controller.subscribe(listener2);
140+
controller.destroy();
141+
controller.update(() => {
142+
return { count: 1 };
143+
});
144+
145+
expect(listener1.callCount).toEqual(0);
146+
expect(listener2.callCount).toEqual(0);
147+
});
148+
});

src/BaseControllerV2.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { produce } from 'immer';
2+
3+
// Imported separately because only the type is used
4+
// eslint-disable-next-line no-duplicate-imports
5+
import type { Draft } from 'immer';
6+
7+
/**
8+
* State change callbacks
9+
*/
10+
export type Listener<T> = (state: T) => void;
11+
12+
/**
13+
* Controller class that provides state management and subscriptions
14+
*/
15+
export class BaseController<S extends Record<string, any>> {
16+
private internalState: S;
17+
18+
private internalListeners: Set<Listener<S>> = new Set();
19+
20+
/**
21+
* Creates a BaseController instance.
22+
*
23+
* @param state - Initial controller state
24+
*/
25+
constructor(state: S) {
26+
this.internalState = state;
27+
}
28+
29+
/**
30+
* Retrieves current controller state
31+
*
32+
* @returns - Current state
33+
*/
34+
get state() {
35+
return this.internalState;
36+
}
37+
38+
set state(_) {
39+
throw new Error(`Controller state cannot be directly mutated; use 'update' method instead.`);
40+
}
41+
42+
/**
43+
* Adds new listener to be notified of state changes
44+
*
45+
* @param listener - Callback triggered when state changes
46+
*/
47+
subscribe(listener: Listener<S>) {
48+
this.internalListeners.add(listener);
49+
}
50+
51+
/**
52+
* Removes existing listener from receiving state changes
53+
*
54+
* @param listener - Callback to remove
55+
*/
56+
unsubscribe(listener: Listener<S>) {
57+
this.internalListeners.delete(listener);
58+
}
59+
60+
/**
61+
* Updates controller state. Accepts a callback that is passed a draft copy
62+
* of the controller state. If a value is returned, it is set as the new
63+
* state. Otherwise, any changes made within that callback to the draft are
64+
* applied to the controller state.
65+
*
66+
* @param callback - Callback for updating state, passed a draft state
67+
* object. Return a new state object or mutate the draft to update state.
68+
*/
69+
protected update(callback: (state: Draft<S>) => void | S) {
70+
const nextState = produce(this.internalState, callback) as S;
71+
this.internalState = nextState;
72+
for (const listener of this.internalListeners) {
73+
listener(nextState);
74+
}
75+
}
76+
77+
/**
78+
* Prepares the controller for garbage collection. This should be extended
79+
* by any subclasses to clean up any additional connections or events.
80+
*
81+
* The only cleanup performed here is to remove listeners. While technically
82+
* this is not required to ensure this instance is garbage collected, it at
83+
* least ensures this instance won't be responsible for preventing the
84+
* listeners from being garbage collected.
85+
*/
86+
protected destroy() {
87+
this.internalListeners.clear();
88+
}
89+
}

yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -3597,6 +3597,11 @@ immediate@^3.2.3:
35973597
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.2.3.tgz#d140fa8f614659bd6541233097ddaac25cdd991c"
35983598
integrity sha1-0UD6j2FGWb1lQSMwl92qwlzdmRw=
35993599

3600+
immer@^8.0.1:
3601+
version "8.0.1"
3602+
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"
3603+
integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==
3604+
36003605
import-fresh@^3.0.0:
36013606
version "3.2.1"
36023607
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"

0 commit comments

Comments
 (0)