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
6 changes: 6 additions & 0 deletions packages/messenger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `delegateAll` method for exhaustive delegation with compile-time checking ([#8338](https://github.com/MetaMask/core/pull/8338))
- Unlike `delegate`, this method requires all external actions and events to be listed, producing a TypeScript error showing exactly which items are missing.
- Add `MessengerNamespace` utility type to extract the namespace from a Messenger type ([#8338](https://github.com/MetaMask/core/pull/8338))

## [1.0.0]

### Changed
Expand Down
126 changes: 126 additions & 0 deletions packages/messenger/src/Messenger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,132 @@ describe('Messenger', () => {
});
});

describe('delegateAll', () => {
it('delegates all listed actions and events', () => {
type SourceAction = {
type: 'Source:getValue';
handler: () => number;
};
type ChildOwnAction = {
type: 'Child:doStuff';
handler: () => void;
};
type SourceEvent = {
type: 'Source:stateChange';
payload: [{ value: number }];
};

const sourceMessenger = new Messenger<
'Source',
SourceAction | ChildOwnAction,
SourceEvent
>({ namespace: 'Source' });

const childMessenger = new Messenger<
'Child',
SourceAction | ChildOwnAction,
SourceEvent
>({ namespace: 'Child' });

sourceMessenger.registerActionHandler('Source:getValue', () => 42);

sourceMessenger.delegateAll({
messenger: childMessenger,
actions: ['Source:getValue'],
events: ['Source:stateChange'],
});

// Child can now call the delegated action
expect(childMessenger.call('Source:getValue')).toBe(42);

// Child can now subscribe to the delegated event
const subscriber = jest.fn();
childMessenger.subscribe('Source:stateChange', subscriber);
sourceMessenger.publish('Source:stateChange', { value: 1 });
expect(subscriber).toHaveBeenCalledWith({ value: 1 });
});

it('delegates actions with an empty events array', () => {
type SourceAction = {
type: 'Source:getValue';
handler: () => number;
};

const sourceMessenger = new Messenger<'Source', SourceAction, never>({
namespace: 'Source',
});
const childMessenger = new Messenger<'Child', SourceAction, never>({
namespace: 'Child',
});

sourceMessenger.registerActionHandler('Source:getValue', () => 99);

sourceMessenger.delegateAll({
messenger: childMessenger,
actions: ['Source:getValue'],
events: [],
});

expect(childMessenger.call('Source:getValue')).toBe(99);
});

it('excludes the delegatee own-namespace actions from the exhaustiveness check', () => {
type SourceAction = {
type: 'Source:getValue';
handler: () => number;
};
type ChildOwnAction = {
type: 'Child:doStuff';
handler: () => void;
};

const sourceMessenger = new Messenger<
'Source',
SourceAction | ChildOwnAction,
never
>({ namespace: 'Source' });

const childMessenger = new Messenger<
'Child',
SourceAction | ChildOwnAction,
never
>({ namespace: 'Child' });

sourceMessenger.registerActionHandler('Source:getValue', () => 42);

// This should compile without listing 'Child:doStuff' — it belongs
// to the child's own namespace and will be registered by the child.
sourceMessenger.delegateAll({
messenger: childMessenger,
actions: ['Source:getValue'],
events: [],
});

expect(childMessenger.call('Source:getValue')).toBe(42);
});

it('produces a type error when an action is missing', () => {
type ActionA = { type: 'A:getValue'; handler: () => number };
type ActionB = { type: 'B:getName'; handler: () => string };

const source = new Messenger<'Source', ActionA | ActionB, never>({
namespace: 'Source',
});
const child = new Messenger<'Child', ActionA | ActionB, never>({
namespace: 'Child',
});

expect(() =>
// @ts-expect-error — 'B:getName' is missing from the actions list
source.delegateAll({
messenger: child,
actions: ['A:getValue'],
events: [],
}),
).not.toThrow();
});
});

describe('revoke', () => {
it('throws when attempting to revoke from parent', () => {
type ExampleEvent = {
Expand Down
99 changes: 99 additions & 0 deletions packages/messenger/src/Messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,46 @@ export type MessengerEvents<
? Event
: never;

/**
* Extract the namespace from a Messenger type.
*
* @template Subject - The messenger type to extract from.
*/
export type MessengerNamespace<
Subject extends Messenger<string, ActionConstraint, EventConstraint>,
> =
Subject extends Messenger<infer N, ActionConstraint, EventConstraint>
? N
: never;

/**
* Validate that all members of a union are present in a tuple.
*
* When all required members are present, evaluates to the input tuple unchanged.
* When members are missing, evaluates to a branded intersection type that
* produces a clear compile error showing exactly which items are missing via
* the `missingDelegations` property.
*
* @template Required - The union of all required string types.
* @template Provided - The readonly tuple of provided string types.
* @example
* ```typescript
* // OK — all required items present
* type T1 = RequireExhaustive<'A' | 'B', readonly ['A', 'B']>;
* // => readonly ['A', 'B']
*
* // Error — 'C' is missing
* type T2 = RequireExhaustive<'A' | 'B' | 'C', readonly ['A', 'B']>;
* // => readonly ['A', 'B'] & { missingDelegations: 'C' }
* ```
*/
type RequireExhaustive<
Required extends string,
Provided extends readonly string[],
> = [Exclude<Required, Provided[number]>] extends [never]
? Provided
: Provided & { missingDelegations: Exclude<Required, Provided[number]> };

/**
* Messenger namespace checks can be disabled by using this as the `namespace` constructor
* parameter, and using `MockAnyNamespace` as the Namespace type parameter.
Expand Down Expand Up @@ -855,6 +895,65 @@ export class Messenger<
}
}

/**
* Delegate all external actions and events to another messenger, with
* compile-time exhaustiveness checking.
*
* Unlike {@link delegate}, which accepts a partial list of actions/events,
* this method requires that **every** action and event the delegatee needs
* from outside its own namespace is included. If any are missing, TypeScript
* produces a type error showing the missing items.
*
* Use this when a single source messenger provides all external
* actions/events for a child messenger (the common pattern in controller
* initialisation).
*
* @param args - Arguments.
* @param args.actions - The action types to delegate. Must include every
* action type defined on the delegatee that is **not** under its own
* namespace.
* @param args.events - The event types to delegate. Must include every event
* type defined on the delegatee that is **not** under its own namespace.
* @param args.messenger - The messenger to delegate to.
* @template Delegatee - The messenger the actions/events are delegated to.
* @template DelegatedActions - A const tuple of delegated action type
* strings.
* @template DelegatedEvents - A const tuple of delegated event type strings.
*/
delegateAll<
Delegatee extends Messenger<string, ActionConstraint, EventConstraint>,
const DelegatedActions extends readonly (MessengerActions<Delegatee> &
Action)['type'][],
const DelegatedEvents extends readonly (MessengerEvents<Delegatee> &
Event)['type'][],
>({
actions,
events,
messenger,
}: {
messenger: Delegatee;
actions: RequireExhaustive<
NotNamespacedBy<
MessengerNamespace<Delegatee>,
(MessengerActions<Delegatee> & Action)['type']
>,
DelegatedActions
>;
events: RequireExhaustive<
NotNamespacedBy<
MessengerNamespace<Delegatee>,
(MessengerEvents<Delegatee> & Event)['type']
>,
DelegatedEvents
>;
}): void {
this.delegate({
actions: actions as (MessengerActions<Delegatee> & Action)['type'][],
events: events as (MessengerEvents<Delegatee> & Event)['type'][],
messenger,
});
}

/**
* Revoke delegated actions and/or events from another messenger.
*
Expand Down
1 change: 1 addition & 0 deletions packages/messenger/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
EventConstraint,
MessengerActions,
MessengerEvents,
MessengerNamespace,
MockAnyNamespace,
NamespacedBy,
NotNamespacedBy,
Expand Down
Loading