diff --git a/packages/messenger/CHANGELOG.md b/packages/messenger/CHANGELOG.md index 83ce46c92c4..6b868853ff2 100644 --- a/packages/messenger/CHANGELOG.md +++ b/packages/messenger/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `subscribeOnce` and `waitUntil` utility methods to `Messenger` ([#8575](https://github.com/MetaMask/core/pull/8575)) + ### Deprecated - Deprecate `generate-action-types` CLI tool and `messenger-generate-action-types` binary ([#8378](https://github.com/MetaMask/core/pull/8378)) diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index ea37567b35a..53aca5f1f9a 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1010,6 +1010,170 @@ describe('Messenger', () => { }); }); + describe('subscribeOnce', () => { + it('unsubscribes automatically after receiving the first event', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribeOnce('Fixture:message', handler); + messenger.publish('Fixture:message', 'foo'); + messenger.publish('Fixture:message', 'bar'); + + expect(handler).toHaveBeenCalledWith('foo'); + expect(handler).not.toHaveBeenCalledWith('bar'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('supports selectors', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [{ value: string }]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribeOnce('Fixture:message', handler, { + selector: ({ value }) => value, + }); + messenger.publish('Fixture:message', { value: 'foo' }); + messenger.publish('Fixture:message', { value: 'bar' }); + + expect(handler).toHaveBeenCalledWith('foo', undefined); + expect(handler).not.toHaveBeenCalledWith('bar', 'foo'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('supports conditions without a selector', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribeOnce('Fixture:message', handler, { + condition: (value) => value === 'bar', + }); + messenger.publish('Fixture:message', 'foo'); + messenger.publish('Fixture:message', 'bar'); + + expect(handler).not.toHaveBeenCalledWith('foo'); + expect(handler).toHaveBeenCalledWith('bar'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('supports conditions with a selector', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [{ value: string }]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribeOnce('Fixture:message', handler, { + selector: ({ value }) => value, + condition: (value) => value === 'bar', + }); + messenger.publish('Fixture:message', { value: 'foo' }); + messenger.publish('Fixture:message', { value: 'bar' }); + + expect(handler).not.toHaveBeenCalledWith('foo'); + expect(handler).toHaveBeenCalledWith('bar', 'foo'); + expect(handler.mock.calls).toHaveLength(1); + }); + }); + + describe('waitUntil', () => { + it('resolves the promise when the event fires', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message'); + messenger.publish('Fixture:message', 'foo'); + + expect(await promise).toBe('foo'); + }); + + it('supports selectors', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [{ value: string }]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message', { + selector: ({ value }) => value, + }); + messenger.publish('Fixture:message', { value: 'foo' }); + + expect(await promise).toBe('foo'); + }); + + it('supports conditions without a selector', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message', { + condition: (value) => value === 'bar', + }); + messenger.publish('Fixture:message', 'foo'); + messenger.publish('Fixture:message', 'bar'); + + expect(await promise).toBe('bar'); + }); + + it('supports conditions with a selector', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [{ value: string }]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message', { + selector: ({ value }) => value, + condition: (value) => value === 'bar', + }); + messenger.publish('Fixture:message', { value: 'foo' }); + messenger.publish('Fixture:message', { value: 'bar' }); + + expect(await promise).toBe('bar'); + }); + }); + describe('clearEventSubscriptions', () => { it('does not call subscriber after clearing event subscriptions', () => { type MessageEvent = { type: 'Fixture:message'; payload: [string] }; diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index 7b4db349933..4739fdb1f59 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -656,6 +656,161 @@ export class Messenger< subscribers.set(handler, metadata); } + /** + * Subscribe to an event, with a selector, invoking the handler exactly once. + * + * Registers the given handler function as an event handler for the given + * event type. When an event is published, its payload is first passed to the + * selector. The event handler is only called if the selector's return value + * differs from its last known return value. Additionally if the optional condition + * function is provided, it is checked whether it returns `true`. + * The handler is invoked at most once, after which the subscription is removed. + * + * @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 return type of the selector. + * @param options - Options bag. + * @param options.selector - The selector function used to select relevant data + * from the event payload. The type of the parameters for this selector must + * match the type of the payload for this event type. + * @param options.condition - An optional predicate evaluated against the + * selector's return value. The handler is only invoked when this returns `true`. + * @template EventType - A type union of Event type strings. + * @template SelectorReturnValue - The selector return value. + */ + subscribeOnce( + eventType: EventType, + handler: SelectorEventHandler, + options: { + selector: SelectorFunction; + condition?: (value: SelectorReturnValue) => boolean; + }, + ): void; + + /** + * Subscribe to an event, invoking the handler exactly once. + * + * Registers the given function as an event handler for the given event type + * and automatically unsubscribes after the first invocation. + * + * If `options.condition` is provided, the handler is only invoked (and the + * subscription only removed) when the condition returns `true`. + * + * @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. + * @param options - Options bag. + * @param options.condition - A predicate evaluated against the event payload. + * The handler is only invoked when this returns `true`. + * @template EventType - A type union of Event type strings. + */ + subscribeOnce( + eventType: EventType, + handler: ExtractEventHandler, + options?: { + condition?: ( + ...payload: ExtractEventPayload + ) => boolean; + }, + ): void; + + subscribeOnce( + eventType: EventType, + handler: + | ExtractEventHandler + | SelectorEventHandler, + options?: { + selector?: SelectorFunction; + condition?: + | ((...payload: ExtractEventPayload) => boolean) + | ((value: SelectorReturnValue) => boolean); + }, + ): void { + const { selector, condition } = options ?? {}; + // Casting to unknown to handle both the code path where a selector is defined and where it is omitted. + const internalHandler = (...args: unknown[]): void => { + if ( + condition && + !(condition as (...args: unknown[]) => boolean)(...args) + ) { + return; + } + (handler as (...args: unknown[]) => void)(...args); + this.unsubscribe(eventType, internalHandler); + }; + + this.subscribe( + eventType, + internalHandler, + selector as SelectorFunction, + ); + } + + /** + * Return a promise that resolves the next time the selector's return value + * changes and, if provided, the `options.condition` predicate returns `true`. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param options - Options bag. + * @param options.selector - The selector function used to select relevant data + * from the event payload. + * @param options.condition - An optional predicate evaluated against the + * selector's return value. The promise only resolves when this returns `true`. + * @template EventType - A type union of Event type strings. + * @template SelectorReturnValue - The selector return value. + * @returns A promise that resolves with the selector's return value. + */ + waitUntil( + eventType: EventType, + options: { + selector: SelectorFunction; + condition?: (value: SelectorReturnValue) => boolean; + }, + ): Promise; + + /** + * Return a promise that resolves the next time the given event is published. + * + * If `options.condition` is provided, the promise only resolves when the + * condition returns `true`. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param options - Options bag. + * @param options.condition - A predicate evaluated against the event payload. + * The promise only resolves when this returns `true`. + * @template EventType - A type union of Event type strings. + * @returns A promise that resolves with the event payload's first argument. + */ + waitUntil( + eventType: EventType, + options?: { + condition?: ( + ...payload: ExtractEventPayload + ) => boolean; + }, + ): Promise[0]>; + + waitUntil( + eventType: EventType, + options?: { + selector?: SelectorFunction; + condition?: + | ((...payload: ExtractEventPayload) => boolean) + | ((value: SelectorReturnValue) => boolean); + }, + ): Promise[0]> { + return new Promise((resolve) => { + this.subscribeOnce( + eventType, + resolve as SelectorEventHandler, + options as { + selector: SelectorFunction; + condition?: (value: SelectorReturnValue) => boolean; + }, + ); + }); + } + /** * Unsubscribe from an event. *