diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index 0c267fc1e7b..83a1fe0e9b8 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `:accounts{Added,Removed}` batch events ([#8151](https://github.com/MetaMask/core/pull/8151)) + - Those new events can be used instead of single `:accountAdded` and `:accountRemoved` events to reduce the number of events emitted during batch operations (e.g. `KeyringController` state re-synchronization). + ## [37.0.0] ### Added diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index 6fe0bd849b8..2b56924135a 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -1255,6 +1255,116 @@ describe('AccountsController', () => { expect(accountsController.getSelectedAccount().id).toBe(mockAccount.id); }); + it('publishes accountsAdded event with all added accounts', async () => { + const messenger = buildMessenger(); + + mockUUIDWithNormalAccounts([mockAccount, mockAccount2]); + + setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockAccount.id]: mockAccount, + }, + selectedAccount: mockAccount.id, + }, + accountIdByAddress: { + [mockAccount.address]: mockAccount.id, + }, + }, + messenger, + }); + + const mockNewKeyringState = { + isUnlocked: true, + keyrings: [ + { + type: KeyringTypes.hd, + accounts: [mockAccount.address, mockAccount2.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, + }, + ], + }; + + const accountsAddedListener = jest.fn(); + messenger.subscribe( + 'AccountsController:accountsAdded', + accountsAddedListener, + ); + + messenger.publish( + 'KeyringController:stateChange', + mockNewKeyringState, + [], + ); + + expect(accountsAddedListener).toHaveBeenCalledTimes(1); + expect(accountsAddedListener).toHaveBeenCalledWith([ + expect.objectContaining({ id: mockAccount2.id }), + ]); + }); + + it('publishes accountsAdded after all individual accountAdded events', async () => { + const messenger = buildMessenger(); + + mockUUIDWithNormalAccounts([mockAccount, mockAccount2]); + + setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockAccount.id]: mockAccount, + }, + selectedAccount: mockAccount.id, + }, + accountIdByAddress: { + [mockAccount.address]: mockAccount.id, + }, + }, + messenger, + }); + + const mockNewKeyringState = { + isUnlocked: true, + keyrings: [ + { + type: KeyringTypes.hd, + accounts: [mockAccount.address, mockAccount2.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, + }, + ], + }; + + const mockEventsOrder = jest.fn(); + messenger.subscribe('AccountsController:accountAdded', () => { + mockEventsOrder('AccountsController:accountAdded'); + }); + messenger.subscribe('AccountsController:accountsAdded', () => { + mockEventsOrder('AccountsController:accountsAdded'); + }); + + messenger.publish( + 'KeyringController:stateChange', + mockNewKeyringState, + [], + ); + + expect(mockEventsOrder).toHaveBeenNthCalledWith( + 1, + 'AccountsController:accountAdded', + ); + expect(mockEventsOrder).toHaveBeenNthCalledWith( + 2, + 'AccountsController:accountsAdded', + ); + }); + it('publishes accountAdded event', async () => { const messenger = buildMessenger(); @@ -1626,6 +1736,118 @@ describe('AccountsController', () => { mockAccount3.id, ); }); + + it('publishes accountsRemoved event with all removed accounts', async () => { + const messenger = buildMessenger(); + + mockUUIDWithNormalAccounts([mockAccount, mockAccount2]); + + setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockAccount.id]: mockAccount, + [mockAccount3.id]: mockAccount3, + }, + selectedAccount: mockAccount.id, + }, + accountIdByAddress: { + [mockAccount.address]: mockAccount.id, + [mockAccount3.address]: mockAccount3.id, + }, + }, + messenger, + }); + + const mockNewKeyringState = { + isUnlocked: true, + keyrings: [ + { + type: KeyringTypes.hd, + accounts: [mockAccount.address, mockAccount2.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, + }, + ], + }; + + const accountsRemovedListener = jest.fn(); + messenger.subscribe( + 'AccountsController:accountsRemoved', + accountsRemovedListener, + ); + + messenger.publish( + 'KeyringController:stateChange', + mockNewKeyringState, + [], + ); + + expect(accountsRemovedListener).toHaveBeenCalledTimes(1); + expect(accountsRemovedListener).toHaveBeenCalledWith([mockAccount3.id]); + }); + + it('publishes accountsRemoved after all individual accountRemoved events', async () => { + const messenger = buildMessenger(); + + mockUUIDWithNormalAccounts([mockAccount, mockAccount2]); + + setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockAccount.id]: mockAccount, + [mockAccount3.id]: mockAccount3, + }, + selectedAccount: mockAccount.id, + }, + accountIdByAddress: { + [mockAccount.address]: mockAccount.id, + [mockAccount3.address]: mockAccount3.id, + }, + }, + messenger, + }); + + const mockNewKeyringState = { + isUnlocked: true, + keyrings: [ + { + type: KeyringTypes.hd, + accounts: [mockAccount.address, mockAccount2.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, + }, + ], + }; + + const mockEventsOrder = jest.fn(); + messenger.subscribe('AccountsController:accountRemoved', () => { + mockEventsOrder('AccountsController:accountRemoved'); + }); + messenger.subscribe('AccountsController:accountsRemoved', () => { + mockEventsOrder('AccountsController:accountsRemoved'); + }); + + messenger.publish( + 'KeyringController:stateChange', + mockNewKeyringState, + [], + ); + + expect(mockEventsOrder).toHaveBeenNthCalledWith( + 1, + 'AccountsController:accountRemoved', + ); + expect(mockEventsOrder).toHaveBeenNthCalledWith( + 2, + 'AccountsController:accountsRemoved', + ); + }); }); it('handle keyring reinitialization', async () => { @@ -1821,6 +2043,9 @@ describe('AccountsController', () => { messenger.subscribe('AccountsController:accountAdded', () => { mockEventsOrder('AccountsController:accountAdded'); }); + messenger.subscribe('AccountsController:accountsAdded', () => { + mockEventsOrder('AccountsController:accountsAdded'); + }); messenger.subscribe('AccountsController:selectedAccountChange', () => { mockEventsOrder('AccountsController:selectedAccountChange'); }); @@ -1839,6 +2064,10 @@ describe('AccountsController', () => { ); expect(mockEventsOrder).toHaveBeenNthCalledWith( 2, + 'AccountsController:accountsAdded', + ); + expect(mockEventsOrder).toHaveBeenNthCalledWith( + 3, 'AccountsController:selectedAccountChange', ); }); diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index 5bd67e9a36f..3f84f8709fe 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -151,6 +151,11 @@ export type AccountsControllerAccountAddedEvent = { payload: [InternalAccount]; }; +export type AccountsControllerAccountsAddedEvent = { + type: `${typeof controllerName}:accountsAdded`; + payload: [InternalAccount[]]; +}; + /** * @deprecated This type is deprecated and will be removed in a future version. * Use `AccountTreeController`, `MultichainAccountService`, or the Keyring API v2 instead. @@ -160,6 +165,11 @@ export type AccountsControllerAccountRemovedEvent = { payload: [AccountId]; }; +export type AccountsControllerAccountsRemovedEvent = { + type: `${typeof controllerName}:accountsRemoved`; + payload: [AccountId[]]; +}; + /** * @deprecated This type is deprecated and will be removed in a future version. * Use `AccountTreeController`, `MultichainAccountService`, or the Keyring API v2 instead. @@ -217,7 +227,9 @@ export type AccountsControllerEvents = | AccountsControllerSelectedAccountChangeEvent | AccountsControllerSelectedEvmAccountChangeEvent | AccountsControllerAccountAddedEvent + | AccountsControllerAccountsAddedEvent | AccountsControllerAccountRemovedEvent + | AccountsControllerAccountsRemovedEvent | AccountsControllerAccountRenamedEvent | AccountsControllerAccountBalancesUpdatesEvent | AccountsControllerAccountTransactionsUpdatedEvent @@ -991,10 +1003,22 @@ export class AccountsController extends BaseController< for (const id of diff.removed) { this.messenger.publish('AccountsController:accountRemoved', id); } + if (diff.removed.length > 0) { + this.messenger.publish( + 'AccountsController:accountsRemoved', + diff.removed, + ); + } for (const account of diff.added) { this.messenger.publish('AccountsController:accountAdded', account); } + if (diff.added.length > 0) { + this.messenger.publish( + 'AccountsController:accountsAdded', + diff.added, + ); + } }, ); diff --git a/packages/accounts-controller/src/index.ts b/packages/accounts-controller/src/index.ts index cbf415bd443..bfaf0be8e56 100644 --- a/packages/accounts-controller/src/index.ts +++ b/packages/accounts-controller/src/index.ts @@ -8,7 +8,9 @@ export type { AccountsControllerSelectedAccountChangeEvent, AccountsControllerSelectedEvmAccountChangeEvent, AccountsControllerAccountAddedEvent, + AccountsControllerAccountsAddedEvent, AccountsControllerAccountRemovedEvent, + AccountsControllerAccountsRemovedEvent, AccountsControllerAccountRenamedEvent, AccountsControllerAccountBalancesUpdatesEvent, AccountsControllerAccountTransactionsUpdatedEvent,