diff --git a/src/BaseControllerV2.test.ts b/src/BaseControllerV2.test.ts index dd1161497c1..a8ffe37e9de 100644 --- a/src/BaseControllerV2.test.ts +++ b/src/BaseControllerV2.test.ts @@ -1,12 +1,18 @@ -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 = { count: number; }; +type MockControllerMessengerEvent = { + type: `MockController:state-change`; + payload: [MockControllerState, Patch[]]; +}; + const mockControllerStateMetadata = { count: { persist: true, @@ -14,7 +20,7 @@ const mockControllerStateMetadata = { }, }; -class MockController extends BaseController { +class MockController extends BaseController<'MockController', MockControllerState> { update(callback: (state: Draft) => void | MockControllerState) { 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); expect(controller.metadata).toEqual(mockControllerStateMetadata); }); it('should not allow mutating state directly', () => { - const controller = new MockController({ count: 0 }, mockControllerStateMetadata); + const controllerMessenger = new ControllerMessenger(); + const controller = new MockController( + controllerMessenger, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); expect(() => { controller.update((draft) => { @@ -77,7 +119,13 @@ 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); const listener1 = sinon.stub(); const listener2 = sinon.stub(); @@ -94,7 +142,13 @@ 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); const listener1 = sinon.stub(); controller.subscribe(listener1); @@ -108,7 +162,13 @@ 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); const listener1 = sinon.stub(); controller.subscribe(listener1); @@ -121,7 +181,13 @@ 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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); const listener1 = sinon.stub(); controller.subscribe(listener1); @@ -134,17 +200,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(); + const controller = new MockController( + controllerMessenger, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); const listener1 = sinon.stub(); expect(() => { controller.unsubscribe(listener1); - }).not.toThrow(); + }).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, + 'MockController', + { count: 0 }, + mockControllerStateMetadata, + ); const listener1 = sinon.stub(); const listener2 = sinon.stub(); diff --git a/src/BaseControllerV2.ts b/src/BaseControllerV2.ts index 6ece6f31412..d13d1a39a6c 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,23 +98,33 @@ 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.internalState = state; this.metadata = metadata; + this.name = name; } /** @@ -134,7 +146,7 @@ export class BaseController> { * @param listener - Callback triggered when state changes */ subscribe(listener: Listener) { - this.internalListeners.add(listener); + this.messagingSystem.subscribe(`${this.name}:state-change` as `${N}:state-change`, listener); } /** @@ -143,7 +155,7 @@ export class BaseController> { * @param listener - Callback to remove */ unsubscribe(listener: Listener) { - this.internalListeners.delete(listener); + this.messagingSystem.unsubscribe(`${this.name}:state-change` as `${N}:state-change`, listener); } /** @@ -158,9 +170,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}:state-change` as `${N}:state-change`, nextState as S, patches); } /** @@ -173,7 +183,7 @@ export class BaseController> { * listeners from being garbage collected. */ protected destroy() { - this.internalListeners.clear(); + this.messagingSystem.clearEventSubscriptions(`${this.name}:state-change` as `${N}:state-change`); } } diff --git a/src/ControllerMessenger.test.ts b/src/ControllerMessenger.test.ts new file mode 100644 index 00000000000..e116220f154 --- /dev/null +++ b/src/ControllerMessenger.test.ts @@ -0,0 +1,269 @@ +import * as sinon from 'sinon'; + +import { ControllerMessenger } from './ControllerMessenger'; + +type MockMessengerAction = { type: 'count'; handler: (increment: number) => void }; +type MockMessengerEvent = { type: 'message'; payload: [string] }; + +describe('ControllerMessenger', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should allow register and calling an action handler', () => { + const controller = new ControllerMessenger(); + + let count = 0; + controller.registerActionHandler('count', (increment: number) => { + count += increment; + }); + controller.call('count', 1); + + expect(count).toEqual(1); + }); + + it('should allow registering and calling multiple different action handlers', () => { + type additionalMockMessengerAction = { type: 'concat'; handler: (s: string) => void }; + const controller = new ControllerMessenger(); + + let count = 0; + controller.registerActionHandler('count', (increment: number) => { + count += increment; + }); + let message = ''; + controller.registerActionHandler('concat', (s: string) => { + message += s; + }); + + controller.call('count', 1); + controller.call('concat', 'hello'); + + expect(count).toEqual(1); + expect(message).toEqual('hello'); + }); + + it('should allow register and calling an action handler with no parameters', () => { + type messengerAction = { type: 'count'; handler: () => void }; + const controller = new ControllerMessenger(); + + let count = 0; + controller.registerActionHandler('count', () => { + count += 1; + }); + controller.call('count'); + + expect(count).toEqual(1); + }); + + it('should allow register and calling an action handler with multiple parameters', () => { + type messengerAction = { type: 'count'; handler: (increment: number, reverse: boolean) => void }; + const controller = new ControllerMessenger(); + + let count = 0; + controller.registerActionHandler('count', (increment, reverse) => { + if (reverse) { + count -= increment; + } else { + count += increment; + } + }); + controller.call('count', 1, true); + + expect(count).toEqual(-1); + }); + + it('should not allow registering multiple action handlers under the same name', () => { + type messengerAction = { type: 'count'; handler: () => void }; + const controller = new ControllerMessenger(); + + controller.registerActionHandler('count', () => undefined); + + expect(() => { + controller.registerActionHandler('count', () => undefined); + }).toThrow(); + }); + + it('should throw when calling unregistered action', () => { + const controller = new ControllerMessenger(); + + expect(() => { + controller.call('count', 1); + }).toThrow(); + }); + + it('should throw when calling an action that has been unregistered', () => { + const controller = new ControllerMessenger(); + + let count = 0; + controller.registerActionHandler('count', (increment: number) => { + count += increment; + }); + + controller.unregisterActionHandler('count'); + + expect(() => { + controller.call('count', 1); + }).toThrow(); + expect(count).toEqual(0); + }); + + it('should throw when calling an action after actions have been reset', () => { + const controller = new ControllerMessenger(); + + let count = 0; + controller.registerActionHandler('count', (increment: number) => { + count += increment; + }); + + controller.clearActions(); + + expect(() => { + controller.call('count', 1); + }).toThrow(); + expect(count).toEqual(0); + }); + + it('should publish event to subscriber', () => { + const controller = new ControllerMessenger(); + + const handler = sinon.stub(); + controller.subscribe('message', handler); + + controller.publish('message', 'hello'); + + expect(handler.calledWithExactly('hello')).toBeTruthy(); + expect(handler.callCount).toEqual(1); + }); + + it('should allow publishing multiple different events to subscriber', () => { + type additionalMessengerEvent = { type: 'ping'; payload: [] }; + const controller = new ControllerMessenger(); + + const messageHandler = sinon.stub(); + const pingHandler = sinon.stub(); + controller.subscribe('message', messageHandler); + controller.subscribe('ping', pingHandler); + + controller.publish('message', 'hello'); + controller.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 messengerEvent = { type: 'ping'; payload: [] }; + const controller = new ControllerMessenger(); + + const handler = sinon.stub(); + controller.subscribe('ping', handler); + + controller.publish('ping'); + + expect(handler.calledWithExactly()).toBeTruthy(); + expect(handler.callCount).toEqual(1); + }); + + it('should publish event with multiple payload parameters to subscriber', () => { + type messengerEvent = { type: 'two-part-message'; payload: [string, string] }; + const controller = new ControllerMessenger(); + + const handler = sinon.stub(); + controller.subscribe('two-part-message', handler); + + controller.publish('two-part-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', () => { + const controller = new ControllerMessenger(); + + const handler = sinon.stub(); + controller.subscribe('message', handler); + controller.subscribe('message', handler); + + controller.publish('message', 'hello'); + + expect(handler.calledWithExactly('hello')).toBeTruthy(); + expect(handler.callCount).toEqual(1); + }); + + it('should publish event to many subscribers', () => { + const controller = new ControllerMessenger(); + + const handler1 = sinon.stub(); + const handler2 = sinon.stub(); + controller.subscribe('message', handler1); + controller.subscribe('message', handler2); + + controller.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', () => { + const controller = new ControllerMessenger(); + + const handler = sinon.stub(); + controller.subscribe('message', handler); + controller.unsubscribe('message', handler); + + controller.publish('message', 'hello'); + + expect(handler.callCount).toEqual(0); + }); + + it('should throw when unsubscribing when there are no subscriptions', () => { + const controller = new ControllerMessenger(); + + const handler = sinon.stub(); + expect(() => controller.unsubscribe('message', handler)).toThrow(); + }); + + it('should throw when unsubscribing a handler that is not subscribed', () => { + const controller = new ControllerMessenger(); + + const handler1 = sinon.stub(); + const handler2 = sinon.stub(); + controller.subscribe('message', handler1); + expect(() => controller.unsubscribe('message', handler2)).toThrow(); + }); + + it('should not call subscriber after clearing event subscriptions', () => { + const controller = new ControllerMessenger(); + + const handler = sinon.stub(); + controller.subscribe('message', handler); + controller.clearEventSubscriptions('message'); + + controller.publish('message', 'hello'); + + expect(handler.callCount).toEqual(0); + }); + + it('should not throw when clearing event that has no subscriptions', () => { + const controller = new ControllerMessenger(); + + expect(() => controller.clearEventSubscriptions('message')).not.toThrow(); + }); + + it('should not call subscriber after resetting subscriptions', () => { + const controller = new ControllerMessenger(); + + const handler = sinon.stub(); + controller.subscribe('message', handler); + controller.clearSubscriptions(); + + controller.publish('message', 'hello'); + + expect(handler.callCount).toEqual(0); + }); +}); diff --git a/src/ControllerMessenger.ts b/src/ControllerMessenger.ts new file mode 100644 index 00000000000..70c40bf66dc --- /dev/null +++ b/src/ControllerMessenger.ts @@ -0,0 +1,89 @@ +export type ActionHandler = ( + ...args: ExtractActionParameters +) => ExtractActionResponse; +export type ExtractActionParameters = Action extends { type: T; handler: (...args: infer H) => any } + ? H + : never; +export type ExtractActionResponse = Action extends { type: T; handler: (...args: any) => infer H } + ? H + : never; + +export type ExtractEvenHandler = Event extends { type: T; payload: infer P } + ? P extends any[] + ? (...payload: P) => void + : never + : never; +export type ExtractEventPayload = Event extends { type: T; payload: infer P } ? P : never; + +export class ControllerMessenger< + Action extends { type: string; handler: (...args: any) => unknown }, + Event extends { type: string; payload: unknown[] } +> { + private actions = new Map(); + + private events = new Map>(); + + registerActionHandler(action: T, handler: ActionHandler) { + if (this.actions.has(action)) { + throw new Error(`A handler for ${action} has already been registered`); + } + this.actions.set(action, handler); + } + + unregisterActionHandler(action: string) { + this.actions.delete(action); + } + + clearActions() { + this.actions.clear(); + } + + call( + action: T, + ...params: ExtractActionParameters + ): ExtractActionResponse { + const handler = this.actions.get(action) as ActionHandler; + if (!handler) { + throw new Error(`A handler for ${action} has not been registered`); + } + return handler(...params); + } + + publish(event: E, ...payload: ExtractEventPayload) { + const subscribers = this.events.get(event) as Set>; + + if (subscribers) { + for (const eventHandler of subscribers) { + eventHandler(...payload); + } + } + } + + subscribe(event: E, handler: ExtractEvenHandler) { + let subscribers = this.events.get(event); + if (!subscribers) { + subscribers = new Set(); + } + subscribers.add(handler); + this.events.set(event, subscribers); + } + + unsubscribe(event: E, handler: ExtractEvenHandler) { + const subscribers = this.events.get(event); + + if (!subscribers || !subscribers.has(handler)) { + throw new Error(`Subscription not found for event: '${event}'`); + } + + subscribers.delete(handler); + this.events.set(event, subscribers); + } + + clearEventSubscriptions(event: E) { + this.events.delete(event); + } + + clearSubscriptions() { + this.events.clear(); + } +}