Skip to content
Merged
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: 3 additions & 1 deletion packages/messenger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- These allow delegating or revoking capabilities (actions or events) from one `Messenger` instance to another.
- This allows passing capabilities through chains of messengers of arbitrary length
- See this ADR for details: https://github.com/MetaMask/decisions/blob/main/decisions/core/0012-messenger-delegation.md
- Add `parent` constructor parameter and type parameter to `Messenger` ([#6142](https://github.com/MetaMask/core/pull/6142))
- All capabilities registered under this messenger's namespace are delegated to the parent automatically. This is similar to how the `RestrictedMessenger` would automatically delegate all capabilities to the messenger it was created from.

### Changed

Expand All @@ -25,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed

- **BREAKING:** Remove `RestrictedMessenger` class ([#6132](https://github.com/MetaMask/core/pull/6132))
- Existing `RestrictedMessenger` instances should be replaced with a `Messenger`. We can now use the same class everywhere, passing capabilities using `delegate`.
- Existing `RestrictedMessenger` instances should be replaced with a `Messenger` with the `parent` constructor parameter set to the global messenger. We can now use the same class everywhere, passing capabilities using `delegate`.
- See this ADR for details: https://github.com/MetaMask/decisions/blob/main/decisions/core/0012-messenger-delegation.md

### Fixed
Expand Down
117 changes: 117 additions & 0 deletions packages/messenger/src/Messenger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,33 @@ describe('Messenger', () => {
expect(count).toBe(1);
});

it('automatically delegates actions to parent upon registration', () => {
type CountAction = {
type: 'Fixture:count';
handler: (increment: number) => void;
};
const parentMessenger = new Messenger<'Parent', CountAction, never>({
namespace: 'Parent',
});
const messenger = new Messenger<
'Fixture',
CountAction,
never,
typeof parentMessenger
>({
namespace: 'Fixture',
parent: parentMessenger,
});

let count = 0;
messenger.registerActionHandler('Fixture:count', (increment: number) => {
count += increment;
});
parentMessenger.call('Fixture:count', 1);

expect(count).toBe(1);
});

it('should allow registering and calling multiple different action handlers', () => {
// These 'Other' types are included to demonstrate that messenger generics can indeed be unions
// of actions and events from different modules.
Expand Down Expand Up @@ -225,6 +252,29 @@ describe('Messenger', () => {
expect(handler.callCount).toBe(1);
});

it('automatically delegates events to parent upon first publish', () => {
type MessageEvent = { type: 'Fixture:message'; payload: [string] };
const parentMessenger = new Messenger<'Parent', never, MessageEvent>({
namespace: 'Parent',
});
const messenger = new Messenger<
'Fixture',
never,
MessageEvent,
typeof parentMessenger
>({
namespace: 'Fixture',
parent: parentMessenger,
});

const handler = sinon.stub();
parentMessenger.subscribe('Fixture:message', handler);
messenger.publish('Fixture:message', 'hello');

expect(handler.calledWithExactly('hello')).toBe(true);
expect(handler.callCount).toBe(1);
});

it('should allow publishing multiple different events to subscriber', () => {
type MessageEvent =
| { type: 'Fixture:message'; payload: [string] }
Expand Down Expand Up @@ -513,6 +563,47 @@ describe('Messenger', () => {
});
});

it('automatically delegates to parent when an initial payload is registered', () => {
const state = {
propA: 1,
propB: 1,
};
type MessageEvent = {
type: 'Fixture:complexMessage';
payload: [typeof state];
};
const parentMessenger = new Messenger<'Parent', never, MessageEvent>({
namespace: 'Parent',
});
const messenger = new Messenger<
'Fixture',
never,
MessageEvent,
typeof parentMessenger
>({
namespace: 'Fixture',
parent: parentMessenger,
});
const handler = sinon.stub();

messenger.registerInitialEventPayload({
eventType: 'Fixture:complexMessage',
getPayload: () => [state],
});

parentMessenger.subscribe(
'Fixture:complexMessage',
handler,
(obj) => obj.propA,
);
messenger.publish('Fixture:complexMessage', state);
expect(handler.callCount).toBe(0);
state.propA += 1;
messenger.publish('Fixture:complexMessage', state);
expect(handler.getCall(0)?.args).toStrictEqual([2, 1]);
expect(handler.callCount).toBe(1);
});

it('should publish event to many subscribers with the same selector', () => {
type MessageEvent = {
type: 'Fixture:complexMessage';
Expand Down Expand Up @@ -1205,6 +1296,32 @@ describe('Messenger', () => {
});

describe('revoke', () => {
it('throws when attempting to revoke from parent', () => {
type ExampleEvent = {
type: 'Source:event';
payload: ['test'];
};
const parentMessenger = new Messenger<'Parent', never, ExampleEvent>({
namespace: 'Parent',
});
const sourceMessenger = new Messenger<
'Source',
never,
ExampleEvent,
typeof parentMessenger
>({
namespace: 'Source',
parent: parentMessenger,
});

expect(() =>
sourceMessenger.revoke({
messenger: parentMessenger,
events: ['Source:event'],
}),
).toThrow('Cannot revoke from parent');
});

it('allows revoking a delegated event', () => {
type ExampleEvent = {
type: 'Source:event';
Expand Down
56 changes: 55 additions & 1 deletion packages/messenger/src/Messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,24 @@ export class Messenger<
Namespace extends string,
Action extends ActionConstraint,
Copy link
Contributor

Choose a reason for hiding this comment

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

Side note, but should Action and Event default to never so that if you don't supply any type parameters to Messenger, allowing TypeScript to infer them, you don't end up with a messenger that recognizes all actions and events?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea. I will try this separately

Copy link
Member Author

Choose a reason for hiding this comment

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

Done here: #6311

Event extends EventConstraint,
Parent extends Messenger<
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a new addition from what was described in the ADR. As you can see in the tests, it does make using this a bit more verbose, but it's not too bad IMO. This was the only way I could find to validate that the parent had all necessary actions/events (this was validated in the constructor params, but the method of validation relied on there being a generic type param to validate against).

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense given how we've designed Messenger. I can't think of another way to do this. We can use the typeof trick you used in the tests, though, to make providing this type easier.

string,
ActionConstraint,
EventConstraint,
// Use `any` to avoid preventing a parent from having a parent. `any` is harmless in a type
Copy link
Contributor

Choose a reason for hiding this comment

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

This does work, but wouldn't leaving this type parameter out also work? Then the default would be never which seems more accurate.

Copy link
Member Author

Choose a reason for hiding this comment

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

I was hesitant to do this because there is nothing really stopping someone from writing a messenger that is both a parent and has a parent. It might even be a reasonably good design. For example, if the TransactionController wanted to have internal messengers for each helper class it uses, they might delegate to the main transaction controller messenger so their actions/events can be used internally in the transaction controller.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, I missed the double negative. "Avoid preventing". Got it :)

Yes I agree we should make it possible to essentially create a delegation chain.

// constraint anyway, it's the one totally safe place to use it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any
> = never,
> {
readonly #namespace: Namespace;

/**
* The parent messenger. All actions/events under this namespace are automatically delegated to
* the parent messenger.
*/
readonly #parent?: DelegatedMessenger;

readonly #actions = new Map<Action['type'], Action['handler']>();

readonly #events = new Map<Event['type'], EventSubscriptionMap<Event>>();
Expand Down Expand Up @@ -225,11 +240,26 @@ export class Messenger<
/**
* Construct a messenger.
*
* If a parent messenger is given, all actions and events under this messenger's namespace will
* be delegated to the parent automatically.
*
* @param args - Constructor arguments
* @param args.namespace - The messenger namespace.
* @param args.parent - The parent messenger.
*/
constructor({ namespace }: { namespace: Namespace }) {
constructor({
namespace,
parent,
}: {
namespace: Namespace;
parent?: Action['type'] extends MessengerActions<Parent>['type']
? Event['type'] extends MessengerEvents<Parent>['type']
? Parent
: never
: never;
}) {
this.#namespace = namespace;
this.#parent = parent;
}

/**
Expand Down Expand Up @@ -257,6 +287,11 @@ export class Messenger<
);
}
this.#registerActionHandler(actionType, handler);
if (this.#parent) {
// @ts-expect-error The parent type isn't constructed in a way that proves it supports this
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like there ought to be a way to align the types so this isn't necessary, but I don't know how. I tried changing the type of #parent from DelegatedMessenger to Parent but then the if (messenger === this.#parent) { statement you added at the beginning of revoke failed because TypeScript thought there was no overlap between the two values. So it seems that we need @ts-expect-error somewhere...

I do agree this seems type-safe though given the check we have in the constructor.

// action, but this is OK because it's validated in the constructor.
this.delegate({ actions: [actionType], messenger: this.#parent });
}
Copy link

Choose a reason for hiding this comment

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

Bug: Parent Delegation Fails: Non-Transitive & Duplicate Errors

The new automatic parent delegation feature introduces two bugs:

  1. Non-transitive delegation: Actions and events delegated to a parent are not automatically re-delegated further up the parent chain (e.g., to a grandparent). This occurs because internal delegation methods (e.g., _internalRegisterDelegatedActionHandler, _internalPublishDelegated) bypass the new parent delegation logic present in public methods, leading to "handler not registered" errors for actions and missed events for ancestor subscribers.
  2. Duplicate action delegation error: registerActionHandler unconditionally delegates actions to the parent. If the action was already manually delegated to the parent, this throws "The action 'X' has already been delegated to this messenger", preventing handler registration. Unlike events, actions lack a check for existing delegation targets.
Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

Duplicate action delegation error

This is fine; it's OK for there to be an error if someone manually delegates to a parent. Just don't do that.

The transitive delegation issue is interesting though. I hadn't thought too much about that. We could disallow it for simplicity, but it may come in handy in some cases so I'll take a closer look.

Copy link
Member Author

Choose a reason for hiding this comment

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

Wait no, delegation to parents only applies to capabilities under the current namespace. Nothing delegated to a messenger would be under its namespace. Therefore delegation to a parent is inherently not transitive. It's intentional and OK for it not to be transitive.

}

#registerActionHandler<ActionType extends Action['type']>(
Expand Down Expand Up @@ -400,6 +435,14 @@ export class Messenger<
}:'`,
);
}
if (
this.#parent &&
!this.#subscriptionDelegationTargets.get(eventType)?.has(this.#parent)
) {
// @ts-expect-error The parent type isn't constructed in a way that proves it supports this
// event, but this is OK because it's validated in the constructor.
this.delegate({ events: [eventType], messenger: this.#parent });
}
this.#registerInitialEventPayload({ eventType, getPayload });
}

Expand Down Expand Up @@ -449,6 +492,14 @@ export class Messenger<
`Only allowed publishing events prefixed by '${this.#namespace}:'`,
);
}
if (
this.#parent &&
!this.#subscriptionDelegationTargets.get(eventType)?.has(this.#parent)
) {
// @ts-expect-error The parent type isn't constructed in a way that proves it supports this
// event, but this is OK because it's validated in the constructor.
this.delegate({ events: [eventType], messenger: this.#parent });
}
this.#publish(eventType, ...payload);
}

Expand Down Expand Up @@ -805,6 +856,9 @@ export class Messenger<
events?: DelegatedEvents;
messenger: Delegatee;
}) {
if (messenger === this.#parent) {
throw new Error('Cannot revoke from parent');
}
for (const actionType of actions || []) {
const delegationTargets = this.#actionDelegationTargets.get(actionType);
if (!delegationTargets || !delegationTargets.has(messenger)) {
Expand Down
Loading