diff --git a/packages/messenger/CHANGELOG.md b/packages/messenger/CHANGELOG.md index bc158f82d72..28a120431ff 100644 --- a/packages/messenger/CHANGELOG.md +++ b/packages/messenger/CHANGELOG.md @@ -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 diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index df0547199ec..ba682a7dbbd 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -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 = { diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index e592da27586..f9eda99db55 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -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, +> = + Subject extends Messenger + ? 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] extends [never] + ? Provided + : Provided & { missingDelegations: Exclude }; + /** * Messenger namespace checks can be disabled by using this as the `namespace` constructor * parameter, and using `MockAnyNamespace` as the Namespace type parameter. @@ -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, + const DelegatedActions extends readonly (MessengerActions & + Action)['type'][], + const DelegatedEvents extends readonly (MessengerEvents & + Event)['type'][], + >({ + actions, + events, + messenger, + }: { + messenger: Delegatee; + actions: RequireExhaustive< + NotNamespacedBy< + MessengerNamespace, + (MessengerActions & Action)['type'] + >, + DelegatedActions + >; + events: RequireExhaustive< + NotNamespacedBy< + MessengerNamespace, + (MessengerEvents & Event)['type'] + >, + DelegatedEvents + >; + }): void { + this.delegate({ + actions: actions as (MessengerActions & Action)['type'][], + events: events as (MessengerEvents & Event)['type'][], + messenger, + }); + } + /** * Revoke delegated actions and/or events from another messenger. * diff --git a/packages/messenger/src/index.ts b/packages/messenger/src/index.ts index 6fb7aaefeda..f2f28fffb06 100644 --- a/packages/messenger/src/index.ts +++ b/packages/messenger/src/index.ts @@ -10,6 +10,7 @@ export type { EventConstraint, MessengerActions, MessengerEvents, + MessengerNamespace, MockAnyNamespace, NamespacedBy, NotNamespacedBy,