diff --git a/packages/base-controller/src/ControllerMessenger.test.ts b/packages/base-controller/src/ControllerMessenger.test.ts index 48c2a294a85..2499e7b2dcd 100644 --- a/packages/base-controller/src/ControllerMessenger.test.ts +++ b/packages/base-controller/src/ControllerMessenger.test.ts @@ -252,73 +252,193 @@ describe('ControllerMessenger', () => { expect(handler2.callCount).toBe(1); }); - it('should publish event with selector to subscriber', () => { - type MessageEvent = { - type: 'complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - controllerMessenger.subscribe('complexMessage', handler, selector); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); + describe('on first state change with an initial payload function registered', () => { + it('should publish event if selected payload differs', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + controllerMessenger.registerInitialEventPayload({ + eventType: 'complexMessage', + getPayload: () => [state], + }); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + controllerMessenger.subscribe('complexMessage', handler, selector); + + state.propA += 1; + controllerMessenger.publish('complexMessage', state); + + expect(handler.getCall(0)?.args).toStrictEqual([2, 1]); + expect(handler.callCount).toBe(1); + }); - expect(handler.calledWithExactly('a', undefined)).toBe(true); - expect(handler.callCount).toBe(1); - expect(selector.calledWithExactly({ prop1: 'a', prop2: 'b' })).toBe(true); - expect(selector.callCount).toBe(1); + it('should not publish event if selected payload is the same', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + controllerMessenger.registerInitialEventPayload({ + eventType: 'complexMessage', + getPayload: () => [state], + }); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + controllerMessenger.subscribe('complexMessage', handler, selector); + + controllerMessenger.publish('complexMessage', state); + + expect(handler.callCount).toBe(0); + }); }); - it('should call selector event handler with previous selector return value', () => { - type MessageEvent = { - type: 'complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - controllerMessenger.subscribe('complexMessage', handler, selector); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); - controllerMessenger.publish('complexMessage', { prop1: 'z', prop2: 'b' }); + describe('on first state change without an initial payload function registered', () => { + it('should publish event if selected payload differs', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + controllerMessenger.subscribe('complexMessage', handler, selector); + + state.propA += 1; + controllerMessenger.publish('complexMessage', state); + + expect(handler.getCall(0)?.args).toStrictEqual([2, undefined]); + expect(handler.callCount).toBe(1); + }); - expect(handler.getCall(0).calledWithExactly('a', undefined)).toBe(true); - expect(handler.getCall(1).calledWithExactly('z', 'a')).toBe(true); - expect(handler.callCount).toBe(2); - expect( - selector.getCall(0).calledWithExactly({ prop1: 'a', prop2: 'b' }), - ).toBe(true); + it('should publish event even when selected payload does not change', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + controllerMessenger.subscribe('complexMessage', handler, selector); + + controllerMessenger.publish('complexMessage', state); + + expect(handler.getCall(0)?.args).toStrictEqual([1, undefined]); + expect(handler.callCount).toBe(1); + }); - expect( - selector.getCall(1).calledWithExactly({ prop1: 'z', prop2: 'b' }), - ).toBe(true); - expect(selector.callCount).toBe(2); + it('should not publish if selector returns undefined', () => { + const state = { + propA: undefined, + propB: 1, + }; + type MessageEvent = { + type: 'complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + controllerMessenger.subscribe('complexMessage', handler, selector); + + controllerMessenger.publish('complexMessage', state); + + expect(handler.callCount).toBe(0); + }); }); - it('should not publish event with selector if selector return value is unchanged', () => { - type MessageEvent = { - type: 'complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - controllerMessenger.subscribe('complexMessage', handler, selector); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop3: 'c' }); + describe('on later state change', () => { + it('should call selector event handler with previous selector return value', () => { + type MessageEvent = { + type: 'complexMessage'; + payload: [Record]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.prop1); + controllerMessenger.subscribe('complexMessage', handler, selector); + controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); + controllerMessenger.publish('complexMessage', { prop1: 'z', prop2: 'b' }); + + expect(handler.getCall(0).calledWithExactly('a', undefined)).toBe(true); + expect(handler.getCall(1).calledWithExactly('z', 'a')).toBe(true); + expect(handler.callCount).toBe(2); + }); - expect(handler.calledWithExactly('a', undefined)).toBe(true); - expect(handler.callCount).toBe(1); - expect( - selector.getCall(0).calledWithExactly({ prop1: 'a', prop2: 'b' }), - ).toBe(true); + it('should publish event with selector to subscriber', () => { + type MessageEvent = { + type: 'complexMessage'; + payload: [Record]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.prop1); + controllerMessenger.subscribe('complexMessage', handler, selector); + controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); + + expect(handler.calledWithExactly('a', undefined)).toBe(true); + expect(handler.callCount).toBe(1); + }); - expect( - selector.getCall(1).calledWithExactly({ prop1: 'a', prop3: 'c' }), - ).toBe(true); - expect(selector.callCount).toBe(2); + it('should not publish event with selector if selector return value is unchanged', () => { + type MessageEvent = { + type: 'complexMessage'; + payload: [Record]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.prop1); + controllerMessenger.subscribe('complexMessage', handler, selector); + controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); + controllerMessenger.publish('complexMessage', { prop1: 'a', prop3: 'c' }); + + expect(handler.calledWithExactly('a', undefined)).toBe(true); + expect(handler.callCount).toBe(1); + }); }); it('should publish event to many subscribers with the same selector', () => { diff --git a/packages/base-controller/src/ControllerMessenger.ts b/packages/base-controller/src/ControllerMessenger.ts index 398005f0607..486c9a80f3d 100644 --- a/packages/base-controller/src/ControllerMessenger.ts +++ b/packages/base-controller/src/ControllerMessenger.ts @@ -129,6 +129,16 @@ export class ControllerMessenger< readonly #events = new Map>(); + /** + * A map of functions for getting the initial event payload. + * + * Used only for events that represent state changes. + */ + readonly #getEventPayload = new Map< + Event['type'], + () => ExtractEventPayload + >(); + /** * A cache of selector return values for their respective handlers. */ @@ -210,6 +220,27 @@ export class ControllerMessenger< return handler(...params); } + /** + * Register a function for getting the initial payload for an event. + * + * This is used for events that represent a state change, where the payload is the state. + * Registering a function for getting the payload allows event selectors to have a point of + * comparison the first time state changes. + * + * @param args - The arguments to this function + * @param args.eventType - The event type to register a payload for. + * @param args.getPayload - A function for retrieving the event payload. + */ + registerInitialEventPayload({ + eventType, + getPayload, + }: { + eventType: EventType; + getPayload: () => ExtractEventPayload; + }) { + this.#getEventPayload.set(eventType, getPayload); + } + /** * Publish an event. * @@ -310,6 +341,14 @@ export class ControllerMessenger< } subscribers.set(handler, selector); + + if (selector) { + const getPayload = this.#getEventPayload.get(eventType); + if (getPayload) { + const initialValue = selector(...getPayload()); + this.#eventPayloadCache.set(handler, initialValue); + } + } } /** diff --git a/packages/base-controller/src/RestrictedControllerMessenger.test.ts b/packages/base-controller/src/RestrictedControllerMessenger.test.ts index d10b59af8cc..dd633c7a726 100644 --- a/packages/base-controller/src/RestrictedControllerMessenger.test.ts +++ b/packages/base-controller/src/RestrictedControllerMessenger.test.ts @@ -372,6 +372,28 @@ describe('RestrictedControllerMessenger', () => { expect(pingCount).toBe(0); }); + it('should throw when registering an initial event payload outside of the namespace', () => { + type MessageEvent = { + type: 'OtherController:complexMessage'; + payload: [Record]; + }; + const controllerMessenger = new ControllerMessenger(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + + expect(() => + restrictedControllerMessenger.registerInitialEventPayload({ + // @ts-expect-error suppressing to test runtime error handling + eventType: 'OtherController:complexMessage', + // @ts-expect-error suppressing to test runtime error handling + getPayload: () => [{}], + }), + ).toThrow( + `Only allowed publishing events prefixed by 'MessageController:'`, + ); + }); + it('should publish event to subscriber', () => { type MessageEvent = { type: 'MessageController:message'; @@ -393,33 +415,294 @@ describe('RestrictedControllerMessenger', () => { expect(handler.callCount).toBe(1); }); - it('should publish event with selector to subscriber', () => { - type MessageEvent = { - type: 'MessageController:complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', + describe('on first state change with an initial payload function registered', () => { + it('should publish event if selected payload differs', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'MessageController:complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + restrictedControllerMessenger.registerInitialEventPayload({ + eventType: 'MessageController:complexMessage', + getPayload: () => [state], + }); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + restrictedControllerMessenger.subscribe( + 'MessageController:complexMessage', + handler, + selector, + ); + + state.propA += 1; + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + state, + ); + + expect(handler.getCall(0)?.args).toStrictEqual([2, 1]); + expect(handler.callCount).toBe(1); }); - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - restrictedControllerMessenger.subscribe( - 'MessageController:complexMessage', - handler, - selector, - ); + it('should not publish event if selected payload is the same', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'MessageController:complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + restrictedControllerMessenger.registerInitialEventPayload({ + eventType: 'MessageController:complexMessage', + getPayload: () => [state], + }); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + restrictedControllerMessenger.subscribe( + 'MessageController:complexMessage', + handler, + selector, + ); + + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + state, + ); - restrictedControllerMessenger.publish('MessageController:complexMessage', { - prop1: 'a', - prop2: 'b', + expect(handler.callCount).toBe(0); }); + }); - expect(handler.calledWithExactly('a', undefined)).toBe(true); - expect(handler.callCount).toBe(1); - expect(selector.calledWithExactly({ prop1: 'a', prop2: 'b' })).toBe(true); - expect(selector.callCount).toBe(1); + describe('on first state change without an initial payload function registered', () => { + it('should publish event if selected payload differs', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'MessageController:complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + restrictedControllerMessenger.subscribe( + 'MessageController:complexMessage', + handler, + selector, + ); + + state.propA += 1; + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + state, + ); + + expect(handler.getCall(0)?.args).toStrictEqual([2, undefined]); + expect(handler.callCount).toBe(1); + }); + + it('should publish event even when selected payload does not change', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'MessageController:complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + restrictedControllerMessenger.subscribe( + 'MessageController:complexMessage', + handler, + selector, + ); + + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + state, + ); + + expect(handler.getCall(0)?.args).toStrictEqual([1, undefined]); + expect(handler.callCount).toBe(1); + }); + + it('should not publish if selector returns undefined', () => { + const state = { + propA: undefined, + propB: 1, + }; + type MessageEvent = { + type: 'MessageController:complexMessage'; + payload: [typeof state]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.propA); + restrictedControllerMessenger.subscribe( + 'MessageController:complexMessage', + handler, + selector, + ); + + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + state, + ); + + expect(handler.callCount).toBe(0); + }); + }); + + describe('on later state change', () => { + it('should call selector event handler with previous selector return value', () => { + type MessageEvent = { + type: 'MessageController:complexMessage'; + payload: [Record]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.prop1); + controllerMessenger.subscribe( + 'MessageController:complexMessage', + handler, + selector, + ); + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + { + prop1: 'a', + prop2: 'b', + }, + ); + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + { + prop1: 'z', + prop2: 'b', + }, + ); + + expect(handler.getCall(0).calledWithExactly('a', undefined)).toBe(true); + expect(handler.getCall(1).calledWithExactly('z', 'a')).toBe(true); + expect(handler.callCount).toBe(2); + }); + + it('should publish event with selector to subscriber', () => { + type MessageEvent = { + type: 'MessageController:complexMessage'; + payload: [Record]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.prop1); + restrictedControllerMessenger.subscribe( + 'MessageController:complexMessage', + handler, + selector, + ); + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + { + prop1: 'a', + prop2: 'b', + }, + ); + + expect(handler.calledWithExactly('a', undefined)).toBe(true); + expect(handler.callCount).toBe(1); + }); + + it('should not publish event with selector if selector return value is unchanged', () => { + type MessageEvent = { + type: 'MessageController:complexMessage'; + payload: [Record]; + }; + const controllerMessenger = new ControllerMessenger< + never, + MessageEvent + >(); + const restrictedControllerMessenger = controllerMessenger.getRestricted({ + name: 'MessageController', + }); + + const handler = sinon.stub(); + const selector = sinon.fake((obj: Record) => obj.prop1); + restrictedControllerMessenger.subscribe( + 'MessageController:complexMessage', + handler, + selector, + ); + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + { + prop1: 'a', + prop2: 'b', + }, + ); + restrictedControllerMessenger.publish( + 'MessageController:complexMessage', + { + prop1: 'a', + prop3: 'c', + }, + ); + + expect(handler.calledWithExactly('a', undefined)).toBe(true); + expect(handler.callCount).toBe(1); + }); }); it('should allow publishing multiple different events to subscriber', () => { diff --git a/packages/base-controller/src/RestrictedControllerMessenger.ts b/packages/base-controller/src/RestrictedControllerMessenger.ts index d22e7b3ea83..57784b4a814 100644 --- a/packages/base-controller/src/RestrictedControllerMessenger.ts +++ b/packages/base-controller/src/RestrictedControllerMessenger.ts @@ -168,6 +168,40 @@ export class RestrictedControllerMessenger< return response; } + /** + * Register a function for getting the initial payload for an event. + * + * This is used for events that represent a state change, where the payload is the state. + * Registering a function for getting the payload allows event selectors to have a point of + * comparison the first time state changes. + * + * The event type *must* be in the current namespace + * + * @param args - The arguments to this function + * @param args.eventType - The event type to register a payload for. + * @param args.getPayload - A function for retrieving the event payload. + */ + registerInitialEventPayload< + EventType extends Event['type'] & NamespacedName, + >({ + eventType, + getPayload, + }: { + eventType: EventType; + getPayload: () => ExtractEventPayload; + }) { + /* istanbul ignore if */ // Branch unreachable with valid types + if (!this.#isInCurrentNamespace(eventType)) { + throw new Error( + `Only allowed publishing events prefixed by '${this.#controllerName}:'`, + ); + } + this.#controllerMessenger.registerInitialEventPayload({ + eventType, + getPayload, + }); + } + /** * Publish an event. *