From 7b51e38c30ba73689f3692e7f026c0c955c672c8 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 11 May 2026 20:31:21 +0200 Subject: [PATCH 1/5] feat(account-tree-controller): add :accountGroup{Created,Updated} --- eslint-suppressions.json | 2 +- packages/account-tree-controller/CHANGELOG.md | 7 + .../src/AccountTreeController.test.ts | 255 ++++++++++++++++++ .../src/AccountTreeController.ts | 90 ++++++- packages/account-tree-controller/src/index.ts | 2 + packages/account-tree-controller/src/types.ts | 24 +- 6 files changed, 368 insertions(+), 12 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7c61892367..62d7af273e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -12,7 +12,7 @@ }, "packages/account-tree-controller/src/AccountTreeController.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 8 + "count": 7 }, "@typescript-eslint/prefer-nullish-coalescing": { "count": 1 diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index b5e4965c01..68b38f4b70 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AccountTreeController:accountGroupCreated` and `AccountTreeController:accountGroupUpdated` messenger events + - `accountGroupCreated` fires with the affected `AccountGroupObject` when a new group is added to the tree after the controller has been initialized. + - `accountGroupUpdated` fires with the affected `AccountGroupObject` when an existing group's membership or metadata changes (`setAccountGroupName`, `setAccountGroupPinned`, `setAccountGroupHidden`, or accounts added/removed without pruning the group). + - Neither event fires during `init`/`reinit`; consumers should bootstrap from `:getState` or `:accountTreeChange`. + ### Changed - Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 71db24735f..d4182e2797 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4740,6 +4740,261 @@ describe('AccountTreeController', () => { expect(selectedAccountGroupChangeListener).not.toHaveBeenCalled(); }); + + it('does NOT emit accountGroupCreated or accountGroupUpdated during init', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + + expect(createdListener).not.toHaveBeenCalled(); + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupCreated when a new group is added post-init', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsAdded', [ + { ...MOCK_HD_ACCOUNT_2 }, + ]); + + const newWalletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const newGroupId = toMultichainAccountGroupId( + newWalletId, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[newWalletId].groups[newGroupId]; + + expect(createdListener).toHaveBeenCalledTimes(1); + expect(createdListener).toHaveBeenCalledWith(expectedGroup); + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when an account is added to an existing group', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsAdded', [ + { ...MOCK_TRX_ACCOUNT_1 }, + ]); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(createdListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when an account is removed but the group remains', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_TRX_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_TRX_ACCOUNT_1.id, + ]); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + }); + + it('does NOT emit accountGroupUpdated when a removed account causes the group to be pruned', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SNAP_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_SNAP_ACCOUNT_1.id, + ]); + + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when setAccountGroupName is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupName(groupId, 'Renamed Group'); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.name).toBe('Renamed Group'); + }); + + it('emits accountGroupUpdated when setAccountGroupPinned is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupPinned(groupId, true); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.pinned).toBe(true); + }); + + it('emits accountGroupUpdated when setAccountGroupHidden is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupHidden(groupId, true); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.hidden).toBe(true); + }); }); describe('syncWithUserStorage', () => { diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 211b16041f..ecba478d0d 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -908,19 +908,26 @@ export class AccountTreeController extends BaseController< return; } + const createdGroups = new Map(); + const updatedGroups = new Map(); + this.update((state) => { for (const account of newAccounts) { - this.#insert(state.accountTree.wallets, account); + const { walletId, groupId, created } = this.#insert( + state.accountTree.wallets, + account, + ); - const context = this.#accountIdToContext.get(account.id); - if (context) { - const { walletId, groupId } = context; + if (created) { + createdGroups.set(groupId, walletId); + } else if (!createdGroups.has(groupId)) { + updatedGroups.set(groupId, walletId); + } - const wallet = state.accountTree.wallets[walletId]; - if (wallet) { - this.#applyAccountWalletMetadata(state, walletId); - this.#applyAccountGroupMetadata(state, walletId, groupId); - } + const wallet = state.accountTree.wallets[walletId]; + if (wallet) { + this.#applyAccountWalletMetadata(state, walletId); + this.#applyAccountGroupMetadata(state, walletId, groupId); } } }); @@ -929,6 +936,13 @@ export class AccountTreeController extends BaseController< `${controllerName}:accountTreeChange`, this.state.accountTree, ); + + for (const [groupId, walletId] of createdGroups) { + this.#publishAccountGroupCreated(walletId, groupId); + } + for (const [groupId, walletId] of updatedGroups) { + this.#publishAccountGroupUpdated(walletId, groupId); + } } /** @@ -958,6 +972,7 @@ export class AccountTreeController extends BaseController< } const previousSelectedAccountGroup = this.state.selectedAccountGroup; + const updatedGroups = new Map(); this.update((state) => { for (const { id: accountId, context } of knownAccounts) { @@ -981,6 +996,9 @@ export class AccountTreeController extends BaseController< } if (accounts.length === 0) { this.#pruneEmptyGroupAndWallet(state, walletId, groupId); + updatedGroups.delete(groupId); + } else { + updatedGroups.set(groupId, walletId); } } } @@ -996,6 +1014,10 @@ export class AccountTreeController extends BaseController< this.state.accountTree, ); + for (const [groupId, walletId] of updatedGroups) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + const newSelectedAccountGroup = this.state.selectedAccountGroup; if (newSelectedAccountGroup !== previousSelectedAccountGroup) { this.messenger.publish( @@ -1039,6 +1061,40 @@ export class AccountTreeController extends BaseController< return state; } + /** + * Publishes the `:accountGroupCreated` event for a newly added group. + * No-op if the group is not in state (defensive). + * + * @param walletId - The parent wallet ID. + * @param groupId - The newly created group's ID. + */ + #publishAccountGroupCreated( + walletId: AccountWalletId, + groupId: AccountGroupId, + ): void { + const group = this.state.accountTree.wallets[walletId]?.groups[groupId]; + if (group) { + this.messenger.publish(`${controllerName}:accountGroupCreated`, group); + } + } + + /** + * Publishes the `:accountGroupUpdated` event for an existing group. + * No-op if the group is not in state (e.g. it was pruned). + * + * @param walletId - The parent wallet ID. + * @param groupId - The updated group's ID. + */ + #publishAccountGroupUpdated( + walletId: AccountWalletId, + groupId: AccountGroupId, + ): void { + const group = this.state.accountTree.wallets[walletId]?.groups[groupId]; + if (group) { + this.messenger.publish(`${controllerName}:accountGroupUpdated`, group); + } + } + /** * Insert an account inside an account tree. * @@ -1048,11 +1104,12 @@ export class AccountTreeController extends BaseController< * * @param wallets - Account tree. * @param account - The account to be inserted. + * @returns The wallet ID, group ID, and whether the group has been created or not. */ #insert( wallets: AccountTreeControllerState['accountTree']['wallets'], account: InternalAccount, - ) { + ): { walletId: AccountWalletId; groupId: AccountGroupId; created: boolean } { const result = this.#getEntropyRule().match(account) ?? this.#getSnapRule().match(account) ?? @@ -1086,6 +1143,7 @@ export class AccountTreeController extends BaseController< let group = wallet.groups[groupId]; const { type, id } = account; const sortOrder = ACCOUNT_TYPE_TO_SORT_ORDER[type]; + const created = !group; if (!group) { log(`[${walletId}] Add new group: [${groupId}]`); @@ -1143,6 +1201,8 @@ export class AccountTreeController extends BaseController< groupId: group.id, sortOrder, }); + + return { walletId: wallet.id, groupId: group.id, created }; } /** @@ -1526,6 +1586,8 @@ export class AccountTreeController extends BaseController< finalName; }); + this.#publishAccountGroupUpdated(walletId, groupId); + // Trigger atomic sync for group rename (only for groups from entropy wallets) if (wallet.type === AccountWalletType.Entropy) { this.#backupAndSyncService.enqueueSingleGroupSync(groupId); @@ -1596,6 +1658,10 @@ export class AccountTreeController extends BaseController< } }); + if (walletId) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + // Trigger atomic sync for group pinning (only for groups from entropy wallets) if ( walletId && @@ -1638,6 +1704,10 @@ export class AccountTreeController extends BaseController< } }); + if (walletId) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + // Trigger atomic sync for group hiding (only for groups from entropy wallets) if ( walletId && diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index ab9fdcd59f..44a540a0ae 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -14,6 +14,8 @@ export type { AccountTreeControllerStateChangeEvent, AccountTreeControllerAccountTreeChangeEvent, AccountTreeControllerSelectedAccountGroupChangeEvent, + AccountTreeControllerAccountGroupCreatedEvent, + AccountTreeControllerAccountGroupUpdatedEvent, AccountTreeControllerEvents, AccountTreeControllerMessenger, } from './types'; diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 6b0b91d096..4efde35445 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -123,6 +123,26 @@ export type AccountTreeControllerSelectedAccountGroupChangeEvent = { payload: [AccountGroupId | '', AccountGroupId | '']; }; +/** + * Represents the `AccountTreeController:accountGroupCreated` event. + * This event is emitted when a new account group is added to the tree + * after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupCreatedEvent = { + type: `${typeof controllerName}:accountGroupCreated`; + payload: [AccountGroupObject]; +}; + +/** + * Represents the `AccountTreeController:accountGroupUpdated` event. + * This event is emitted when an existing account group's metadata or + * membership changes after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupUpdatedEvent = { + type: `${typeof controllerName}:accountGroupUpdated`; + payload: [AccountGroupObject]; +}; + export type AllowedEvents = | AccountsControllerAccountsAddedEvent | AccountsControllerAccountsRemovedEvent @@ -133,7 +153,9 @@ export type AllowedEvents = export type AccountTreeControllerEvents = | AccountTreeControllerStateChangeEvent | AccountTreeControllerAccountTreeChangeEvent - | AccountTreeControllerSelectedAccountGroupChangeEvent; + | AccountTreeControllerSelectedAccountGroupChangeEvent + | AccountTreeControllerAccountGroupCreatedEvent + | AccountTreeControllerAccountGroupUpdatedEvent; export type AccountTreeControllerMessenger = Messenger< typeof controllerName, From ef5577cfe3d5e6ab2203649e60a8d950cfc7fef7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 11 May 2026 20:34:21 +0200 Subject: [PATCH 2/5] chore: changelog --- packages/account-tree-controller/CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 68b38f4b70..39a8e7bf24 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -9,10 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `AccountTreeController:accountGroupCreated` and `AccountTreeController:accountGroupUpdated` messenger events - - `accountGroupCreated` fires with the affected `AccountGroupObject` when a new group is added to the tree after the controller has been initialized. - - `accountGroupUpdated` fires with the affected `AccountGroupObject` when an existing group's membership or metadata changes (`setAccountGroupName`, `setAccountGroupPinned`, `setAccountGroupHidden`, or accounts added/removed without pruning the group). - - Neither event fires during `init`/`reinit`; consumers should bootstrap from `:getState` or `:accountTreeChange`. +- Add `AccountTreeController:accountGroupCreated` and `AccountTreeController:accountGroupUpdated` events ([#8766](https://github.com/MetaMask/core/pull/8766)) + - Neither event fires during `init`/`reinit`, consumers should bootstrap from `:getState` or `:accountTreeChange`. ### Changed From 1d842f819cd7538722268efd305a00002badc4dc Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 12 May 2026 10:21:35 +0200 Subject: [PATCH 3/5] chore: comment --- .../account-tree-controller/src/AccountTreeController.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index ecba478d0d..370bde989f 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -921,6 +921,12 @@ export class AccountTreeController extends BaseController< if (created) { createdGroups.set(groupId, walletId); } else if (!createdGroups.has(groupId)) { + // ^ We check that the group has not been created in this same batch before adding it to the `updatedGroups` + // map, to avoid sending both created and updated events for the same group: + // - Account 1 + Account 2 + Account 3 + // - Account 1 and 3 belong to the same group + // - Account 1 will create the group + // - Account 3 will update the group (but we only want to send a created event, not an updated one). updatedGroups.set(groupId, walletId); } From 64788588ee73e115aa1a3b718cb8691973d19456 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 12 May 2026 10:23:11 +0200 Subject: [PATCH 4/5] chore: comment --- packages/account-tree-controller/src/AccountTreeController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 370bde989f..6efef1800c 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -926,7 +926,7 @@ export class AccountTreeController extends BaseController< // - Account 1 + Account 2 + Account 3 // - Account 1 and 3 belong to the same group // - Account 1 will create the group - // - Account 3 will update the group (but we only want to send a created event, not an updated one). + // - Account 3 will update the group (but we only want to send a created event, not an updated one) updatedGroups.set(groupId, walletId); } From f2ce76ba2e6aed8875f7b02cb47e12bbcd69148b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 12 May 2026 10:39:19 +0200 Subject: [PATCH 5/5] feat: add :accountGroupRemoved --- packages/account-tree-controller/CHANGELOG.md | 4 +- .../src/AccountTreeController.test.ts | 53 +++++++++++++++++++ .../src/AccountTreeController.ts | 16 ++++++ packages/account-tree-controller/src/index.ts | 1 + packages/account-tree-controller/src/types.ts | 13 ++++- 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 39a8e7bf24..8eef03ff2d 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `AccountTreeController:accountGroupCreated` and `AccountTreeController:accountGroupUpdated` events ([#8766](https://github.com/MetaMask/core/pull/8766)) - - Neither event fires during `init`/`reinit`, consumers should bootstrap from `:getState` or `:accountTreeChange`. +- Add `AccountTreeController:accountGroup{Created,Updated,Removed}` events ([#8766](https://github.com/MetaMask/core/pull/8766)) + - None of these events fire during `init`/`reinit`, consumers should bootstrap from `:getState` or `:accountTreeChange`. ### Changed diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index d4182e2797..dfdf7bfa5e 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4897,6 +4897,59 @@ describe('AccountTreeController', () => { expect(updatedListener).not.toHaveBeenCalled(); }); + it('emits accountGroupRemoved when the last account of a group is removed', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SNAP_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const removedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupRemoved', + removedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const removedWalletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const removedGroupId = toMultichainAccountGroupId( + removedWalletId, + MOCK_SNAP_ACCOUNT_1.options.entropy.groupIndex, + ); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_SNAP_ACCOUNT_1.id, + ]); + + expect(removedListener).toHaveBeenCalledTimes(1); + expect(removedListener).toHaveBeenCalledWith(removedGroupId); + }); + + it('does NOT emit accountGroupRemoved when the group still has accounts', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_TRX_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const removedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupRemoved', + removedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_TRX_ACCOUNT_1.id, + ]); + + expect(removedListener).not.toHaveBeenCalled(); + }); + it('emits accountGroupUpdated when setAccountGroupName is called', () => { const { controller, messenger } = setup({ accounts: [MOCK_HD_ACCOUNT_1], diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 6efef1800c..5640662e9f 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -979,6 +979,7 @@ export class AccountTreeController extends BaseController< const previousSelectedAccountGroup = this.state.selectedAccountGroup; const updatedGroups = new Map(); + const removedGroups = new Set(); this.update((state) => { for (const { id: accountId, context } of knownAccounts) { @@ -1002,7 +1003,10 @@ export class AccountTreeController extends BaseController< } if (accounts.length === 0) { this.#pruneEmptyGroupAndWallet(state, walletId, groupId); + + // If the group gets pruned, we should not consider it as updated. updatedGroups.delete(groupId); + removedGroups.add(groupId); } else { updatedGroups.set(groupId, walletId); } @@ -1023,6 +1027,9 @@ export class AccountTreeController extends BaseController< for (const [groupId, walletId] of updatedGroups) { this.#publishAccountGroupUpdated(walletId, groupId); } + for (const groupId of removedGroups) { + this.#publishAccountGroupRemoved(groupId); + } const newSelectedAccountGroup = this.state.selectedAccountGroup; if (newSelectedAccountGroup !== previousSelectedAccountGroup) { @@ -1101,6 +1108,15 @@ export class AccountTreeController extends BaseController< } } + /** + * Publishes the `:accountGroupRemoved` event for a pruned group. + * + * @param groupId - The removed group's ID. + */ + #publishAccountGroupRemoved(groupId: AccountGroupId): void { + this.messenger.publish(`${controllerName}:accountGroupRemoved`, groupId); + } + /** * Insert an account inside an account tree. * diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 44a540a0ae..28b2f17f64 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -16,6 +16,7 @@ export type { AccountTreeControllerSelectedAccountGroupChangeEvent, AccountTreeControllerAccountGroupCreatedEvent, AccountTreeControllerAccountGroupUpdatedEvent, + AccountTreeControllerAccountGroupRemovedEvent, AccountTreeControllerEvents, AccountTreeControllerMessenger, } from './types'; diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 4efde35445..e7a6657139 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -143,6 +143,16 @@ export type AccountTreeControllerAccountGroupUpdatedEvent = { payload: [AccountGroupObject]; }; +/** + * Represents the `AccountTreeController:accountGroupRemoved` event. + * This event is emitted when an account group is pruned from the tree + * (its last account was removed) after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupRemovedEvent = { + type: `${typeof controllerName}:accountGroupRemoved`; + payload: [AccountGroupId]; +}; + export type AllowedEvents = | AccountsControllerAccountsAddedEvent | AccountsControllerAccountsRemovedEvent @@ -155,7 +165,8 @@ export type AccountTreeControllerEvents = | AccountTreeControllerAccountTreeChangeEvent | AccountTreeControllerSelectedAccountGroupChangeEvent | AccountTreeControllerAccountGroupCreatedEvent - | AccountTreeControllerAccountGroupUpdatedEvent; + | AccountTreeControllerAccountGroupUpdatedEvent + | AccountTreeControllerAccountGroupRemovedEvent; export type AccountTreeControllerMessenger = Messenger< typeof controllerName,