Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/messenger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
164 changes: 164 additions & 0 deletions packages/messenger/src/Messenger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When not using a selector this just returns the first parameter of the event. The other option is returning the entire array, but that feels a bit complicated 🤔

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, why does it feel complicated? If it's easy to support, I say we give consumers the whole payload.

});

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] };
Expand Down
155 changes: 155 additions & 0 deletions packages/messenger/src/Messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 extends Event['type'], SelectorReturnValue>(
eventType: EventType,
handler: SelectorEventHandler<SelectorReturnValue>,
options: {
selector: SelectorFunction<Event, EventType, SelectorReturnValue>;
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 extends Event['type']>(
eventType: EventType,
handler: ExtractEventHandler<Event, EventType>,
options?: {
condition?: (
...payload: ExtractEventPayload<Event, EventType>
) => boolean;
},
): void;

subscribeOnce<EventType extends Event['type'], SelectorReturnValue>(
eventType: EventType,
handler:
| ExtractEventHandler<Event, EventType>
| SelectorEventHandler<SelectorReturnValue>,
options?: {
selector?: SelectorFunction<Event, EventType, SelectorReturnValue>;
condition?:
| ((...payload: ExtractEventPayload<Event, EventType>) => 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<Event, EventType, SelectorReturnValue>,
);
}

/**
* 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 extends Event['type'], SelectorReturnValue>(
eventType: EventType,
options: {
selector: SelectorFunction<Event, EventType, SelectorReturnValue>;
condition?: (value: SelectorReturnValue) => boolean;
},
): Promise<SelectorReturnValue>;

/**
* 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 extends Event['type']>(
eventType: EventType,
options?: {
condition?: (
...payload: ExtractEventPayload<Event, EventType>
) => boolean;
},
): Promise<ExtractEventPayload<Event, EventType>[0]>;

waitUntil<EventType extends Event['type'], SelectorReturnValue>(
eventType: EventType,
options?: {
selector?: SelectorFunction<Event, EventType, SelectorReturnValue>;
condition?:
| ((...payload: ExtractEventPayload<Event, EventType>) => boolean)
| ((value: SelectorReturnValue) => boolean);
},
): Promise<SelectorReturnValue | ExtractEventPayload<Event, EventType>[0]> {
return new Promise((resolve) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How we expect waitUntil to be used? In Slack you gave this example:

  getUnlockPromise: () => {
    if (engineContext.KeyringController.isUnlocked()) {
      return Promise.resolve();
    }
    return new Promise<void>((resolve) => {
      controllerMessenger.subscribeOnceIf(
        'KeyringController:unlock',
        resolve,
        () => true,
      );
    });
  },

Is the idea that with this method you could shorten this to the following?

  getUnlockPromise: () => {
    return await controllerMessenger.waitUntil(
      'KeyringController:unlock',
      {
        condition: () => {
          return engineContext.KeyringController.isUnlocked();
        }
      }
    );
  },

Or is it that we would expect people to write this instead?

  getUnlockPromise: () => {
    return await controllerMessenger.waitUntil(
      'KeyringController:stateChange',
      {
        selector: (state) => state.isUnlocked,
      }
    );
  },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either way would we want to check the condition beforehand to make sure is not true before subscribing? (I guess the same goes for subscribeOnce as well.)

this.subscribeOnce(
eventType,
resolve as SelectorEventHandler<SelectorReturnValue>,
options as {
selector: SelectorFunction<Event, EventType, SelectorReturnValue>;
condition?: (value: SelectorReturnValue) => boolean;
},
);
});
}

/**
* Unsubscribe from an event.
*
Expand Down
Loading