diff --git a/src/BaseControllerV2.test.ts b/src/BaseControllerV2.test.ts index dd1161497c1..ab9b777f606 100644 --- a/src/BaseControllerV2.test.ts +++ b/src/BaseControllerV2.test.ts @@ -1,21 +1,27 @@ -import type { Draft } from 'immer'; +import type { Draft, Patch } from 'immer'; import * as sinon from 'sinon'; import { BaseController, getAnonymizedState, getPersistentState } from './BaseControllerV2'; +import { ControllerMessenger } from './ControllerMessenger'; -type MockControllerState = { +type CountControllerState = { count: number; }; -const mockControllerStateMetadata = { +type CountControllerEvent = { + type: `CountController:stateChange`; + payload: [CountControllerState, Patch[]]; +}; + +const CountControllerStateMetadata = { count: { persist: true, anonymous: true, }, }; -class MockController extends BaseController { - update(callback: (state: Draft) => void | MockControllerState) { +class MockController extends BaseController<'CountController', CountControllerState> { + update(callback: (state: Draft) => void | CountControllerState) { super.update(callback); } @@ -26,19 +32,37 @@ class MockController extends BaseController { describe('BaseController', () => { it('should set initial state', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); expect(controller.state).toEqual({ count: 0 }); }); it('should set initial schema', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); - expect(controller.metadata).toEqual(mockControllerStateMetadata); + expect(controller.metadata).toEqual(CountControllerStateMetadata); }); it('should not allow mutating state directly', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); expect(() => { controller.state = { count: 1 }; @@ -46,7 +70,13 @@ describe('BaseController', () => { }); it('should allow updating state by modifying draft', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); controller.update((draft) => { draft.count += 1; @@ -56,7 +86,13 @@ describe('BaseController', () => { }); it('should allow updating state by return a value', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); controller.update(() => { return { count: 1 }; @@ -66,7 +102,13 @@ describe('BaseController', () => { }); it('should throw an error if update callback modifies draft and returns value', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); expect(() => { controller.update((draft) => { @@ -77,12 +119,18 @@ describe('BaseController', () => { }); it('should inform subscribers of state changes', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); const listener1 = sinon.stub(); const listener2 = sinon.stub(); - controller.subscribe(listener1); - controller.subscribe(listener2); + controllerMessenger.subscribe('CountController:stateChange', listener1); + controllerMessenger.subscribe('CountController:stateChange', listener2); controller.update(() => { return { count: 1 }; }); @@ -94,11 +142,18 @@ describe('BaseController', () => { }); it('should inform a subscriber of each state change once even after multiple subscriptions', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); const listener1 = sinon.stub(); - controller.subscribe(listener1); - controller.subscribe(listener1); + controllerMessenger.subscribe('CountController:stateChange', listener1); + controllerMessenger.subscribe('CountController:stateChange', listener1); + controller.update(() => { return { count: 1 }; }); @@ -108,11 +163,17 @@ describe('BaseController', () => { }); it('should no longer inform a subscriber about state changes after unsubscribing', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); const listener1 = sinon.stub(); - controller.subscribe(listener1); - controller.unsubscribe(listener1); + controllerMessenger.subscribe('CountController:stateChange', listener1); + controllerMessenger.unsubscribe('CountController:stateChange', listener1); controller.update(() => { return { count: 1 }; }); @@ -121,12 +182,18 @@ describe('BaseController', () => { }); 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 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); const listener1 = sinon.stub(); - controller.subscribe(listener1); - controller.subscribe(listener1); - controller.unsubscribe(listener1); + controllerMessenger.subscribe('CountController:stateChange', listener1); + controllerMessenger.subscribe('CountController:stateChange', listener1); + controllerMessenger.unsubscribe('CountController:stateChange', listener1); controller.update(() => { return { count: 1 }; }); @@ -134,22 +201,29 @@ describe('BaseController', () => { expect(listener1.callCount).toEqual(0); }); - it('should allow unsubscribing listeners who were never subscribed', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + it('should throw when unsubscribing listener who was never subscribed', () => { + const controllerMessenger = new ControllerMessenger(); + new MockController(controllerMessenger, 'CountController', { count: 0 }, CountControllerStateMetadata); const listener1 = sinon.stub(); expect(() => { - controller.unsubscribe(listener1); - }).not.toThrow(); + controllerMessenger.unsubscribe('CountController:stateChange', listener1); + }).toThrow(); }); it('should no longer update subscribers after being destroyed', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'CountController', + { count: 0 }, + CountControllerStateMetadata, + ); const listener1 = sinon.stub(); const listener2 = sinon.stub(); - controller.subscribe(listener1); - controller.subscribe(listener2); + controllerMessenger.subscribe('CountController:stateChange', listener1); + controllerMessenger.subscribe('CountController:stateChange', listener2); controller.destroy(); controller.update(() => { return { count: 1 }; diff --git a/src/BaseControllerV2.ts b/src/BaseControllerV2.ts index 6ece6f31412..bbc01112b62 100644 --- a/src/BaseControllerV2.ts +++ b/src/BaseControllerV2.ts @@ -4,6 +4,8 @@ import { enablePatches, produceWithPatches } from 'immer'; // eslint-disable-next-line no-duplicate-imports import type { Draft, Patch } from 'immer'; +import type { ControllerMessenger } from './ControllerMessenger'; + enablePatches(); /** @@ -96,21 +98,31 @@ type Json = null | boolean | number | string | Json[] | { [prop: string]: Json } /** * Controller class that provides state management, subscriptions, and state metadata */ -export class BaseController> { +export class BaseController> { private internalState: IsJsonable; - private internalListeners: Set> = new Set(); + private messagingSystem: ControllerMessenger; + + private name: N; public readonly metadata: StateMetadata; /** * Creates a BaseController instance. * + * @param messagingSystem - Controller messaging system * @param state - Initial controller state * @param metadata - State metadata, describing how to "anonymize" the state, * and which parts should be persisted. */ - constructor(state: IsJsonable, metadata: StateMetadata) { + constructor( + messagingSystem: ControllerMessenger, + name: N, + state: IsJsonable, + metadata: StateMetadata, + ) { + this.messagingSystem = messagingSystem; + this.name = name; this.internalState = state; this.metadata = metadata; } @@ -128,24 +140,6 @@ export class BaseController> { 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 @@ -158,9 +152,7 @@ export class BaseController> { protected update(callback: (state: Draft>) => void | IsJsonable) { const [nextState, patches] = produceWithPatches(this.internalState, callback); this.internalState = nextState as IsJsonable; - for (const listener of this.internalListeners) { - listener(nextState as S, patches); - } + this.messagingSystem.publish(`${this.name}:stateChange` as `${N}:stateChange`, nextState as S, patches); } /** @@ -173,7 +165,7 @@ export class BaseController> { * listeners from being garbage collected. */ protected destroy() { - this.internalListeners.clear(); + this.messagingSystem.clearEventSubscriptions(`${this.name}:stateChange` as `${N}:stateChange`); } } diff --git a/src/ControllerMessenger.test.ts b/src/ControllerMessenger.test.ts new file mode 100644 index 00000000000..672c853d474 --- /dev/null +++ b/src/ControllerMessenger.test.ts @@ -0,0 +1,287 @@ +import * as sinon from 'sinon'; + +import { ControllerMessenger } from './ControllerMessenger'; + +describe('ControllerMessenger', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should allow registering and calling an action handler', () => { + type CountAction = { type: 'count'; handler: (increment: number) => void }; + const controllerMessenger = new ControllerMessenger(); + + let count = 0; + controllerMessenger.registerActionHandler('count', (increment: number) => { + count += increment; + }); + controllerMessenger.call('count', 1); + + expect(count).toEqual(1); + }); + + it('should allow registering and calling multiple different action handlers', () => { + type MessageAction = + | { type: 'concat'; handler: (message: string) => void } + | { type: 'reset'; handler: (initialMessage: string) => void }; + const controllerMessenger = new ControllerMessenger(); + + let message = ''; + controllerMessenger.registerActionHandler('reset', (initialMessage: string) => { + message = initialMessage; + }); + controllerMessenger.registerActionHandler('concat', (s: string) => { + message += s; + }); + + controllerMessenger.call('reset', 'hello'); + controllerMessenger.call('concat', ', world'); + + expect(message).toEqual('hello, world'); + }); + + it('should allow registering and calling an action handler with no parameters', () => { + type IncrementAction = { type: 'increment'; handler: () => void }; + const controllerMessenger = new ControllerMessenger(); + + let count = 0; + controllerMessenger.registerActionHandler('increment', () => { + count += 1; + }); + controllerMessenger.call('increment'); + + expect(count).toEqual(1); + }); + + it('should allow registering and calling an action handler with multiple parameters', () => { + type MessageAction = { type: 'message'; handler: (to: string, message: string) => void }; + const controllerMessenger = new ControllerMessenger(); + + const messages: Record = {}; + controllerMessenger.registerActionHandler('message', (to, message) => { + messages[to] = message; + }); + controllerMessenger.call('message', '0x123', 'hello'); + + expect(messages['0x123']).toEqual('hello'); + }); + + it('should allow registering and calling an action handler with a return value', () => { + type AddAction = { type: 'add'; handler: (a: number, b: number) => number }; + const controllerMessenger = new ControllerMessenger(); + + controllerMessenger.registerActionHandler('add', (a, b) => { + return a + b; + }); + const result = controllerMessenger.call('add', 5, 10); + + expect(result).toEqual(15); + }); + + it('should not allow registering multiple action handlers under the same name', () => { + type PingAction = { type: 'ping'; handler: () => void }; + const controllerMessenger = new ControllerMessenger(); + + controllerMessenger.registerActionHandler('ping', () => undefined); + + expect(() => { + controllerMessenger.registerActionHandler('ping', () => undefined); + }).toThrow(); + }); + + it('should throw when calling unregistered action', () => { + type PingAction = { type: 'ping'; handler: () => void }; + const controllerMessenger = new ControllerMessenger(); + + expect(() => { + controllerMessenger.call('ping'); + }).toThrow(); + }); + + it('should throw when calling an action that has been unregistered', () => { + type PingAction = { type: 'ping'; handler: () => void }; + const controllerMessenger = new ControllerMessenger(); + + expect(() => { + controllerMessenger.call('ping'); + }).toThrow(); + + let pingCount = 0; + controllerMessenger.registerActionHandler('ping', () => { + pingCount += 1; + }); + + controllerMessenger.unregisterActionHandler('ping'); + + expect(() => { + controllerMessenger.call('ping'); + }).toThrow(); + expect(pingCount).toEqual(0); + }); + + it('should throw when calling an action after actions have been reset', () => { + type PingAction = { type: 'ping'; handler: () => void }; + const controllerMessenger = new ControllerMessenger(); + + expect(() => { + controllerMessenger.call('ping'); + }).toThrow(); + + let pingCount = 0; + controllerMessenger.registerActionHandler('ping', () => { + pingCount += 1; + }); + + controllerMessenger.clearActions(); + + expect(() => { + controllerMessenger.call('ping'); + }).toThrow(); + expect(pingCount).toEqual(0); + }); + + it('should publish event to subscriber', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler = sinon.stub(); + controllerMessenger.subscribe('message', handler); + controllerMessenger.publish('message', 'hello'); + + expect(handler.calledWithExactly('hello')).toBeTruthy(); + expect(handler.callCount).toEqual(1); + }); + + it('should allow publishing multiple different events to subscriber', () => { + type MessageEvent = { type: 'message'; payload: [string] } | { type: 'ping'; payload: [] }; + const controllerMessenger = new ControllerMessenger(); + + const messageHandler = sinon.stub(); + const pingHandler = sinon.stub(); + controllerMessenger.subscribe('message', messageHandler); + controllerMessenger.subscribe('ping', pingHandler); + + controllerMessenger.publish('message', 'hello'); + controllerMessenger.publish('ping'); + + expect(messageHandler.calledWithExactly('hello')).toBeTruthy(); + expect(messageHandler.callCount).toEqual(1); + expect(pingHandler.calledWithExactly()).toBeTruthy(); + expect(pingHandler.callCount).toEqual(1); + }); + + it('should publish event with no payload to subscriber', () => { + type PingEvent = { type: 'ping'; payload: [] }; + const controllerMessenger = new ControllerMessenger(); + + const handler = sinon.stub(); + controllerMessenger.subscribe('ping', handler); + controllerMessenger.publish('ping'); + + expect(handler.calledWithExactly()).toBeTruthy(); + expect(handler.callCount).toEqual(1); + }); + + it('should publish event with multiple payload parameters to subscriber', () => { + type MessageEvent = { type: 'message'; payload: [string, string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler = sinon.stub(); + controllerMessenger.subscribe('message', handler); + controllerMessenger.publish('message', 'hello', 'there'); + + expect(handler.calledWithExactly('hello', 'there')).toBeTruthy(); + expect(handler.callCount).toEqual(1); + }); + + it('should publish event once to subscriber even if subscribed multiple times', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler = sinon.stub(); + controllerMessenger.subscribe('message', handler); + controllerMessenger.subscribe('message', handler); + controllerMessenger.publish('message', 'hello'); + + expect(handler.calledWithExactly('hello')).toBeTruthy(); + expect(handler.callCount).toEqual(1); + }); + + it('should publish event to many subscribers', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler1 = sinon.stub(); + const handler2 = sinon.stub(); + controllerMessenger.subscribe('message', handler1); + controllerMessenger.subscribe('message', handler2); + controllerMessenger.publish('message', 'hello'); + + expect(handler1.calledWithExactly('hello')).toBeTruthy(); + expect(handler1.callCount).toEqual(1); + expect(handler2.calledWithExactly('hello')).toBeTruthy(); + expect(handler2.callCount).toEqual(1); + }); + + it('should not call subscriber after unsubscribing', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler = sinon.stub(); + controllerMessenger.subscribe('message', handler); + controllerMessenger.unsubscribe('message', handler); + controllerMessenger.publish('message', 'hello'); + + expect(handler.callCount).toEqual(0); + }); + + it('should throw when unsubscribing when there are no subscriptions', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler = sinon.stub(); + expect(() => controllerMessenger.unsubscribe('message', handler)).toThrow(); + }); + + it('should throw when unsubscribing a handler that is not subscribed', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler1 = sinon.stub(); + const handler2 = sinon.stub(); + controllerMessenger.subscribe('message', handler1); + + expect(() => controllerMessenger.unsubscribe('message', handler2)).toThrow(); + }); + + it('should not call subscriber after clearing event subscriptions', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler = sinon.stub(); + controllerMessenger.subscribe('message', handler); + controllerMessenger.clearEventSubscriptions('message'); + controllerMessenger.publish('message', 'hello'); + + expect(handler.callCount).toEqual(0); + }); + + it('should not throw when clearing event that has no subscriptions', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + expect(() => controllerMessenger.clearEventSubscriptions('message')).not.toThrow(); + }); + + it('should not call subscriber after resetting subscriptions', () => { + type MessageEvent = { type: 'message'; payload: [string] }; + const controllerMessenger = new ControllerMessenger(); + + const handler = sinon.stub(); + controllerMessenger.subscribe('message', handler); + controllerMessenger.clearSubscriptions(); + controllerMessenger.publish('message', 'hello'); + + expect(handler.callCount).toEqual(0); + }); +}); diff --git a/src/ControllerMessenger.ts b/src/ControllerMessenger.ts new file mode 100644 index 00000000000..12568efc6c1 --- /dev/null +++ b/src/ControllerMessenger.ts @@ -0,0 +1,164 @@ +type ActionHandler = ( + ...args: ExtractActionParameters +) => ExtractActionResponse; +type ExtractActionParameters = Action extends { type: T; handler: (...args: infer H) => any } ? H : never; +type ExtractActionResponse = Action extends { type: T; handler: (...args: any) => infer H } ? H : never; + +type ExtractEvenHandler = Event extends { type: T; payload: infer P } + ? P extends any[] + ? (...payload: P) => void + : never + : never; +type ExtractEventPayload = Event extends { type: T; payload: infer P } ? P : never; + +type ActionConstraint = { type: string; handler: (...args: any) => unknown }; +type EventConstraint = { type: string; payload: unknown[] }; + +/** + * A messaging system for controllers. + * + * The controller messenger allows registering functions as 'actions' that can be called elsewhere, + * and it allows publishing and subscribing to events. Both actions and events are identified by + * unique strings. + */ +export class ControllerMessenger { + private actions = new Map(); + + private events = new Map>(); + + /** + * Register an action handler. + * + * This will make the registered function available to call via the `call` method. + * + * @param actionType - The action type. This is a unqiue identifier for this action. + * @param handler- The action handler. This function gets called when the `call` method is + * invoked with the given action type. + * @throws Will throw when a handler has been registered for this action type already. + */ + registerActionHandler(actionType: T, handler: ActionHandler) { + if (this.actions.has(actionType)) { + throw new Error(`A handler for ${actionType} has already been registered`); + } + this.actions.set(actionType, handler); + } + + /** + * Unregister an action handler. + * + * This will prevent this action from being called. + * + * @param actionType - The action type. This is a unqiue identifier for this action. + */ + unregisterActionHandler(actionType: T) { + this.actions.delete(actionType); + } + + /** + * Unregister all action handlers. + * + * This prevents all actions from being called. + */ + clearActions() { + this.actions.clear(); + } + + /** + * Call an action. + * + * This function will call the action handler corresponding to the given action type, passing + * along any parameters given. + * + * @param actionType - The action type. This is a unqiue identifier for this action. + * @param params - The action parameters. These must match the type of the parameters of the + * registered action handler. + * @throws Will throw when no handler has been registered for the given type. + */ + call( + actionType: T, + ...params: ExtractActionParameters + ): ExtractActionResponse { + const handler = this.actions.get(actionType) as ActionHandler; + if (!handler) { + throw new Error(`A handler for ${actionType} has not been registered`); + } + return handler(...params); + } + + /** + * Publish an event. + * + * Publishes the given payload to all subscribers of the given event type. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param payload - The event payload. The type of the parameters for each event handler must + * match the type of this payload. + */ + publish(eventType: E, ...payload: ExtractEventPayload) { + const subscribers = this.events.get(eventType) as Set>; + + if (subscribers) { + for (const eventHandler of subscribers) { + eventHandler(...payload); + } + } + } + + /** + * Subscribe to an event. + * + * Registers the given function as an event handler for the given event type. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param handler - The event handler. The type of the parameters for this event handler must + * match the type of the payload for this event type. + */ + subscribe(eventType: E, handler: ExtractEvenHandler) { + let subscribers = this.events.get(eventType); + if (!subscribers) { + subscribers = new Set(); + } + subscribers.add(handler); + this.events.set(eventType, subscribers); + } + + /** + * Unsubscribe from an event. + * + * Unregisters the given function as an event handler for the given event. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param handler - The event handler to unregister. + * @throws Will throw when the given event handler is not registered for this event. + */ + unsubscribe(eventType: E, handler: ExtractEvenHandler) { + const subscribers = this.events.get(eventType); + + if (!subscribers || !subscribers.has(handler)) { + throw new Error(`Subscription not found for event: '${eventType}'`); + } + + subscribers.delete(handler); + this.events.set(eventType, subscribers); + } + + /** + * Clear subscriptions for a specific event. + * + * This will remove all subscribed handlers for this event. + * + * @param eventType - The event type. This is a unique identifier for this event. + */ + clearEventSubscriptions(eventType: E) { + this.events.delete(eventType); + } + + /** + * Clear all subscriptions. + * + * This will remove all subscribed handlers for all events. + */ + clearSubscriptions() { + this.events.clear(); + } +}